diff --git a/race/nintendo_racing_service.go b/race/nintendo_racing_service.go index 3878ee6..8cefe4a 100644 --- a/race/nintendo_racing_service.go +++ b/race/nintendo_racing_service.go @@ -1,6 +1,8 @@ package race import ( + "bytes" + "encoding/binary" "encoding/xml" "io" "net/http" @@ -14,15 +16,14 @@ import ( ) type rankingsRequestEnvelope struct { - Body rankingsRequestBody + Body rankingsRequestBody `xml:"Body"` } type rankingsRequestBody struct { - Data rankingsRequestData `xml:",any"` + GetTopTenRankings rankingsRequestGetTopTenRankings `xml:"GetTopTenRankings"` } -type rankingsRequestData struct { - XMLName xml.Name +type rankingsRequestGetTopTenRankings struct { GameId int `xml:"gameid"` RegionId common.MarioKartWiiLeaderboardRegionId `xml:"regionid"` CourseId common.MarioKartWiiCourseId `xml:"courseid"` @@ -56,7 +57,56 @@ type rankingsResponseRankingData struct { UserData string `xml:"userdata"` } +type submitScoresRequestEnvelope struct { + Body submitScoresRequestBody `xml:"Body"` +} + +type submitScoresRequestBody struct { + SubmitScores submitScoresRequestSubmitScores `xml:"SubmitScores"` +} + +type submitScoresRequestSubmitScores struct { + GameData submitScoresRequestGameData `xml:"gameData"` +} + +type submitScoresRequestGameData struct { + RegionId common.MarioKartWiiLeaderboardRegionId `xml:"regionid"` + ProfileID int `xml:"profileid"` + GameId int `xml:"gameid"` + ScoreMode scoreMode `xml:"scoremode"` + ScoreDatas submitScoresRequestScoreDatas `xml:"ScoreDatas"` +} + +type submitScoresRequestScoreDatas struct { + ScoreData []submitScoresRequestScoreData `xml:"ScoreData"` +} + +type submitScoresRequestScoreData struct { + Time int `xml:"time"` + CourseID common.MarioKartWiiCourseId `xml:"courseid"` + PlayerInfoBase64 string `xml:"playerinfobase64"` +} + +type submitScoresResponse struct { + XMLName xml.Name `xml:"SubmitScoresResult"` + XMLNSXSI string `xml:"xmlns:xsi,attr"` + XMLNSXSD string `xml:"xmlns:xsd,attr"` + XMLNS string `xml:"xmlns,attr"` + ResponseCode raceServiceResult `xml:"responseCode"` +} + +type playerInfo struct { + MiiData common.Mii // 0x00 + ControllerId byte // 0x4C + Unknown byte // 0x4D + StateCode byte // 0x4E + CountryCode byte // 0x4F +} + type raceServiceResult int +type scoreMode int + +const playerInfoSize = 0x50 // https://github.com/GameProgressive/UniSpySDK/blob/master/webservices/RacingService.h const ( @@ -66,6 +116,11 @@ const ( raceServiceResultInvalidParameters = 105 ) +const ( + scoreModeTimeTrials = iota // 0x00 + scoreModeContest // 0x01 +) + const ( xmlNamespaceXSI = "http://www.w3.org/2001/XMLSchema-instance" xmlNamespaceXSD = "http://www.w3.org/2001/XMLSchema" @@ -78,20 +133,17 @@ func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.R soapActionHeader := request.Header.Get("SOAPAction") if soapActionHeader == "" { logging.Error(moduleName, "No SOAPAction header") - writeErrorResponse(raceServiceResultParseError, responseWriter) return } slashIndex := strings.LastIndex(soapActionHeader, "/") if slashIndex == -1 { logging.Error(moduleName, "Invalid SOAPAction header") - writeErrorResponse(raceServiceResultParseError, responseWriter) return } quotationMarkIndex := strings.Index(soapActionHeader[slashIndex+1:], "\"") if quotationMarkIndex == -1 { logging.Error(moduleName, "Invalid SOAPAction header") - writeErrorResponse(raceServiceResultParseError, responseWriter) return } @@ -104,10 +156,8 @@ func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.R switch soapAction { case "GetTopTenRankings": handleGetTopTenRankingsRequest(moduleName, responseWriter, requestBody) - - // TODO SubmitScores - default: - logging.Info(moduleName, "Unhandled SOAPAction:", aurora.Cyan(soapAction)) + case "SubmitScores": + handleSubmitScoresRequest(moduleName, responseWriter, requestBody) } } @@ -116,37 +166,36 @@ func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.Respo err := xml.Unmarshal(requestBody, &requestXML) if err != nil { logging.Error(moduleName, "Got malformed XML") - writeErrorResponse(raceServiceResultParseError, responseWriter) + writeGetTop10RankingsResponse(raceServiceResultParseError, responseWriter, rankingsResponseDataArray{}) return } - requestData := requestXML.Body.Data + getTopTenRankings := requestXML.Body.GetTopTenRankings - gameId := requestData.GameId + gameId := getTopTenRankings.GameId if gameId != marioKartWiiGameID { logging.Error(moduleName, "Wrong GameSpy game ID:", aurora.Cyan(gameId)) - writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + writeGetTop10RankingsResponse(raceServiceResultInvalidParameters, responseWriter, rankingsResponseDataArray{}) return } - regionId := requestData.RegionId - courseId := requestData.CourseId - + regionId := getTopTenRankings.RegionId if !regionId.IsValid() { logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionId)) - writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + writeGetTop10RankingsResponse(raceServiceResultInvalidParameters, responseWriter, rankingsResponseDataArray{}) return } + courseId := getTopTenRankings.CourseId if courseId < common.MarioCircuit || courseId > 32767 { logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseId)) - writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + writeGetTop10RankingsResponse(raceServiceResultInvalidParameters, responseWriter, rankingsResponseDataArray{}) return } topTenRankings, err := database.GetMarioKartWiiTopTenRankings(pool, ctx, regionId, courseId) if err != nil { logging.Error(moduleName, "Failed to get the Top 10 rankings:", err) - writeErrorResponse(raceServiceResultDatabaseError, responseWriter) + writeGetTop10RankingsResponse(raceServiceResultDatabaseError, responseWriter, rankingsResponseDataArray{}) return } @@ -172,30 +221,99 @@ func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.Respo Data: data, } + writeGetTop10RankingsResponse(raceServiceResultSuccess, responseWriter, dataArray) +} + +func handleSubmitScoresRequest(moduleName string, responseWriter http.ResponseWriter, requestBody []byte) { + requestXML := submitScoresRequestEnvelope{} + err := xml.Unmarshal(requestBody, &requestXML) + if err != nil { + logging.Error(moduleName, "Got malformed XML") + writeSubmitScoresResponse(raceServiceResultParseError, responseWriter) + return + } + + gameData := requestXML.Body.SubmitScores.GameData + + gameId := gameData.GameId + if gameId != marioKartWiiGameID { + logging.Error(moduleName, "Wrong GameSpy game ID:", aurora.Cyan(gameId)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + if gameData.ProfileID <= 0 { + logging.Error(moduleName, "Invalid profile ID:", aurora.Cyan(gameData.ProfileID)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + if !gameData.RegionId.IsValid() || gameData.RegionId == common.Worldwide { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(gameData.RegionId)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + scoreMode := gameData.ScoreMode + if scoreMode != scoreModeTimeTrials && scoreMode != scoreModeContest { + logging.Error(moduleName, "Invalid score mode:", aurora.Cyan(scoreMode)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + for _, scoreData := range gameData.ScoreDatas.ScoreData { + if scoreData.Time <= 0 || scoreData.Time >= 360000 /* 6 minutes */ { + logging.Error(moduleName, "Invalid time:", aurora.Cyan(scoreData.Time)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + courseId := scoreData.CourseID + isContest := scoreMode == scoreModeContest + if courseId < common.MarioCircuit || isContest == courseId.IsValid() || courseId > 32767 { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseId)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + + if !IsPlayerInfoValid(scoreData.PlayerInfoBase64) { + logging.Error(moduleName, "Invalid player info:", aurora.Cyan(scoreData.PlayerInfoBase64)) + writeSubmitScoresResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + } + + // While we recognize that we have received the time, in contrast to the original Race server, + // we require that a ghost accompanies a time in order to display it on the rankings. + writeSubmitScoresResponse(raceServiceResultSuccess, responseWriter) +} + +func writeGetTop10RankingsResponse(raceServiceResult raceServiceResult, responseWriter http.ResponseWriter, + dataArray rankingsResponseDataArray) { rankingDataResponse := rankingsResponseRankingDataResponse{ XMLNSXSI: xmlNamespaceXSI, XMLNSXSD: xmlNamespaceXSD, XMLNS: xmlNamespace, - ResponseCode: raceServiceResultSuccess, + ResponseCode: raceServiceResult, DataArray: dataArray, } writeResponse(responseWriter, rankingDataResponse) } -func writeErrorResponse(raceServiceResult raceServiceResult, responseWriter http.ResponseWriter) { - rankingDataResponse := rankingsResponseRankingDataResponse{ +func writeSubmitScoresResponse(raceServiceResult raceServiceResult, responseWriter http.ResponseWriter) { + submitScoresResponse := submitScoresResponse{ XMLNSXSI: xmlNamespaceXSI, XMLNSXSD: xmlNamespaceXSD, XMLNS: xmlNamespace, ResponseCode: raceServiceResult, } - writeResponse(responseWriter, rankingDataResponse) + writeResponse(responseWriter, submitScoresResponse) } -func writeResponse(responseWriter http.ResponseWriter, rankingDataResponse rankingsResponseRankingDataResponse) { - responseBody, err := xml.Marshal(rankingDataResponse) +func writeResponse(responseWriter http.ResponseWriter, data any) { + responseBody, err := xml.Marshal(data) if err != nil { panic(err) } @@ -206,3 +324,27 @@ func writeResponse(responseWriter http.ResponseWriter, rankingDataResponse ranki responseWriter.Header().Set("Content-Type", "text/xml") responseWriter.Write(responseBody) } + +func IsPlayerInfoValid(playerInfoString string) bool { + playerInfoByteArray, err := common.DecodeGameSpyBase64(playerInfoString, common.GameSpyBase64EncodingURLSafe) + if err != nil { + return false + } + + if len(playerInfoByteArray) != playerInfoSize { + return false + } + + var playerInfo playerInfo + reader := bytes.NewReader(playerInfoByteArray) + err = binary.Read(reader, binary.BigEndian, &playerInfo) + if err != nil { + return false + } + + if playerInfo.MiiData.RFLCalculateCRC() != 0x0000 { + return false + } + + return common.MarioKartWiiControllerId(playerInfo.ControllerId).IsValid() +} diff --git a/sake/mario_kart_wii.go b/sake/mario_kart_wii.go index 101fbd7..62c81be 100644 --- a/sake/mario_kart_wii.go +++ b/sake/mario_kart_wii.go @@ -1,7 +1,6 @@ package sake import ( - "bytes" "encoding/binary" "fmt" "io" @@ -11,23 +10,12 @@ import ( "wwfc/common" "wwfc/database" "wwfc/logging" + "wwfc/race" "github.com/logrusorgru/aurora/v3" ) -type playerInfo struct { - MiiData common.Mii // 0x00 - ControllerId byte // 0x4C - Unknown byte // 0x4D - StateCode byte // 0x4E - CountryCode byte // 0x4F -} - -const ( - playerInfoSize = 0x50 - - rkgdFileName = "ghost.bin" -) +const rkgdFileName = "ghost.bin" func handleMarioKartWiiFileDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { if strings.HasSuffix(request.URL.Path, "ghostdownload.aspx") { @@ -128,6 +116,7 @@ func handleMarioKartWiiGhostDownloadRequest(moduleName string, responseWriter ht } func handleMarioKartWiiFileUploadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + return if strings.HasSuffix(request.URL.Path, "ghostupload.aspx") { handleMarioKartWiiGhostUploadRequest(moduleName, responseWriter, request) return @@ -184,7 +173,7 @@ func handleMarioKartWiiGhostUploadRequest(moduleName string, responseWriter http return } - if !isPlayerInfoValid(playerInfo) { + if !race.IsPlayerInfoValid(playerInfo) { logging.Error(moduleName, "Invalid player info:", aurora.Cyan(playerInfo)) responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) return @@ -259,32 +248,6 @@ func downloadedGhostFileHeader() []byte { return downloadedGhostFileHeader[:] } -func isPlayerInfoValid(playerInfoString string) bool { - playerInfoByteArray, err := common.DecodeGameSpyBase64(playerInfoString, common.GameSpyBase64EncodingURLSafe) - if err != nil { - return false - } - - if len(playerInfoByteArray) != playerInfoSize { - return false - } - - var playerInfo playerInfo - reader := bytes.NewReader(playerInfoByteArray) - err = binary.Read(reader, binary.BigEndian, &playerInfo) - if err != nil { - return false - } - - if playerInfo.MiiData.RFLCalculateCRC() != 0x0000 { - return false - } - - controllerId := common.MarioKartWiiControllerId(playerInfo.ControllerId) - - return controllerId.IsValid() -} - func getMultipartBoundary(contentType string) string { startIndex := strings.Index(contentType, "boundary=") if startIndex == -1 {