diff --git a/common/encoding.go b/common/encoding.go index 86c2c9b..aee84a7 100644 --- a/common/encoding.go +++ b/common/encoding.go @@ -2,6 +2,16 @@ package common import ( "encoding/base64" + "errors" + "strings" +) + +type GameSpyBase64Encoding int + +const ( + GameSpyBase64EncodingDefault = iota // 0 + GameSpyBase64EncodingAlternate // 1 + GameSpyBase64EncodingURLSafe // 2 ) var Base64DwcEncoding = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-").WithPadding('*') @@ -20,6 +30,31 @@ func Base32Encode(value uint64) string { return encoded } +func DecodeGameSpyBase64(gameSpyBase64 string, gameSpyBase64Encoding GameSpyBase64Encoding) ([]byte, error) { + base64String, err := GameSpyBase64ToBase64(gameSpyBase64, gameSpyBase64Encoding) + if err != nil { + return nil, err + } + + return base64.StdEncoding.DecodeString(base64String) +} + +func GameSpyBase64ToBase64(gameSpyBase64 string, gameSpyBase64Encoding GameSpyBase64Encoding) (string, error) { + switch gameSpyBase64Encoding { + case GameSpyBase64EncodingDefault: + return gameSpyBase64, nil + + case GameSpyBase64EncodingAlternate: + return strings.NewReplacer("[", "+", "]", "/", "_", "=").Replace(gameSpyBase64), nil + + case GameSpyBase64EncodingURLSafe: + return strings.NewReplacer("-", "+", "_", "/" /*, "=", "="*/).Replace(gameSpyBase64), nil + + default: + return "", errors.New("invalid GameSpy Base64 encoding specified") + } +} + func reverse(s string) string { rns := []rune(s) for i, j := 0, len(rns)-1; i < j; i, j = i+1, j-1 { diff --git a/common/game_list.go b/common/game_list.go index 8ff5ed6..1a5f19e 100644 --- a/common/game_list.go +++ b/common/game_list.go @@ -26,6 +26,8 @@ var ( ) func GetGameInfoByID(gameId int) *GameInfo { + ReadGameList() + mutex.Lock() defer mutex.Unlock() @@ -37,6 +39,8 @@ func GetGameInfoByID(gameId int) *GameInfo { } func GetGameInfoByName(name string) *GameInfo { + ReadGameList() + mutex.Lock() defer mutex.Unlock() @@ -47,6 +51,24 @@ func GetGameInfoByName(name string) *GameInfo { return nil } +func GetGameID(name string) int { + info := GetGameInfoByName(name) + if info != nil { + return info.GameID + } + + return -1 +} + +func GetGameIDOrPanic(name string) int { + id := GetGameID(name) + if id == -1 { + panic("Game not found: " + name) + } + + return id +} + func ReadGameList() { mutex.Lock() defer mutex.Unlock() diff --git a/common/gamespy_message.go b/common/gamespy_message.go index cae62db..b8d3d8d 100644 --- a/common/gamespy_message.go +++ b/common/gamespy_message.go @@ -73,7 +73,7 @@ func ParseGameSpyMessage(msg string) ([]GameSpyCommand, error) { func CreateGameSpyMessage(command GameSpyCommand) string { query := "" for k, v := range command.OtherValues { - query += fmt.Sprintf(`\%s\%s`, strings.Replace(k, `\`, ``, -1), strings.Replace(v, `\`, ``, -1)) + query += fmt.Sprintf(`\%s\%s`, k, v) } if command.Command != "" { diff --git a/common/mario_kart_wii.go b/common/mario_kart_wii.go index ad7f114..d9a4e72 100644 --- a/common/mario_kart_wii.go +++ b/common/mario_kart_wii.go @@ -1,110 +1,565 @@ package common -type MarioKartWiiRegionID int -type MarioKartWiiCourseID int +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "wwfc/logging" -const MarioKartWiiGameSpyGameID int = 1687 + "github.com/logrusorgru/aurora/v3" +) + +type MarioKartWiiLeaderboardRegionId int +type MarioKartWiiCourseId int +type MarioKartWiiCharacterId int +type MarioKartWiiVehicleId int +type MarioKartWiiControllerId int +type MarioKartWiiWeightClassId int +// MarioKartWiiLeaderboardRegionId const ( - Worldwide = iota // 0 - Japan = iota // 1 - UnitedStates = iota // 2 - Europe = iota // 3 - Australia = iota // 4 - Taiwan = iota // 5 - Korea = iota // 6 - China = iota // 7 + Worldwide = iota // 0x00 + Japan // 0x01 + UnitedStates // 0x02 + Europe // 0x03 + Australia // 0x04 + Taiwan // 0x05 + Korea // 0x06 + China // 0x07 ) +// MarioKartWiiCourseId const ( MarioCircuit = iota // 0x00 - MooMooMeadows = iota // 0x01 - MushroomGorge = iota // 0x02 - GrumbleVolcano = iota // 0x03 - ToadsFactory = iota // 0x04 - CoconutMall = iota // 0x05 - DKSummit = iota // 0x06 - WarioGoldMine = iota // 0x07 - LuigiCircuit = iota // 0x08 - DaisyCircuit = iota // 0x09 - MoonviewHighway = iota // 0x0A - MapleTreeway = iota // 0x0B - BowsersCastle = iota // 0x0C - RainbowRoad = iota // 0x0D - DryDryRuins = iota // 0x0E - KoopaCape = iota // 0x0F - GCNPeachBeach = iota // 0x10 - GCNMarioCircuit = iota // 0x11 - GCNWaluigiStadium = iota // 0x12 - GCNDKMountain = iota // 0x13 - DSYoshiFalls = iota // 0x14 - DSDesertHills = iota // 0x15 - DSPeachGardens = iota // 0x16 - DSDelfinoSquare = iota // 0x17 - SNESMarioCircuit3 = iota // 0x18 - SNESGhostValley2 = iota // 0x19 - N64MarioRaceway = iota // 0x1A - N64SherbetLand = iota // 0x1B - N64BowsersCastle = iota // 0x1C - N64DKsJungleParkway = iota // 0x1D - GBABowserCastle3 = iota // 0x1E - GBAShyGuyBeach = iota // 0x1F + MooMooMeadows // 0x01 + MushroomGorge // 0x02 + GrumbleVolcano // 0x03 + ToadsFactory // 0x04 + CoconutMall // 0x05 + DKSummit // 0x06 + WarioGoldMine // 0x07 + LuigiCircuit // 0x08 + DaisyCircuit // 0x09 + MoonviewHighway // 0x0A + MapleTreeway // 0x0B + BowsersCastle // 0x0C + RainbowRoad // 0x0D + DryDryRuins // 0x0E + KoopaCape // 0x0F + GCNPeachBeach // 0x10 + GCNMarioCircuit // 0x11 + GCNWaluigiStadium // 0x12 + GCNDKMountain // 0x13 + DSYoshiFalls // 0x14 + DSDesertHills // 0x15 + DSPeachGardens // 0x16 + DSDelfinoSquare // 0x17 + SNESMarioCircuit3 // 0x18 + SNESGhostValley2 // 0x19 + N64MarioRaceway // 0x1A + N64SherbetLand // 0x1B + N64BowsersCastle // 0x1C + N64DKsJungleParkway // 0x1D + GBABowserCastle3 // 0x1E + GBAShyGuyBeach // 0x1F +) + +// MarioKartWiiCharacterId +const ( + Mario = iota // 0x00 + BabyPeach // 0x01 + Waluigi // 0x02 + Bowser // 0x03 + BabyDaisy // 0x04 + DryBones // 0x05 + BabyMario // 0x06 + Luigi // 0x07 + Toad // 0x08 + DonkeyKong // 0x09 + Yoshi // 0x0A + Wario // 0x0B + BabyLuigi // 0x0C + Toadette // 0x0D + KoopaTroopa // 0x0E + Daisy // 0x0F + Peach // 0x10 + Birdo // 0x11 + DiddyKong // 0x12 + KingBoo // 0x13 + BowserJr // 0x14 + DryBowser // 0x15 + FunkyKong // 0x16 + Rosalina // 0x17 + SmallMiiOutfitAMale // 0x18 + SmallMiiOutfitAFemale // 0x19 + SmallMiiOutfitBMale // 0x1A + SmallMiiOutfitBFemale // 0x1B + SmallMiiOutfitCMale // 0x1C + SmallMiiOutfitCFemale // 0x1D + MediumMiiOutfitAMale // 0x1E + MediumMiiOutfitAFemale // 0x1F + MediumMiiOutfitBMale // 0x20 + MediumMiiOutfitBFemale // 0x21 + MediumMiiOutfitCMale // 0x22 + MediumMiiOutfitCFemale // 0x23 + LargeMiiOutfitAMale // 0x24 + LargeMiiOutfitAFemale // 0x25 + LargeMiiOutfitBMale // 0x26 + LargeMiiOutfitBFemale // 0x27 + LargeMiiOutfitCMale // 0x28 + LargeMiiOutfitCFemale // 0x29 ) -func (regionId MarioKartWiiRegionID) IsValid() bool { +// MarioKartWiiVehicleId +const ( + StandardKartSmall = iota // 0x00 + StandardKartMedium // 0x01 + StandardKartLarge // 0x02 + BoosterSeat // 0x03 + ClassicDragster // 0x04 + Offroader // 0x05 + MiniBeast // 0x06 + WildWing // 0x07 + FlameFlyer // 0x08 + CheepCharger // 0x09 + SuperBlooper // 0x0A + PiranhaProwler // 0x0B + TinyTitan // 0x0C + Daytripper // 0x0D + Jetsetter // 0x0E + BlueFalcon // 0x0F + Sprinter // 0x10 + Honeycoupe // 0x11 + StandardBikeSmall // 0x12 + StandardBikeMedium // 0x13 + StandardBikeLarge // 0x14 + BulletBike // 0x15 + MachBike // 0x16 + FlameRunner // 0x17 + BitBike // 0x18 + Sugarscoot // 0x19 + WarioBike // 0x1A + Quacker // 0x1B + ZipZip // 0x1C + ShootingStar // 0x1D + Magikruiser // 0x1E + Sneakster // 0x1F + Spear // 0x20 + JetBubble // 0x21 + DolphinDasher // 0x22 + Phantom // 0x23 +) + +// MarioKartWiiControllerId +const ( + WiiWheel = iota // 0x00 + WiiRemoteAndNunchuck // 0x01 + Classic // 0x02 + GameCube // 0x03 +) + +// MarioKartWiiWeightClassId +const ( + LightWeight = iota + MiddleWeight + HeavyWeight +) + +func (regionId MarioKartWiiLeaderboardRegionId) IsValid() bool { return regionId >= Worldwide && regionId <= China } -func (courseId MarioKartWiiCourseID) IsValid() bool { +func (courseId MarioKartWiiCourseId) IsValid() bool { return courseId >= MarioCircuit && courseId <= GBAShyGuyBeach } -func (regionId MarioKartWiiRegionID) ToString() string { - return [...]string{ - "Worldwide", - "Japan", - "United States", - "Europe", - "Australia", - "Taiwan", - "Korea", - "China", - }[regionId] -} - -func (courseId MarioKartWiiCourseID) ToString() string { - return [...]string{ - "Mario Circuit", - "Moo Moo Meadows", - "Mushroom Gorge", - "Grumble Volcano", - "Toad's Factory", - "Coconut Mall", - "DK Summit", - "Wario's Gold Mine", - "Luigi Circuit", - "Daisy Circuit", - "Moonview Highway", - "Maple Treeway", - "Bowser's Castle", - "Rainbow Road", - "Dry Dry Ruins", - "Koopa Cape", - "GCN Peach Beach", - "GCN Mario Circuit", - "GCN Waluigi Stadium", - "GCN DK Mountain", - "DS Yoshi Falls", - "DS Desert Hills", - "DS Peach Gardens", - "DS Delfino Square", - "SNES Mario Circuit 3", - "SNES Ghost Valley 2", - "N64 Mario Raceway", - "N64 Sherbet Land", - "N64 Bowser's Castle", - "N64 DK's Jungle Parkway", - "GBA Bowser Castle 3", - "GBA Shy Guy Beach", - }[courseId] +func (characterId MarioKartWiiCharacterId) IsValid() bool { + // Mii Outfit C is not allowed + if characterId == SmallMiiOutfitCMale || characterId == SmallMiiOutfitCFemale || characterId == MediumMiiOutfitCMale || characterId == MediumMiiOutfitCFemale { + return false + } + + return characterId >= Mario && characterId <= LargeMiiOutfitBFemale +} + +func (characterId MarioKartWiiCharacterId) GetWeightClass() MarioKartWiiWeightClassId { + switch characterId { + case BabyPeach, BabyDaisy, DryBones, BabyMario, Toad, BabyLuigi, Toadette, KoopaTroopa, SmallMiiOutfitAMale, SmallMiiOutfitAFemale, SmallMiiOutfitBMale, SmallMiiOutfitBFemale, SmallMiiOutfitCMale, SmallMiiOutfitCFemale: + return LightWeight + + case Mario, Luigi, Yoshi, Daisy, Peach, Birdo, DiddyKong, BowserJr, MediumMiiOutfitAMale, MediumMiiOutfitAFemale, MediumMiiOutfitBMale, MediumMiiOutfitBFemale, MediumMiiOutfitCMale, MediumMiiOutfitCFemale: + return MiddleWeight + + case Waluigi, Bowser, Wario, DonkeyKong, KingBoo, DryBowser, FunkyKong, Rosalina, LargeMiiOutfitAMale, LargeMiiOutfitAFemale, LargeMiiOutfitBMale, LargeMiiOutfitBFemale, LargeMiiOutfitCMale, LargeMiiOutfitCFemale: + return HeavyWeight + } + + return -1 +} + +func (vehicleId MarioKartWiiVehicleId) IsValid() bool { + return vehicleId >= StandardKartSmall && vehicleId <= Phantom +} + +func (vehicleId MarioKartWiiVehicleId) GetWeightClass() MarioKartWiiWeightClassId { + if vehicleId < 0 { + return -1 + } + + return MarioKartWiiWeightClassId(vehicleId % 3) +} + +func (controllerId MarioKartWiiControllerId) IsValid() bool { + return controllerId >= WiiWheel && controllerId <= GameCube +} + +type RKGhostData []byte + +const ( + RKGDFileMaxSize = 0x2800 + RKGDFileMinSize = 0x0088 + 0x0014 + 0x0004 +) + +func (rkgd RKGhostData) GetBits(byteOffset int, bitOffset int, bitLength int) uint32 { + if bitLength == 0 { + return 0 + } + + byteIndex := byteOffset + (bitOffset / 8) + bitIndex := bitOffset % 8 + byteCount := (bitLength + bitIndex + 7) / 8 + + var value uint32 + for i := 0; i < byteCount; i++ { + value |= uint32(rkgd[byteIndex+i]) << uint32((byteCount-i-1)*8) + } + + endBitIndex := (bitIndex + bitLength) % 8 + value >>= uint32(8-endBitIndex) % 8 + value &= (1 << uint32(bitLength)) - 1 + + return value +} + +func (rkgd RKGhostData) GetMinutes(lap int) int { + if lap == 0 { + return int(rkgd.GetBits(0x04, 0, 7)) + } + + if lap < 1 || lap > 5 { + return 0 + } + + return int(rkgd.GetBits(0x11+(lap-1)*3, 0, 7)) +} + +func (rkgd RKGhostData) GetSeconds(lap int) int { + if lap == 0 { + return int(rkgd.GetBits(0x04, 7, 7)) + } + + if lap < 1 || lap > 5 { + return 0 + } + + return int(rkgd.GetBits(0x11+(lap-1)*3, 7, 7)) +} + +func (rkgd RKGhostData) GetMilliseconds(lap int) int { + if lap == 0 { + return int(rkgd.GetBits(0x05, 6, 10)) + } + + if lap < 1 || lap > 5 { + return 0 + } + + return int(rkgd.GetBits(0x11+(lap-1)*3, 14, 10)) +} + +func (rkgd RKGhostData) GetTime(lap int) int { + return rkgd.GetMinutes(lap)*60000 + rkgd.GetSeconds(lap)*1000 + rkgd.GetMilliseconds(lap) +} + +func (rkgd RKGhostData) GetCourse() MarioKartWiiCourseId { + return MarioKartWiiCourseId(rkgd.GetBits(0x07, 0, 6)) +} + +func (rkgd RKGhostData) GetVehicle() MarioKartWiiVehicleId { + return MarioKartWiiVehicleId(rkgd.GetBits(0x08, 0, 6)) +} + +func (rkgd RKGhostData) GetCharacter() MarioKartWiiCharacterId { + return MarioKartWiiCharacterId(rkgd.GetBits(0x08, 6, 6)) +} + +func (rkgd RKGhostData) GetYear() int { + return int(rkgd.GetBits(0x09, 4, 7)) +} + +func (rkgd RKGhostData) GetMonth() int { + return int(rkgd.GetBits(0x0A, 3, 4)) +} + +func (rkgd RKGhostData) GetDay() int { + return int(rkgd.GetBits(0x0A, 7, 5)) +} + +func (rkgd RKGhostData) GetController() MarioKartWiiControllerId { + return MarioKartWiiControllerId(rkgd.GetBits(0x0B, 4, 4)) +} + +func (rkgd RKGhostData) IsCompressed() bool { + return rkgd.GetBits(0x0C, 4, 1) == 1 +} + +func (rkgd RKGhostData) GetGhostType() int { + return int(rkgd.GetBits(0x0C, 7, 7)) +} + +func (rkgd RKGhostData) GetDriftType() int { + return int(rkgd.GetBits(0x0D, 6, 1)) +} + +func (rkgd RKGhostData) GetInputDataLength() uint16 { + return uint16(rkgd.GetBits(0x0E, 0, 16)) +} + +func (rkgd RKGhostData) GetLapCount() int { + return int(rkgd.GetBits(0x10, 0, 8)) +} + +func (rkgd RKGhostData) GetCountryCode() byte { + return byte(rkgd.GetBits(0x34, 0, 8)) +} + +func (rkgd RKGhostData) GetStateCode() byte { + return byte(rkgd.GetBits(0x35, 0, 8)) +} + +func (rkgd RKGhostData) GetLocationCode() uint16 { + return uint16(rkgd.GetBits(0x36, 0, 16)) +} + +func (rkgd RKGhostData) GetMiiData() Mii { + return Mii(rkgd[0x3C : 0x3C+0x4C]) +} + +func (rkgd RKGhostData) GetCompressedSize() uint32 { + return binary.BigEndian.Uint32(rkgd[0x88:0x8C]) +} + +func (rkgd RKGhostData) GetCompressedData() []byte { + return []byte(rkgd[0x8C : len(rkgd)-4]) +} + +func (rkgd RKGhostData) IsRKGDFileValid(moduleName string, expectedCourse MarioKartWiiCourseId, expectedScore int) bool { + rkgdFileMagic := []byte{'R', 'K', 'G', 'D'} + rkgdFileLength := len(rkgd) + + if rkgdFileLength < RKGDFileMinSize || rkgdFileLength > RKGDFileMaxSize { + logging.Error(moduleName, "Invalid RKGD length:", aurora.Cyan(rkgdFileLength)) + return false + } + + if !bytes.Equal(rkgd[:4], rkgdFileMagic) { + logging.Error(moduleName, "Invalid RKGD magic:", aurora.Cyan(string(rkgd[:4]))) + return false + } + + expectedChecksum := binary.BigEndian.Uint32(rkgd[rkgdFileLength-4:]) + checksum := crc32.ChecksumIEEE(rkgd[:rkgdFileLength-4]) + + if checksum != expectedChecksum { + logging.Error(moduleName, "Invalid RKGD checksum:", aurora.Cyan(checksum), "expected:", aurora.Cyan(expectedChecksum)) + return false + } + + lapCount := rkgd.GetLapCount() + // This will need to be changed if/when we add support for tournament ghosts + // Always make sure this value <= 5 due to a stack buffer overflow in the game's code + if lapCount != 3 { + logging.Error(moduleName, "Invalid RKGD lap count:", aurora.Cyan(lapCount)) + return false + } + + lapScore := 0 + for lap := 0; lap <= lapCount; lap++ { + if rkgd.GetTime(lap) == 0 { + logging.Error(moduleName, "Zero RKGD time for lap:", aurora.Cyan(lap)) + return false + } + + if rkgd.GetMinutes(lap) > 5 || rkgd.GetSeconds(lap) > 59 || rkgd.GetMilliseconds(lap) > 999 { + logging.Error(moduleName, "Invalid RKGD time for lap m =", aurora.Cyan(rkgd.GetMinutes(lap)), "s =", aurora.Cyan(rkgd.GetSeconds(lap)), "ms =", aurora.Cyan(rkgd.GetMilliseconds(lap))) + return false + } + + if lap > 0 { + lapScore += rkgd.GetTime(lap) + } + } + + totalScore := rkgd.GetTime(0) + + if expectedScore != -1 && totalScore != expectedScore { + logging.Error(moduleName, "RKGD total score mismatch:", aurora.Cyan(rkgd.GetTime(0)), "expected:", aurora.Cyan(expectedScore)) + return false + } + + // We'll do a grace of ~1 millisecond for the lap score compared to the total score, + // in case there is a rounding error in the game's code + if lapScore+1 < totalScore || lapScore-1 > totalScore { + logging.Error(moduleName, "RKGD lap score mismatch:", aurora.Cyan(lapScore), "expected:", aurora.Cyan(expectedScore)) + return false + } + + if expectedCourse != -1 && rkgd.GetCourse() != expectedCourse { + logging.Error(moduleName, "RKGD course mismatch:", aurora.Cyan(rkgd.GetCourse()), "expected:", aurora.Cyan(expectedCourse)) + return false + } + + if !rkgd.GetCourse().IsValid() { + logging.Error(moduleName, "Invalid RKGD course:", aurora.Cyan(rkgd.GetCourse())) + return false + } + + if !rkgd.GetCharacter().IsValid() { + logging.Error(moduleName, "Invalid RKGD character:", aurora.Cyan(rkgd.GetCharacter())) + return false + } + + if !rkgd.GetVehicle().IsValid() { + logging.Error(moduleName, "Invalid RKGD vehicle:", aurora.Cyan(rkgd.GetVehicle())) + return false + } + + if rkgd.GetCharacter().GetWeightClass() != rkgd.GetVehicle().GetWeightClass() { + logging.Error(moduleName, "RKGD character/vehicle weight class mismatch: c =", aurora.Cyan(rkgd.GetCharacter()), "v =", aurora.Cyan(rkgd.GetVehicle())) + } + + if !rkgd.GetController().IsValid() { + logging.Error(moduleName, "Invalid RKGD controller:", aurora.Cyan(rkgd.GetController())) + return false + } + + if rkgd.GetMiiData().RFLCalculateCRC() != 0x0000 { + logging.Error(moduleName, "Invalid RKGD Mii data CRC") + return false + } + + // RKG uploaded to the server must be SZS (Yaz1) compressed + if !rkgd.IsCompressed() { + logging.Error(moduleName, "RKGD is not compressed") + return false + } + + szsData := rkgd.GetCompressedData() + + if string(szsData[:0x4]) != "Yaz1" { + logging.Error(moduleName, "Invalid Yaz1 magic:", aurora.Cyan(string(szsData[:4]))) + return false + } + + decompSize := binary.BigEndian.Uint32(szsData[0x4:0x8]) + if uint32(rkgd.GetInputDataLength()) != decompSize { + logging.Error(moduleName, "Invalid RKGD input data length:", aurora.Cyan(rkgd.GetInputDataLength()), "actual:", aurora.Cyan(len(szsData))) + } + + if rkgd.GetCompressedSize() != uint32(len(szsData)) { + logging.Error(moduleName, "Invalid RKGD compressed size:", aurora.Cyan(rkgd.GetCompressedSize()), "actual:", aurora.Cyan(len(szsData))) + } + + if !bytes.Equal(szsData[0x8:0x10], []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) { + logging.Error(moduleName, "Invalid SZS header padding") + } + + valid, consumed := VerifyYaz1Data(moduleName, szsData[0x10:], int(decompSize), 0) + + if !valid { + return false + } + + if consumed+3 < len(szsData)-0x10 { + logging.Error(moduleName, "Too much padding at end of RKGD") + return false + } + + return true +} + +// Verify that SZS compressed data fits the standard. A buffer overflow bug is the basis for a critical RCE vulnerability in the game (szsHaxx). +func VerifyYaz1Data(moduleName string, szsData []byte, expectedDecompSize int, decoded int) (bool, int) { + i := 0 + for decoded < expectedDecompSize { + if i >= len(szsData) { + logging.Error(moduleName, "Yaz1: Unexpected end of data") + return false, i + } + + flags := szsData[i] + i++ + + // This happens a lot, so might as well check for the sake of performance + if flags == 0xFF { + decoded += 8 + i += 8 + continue + } + + for j := 0; j < 8; j++ { + if flags&0x80 == 0 { + if i+1 >= len(szsData) { + logging.Error(moduleName, "Yaz1: Unexpected end of data") + return false, i + } + + copyLen := (szsData[i] >> 4) + 2 + + copySrc := (int(szsData[i])&0x0F)<<8 | int(szsData[i+1]) + copySrc = decoded - copySrc - 1 + + i += 2 + + if copySrc < 0 { + logging.Error(moduleName, "Yaz1: Copy source is out of bounds") + return false, i + } + + if copyLen == 2 { + if i >= len(szsData) { + logging.Error(moduleName, "Yaz1: Unexpected end of data") + return false, i + } + + copyLen = szsData[i] + 0x12 + i += 1 + } + + decoded += int(copyLen) + } else { + decoded++ + i++ + } + + if decoded >= expectedDecompSize { + break + } + + flags <<= 1 + } + + } + + if decoded > expectedDecompSize { + logging.Error(moduleName, "Yaz1: Overran expected decompressed size") + return false, i + } + + if i > len(szsData) { + logging.Error(moduleName, "Yaz1: Unexpected end of data") + return false, i + } + + return true, i } diff --git a/common/mii.go b/common/mii.go index f3ea12a..2147cef 100644 --- a/common/mii.go +++ b/common/mii.go @@ -4,7 +4,9 @@ package common // https://wiibrew.org/wiki/Mii_Data // https://github.com/kiwi515/ogws/tree/master/src/RVLFaceLib -func RFLCalculateCRC(data []byte) uint16 { +type Mii [0x4C]byte + +func (data Mii) RFLCalculateCRC() uint16 { crc := uint16(0) for _, val := range data { diff --git a/database/mario_kart_wii.go b/database/mario_kart_wii.go new file mode 100644 index 0000000..9db7667 --- /dev/null +++ b/database/mario_kart_wii.go @@ -0,0 +1,135 @@ +package database + +import ( + "context" + "wwfc/common" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type MarioKartWiiTopTenRanking struct { + Score int + PID int + PlayerInfo string +} + +const ( + getTopTenRankingsQuery = "" + + "SELECT score, pid, playerinfo " + + "FROM mario_kart_wii_sake " + + "WHERE ($1 = 0 OR regionid = $1) " + + "AND courseid = $2 " + + "ORDER BY score ASC " + + "LIMIT 10" + getGhostDataQuery = "" + + "SELECT id " + + "FROM mario_kart_wii_sake " + + "WHERE courseid = $1 " + + "AND score < $2 " + + "ORDER BY score DESC " + + "LIMIT 1" + getStoredGhostDataQuery = "" + + "SELECT pid, id " + + "FROM mario_kart_wii_sake " + + "WHERE ($1 = 0 OR regionid = $1) " + + "AND courseid = $2 " + + "ORDER BY score ASC " + + "LIMIT 1" + getFileQuery = "" + + "SELECT ghost " + + "FROM mario_kart_wii_sake " + + "WHERE id = $1 " + + "LIMIT 1" + getGhostFileQuery = "" + + "SELECT ghost " + + "FROM mario_kart_wii_sake " + + "WHERE courseid = $1 " + + "AND score < $2 " + + "AND pid <> $3 " + + "ORDER BY score DESC " + + "LIMIT 1" + insertGhostFileStatement = "" + + "INSERT INTO mario_kart_wii_sake (regionid, courseid, score, pid, playerinfo, ghost, upload_time) " + + "VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) " + + "ON CONFLICT (courseid, pid) DO UPDATE " + + "SET regionid = EXCLUDED.regionid, score = EXCLUDED.score, playerinfo = EXCLUDED.playerinfo, ghost = EXCLUDED.ghost, upload_time = CURRENT_TIMESTAMP" +) + +func GetMarioKartWiiTopTenRankings(pool *pgxpool.Pool, ctx context.Context, regionId common.MarioKartWiiLeaderboardRegionId, + courseId common.MarioKartWiiCourseId) ([]MarioKartWiiTopTenRanking, error) { + rows, err := pool.Query(ctx, getTopTenRankingsQuery, regionId, courseId) + if err != nil { + return nil, err + } + defer rows.Close() + + topTenRankings := make([]MarioKartWiiTopTenRanking, 0, 10) + for rows.Next() { + var topTenRanking MarioKartWiiTopTenRanking + err = rows.Scan(&topTenRanking.Score, &topTenRanking.PID, &topTenRanking.PlayerInfo) + if err != nil { + return nil, err + } + + topTenRankings = append(topTenRankings, topTenRanking) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return topTenRankings, nil +} + +func GetMarioKartWiiGhostData(pool *pgxpool.Pool, ctx context.Context, courseId common.MarioKartWiiCourseId, time int) (int, error) { + row := pool.QueryRow(ctx, getGhostDataQuery, courseId, time) + + var fileId int + if err := row.Scan(&fileId); err != nil { + return 0, err + } + + return fileId, nil +} + +func GetMarioKartWiiStoredGhostData(pool *pgxpool.Pool, ctx context.Context, regionId common.MarioKartWiiLeaderboardRegionId, + courseId common.MarioKartWiiCourseId) (int, int, error) { + row := pool.QueryRow(ctx, getStoredGhostDataQuery, regionId, courseId) + + var pid int + var fileId int + if err := row.Scan(&pid, &fileId); err != nil { + return 0, 0, err + } + + return pid, fileId, nil +} + +func GetMarioKartWiiFile(pool *pgxpool.Pool, ctx context.Context, fileId int) ([]byte, error) { + row := pool.QueryRow(ctx, getFileQuery, fileId) + + var file []byte + if err := row.Scan(&file); err != nil { + return nil, err + } + + return file, nil +} + +func GetMarioKartWiiGhostFile(pool *pgxpool.Pool, ctx context.Context, courseId common.MarioKartWiiCourseId, + time int, pid int) ([]byte, error) { + row := pool.QueryRow(ctx, getGhostFileQuery, courseId, time, pid) + + var ghost []byte + if err := row.Scan(&ghost); err != nil { + return nil, err + } + + return ghost, nil +} + +func InsertMarioKartWiiGhostFile(pool *pgxpool.Pool, ctx context.Context, regionId common.MarioKartWiiLeaderboardRegionId, + courseId common.MarioKartWiiCourseId, score int, pid int, playerInfo string, ghost []byte) error { + _, err := pool.Exec(ctx, insertGhostFileStatement, regionId, courseId, score, pid, playerInfo, ghost) + + return err +} diff --git a/database/schema.go b/database/schema.go index 2f7e26f..ddc9088 100644 --- a/database/schema.go +++ b/database/schema.go @@ -8,16 +8,26 @@ import ( func UpdateTables(pool *pgxpool.Pool, ctx context.Context) { pool.Exec(ctx, ` -ALTER TABLE ONLY public.users - ADD IF NOT EXISTS last_ip_address character varying DEFAULT ''::character varying, - ADD IF NOT EXISTS last_ingamesn character varying DEFAULT ''::character varying, - ADD IF NOT EXISTS has_ban boolean DEFAULT false, - ADD IF NOT EXISTS ban_issued timestamp without time zone, - ADD IF NOT EXISTS ban_expires timestamp without time zone, - ADD IF NOT EXISTS ban_reason character varying, - ADD IF NOT EXISTS ban_reason_hidden character varying, - ADD IF NOT EXISTS ban_moderator character varying, - ADD IF NOT EXISTS ban_tos boolean, - ADD IF NOT EXISTS open_host boolean DEFAULT false -`) + + ALTER TABLE ONLY public.users + ADD IF NOT EXISTS last_ip_address character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS last_ingamesn character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS has_ban boolean DEFAULT false, + ADD IF NOT EXISTS ban_issued timestamp without time zone, + ADD IF NOT EXISTS ban_expires timestamp without time zone, + ADD IF NOT EXISTS ban_reason character varying, + ADD IF NOT EXISTS ban_reason_hidden character varying, + ADD IF NOT EXISTS ban_moderator character varying, + ADD IF NOT EXISTS ban_tos boolean, + ADD IF NOT EXISTS open_host boolean DEFAULT false; + + `) + + pool.Exec(ctx, ` + + ALTER TABLE ONLY public.mario_kart_wii_sake + ADD IF NOT EXISTS id serial PRIMARY KEY, + ADD IF NOT EXISTS upload_time timestamp without time zone; + + `) } diff --git a/game_list.tsv b/game_list.tsv index f7a50d0..b893cc2 100644 --- a/game_list.tsv +++ b/game_list.tsv @@ -2427,7 +2427,7 @@ Metroid Prime 3 (Wii) metprime3wii 1740 i8sP5E Metroid Prime Hunters (DS) mprimeds 1272 Dh1PpC Mevo and The Grooveriders mevoatgrooveriders Mezase!! Tsuri Master 2 (Wii) mezasetm2wii 2003 hfBxDP -Mezase!! Tsuri Master DS (DS) tsurimasterds 2385 BM8WEh +Mezase!! Tsuri Master DS (DS) tsurimasterds 2385 BM8WEh 1 eLmuLWNcNWTvIhWmlzld Mic Chat Channel (Wii) micchannelwii 2253 wkvBfX MicroMan's Crazy Computers CrazyComputers Microsoft Combat Flight Simulator cfs 234 ydxbnf @@ -2786,7 +2786,7 @@ One Must Fall Battlegrounds omfbattle 325 Abm93d One Must Fall Battlegrounds omfbattleb 810 Abm93d One Must Fall Battlegrounds (GMX) omfbattlecp 888 Abm93d One Must Fall Battlergounds Demo omfbattled 785 Abm93d -One Piece (DS) onepiece2ds l4iS9L +One Piece (DS) onepiece2ds l4iS9L 1 qnfrnyeLsIJEpjDzdQui Onslaught: War of the Immortals onslaughtpc 1710 8pLvHm Onslaught: War of the Immortals Automatch onslaughtpcam 1711 8pLvHm Onslaught: War of the Immortals Demo onslaughtpcd 1712 8pLvHm @@ -2970,10 +2970,11 @@ Potion Bar potionbar PowaProkun Pocket10 (DS) PowaPPocketds 1655 Mz5dau Power of Defense PowerOfDefense PowerPro Pocket Koshien 2 (DS) koshien2ds 1320 UKdPFf -PowerPro-kun Pocket 11 (DS) ppkpocket11ds 2217 cstLhz -PowerPro-kun Pocket 12 (DS) propocket12ds 2660 98gFV2 -PowerPro-kun Pocket 13 Automatch (DSi) powprokundsiam ntDCsp -PowerPro-kun Pocket 13 (DSi) powprokundsi ntDCsp +PowerPro-kun Pocket 11 (DS) ppkpocket11ds 2217 cstLhz 1 tsEmtoKXfVmWQRRBvxjR +PowerPro-kun Pocket 12 (DS) propocket12ds 2660 98gFV2 1 ClqBGWPFAiCooeEmjsox +PowerPro-kun Pocket 13 Automatch (DSi) powprokundsiam ntDCsp 1 MGciIieQOsydTHfStgYm +PowerPro-kun Pocket 13 (DSi) powprokundsi ntDCsp 1 MGciIieQOsydTHfStgYm +PowerPro-kun Pocket 14 (DSi) ppropocket14 L1AZBB 1 EGZWUsLRPnbDyXwWWuPl Powerful Koushien (DS) powerkoushds 2827 nTHkC7 1 wcAjDpxBYoHFaLVsEEiA Powershot Pinball Constructor (DS) powerpinconds 1646 za0kET Powerslide powerslide 177 nx6I2v @@ -3403,7 +3404,7 @@ Saint's Row 2 (Xbox 360) srow2xb360 2112 XfwkNR Saint's Row 2 Automatch srow2ps3am 2109 iWKxFZ Saints Row 2 (Digital) srow2digital Sakatsuku DS (DS) sakatsukuds 2211 OwGy5R -Sakatsuku DS WorldChallenge 2010 (DS) sakwcha2010ds 2943 a92bdC +Sakatsuku DS WorldChallenge 2010 (DS) sakwcha2010ds 2943 a92bdC 1 BLQAIzYFxQbwjvvzcuvi Sakura Taisen Dramatic Dungeon - Kimiarugatame (DS) sakuraTDDds 1844 Kw2K7t Samantha Swift and the Golden Touch samanthaswiftandth Samantha Swift and the Mystery from Atlantis samanthaswiftandtm @@ -4214,7 +4215,7 @@ Total Annihilation: Kingdoms taking 232 p5kGg7 Total War: SHOGUN 2 s_TotalWarShogun2 Touch Panic (DS) touchpanicds 1353 zHToa5 Touch! Bomberman Land 2 / Bomberman DS 2 (DS) bombls2ds 1694 8BuVqr 1 lUJtkWkDXhZtZvMWphlm -TouchMaster 4: Megatouch Edition (DSi) touchmast4dsi TGJei2 +TouchMaster 4: Megatouch Edition (DSi) touchmast4dsi TGJei2 1 WshHovJkGouinxUGFIdy Toy Story 3 toystory3 TrackMania Nations Forever tmnations TrackMania United Forever tmuforever diff --git a/gamestats/getpd.go b/gamestats/getpd.go index 944a683..1c9a7b5 100644 --- a/gamestats/getpd.go +++ b/gamestats/getpd.go @@ -7,6 +7,9 @@ import ( ) func (g *GameStatsSession) getpd(command common.GameSpyCommand) { + // Temporary empty data, it's an embedded gamespy \key\value message excluding \final\ + data := `\\` + g.Write(common.GameSpyCommand{ Command: "getpdr", CommandValue: "1", @@ -14,8 +17,8 @@ func (g *GameStatsSession) getpd(command common.GameSpyCommand) { "lid": strconv.Itoa(g.LoginID), "pid": command.OtherValues["pid"], "mod": strconv.Itoa(int(time.Now().Unix())), - "length": "0", - "data": `\\`, + "length": strconv.Itoa(len(data) + 1), + "data": `\` + data + `\`, }, }) } diff --git a/gpcm/report.go b/gpcm/report.go index 3df6a8c..8a174b0 100644 --- a/gpcm/report.go +++ b/gpcm/report.go @@ -77,6 +77,14 @@ func (g *GameSpySession) handleWWFCReport(command common.GameSpyCommand) { profileId := g.User.ProfileId logging.Warn(g.ModuleName, "Kicking", aurora.BrightCyan(strconv.FormatUint(uint64(profileId), 10)), fmt.Sprintf("for dropping too many frames (%d)", framesDropped)) kickPlayer(profileId, "too_many_frames_dropped") + + case "mkw_select_course", "mkw_select_cc": + if g.GameName != "mariokartwii" { + logging.Warn(g.ModuleName, "Ignoring mkw_select_* from wrong game") + continue + } + + qr2.ProcessMKWSelectRecord(g.User.ProfileId, key, value) } } } diff --git a/qr2/group.go b/qr2/group.go index 03e2f5c..bcb3b3e 100644 --- a/qr2/group.go +++ b/qr2/group.go @@ -6,7 +6,6 @@ import ( "encoding/gob" "fmt" "os" - "sort" "strconv" "strings" "time" @@ -26,6 +25,10 @@ type Group struct { LastJoinIndex int server *Session players map[*Session]bool + + MKWRaceNumber int + MKWCourseID int + MKWEngineClassID int } var groups = map[string]*Group{} @@ -390,7 +393,8 @@ func ProcessUSER(senderPid uint32, senderIP uint64, packet []byte) { } index := 0x08 + i*0x4C - if common.RFLCalculateCRC(packet[index:index+0x4C]) != 0x0000 { + mii := common.Mii(packet[index : index+0x4C]) + if mii.RFLCalculateCRC() != 0x0000 { logging.Error(moduleName, "Received USER packet with invalid Mii data CRC") gpErrorCallback(senderPid, "malpacket") return @@ -459,204 +463,64 @@ func (g *Group) updateMatchType() { g.MatchType = g.server.Data["dwc_mtype"] } -type MiiInfo struct { - MiiData string `json:"data"` - MiiName string `json:"name"` -} - -type PlayerInfo struct { - Count string `json:"count"` - ProfileID string `json:"pid"` - InGameName string `json:"name"` - ConnMap string `json:"conn_map"` - ConnFail string `json:"conn_fail"` - Suspend string `json:"suspend"` - - // Mario Kart Wii-specific fields - FriendCode string `json:"fc,omitempty"` - VersusELO string `json:"ev,omitempty"` - BattleELO string `json:"eb,omitempty"` - Mii []MiiInfo `json:"mii,omitempty"` -} - -type GroupInfo struct { - GroupName string `json:"id"` - GameName string `json:"game"` - CreateTime time.Time `json:"created"` - MatchType string `json:"type"` - Suspend bool `json:"suspend"` - ServerIndex string `json:"host,omitempty"` - MKWRegion string `json:"rk,omitempty"` - Players map[string]PlayerInfo `json:"players"` - - PlayersRaw map[string]map[string]string `json:"-"` - SortedJoinIndex []string `json:"-"` -} - -func getGroupsRaw(gameNames []string, groupNames []string) []GroupInfo { - var groupsCopy []GroupInfo +func ProcessMKWSelectRecord(profileId uint32, key string, value string) { + moduleName := "QR2:MKWSelectRecord:" + strconv.FormatUint(uint64(profileId), 10) mutex.Lock() - defer mutex.Unlock() - - for _, group := range groups { - if len(gameNames) > 0 && !common.StringInSlice(group.GameName, gameNames) { - continue - } - - if len(groupNames) > 0 && !common.StringInSlice(group.GroupName, groupNames) { - continue - } - - groupInfo := GroupInfo{ - GroupName: group.GroupName, - GameName: group.GameName, - CreateTime: group.CreateTime.UTC(), - MatchType: "", - Suspend: true, - ServerIndex: "", - MKWRegion: "", - Players: map[string]PlayerInfo{}, - PlayersRaw: map[string]map[string]string{}, - SortedJoinIndex: []string{}, - } + login := logins[profileId] + if login == nil { + mutex.Unlock() + logging.Warn(moduleName, "Received SELECT record from non-existent profile ID", aurora.Cyan(profileId)) + return + } - if group.MatchType == "0" || group.MatchType == "1" { - groupInfo.MatchType = "anybody" - } else if group.MatchType == "2" || group.MatchType == "3" { - groupInfo.MatchType = "private" - } else { - groupInfo.MatchType = "unknown" - } + session := login.session + if session == nil { + mutex.Unlock() + logging.Warn(moduleName, "Received SELECT record from profile ID", aurora.Cyan(profileId), "but no session exists") + return + } + mutex.Unlock() - if group.server != nil { - groupInfo.ServerIndex = group.server.Data["+joinindex"] - } + group := session.groupPointer + if group == nil { + return + } - if groupInfo.GameName == "mariokartwii" { - groupInfo.MKWRegion = group.MKWRegion + switch key { + case "mkw_select_course": + courseId, err := strconv.ParseUint(value, 10, 32) + if err != nil { + logging.Error(moduleName, "Error decoding mkw_select_course:", err.Error()) + return } - for session := range group.players { - mapData := map[string]string{} - for k, v := range session.Data { - mapData[k] = v - } - - if login := session.login; login != nil { - mapData["+ingamesn"] = login.InGameName - } else { - mapData["+ingamesn"] = "" - } - - groupInfo.PlayersRaw[mapData["+joinindex"]] = mapData + logging.Info(moduleName, "Selected course", aurora.BrightCyan(strconv.FormatUint(courseId, 10))) - if mapData["dwc_hoststate"] == "2" && mapData["dwc_suspend"] == "0" { - groupInfo.Suspend = false - } + mutex.Lock() + defer mutex.Unlock() - // Add the join index to the sorted list - myJoinIndex, _ := strconv.Atoi(mapData["+joinindex"]) - added := false - - for i, joinIndex := range groupInfo.SortedJoinIndex { - if joinIndex == mapData["+joinindex"] { - added = true - break - } - - intJoinIndex, _ := strconv.Atoi(joinIndex) - if intJoinIndex > myJoinIndex { - groupInfo.SortedJoinIndex = append(groupInfo.SortedJoinIndex, "") - copy(groupInfo.SortedJoinIndex[i+1:], groupInfo.SortedJoinIndex[i:]) - groupInfo.SortedJoinIndex[i] = mapData["+joinindex"] - added = true - break - } - } + group.MKWRaceNumber++ + group.MKWCourseID = int(courseId) + group.MKWEngineClassID = -1 + return - if !added { - groupInfo.SortedJoinIndex = append(groupInfo.SortedJoinIndex, mapData["+joinindex"]) - } + case "mkw_select_cc": + ccId, err := strconv.ParseUint(value, 10, 32) + if err != nil { + logging.Error(moduleName, "Error decoding mkw_select_cc:", err.Error()) + return } - groupsCopy = append(groupsCopy, groupInfo) - } - - return groupsCopy -} - -// GetGroups returns a copy of all online rooms -func GetGroups(gameNames []string, groupNames []string, sorted bool) []GroupInfo { - groupsCopy := getGroupsRaw(gameNames, groupNames) - - for i, group := range groupsCopy { - for joinIndex, rawPlayer := range group.PlayersRaw { - playerInfo := PlayerInfo{ - Count: rawPlayer["+localplayers"], - ProfileID: rawPlayer["dwc_pid"], - InGameName: rawPlayer["+ingamesn"], - } - - pid, err := strconv.ParseUint(rawPlayer["dwc_pid"], 10, 32) - if err == nil { - if fcGame := rawPlayer["+fcgameid"]; len(fcGame) == 4 { - playerInfo.FriendCode = common.CalcFriendCodeString(uint32(pid), fcGame) - } - } - - if rawPlayer["gamename"] == "mariokartwii" { - playerInfo.VersusELO = rawPlayer["ev"] - playerInfo.BattleELO = rawPlayer["eb"] - } - - for i := 0; i < 32; i++ { - miiData := rawPlayer["+mii"+strconv.Itoa(i)] - if miiData == "" { - continue - } - - playerInfo.Mii = append(playerInfo.Mii, MiiInfo{ - MiiData: miiData, - MiiName: rawPlayer["+mii_name"+strconv.Itoa(i)], - }) - } - - for _, newIndex := range group.SortedJoinIndex { - if newIndex == joinIndex { - continue - } - - if rawPlayer["+conn_"+newIndex] == "" { - playerInfo.ConnMap += "0" - continue - } - - playerInfo.ConnMap += rawPlayer["+conn_"+newIndex] - } - - playerInfo.ConnFail = rawPlayer["+conn_fail"] - if playerInfo.ConnFail == "" { - playerInfo.ConnFail = "0" - } - - playerInfo.Suspend = rawPlayer["dwc_suspend"] + logging.Info(moduleName, "Selected CC", aurora.BrightCyan(strconv.FormatUint(ccId, 10))) - groupsCopy[i].Players[joinIndex] = playerInfo - } - } - - if sorted { - sort.Slice(groupsCopy, func(i, j int) bool { - if groupsCopy[i].CreateTime.Equal(groupsCopy[j].CreateTime) { - return groupsCopy[i].GroupName < groupsCopy[j].GroupName - } + mutex.Lock() + defer mutex.Unlock() - return groupsCopy[i].CreateTime.Before(groupsCopy[j].CreateTime) - }) + group.MKWEngineClassID = int(ccId) + return } - return groupsCopy } // saveGroups saves the current groups state to disk. diff --git a/qr2/group_info.go b/qr2/group_info.go new file mode 100644 index 0000000..ad3c592 --- /dev/null +++ b/qr2/group_info.go @@ -0,0 +1,224 @@ +package qr2 + +import ( + "sort" + "strconv" + "time" + "wwfc/common" +) + +type MiiInfo struct { + MiiData string `json:"data"` + MiiName string `json:"name"` +} + +type PlayerInfo struct { + Count string `json:"count"` + ProfileID string `json:"pid"` + InGameName string `json:"name"` + ConnMap string `json:"conn_map"` + ConnFail string `json:"conn_fail"` + Suspend string `json:"suspend"` + + // Mario Kart Wii-specific fields + FriendCode string `json:"fc,omitempty"` + VersusELO string `json:"ev,omitempty"` + BattleELO string `json:"eb,omitempty"` + Mii []MiiInfo `json:"mii,omitempty"` +} + +type GroupInfo struct { + GroupName string `json:"id"` + GameName string `json:"game"` + CreateTime time.Time `json:"created"` + MatchType string `json:"type"` + Suspend bool `json:"suspend"` + ServerIndex string `json:"host,omitempty"` + MKWRegion string `json:"rk,omitempty"` + + Players map[string]PlayerInfo `json:"players"` + RaceInfo *RaceInfo `json:"race,omitempty"` + + PlayersRaw map[string]map[string]string `json:"-"` + SortedJoinIndex []string `json:"-"` +} + +type RaceInfo struct { + RaceNumber int `json:"num"` + CourseID int `json:"course"` + EngineClassID int `json:"cc"` +} + +func getGroupsRaw(gameNames []string, groupNames []string) []GroupInfo { + var groupsCopy []GroupInfo + + mutex.Lock() + defer mutex.Unlock() + + for _, group := range groups { + if len(gameNames) > 0 && !common.StringInSlice(group.GameName, gameNames) { + continue + } + + if len(groupNames) > 0 && !common.StringInSlice(group.GroupName, groupNames) { + continue + } + + groupInfo := GroupInfo{ + GroupName: group.GroupName, + GameName: group.GameName, + CreateTime: group.CreateTime.UTC(), + MatchType: "", + Suspend: true, + ServerIndex: "", + MKWRegion: "", + Players: map[string]PlayerInfo{}, + PlayersRaw: map[string]map[string]string{}, + SortedJoinIndex: []string{}, + } + + if group.MatchType == "0" || group.MatchType == "1" { + groupInfo.MatchType = "anybody" + } else if group.MatchType == "2" || group.MatchType == "3" { + groupInfo.MatchType = "private" + } else { + groupInfo.MatchType = "unknown" + } + + if group.server != nil { + groupInfo.ServerIndex = group.server.Data["+joinindex"] + } + + if groupInfo.GameName == "mariokartwii" { + groupInfo.MKWRegion = group.MKWRegion + + if group.MKWRaceNumber != 0 { + groupInfo.RaceInfo = &RaceInfo{ + RaceNumber: group.MKWRaceNumber, + CourseID: group.MKWCourseID, + EngineClassID: group.MKWEngineClassID, + } + } + } + + for session := range group.players { + mapData := map[string]string{} + for k, v := range session.Data { + mapData[k] = v + } + + if login := session.login; login != nil { + mapData["+ingamesn"] = login.InGameName + } else { + mapData["+ingamesn"] = "" + } + + groupInfo.PlayersRaw[mapData["+joinindex"]] = mapData + + if mapData["dwc_hoststate"] == "2" && mapData["dwc_suspend"] == "0" { + groupInfo.Suspend = false + } + + // Add the join index to the sorted list + myJoinIndex, _ := strconv.Atoi(mapData["+joinindex"]) + added := false + + for i, joinIndex := range groupInfo.SortedJoinIndex { + if joinIndex == mapData["+joinindex"] { + added = true + break + } + + intJoinIndex, _ := strconv.Atoi(joinIndex) + if intJoinIndex > myJoinIndex { + groupInfo.SortedJoinIndex = append(groupInfo.SortedJoinIndex, "") + copy(groupInfo.SortedJoinIndex[i+1:], groupInfo.SortedJoinIndex[i:]) + groupInfo.SortedJoinIndex[i] = mapData["+joinindex"] + added = true + break + } + } + + if !added { + groupInfo.SortedJoinIndex = append(groupInfo.SortedJoinIndex, mapData["+joinindex"]) + } + } + + groupsCopy = append(groupsCopy, groupInfo) + } + + return groupsCopy +} + +// GetGroups returns a copy of all online rooms +func GetGroups(gameNames []string, groupNames []string, sorted bool) []GroupInfo { + groupsCopy := getGroupsRaw(gameNames, groupNames) + + for i, group := range groupsCopy { + for joinIndex, rawPlayer := range group.PlayersRaw { + playerInfo := PlayerInfo{ + Count: rawPlayer["+localplayers"], + ProfileID: rawPlayer["dwc_pid"], + InGameName: rawPlayer["+ingamesn"], + } + + pid, err := strconv.ParseUint(rawPlayer["dwc_pid"], 10, 32) + if err == nil { + if fcGame := rawPlayer["+fcgameid"]; len(fcGame) == 4 { + playerInfo.FriendCode = common.CalcFriendCodeString(uint32(pid), fcGame) + } + } + + if rawPlayer["gamename"] == "mariokartwii" { + playerInfo.VersusELO = rawPlayer["ev"] + playerInfo.BattleELO = rawPlayer["eb"] + } + + for i := 0; i < 32; i++ { + miiData := rawPlayer["+mii"+strconv.Itoa(i)] + if miiData == "" { + continue + } + + playerInfo.Mii = append(playerInfo.Mii, MiiInfo{ + MiiData: miiData, + MiiName: rawPlayer["+mii_name"+strconv.Itoa(i)], + }) + } + + for _, newIndex := range group.SortedJoinIndex { + if newIndex == joinIndex { + continue + } + + if rawPlayer["+conn_"+newIndex] == "" { + playerInfo.ConnMap += "0" + continue + } + + playerInfo.ConnMap += rawPlayer["+conn_"+newIndex] + } + + playerInfo.ConnFail = rawPlayer["+conn_fail"] + if playerInfo.ConnFail == "" { + playerInfo.ConnFail = "0" + } + + playerInfo.Suspend = rawPlayer["dwc_suspend"] + + groupsCopy[i].Players[joinIndex] = playerInfo + } + } + + if sorted { + sort.Slice(groupsCopy, func(i, j int) bool { + if groupsCopy[i].CreateTime.Equal(groupsCopy[j].CreateTime) { + return groupsCopy[i].GroupName < groupsCopy[j].GroupName + } + + return groupsCopy[i].CreateTime.Before(groupsCopy[j].CreateTime) + }) + } + + return groupsCopy +} diff --git a/race/main.go b/race/main.go index b0c919d..790d697 100644 --- a/race/main.go +++ b/race/main.go @@ -1,14 +1,39 @@ package race import ( + "context" + "fmt" "net/http" - "path" + "strings" + "wwfc/common" "wwfc/logging" + "github.com/jackc/pgx/v4/pgxpool" "github.com/logrusorgru/aurora/v3" ) +var ( + ctx = context.Background() + pool *pgxpool.Pool +) + func StartServer(reload bool) { + // Get config + config := common.GetConfig() + + common.ReadGameList() + + // Start SQL + dbString := fmt.Sprintf("postgres://%s:%s@%s/%s", config.Username, config.Password, config.DatabaseAddress, config.DatabaseName) + dbConf, err := pgxpool.ParseConfig(dbString) + if err != nil { + panic(err) + } + + pool, err = pgxpool.ConnectConfig(ctx, dbConf) + if err != nil { + panic(err) + } } func Shutdown() { @@ -17,8 +42,8 @@ func Shutdown() { func HandleRequest(responseWriter http.ResponseWriter, request *http.Request) { logging.Info("RACE", aurora.Yellow(request.Method), aurora.Cyan(request.URL), "via", aurora.Cyan(request.Host), "from", aurora.BrightCyan(request.RemoteAddr)) - switch path.Base(request.URL.Path) { - case "NintendoRacingService.asmx": + switch { + case strings.HasSuffix(request.URL.Path, "NintendoRacingService.asmx"): moduleName := "RACE:RacingService:" + request.RemoteAddr handleNintendoRacingServiceRequest(moduleName, responseWriter, request) } diff --git a/race/nintendo_racing_service.go b/race/nintendo_racing_service.go index d8e7002..3878ee6 100644 --- a/race/nintendo_racing_service.go +++ b/race/nintendo_racing_service.go @@ -5,7 +5,9 @@ import ( "io" "net/http" "strconv" + "strings" "wwfc/common" + "wwfc/database" "wwfc/logging" "github.com/logrusorgru/aurora/v3" @@ -21,17 +23,17 @@ type rankingsRequestBody struct { type rankingsRequestData struct { XMLName xml.Name - GameId int `xml:"gameid"` - RegionId common.MarioKartWiiRegionID `xml:"regionid"` - CourseId common.MarioKartWiiCourseID `xml:"courseid"` + GameId int `xml:"gameid"` + RegionId common.MarioKartWiiLeaderboardRegionId `xml:"regionid"` + CourseId common.MarioKartWiiCourseId `xml:"courseid"` } type rankingsResponseRankingDataResponse struct { - XMLName xml.Name `xml:"RankingDataResponse"` - XMLNSXsi string `xml:"xmlns:xsi,attr"` - XMLNSXsd string `xml:"xmlns:xsd,attr"` - XMLNS string `xml:"xmlns,attr"` - ResponseCode int `xml:"responseCode"` + XMLName xml.Name `xml:"RankingDataResponse"` + XMLNSXSI string `xml:"xmlns:xsi,attr"` + XMLNSXSD string `xml:"xmlns:xsd,attr"` + XMLNS string `xml:"xmlns,attr"` + ResponseCode raceServiceResult `xml:"responseCode"` DataArray rankingsResponseDataArray } @@ -54,10 +56,42 @@ type rankingsResponseRankingData struct { UserData string `xml:"userdata"` } +type raceServiceResult int + +// https://github.com/GameProgressive/UniSpySDK/blob/master/webservices/RacingService.h +const ( + raceServiceResultSuccess = 0 + raceServiceResultDatabaseError = 6 + raceServiceResultParseError = 101 + raceServiceResultInvalidParameters = 105 +) + +const ( + xmlNamespaceXSI = "http://www.w3.org/2001/XMLSchema-instance" + xmlNamespaceXSD = "http://www.w3.org/2001/XMLSchema" + xmlNamespace = "http://gamespy.net/RaceService/" +) + +var marioKartWiiGameID = common.GetGameIDOrPanic("mariokartwii") // 1687 + func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { 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 } @@ -66,78 +100,104 @@ func handleNintendoRacingServiceRequest(moduleName string, responseWriter http.R panic(err) } + soapAction := soapActionHeader[slashIndex+1 : slashIndex+1+quotationMarkIndex] + switch soapAction { + case "GetTopTenRankings": + handleGetTopTenRankingsRequest(moduleName, responseWriter, requestBody) + + // TODO SubmitScores + default: + logging.Info(moduleName, "Unhandled SOAPAction:", aurora.Cyan(soapAction)) + } +} + +func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.ResponseWriter, requestBody []byte) { requestXML := rankingsRequestEnvelope{} - err = xml.Unmarshal(requestBody, &requestXML) + err := xml.Unmarshal(requestBody, &requestXML) if err != nil { logging.Error(moduleName, "Got malformed XML") + writeErrorResponse(raceServiceResultParseError, responseWriter) return } + requestData := requestXML.Body.Data gameId := requestData.GameId - if gameId != common.MarioKartWiiGameSpyGameID { - logging.Error(moduleName, "Wrong GameSpy game id") + if gameId != marioKartWiiGameID { + logging.Error(moduleName, "Wrong GameSpy game ID:", aurora.Cyan(gameId)) + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) return } - soapAction := requestData.XMLName.Local - switch soapAction { - case "GetTopTenRankings": - regionId := requestData.RegionId - courseId := requestData.CourseId - - if !regionId.IsValid() { - logging.Error(moduleName, "Invalid region id") - return - } - if courseId < common.MarioCircuit { - logging.Error(moduleName, "Invalid course id") - return - } - - var topTenLeaderboard string - if courseId <= common.GBAShyGuyBeach { - topTenLeaderboard = courseId.ToString() - } else { - topTenLeaderboard = "a competition" - } + regionId := requestData.RegionId + courseId := requestData.CourseId - logging.Info(moduleName, "Received a request for the Top 10 of", aurora.BrightCyan(topTenLeaderboard)) - handleGetTopTenRankingsRequest(moduleName, responseWriter) + if !regionId.IsValid() { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionId)) + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + return + } + if courseId < common.MarioCircuit || courseId > 32767 { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseId)) + writeErrorResponse(raceServiceResultInvalidParameters, responseWriter) + return } -} -func handleGetTopTenRankingsRequest(moduleName string, responseWriter http.ResponseWriter) { - rankingData := rankingsResponseRankingData{ - OwnerID: 1000000404, - Rank: 1, - Time: 0, - UserData: "xC0AIABNAGkAawBlAFMAdABhAHIAIAAAhH/RTQAAAAAgB45hkAAQTEDyjqQAeLgPhq4AiiUEACAATQBpAGsAZQBTAHQAYQByACD0UwACAAE=", + 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) + return } - responseData := []rankingsResponseData{ - { + numberOfRankings := len(topTenRankings) + data := make([]rankingsResponseData, 0, numberOfRankings) + for i, topTenRanking := range topTenRankings { + rankingData := rankingsResponseRankingData{ + OwnerID: topTenRanking.PID, + Rank: i + 1, + Time: topTenRanking.Score, + UserData: topTenRanking.PlayerInfo, + } + + responseData := rankingsResponseData{ RankingData: rankingData, - }, + } + + data = append(data, responseData) } dataArray := rankingsResponseDataArray{ - NumRecords: len(responseData), - Data: responseData, + NumRecords: numberOfRankings, + Data: data, } rankingDataResponse := rankingsResponseRankingDataResponse{ - XMLNSXsi: "http://www.w3.org/2001/XMLSchema-instance", - XMLNSXsd: "http://www.w3.org/2001/XMLSchema", - XMLNS: "http://gamespy.net/RaceService/", - ResponseCode: 0, + XMLNSXSI: xmlNamespaceXSI, + XMLNSXSD: xmlNamespaceXSD, + XMLNS: xmlNamespace, + ResponseCode: raceServiceResultSuccess, DataArray: dataArray, } + writeResponse(responseWriter, rankingDataResponse) +} + +func writeErrorResponse(raceServiceResult raceServiceResult, responseWriter http.ResponseWriter) { + rankingDataResponse := rankingsResponseRankingDataResponse{ + XMLNSXSI: xmlNamespaceXSI, + XMLNSXSD: xmlNamespaceXSD, + XMLNS: xmlNamespace, + ResponseCode: raceServiceResult, + } + + writeResponse(responseWriter, rankingDataResponse) +} + +func writeResponse(responseWriter http.ResponseWriter, rankingDataResponse rankingsResponseRankingDataResponse) { responseBody, err := xml.Marshal(rankingDataResponse) if err != nil { - logging.Error(moduleName, "Failed to XML encode the data") - return + panic(err) } responseBody = append([]byte(xml.Header), responseBody...) diff --git a/sake/constants.go b/sake/constants.go new file mode 100644 index 0000000..9a47fea --- /dev/null +++ b/sake/constants.go @@ -0,0 +1,16 @@ +package sake + +const GameSpyMultipartBoundary = "Qr4G823s23d---<<><><<<>--7d118e0536" + +type SakeFileResult int + +// https://documentation.help/GameSpy-SDK/SAKEFileResult.html +const ( + SakeFileResultHeader = "Sake-File-Result" + + SakeFileResultSuccess = 0 + SakeFileResultMissingParameter = 3 + SakeFileResultFileNotFound = 4 + SakeFileResultFileTooLarge = 5 + SakeFileResultServerError = 6 +) diff --git a/sake/file.go b/sake/file.go new file mode 100644 index 0000000..14d1093 --- /dev/null +++ b/sake/file.go @@ -0,0 +1,55 @@ +package sake + +import ( + "net/http" + "strconv" + "wwfc/common" + "wwfc/logging" + + "github.com/logrusorgru/aurora/v3" +) + +const ( + FileRequestDownload = iota + FileRequestUpload +) + +type FileRequest int + +var fileDownloadHandlers = map[int]func(string, http.ResponseWriter, *http.Request){ + common.GetGameIDOrPanic("mariokartwii"): handleMarioKartWiiFileDownloadRequest, +} + +var fileUploadHandlers = map[int]func(string, http.ResponseWriter, *http.Request){ + common.GetGameIDOrPanic("mariokartwii"): handleMarioKartWiiFileUploadRequest, +} + +func handleFileRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request, + fileRequest FileRequest) { + + gameIdString := request.URL.Query().Get("gameid") + gameId, err := strconv.Atoi(gameIdString) + if err != nil { + logging.Error(moduleName, "Invalid GameSpy game id") + return + } + + var handler func(string, http.ResponseWriter, *http.Request) + var handlerExists bool + switch fileRequest { + case FileRequestDownload: + handler, handlerExists = fileDownloadHandlers[gameId] + case FileRequestUpload: + handler, handlerExists = fileUploadHandlers[gameId] + default: + logging.Error(moduleName, "Invalid file request") + return + } + + if !handlerExists { + logging.Warn(moduleName, "Unhandled file request for GameSpy game id:", aurora.Cyan(gameId)) + return + } + + handler(moduleName, responseWriter, request) +} diff --git a/sake/main.go b/sake/main.go index d5c1888..c310d99 100644 --- a/sake/main.go +++ b/sake/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "wwfc/common" "wwfc/logging" @@ -41,9 +42,16 @@ func Shutdown() { func HandleRequest(w http.ResponseWriter, r *http.Request) { logging.Info("SAKE", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) - switch r.URL.String() { - case "/SakeStorageServer/StorageServer.asmx": + urlPath := r.URL.Path + switch { + case urlPath == "/SakeStorageServer/StorageServer.asmx": moduleName := "SAKE:Storage:" + r.RemoteAddr handleStorageRequest(moduleName, w, r) + case strings.HasSuffix(urlPath, "download.aspx"): + moduleName := "SAKE:File:" + r.RemoteAddr + handleFileRequest(moduleName, w, r, FileRequestDownload) + case strings.HasSuffix(urlPath, "upload.aspx"): + moduleName := "SAKE:File:" + r.RemoteAddr + handleFileRequest(moduleName, w, r, FileRequestUpload) } } diff --git a/sake/mario_kart_wii.go b/sake/mario_kart_wii.go new file mode 100644 index 0000000..101fbd7 --- /dev/null +++ b/sake/mario_kart_wii.go @@ -0,0 +1,296 @@ +package sake + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "wwfc/common" + "wwfc/database" + "wwfc/logging" + + "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" +) + +func handleMarioKartWiiFileDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "ghostdownload.aspx") { + handleMarioKartWiiGhostDownloadRequest(moduleName, responseWriter, request) + return + } + + query := request.URL.Query() + + fileIdString := query.Get("fileid") + pidString := query.Get("pid") + + fileId, err := strconv.Atoi(fileIdString) + if err != nil || fileId <= 0 { + logging.Error(moduleName, "Invalid file ID:", aurora.Cyan(fileIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + pid, err := strconv.Atoi(pidString) + if err != nil || pid <= 0 { + logging.Error(moduleName, "Invalid profile ID:", aurora.Cyan(pidString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + file, err := database.GetMarioKartWiiFile(pool, ctx, fileId) + if err != nil { + logging.Error(moduleName, "Failed to get the file from the database:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultServerError)) + return + } + + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultSuccess)) + responseWriter.Header().Set("Content-Length", strconv.Itoa(len(file))) + responseWriter.Write(file) +} + +func handleMarioKartWiiGhostDownloadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + + regionIdString := query.Get("region") + pidString := query.Get("p0") + courseIdString := query.Get("c0") + timeString := query.Get("t0") + + regionIdInt, err := strconv.Atoi(regionIdString) + if err != nil { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + if common.MarioKartWiiLeaderboardRegionId(regionIdInt) != common.Worldwide { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + courseIdInt, err := strconv.Atoi(courseIdString) + if err != nil { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + courseId := common.MarioKartWiiCourseId(courseIdInt) + if !courseId.IsValid() { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + pid, err := strconv.Atoi(pidString) + if err != nil || pid <= 0 { + logging.Error(moduleName, "Invalid profile ID:", aurora.Cyan(pidString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + time, err := strconv.Atoi(timeString) + if err != nil || time <= 0 || time >= 360000 /* 6 minutes */ { + logging.Error(moduleName, "Invalid time:", aurora.Cyan(timeString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + ghost, err := database.GetMarioKartWiiGhostFile(pool, ctx, courseId, time, pid) + if err != nil { + logging.Error(moduleName, "Failed to get a ghost file from the database:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultServerError)) + return + } + + responseBody := append(downloadedGhostFileHeader(), ghost...) + + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultSuccess)) + responseWriter.Header().Set("Content-Length", strconv.Itoa(len(responseBody))) + responseWriter.Write(responseBody) +} + +func handleMarioKartWiiFileUploadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "ghostupload.aspx") { + handleMarioKartWiiGhostUploadRequest(moduleName, responseWriter, request) + return + } +} + +func handleMarioKartWiiGhostUploadRequest(moduleName string, responseWriter http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + + regionIdString := query.Get("regionid") + courseIdString := query.Get("courseid") + scoreString := query.Get("score") + pidString := query.Get("pid") + playerInfo := query.Get("playerinfo") + _, isContest := query["contest"] + + regionIdInt, err := strconv.Atoi(regionIdString) + if err != nil { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + regionId := common.MarioKartWiiLeaderboardRegionId(regionIdInt) + if !regionId.IsValid() || regionId == common.Worldwide { + logging.Error(moduleName, "Invalid region ID:", aurora.Cyan(regionIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + courseIdInt, err := strconv.Atoi(courseIdString) + if err != nil { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + courseId := common.MarioKartWiiCourseId(courseIdInt) + if courseId < common.MarioCircuit || isContest == courseId.IsValid() || courseId > 32767 { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(courseIdString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + score, err := strconv.Atoi(scoreString) + if err != nil || score <= 0 || score >= 360000 /* 6 minutes */ { + logging.Error(moduleName, "Invalid score:", aurora.Cyan(scoreString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + pid, err := strconv.Atoi(pidString) + if err != nil || pid <= 0 { + logging.Error(moduleName, "Invalid profile ID:", aurora.Cyan(pidString)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + + if !isPlayerInfoValid(playerInfo) { + logging.Error(moduleName, "Invalid player info:", aurora.Cyan(playerInfo)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultMissingParameter)) + return + } + // Mario Kart Wii expects player information to be in this form + playerInfo, _ = common.GameSpyBase64ToBase64(playerInfo, common.GameSpyBase64EncodingURLSafe) + + // The multipart boundary utilized by GameSpy does not conform to RFC 2045. To ensure compliance, + // we need to surround it with double quotation marks. + contentType := request.Header.Get("Content-Type") + boundary := getMultipartBoundary(contentType) + if boundary == GameSpyMultipartBoundary { + quotedBoundary := fmt.Sprintf("%q", boundary) + contentType := strings.Replace(contentType, boundary, quotedBoundary, 1) + request.Header.Set("Content-Type", contentType) + } + + err = request.ParseMultipartForm(common.RKGDFileMaxSize) + if err != nil { + logging.Error(moduleName, "Failed to parse the multipart form:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultFileNotFound)) + return + } + + file, fileHeader, err := request.FormFile(rkgdFileName) + if err != nil { + logging.Error(moduleName, "Failed to find the ghost file:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultFileNotFound)) + return + } + defer file.Close() + + if fileHeader.Size < common.RKGDFileMinSize || fileHeader.Size > common.RKGDFileMaxSize { + logging.Error(moduleName, "The size of the ghost file is invalid:", aurora.Cyan(fileHeader.Size)) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultFileTooLarge)) + return + } + + ghostFile := make([]byte, fileHeader.Size) + _, err = io.ReadFull(file, ghostFile) + if err != nil { + logging.Error(moduleName, "Failed to read contents of the ghost file:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultFileTooLarge)) + return + } + + if !common.RKGhostData(ghostFile).IsRKGDFileValid(moduleName, courseId, score) { + logging.Error(moduleName, "Received an invalid ghost file") + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultFileTooLarge)) + return + } + + if isContest { + ghostFile = nil + } + + err = database.InsertMarioKartWiiGhostFile(pool, ctx, regionId, courseId, score, pid, playerInfo, ghostFile) + if err != nil { + logging.Error(moduleName, "Failed to insert the ghost file into the database:", err) + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultServerError)) + return + } + + responseWriter.Header().Set(SakeFileResultHeader, strconv.Itoa(SakeFileResultSuccess)) +} + +func downloadedGhostFileHeader() []byte { + var downloadedGhostFileHeader [0x200]byte + + binary.BigEndian.PutUint32(downloadedGhostFileHeader[0x40:0x44], uint32(len(downloadedGhostFileHeader))) + + 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 { + return "" + } + startIndex += len("boundary=") + + return contentType[startIndex:] +} diff --git a/sake/storage.go b/sake/storage.go index 70a1f90..82159f5 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -167,7 +167,7 @@ func handleStorageRequest(moduleName string, w http.ResponseWriter, r *http.Requ panic(err) } - payload := append([]byte(``), out...) + payload := append([]byte(xml.Header), out...) w.Header().Set("Content-Type", "text/xml") w.Header().Set("Content-Length", strconv.Itoa(len(payload))) @@ -337,6 +337,138 @@ func searchForRecords(moduleName string, gameInfo common.GameInfo, request Stora "info": binaryDataValueBase64(database.GetMKWFriendInfo(pool, ctx, uint32(ownerId))), }, } + + case "mariokartwii/GhostData": + if request.TableID != "GhostData" { + logging.Error(moduleName, "Invalid table name:", aurora.Cyan(request.TableID)) + return &errorResponse + } + + if request.Sort != "time desc" { + logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort)) + return &errorResponse + } + + if request.Offset != 0 { + logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset)) + return &errorResponse + } + + if request.Max != 1 { + logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max)) + return &errorResponse + } + + if request.Surrounding != 0 { + logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) + return &errorResponse + } + + if request.OwnerIDs != "" { + logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) + return &errorResponse + } + + if request.CacheFlag != 0 { + logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag)) + return &errorResponse + } + + match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687 and time < ([1-9][0-9]{0,5})$`).FindStringSubmatch(request.Filter) + if match == nil { + logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter)) + return &errorResponse + } + + courseIdInt, _ := strconv.Atoi(match[1]) + courseId := common.MarioKartWiiCourseId(courseIdInt) + if !courseId.IsValid() { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1])) + return &errorResponse + } + + time, _ := strconv.Atoi(match[2]) + if time >= 360000 /* 6 minutes */ { + logging.Error(moduleName, "Invalid time:", aurora.Cyan(match[2])) + return &errorResponse + } + + fileId, err := database.GetMarioKartWiiGhostData(pool, ctx, courseId, time) + if err != nil { + logging.Error(moduleName, "Failed to get the ghost data from the database:", err) + return &errorResponse + } + + values = []map[string]StorageValue{ + { + "fileid": intValue(int32(fileId)), + }, + } + + case "mariokartwii/StoredGhostData": + if request.Sort != "time" { + logging.Error(moduleName, "Invalid sort string:", aurora.Cyan(request.Sort)) + return &errorResponse + } + + if request.Offset != 0 { + logging.Error(moduleName, "Invalid offset value:", aurora.Cyan(request.Offset)) + return &errorResponse + } + + if request.Max != 1 { + logging.Error(moduleName, "Invalid number of records to return:", aurora.Cyan(request.Max)) + return &errorResponse + } + + if request.Surrounding != 0 { + logging.Error(moduleName, "Invalid number of surrounding records to return:", aurora.Cyan(request.Surrounding)) + return &errorResponse + } + + if request.OwnerIDs != "" { + logging.Error(moduleName, "Invalid owner id array:", aurora.Cyan(request.OwnerIDs)) + return &errorResponse + } + + if request.CacheFlag != 0 { + logging.Error(moduleName, "Invalid cache value:", aurora.Cyan(request.CacheFlag)) + return &errorResponse + } + + match := regexp.MustCompile(`^course = ([1-9]\d?|0) and gameid = 1687(?: and region = ([1-7]))?$`).FindStringSubmatch(request.Filter) + if match == nil { + logging.Error(moduleName, "Invalid filter string:", aurora.Cyan(request.Filter)) + return &errorResponse + } + + courseIdInt, _ := strconv.Atoi(match[1]) + courseId := common.MarioKartWiiCourseId(courseIdInt) + if !courseId.IsValid() { + logging.Error(moduleName, "Invalid course ID:", aurora.Cyan(match[1])) + return &errorResponse + } + + var regionId common.MarioKartWiiLeaderboardRegionId + if regionIdExists := match[2] != ""; regionIdExists { + regionIdInt, _ := strconv.Atoi(match[2]) + regionId = common.MarioKartWiiLeaderboardRegionId(regionIdInt) + } else { + regionId = common.Worldwide + } + + pid, fileId, err := database.GetMarioKartWiiStoredGhostData(pool, ctx, regionId, courseId) + if err != nil { + logging.Error(moduleName, "Failed to get the stored ghost data from the database:", err) + return &errorResponse + } + + values = []map[string]StorageValue{ + { + "profile": intValue(int32(pid)), + "fileid": intValue(int32(fileId)), + }, + } } // Sort the values now diff --git a/schema.sql b/schema.sql index 5bbc04c..ec36213 100644 --- a/schema.sql +++ b/schema.sql @@ -47,11 +47,34 @@ ALTER TABLE ONLY public.users ADD IF NOT EXISTS ban_reason character varying, ADD IF NOT EXISTS ban_reason_hidden character varying, ADD IF NOT EXISTS ban_moderator character varying, - ADD IF NOT EXISTS ban_tos boolean + ADD IF NOT EXISTS ban_tos boolean; ALTER TABLE public.users OWNER TO wiilink; +-- +-- Name: mario_kart_wii_sake; Type: TABLE; Schema: public; Owner: wiilink +-- + +CREATE TABLE IF NOT EXISTS public.mario_kart_wii_sake ( + regionid smallint NOT NULL CHECK (regionid >= 1 AND regionid <= 7), + courseid smallint NOT NULL CHECK (courseid >= 0 AND courseid <= 32767), + score integer NOT NULL CHECK (score > 0 AND score < 360000), + pid integer NOT NULL CHECK (pid > 0), + playerinfo varchar(108) NOT NULL CHECK (LENGTH(playerinfo) = 108), + ghost bytea CHECK (ghost IS NULL OR (OCTET_LENGTH(ghost) BETWEEN 148 AND 10240)), + + CONSTRAINT one_time_per_course_constraint UNIQUE (courseid, pid) +); + + +ALTER TABLE ONLY public.mario_kart_wii_sake + ADD IF NOT EXISTS id serial PRIMARY KEY, + ADD IF NOT EXISTS upload_time timestamp without time zone; + + +ALTER TABLE public.mario_kart_wii_sake OWNER TO wiilink; + -- -- Name: users_profile_id_seq; Type: SEQUENCE; Schema: public; Owner: wiilink -- @@ -83,7 +106,7 @@ ALTER TABLE ONLY public.users ALTER COLUMN profile_id SET DEFAULT nextval('publi -- Set the profile_id start point to 1'000'000'000 -- -ALTER SEQUENCE users_profile_id_seq RESTART WITH 1000000000; +ALTER SEQUENCE public.users_profile_id_seq RESTART WITH 1000000000; -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: wiilink