diff --git a/README.md b/README.md index 8846ec0..7ffaaa9 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,24 @@ A CLI for restoring [Age of Mythology: Retold](https://www.ageofempires.com/game This package was written so that the AoM community could have an easy to use, well-maintained tool for parsing rec files. It is also made so that [aomstats.io](https://aomstats.io) can use it to parse rec files and extract even more stats (e.g., minor god choices and build orders). Heavily inspired by [loggy's work](https://github.com/erin-fitzpatric/next-aom-gg/blob/main/src/server/recParser/recParser.ts) for aom.gg and his [proof of concept python parser](https://github.com/Logg-y/retoldrecprocessor/blob/main/recprocessor.py). I am unabashedly using his work as a reference to build this package. Some portions may be direct copies. + +## Installation + +## Usage + +### Example Output + +## Limitations + +## Roadmap + +- [x] Minor god support +- [x] Rename files support +- [ ] add eAPM support + +- [ ] Add support for team games +- [ ] Add refiners for all command types +- [ ] Add stats calculation +- [ ] Add testing + +## Development diff --git a/cmd/parse.go b/cmd/parse.go index 515e53a..e3b4654 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -13,6 +13,8 @@ import ( var outputPath string var quiet bool = false var prettyPrint bool = false +var slim bool = false +var stats bool = false // parseCmd represents the parse command var parseCmd = &cobra.Command{ @@ -21,13 +23,18 @@ var parseCmd = &cobra.Command{ Long: `Parses .mythrec files to human-readable json`, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { + if stats && slim { + fmt.Fprintf(os.Stderr, "error: you cannot use stats and slim mode together\n") + return + } + absPath, err := validateAndExpandPath(args[0]) if err != nil { fmt.Printf("Error with filepath: %v\n", err) return } - json, err := parser.ParseToJson(absPath, prettyPrint) + json, err := parser.ParseToJson(absPath, prettyPrint, slim, stats, isGzip) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return @@ -53,7 +60,14 @@ func init() { rootCmd.AddCommand(parseCmd) parseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Save the output JSON to the provided filepath") parseCmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Quiet mode, no output to standard output") - parseCmd.Flags().BoolVarP(&prettyPrint, "pretty-print", "p", false, "Pretty print the output JSON") + parseCmd.Flags().BoolVar(&prettyPrint, "pretty-print", false, "Pretty print the output JSON") + parseCmd.Flags().BoolVar(&slim, "slim", false, "Slim mode, don't output game commands") + parseCmd.Flags().BoolVar( + &stats, + "stats", + false, + "Stats mode, add stats to the output, you cannot use this with slim mode", + ) parseCmd.PreRun = func(cmd *cobra.Command, args []string) { if outputPath == "" { diff --git a/cmd/rename.go b/cmd/rename.go new file mode 100644 index 0000000..f13ba66 --- /dev/null +++ b/cmd/rename.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/jerkeeler/restoration/parser" + "github.com/spf13/cobra" +) + +var ( + prefix string + suffix string +) + +var renameCmd = &cobra.Command{ + Use: "rename [directory]", + Short: "Renames all .mythrec (or .mythrec.gz) in a directory based on player names", + Long: `This command will rename replay files in a directory based on the player names in the .mythrec file. + +Only files ending in .mthyrec (or .mythrec.gz if the is-gzip flag is set) will be renamed. All other files will +be ignored. This will override the existing files in the directory. + +You can optionally provide a prefix and/or suffix that will be added to the renamed files. + `, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + inputDir := args[0] + + // Validate directory exists + if fileInfo, err := os.Stat(inputDir); err != nil || !fileInfo.IsDir() { + fmt.Fprintf(os.Stderr, "error: '%s' is not a valid directory\n", inputDir) + os.Exit(1) + } + + err := parser.RenameRecFiles(inputDir, isGzip, prefix, suffix) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(renameCmd) + renameCmd.Flags().StringVar(&prefix, "prefix", "", "Prefix to add to renamed files") + renameCmd.Flags().StringVar(&suffix, "suffix", "", "Suffix to add to renamed files (before the extension)") +} diff --git a/cmd/root.go b/cmd/root.go index ec43b1f..be0671a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,8 @@ import ( "github.com/spf13/cobra" ) +var isGzip bool = false + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "restoration", @@ -25,6 +27,7 @@ func Execute() { func init() { verbose := false + rootCmd.PersistentFlags().BoolVar(&isGzip, "is-gzip", false, "Indicates whether the input files are compressed with gzip") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { opts := &slog.HandlerOptions{ diff --git a/parser/decoder.go b/parser/decoder.go index 62460d3..1159ee0 100644 --- a/parser/decoder.go +++ b/parser/decoder.go @@ -2,6 +2,7 @@ package parser import ( "bytes" + "compress/gzip" "compress/zlib" "encoding/binary" "fmt" @@ -29,6 +30,14 @@ func readBool(data *[]byte, offset int) bool { return (*data)[offset] != 0 } +func readVector(data *[]byte, offset int) Vector3 { + return Vector3{ + X: readInt32(data, offset), + Y: readInt32(data, offset+4), + Z: readInt32(data, offset+8), + } +} + func readString(data *[]byte, offset int) RecString { /* Reads the utf-16 little endian encoded at the given offset. Strings are enocde such that the first 2 bytes @@ -74,3 +83,13 @@ func Decompressl33t(compressed_array *[]byte) ([]byte, error) { return io.ReadAll(reader) } + +func DecompressGzip(compressed_array *[]byte) ([]byte, error) { + reader, err := gzip.NewReader(bytes.NewReader(*compressed_array)) + if err != nil { + return nil, err + } + defer reader.Close() + + return io.ReadAll(reader) +} diff --git a/parser/formatter.go b/parser/formatter.go index 6c78176..59029de 100644 --- a/parser/formatter.go +++ b/parser/formatter.go @@ -10,36 +10,41 @@ import ( ) func formatRawDataToReplay( + slim bool, + stats bool, data *[]byte, rootNode *Node, profileKeys *map[string]ProfileKey, xmbMap *map[string]XmbFile, commandList *[]GameCommand, -) (ReplayFormat, error) { - protoRootNode, err := parseXmb(data, (*xmbMap)["proto"]) - if err != nil { - return ReplayFormat{}, err - } +) (ReplayFormatted, error) { buildString, err := readBuildString(data, *rootNode) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } slog.Debug(buildString) buildNumber := getBuildNumber(buildString) godsRootNode, err := parseXmb(data, (*xmbMap)["civs"]) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } majorGodMap := buildGodMap(&godsRootNode) + techTreeRootNode, err := parseXmb(data, (*xmbMap)["techtree"]) + if err != nil { + return ReplayFormatted{}, err + } + losingPlayer, err := getLosingPlayer(commandList) slog.Debug("Losing player", "losingPlayer", losingPlayer) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } - players := getPlayers(profileKeys, &majorGodMap, losingPlayer) + gameLengthSecs := (*commandList)[len(*commandList)-1].GameTimeSecs() + players := getPlayers(profileKeys, &majorGodMap, losingPlayer, gameLengthSecs, commandList, &techTreeRootNode) + slog.Debug("Game host time", "gameHostTime", (*profileKeys)["gamehosttime"]) // Find winning team by filtering for winners and taking first player's team var winningTeam int @@ -50,14 +55,29 @@ func formatRawDataToReplay( } } - techTreeRootNode, err := parseXmb(data, (*xmbMap)["techtree"]) - if err != nil { - return ReplayFormat{}, err - } + var gameOptions map[string]bool + var gameCommands []ReplayGameCommand + if !slim { + gameOptions = getGameOptions(profileKeys) + protoRootNode, err := parseXmb(data, (*xmbMap)["proto"]) + if err != nil { + return ReplayFormatted{}, err + } + powersRootNode, err := parseXmb(data, (*xmbMap)["powers"]) + if err != nil { + return ReplayFormatted{}, err + } - gameOptions := getGameOptions(profileKeys) + gameCommands = formatCommandsToReplayFormat( + commandList, + &players, + &techTreeRootNode, + &protoRootNode, + &powersRootNode, + ) + } - return ReplayFormat{ + return ReplayFormatted{ MapName: (*profileKeys)["gamemapname"].StringVal, BuildNumber: buildNumber, BuildString: buildString, @@ -68,7 +88,7 @@ func formatRawDataToReplay( WinningTeam: winningTeam, GameOptions: gameOptions, Players: players, - GameCommands: formatCommandsToReplayFormat(commandList, &players, &techTreeRootNode, &protoRootNode), + GameCommands: gameCommands, }, nil } @@ -113,6 +133,7 @@ func formatCommandsToReplayFormat( players *[]ReplayPlayer, techTreeRootNode *XmbNode, protoRootNode *XmbNode, + powers *XmbNode, ) []ReplayGameCommand { playerMap := make(map[int]ReplayPlayer) for _, player := range *players { @@ -120,9 +141,11 @@ func formatCommandsToReplayFormat( } replayCommands := []ReplayGameCommand{} for _, command := range *commandList { + // This is gross, for now, sorry. // TODO: Make this command list formatter better. Should this be a map of command types to formatter functions? // Similar to the refiners? Do I just enrich ReplayGameCommand with all optional fields, such as num units? if researchCmd, ok := command.(ResearchCommand); ok { + replayCommands = append(replayCommands, ReplayGameCommand{ GameTimeSecs: command.GameTimeSecs(), CommandType: "research", @@ -159,6 +182,33 @@ func formatCommandsToReplayFormat( PlayerNum: autoqueueCmd.playerId, }) } + + if buildCmd, ok := command.(BuildCommand); ok { + proto := protoRootNode.children[buildCmd.protoBuildingId].attributes["name"] + replayCommands = append(replayCommands, ReplayGameCommand{ + GameTimeSecs: command.GameTimeSecs(), + CommandType: "build", + Value: proto, + PlayerNum: buildCmd.playerId, + }) + } + + if godPowerCmd, ok := command.(ProtoPowerCommand); ok { + power := powers.children[godPowerCmd.protoPowerId] + var commandType string + if _, ok := power.attributes["godpower"]; ok { + commandType = "godPower" + } else { + commandType = "protoPower" + } + + replayCommands = append(replayCommands, ReplayGameCommand{ + GameTimeSecs: command.GameTimeSecs(), + CommandType: commandType, + Value: power.attributes["name"], + PlayerNum: godPowerCmd.playerId, + }) + } } if len(*commandList) > 0 { lastCommand := (*commandList)[len(*commandList)-1] @@ -195,7 +245,14 @@ func buildGodMap(godRootNode *XmbNode) map[int]string { return godMap } -func getPlayers(profileKeys *map[string]ProfileKey, majorGodMap *map[int]string, losingPlayer int) []ReplayPlayer { +func getPlayers( + profileKeys *map[string]ProfileKey, + majorGodMap *map[int]string, + losingPlayer int, + gameLengthSecs float64, + commandList *[]GameCommand, + techTreeRootNode *XmbNode, +) []ReplayPlayer { // Create a players slice, but checking if each player number exists in the profile keys. If it does, grab // the relevant keys from the profileKeys map to construct a ReplayPlayer. players := make([]ReplayPlayer, 0) @@ -209,7 +266,8 @@ func getPlayers(profileKeys *map[string]ProfileKey, majorGodMap *map[int]string, slog.Error("Error parsing profile id", "error", err) continue } - + minorGods := getMinorGods(playerNum, commandList, techTreeRootNode) + eAPM := getEAPM(playerNum, commandList, gameLengthSecs) players = append(players, ReplayPlayer{ PlayerNum: playerNum, TeamId: int(keys[fmt.Sprintf("%steamid", playerPrefix)].Int32Val), @@ -219,7 +277,9 @@ func getPlayers(profileKeys *map[string]ProfileKey, majorGodMap *map[int]string, RandomGod: keys[fmt.Sprintf("%scivwasrandom", playerPrefix)].BoolVal, God: (*majorGodMap)[int(keys[fmt.Sprintf("%sciv", playerPrefix)].Int32Val)], // TODO: Make this robust to team games, right now this assumes a 1v1 game - Winner: playerNum != losingPlayer, + Winner: playerNum != losingPlayer, + EAPM: eAPM, + MinorGods: minorGods, }) } } @@ -232,6 +292,67 @@ func playerExists(profileKeys *map[string]ProfileKey, playerNum int) bool { return (*profileKeys)[playerKey].StringVal != "" } +func getMinorGods(playerNum int, commandList *[]GameCommand, techTreeRootNode *XmbNode) [3]string { + // Filter to all Research/prequeue techs that are Age Up tech, + ageUpTechs := []string{} + for _, command := range *commandList { + if command.PlayerId() != playerNum { + continue + } + + if researchCmd, ok := command.(ResearchCommand); ok { + tech := techTreeRootNode.children[researchCmd.techId].attributes["name"] + if isAgeUpTech(tech) { + ageUpTechs = append(ageUpTechs, tech) + } + } else if prequeueTechCmd, ok := command.(PrequeueTechCommand); ok { + tech := techTreeRootNode.children[prequeueTechCmd.techId].attributes["name"] + if isAgeUpTech(tech) { + ageUpTechs = append(ageUpTechs, tech) + } + } + } + + slog.Debug("Age up techs", "playerNum", playerNum, "techs", ageUpTechs) + + // Find the last occurrence of each age type and removes the prefix to make it look pretty + var classical, heroic, mythic string + for _, tech := range ageUpTechs { + if strings.HasPrefix(tech, "ClassicalAge") { + classical = strings.TrimPrefix(tech, "ClassicalAge") + } else if strings.HasPrefix(tech, "HeroicAge") { + heroic = strings.TrimPrefix(tech, "HeroicAge") + } else if strings.HasPrefix(tech, "MythicAge") { + mythic = strings.TrimPrefix(tech, "MythicAge") + } + } + + return [3]string{classical, heroic, mythic} +} + +func getEAPM(playerNum int, commandList *[]GameCommand, gameLengthSecs float64) float64 { + actions := 0 + for _, command := range *commandList { + if command.PlayerId() == playerNum && command.AffectsEAPM() { + actions += 1 + } + } + + gameLengthMins := gameLengthSecs / 60.0 + return float64(actions) / gameLengthMins +} + +func isAgeUpTech(value string) bool { + // If it starts with Classical, Heroic, or Mythic Age, return true + ageUpPrefixes := []string{"ClassicalAge", "HeroicAge", "MythicAge"} + for _, prefix := range ageUpPrefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} + func printXmbNode(node *XmbNode) { // Recursively prints the XMB node and its children, useful for debugging slog.Debug("XMB Node", "elementName", node.elementName, "value", node.value, "attributes", node.attributes) diff --git a/parser/gameCommands.go b/parser/gameCommands.go index bd76156..6b76d88 100644 --- a/parser/gameCommands.go +++ b/parser/gameCommands.go @@ -40,6 +40,7 @@ func newBaseCommand(offset int, commandType int, playerId int, lastCommandListId // Basically, the game ticks every 1/20 of a second and batches commands that occur in between into one command list // so we can use the index of the command list to get the game time. gameTimeSecs: float64(lastCommandListIdx) / 20.0, + affectsEAPM: true, } return cmd } @@ -52,6 +53,7 @@ func enrichBaseCommand(baseCommand BaseCommand, byteLength int) BaseCommand { gameTimeSecs: baseCommand.gameTimeSecs, byteLength: byteLength, offsetEnd: baseCommand.offset + byteLength, + affectsEAPM: baseCommand.affectsEAPM, } } @@ -93,14 +95,6 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ 2: func(data *[]byte, baseCommand BaseCommand) GameCommand { // The train commands contains 4 Int32s (16 bytes) and 2 Int8s (2 bytes). The 3rd, Int32 is the protoUnitId, // and the last Int8 is the number of units queued. - // inputTypes := []func() int{ - // unpackInt32, - // unpackInt32, - // unpackInt32, - // unpackInt32, - // unpackInt8, - // unpackInt8, - // } byteLength := 18 protoUnitId := readInt32(data, baseCommand.offset+8) numUnits := int8((*data)[baseCommand.offset+18]) @@ -113,24 +107,19 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ // build 3: func(data *[]byte, baseCommand BaseCommand) GameCommand { - inputTypes := []func() int{ - unpackInt32, - unpackInt32, - unpackInt32, - unpackVector, - unpackInt32, - unpackInt32, - unpackFloat, - unpackInt32, - unpackInt32, - unpackInt32, - unpackInt32, - } - byteLength := 0 - for _, f := range inputTypes { - byteLength += f() + // The build command is 52 bytes in length, consisting of the following values in sequence: + // 4 int32s, 1 vector, 2 int32s 1 float, 4 int32s + byteLength := 52 + // queued attribute comes from "preargument bytes", we will leave it as false for now + // protoUnitId comes from the 3rd int32 in the command + protoUnitId := readInt32(data, baseCommand.offset+8) + location := readVector(data, baseCommand.offset+12) + return BuildCommand{ + BaseCommand: enrichBaseCommand(baseCommand, byteLength), + protoBuildingId: protoUnitId, + location: location, + queued: false, } - return enrichBaseCommand(baseCommand, byteLength) }, 4: func(data *[]byte, baseCommand BaseCommand) GameCommand { @@ -139,6 +128,8 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + // Currently this command triggers a Task subtype move command immediately afterwards, so we don't want to double count + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -164,24 +155,21 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ // useProtoPower 12: func(data *[]byte, baseCommand BaseCommand) GameCommand { - inputTypes := []func() int{ - unpackInt32, - unpackInt32, - unpackInt32, - unpackVector, - unpackVector, - unpackInt32, - unpackInt32, - unpackFloat, - unpackInt32, - unpackInt32, - unpackInt8, + // useProtoPower is 57 bytes in length consisting of: + // 3 int32s, 2 vectors, 2 int32s, 1 float, 2 int32s, 1 int8 + // the last int32 is the protoPowerId that maps to a string god power via the proto XMB data + // Proto powers are unit abilities that are cast by a unit manually by the player (e.g., if the player + // casts the centaur rain of arrows power) AND god powers. The names are stored in the powers XMB data file + // stored in the header of the replay. + // + // The two vectors are the target locations of the power being used, if it has a location. If it has a second + // location (e.g., shifting sands, underworld, etc...) the second vector will be the second location. + byteLength := 57 + protoPowerId := readInt32(data, baseCommand.offset+52) + return ProtoPowerCommand{ + BaseCommand: enrichBaseCommand(baseCommand, byteLength), + protoPowerId: protoPowerId, } - byteLength := 0 - for _, f := range inputTypes { - byteLength += f() - } - return enrichBaseCommand(baseCommand, byteLength) }, // marketBuySellResources @@ -211,6 +199,7 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -281,6 +270,7 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -291,6 +281,9 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + // Every time you change a control group, the game triggers one event per unit in the group (removing them) and then readds them all, with 1 event per unit + // Including this would inflate CPM by a LOT. + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -363,6 +356,8 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + // debateable, selecting a lot of units and doing this creates one command per unit transformed + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -415,6 +410,8 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ for _, f := range inputTypes { byteLength += f() } + // Making a simple wall puts out a LOT of these. + baseCommand.affectsEAPM = false return enrichBaseCommand(baseCommand, byteLength) }, @@ -444,11 +441,8 @@ var REFINERS = map[int]func(*[]byte, BaseCommand) GameCommand{ // prebuyGodPower 75: func(data *[]byte, baseCommand BaseCommand) GameCommand { - inputTypes := []func() int{unpackInt32, unpackInt32, unpackInt32, unpackInt32} - byteLength := 0 - for _, f := range inputTypes { - byteLength += f() - } + // The prebuyGodPower command is 16 bytes in length, consisting of 4 int32s. The 3rd xint32 is the protoPowerId. + byteLength := 16 return enrichBaseCommand(baseCommand, byteLength) }, } diff --git a/parser/parser.go b/parser/parser.go index 2393c17..a55261c 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -8,8 +8,8 @@ import ( "os" ) -func ParseToJson(replayPath string, prettyPrint bool) (string, error) { - replayFormat, err := Parse(replayPath) +func ParseToJson(replayPath string, prettyPrint bool, slim bool, stats bool, isGzip bool) (string, error) { + replayFormat, err := Parse(replayPath, slim, stats, isGzip) if err != nil { return "", err } @@ -35,38 +35,52 @@ func ParseToJson(replayPath string, prettyPrint bool) (string, error) { // pattern or multiple files as input and each file will be parsed in its own go routine. // If we do need to add more optimization, all of the recursive functions could easily spin up a go routine to parse its // subtree. -func Parse(replayPath string) (ReplayFormat, error) { +func Parse(replayPath string, slim bool, stats bool, isGzip bool) (ReplayFormatted, error) { data, err := os.ReadFile(replayPath) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err + } + + if isGzip { + data, err = DecompressGzip(&data) + + if err != nil { + return ReplayFormatted{}, err + } } data, err = Decompressl33t(&data) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } rootNode := parseHeader(&data) + // Note, we are not parsing all XMB files here. We are parsing the map of XMB files so we know where they are. + // Since the XMB files are large we'll saving parsing them until we need them and simply pass the map of XMB files + // around instead. xmbMap, err := parseXmbMap(&data, rootNode) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } + // for key, _ := range xmbMap { + // fmt.Println(key) + // } profileKeys, err := parseProfileKeys(&data, rootNode) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } // printProfileKeys(profileKeys) commandList, err := parseGameCommands(&data, rootNode.endOffset()) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } - replayFormat, err := formatRawDataToReplay(&data, &rootNode, &profileKeys, &xmbMap, &commandList) + replayFormat, err := formatRawDataToReplay(slim, stats, &data, &rootNode, &profileKeys, &xmbMap, &commandList) if err != nil { - return ReplayFormat{}, err + return ReplayFormatted{}, err } return replayFormat, nil diff --git a/parser/renamer.go b/parser/renamer.go new file mode 100644 index 0000000..e22939f --- /dev/null +++ b/parser/renamer.go @@ -0,0 +1,100 @@ +package parser + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" +) + +func RenameRecFiles(dir string, isGzip bool, prefix string, suffix string) error { + slog.Info("Renaming replays in directory", "directory", dir, "isGzip", isGzip) + + // Determine file extension to search for + extension := ".mythrec" + if isGzip { + extension += ".gz" + } + + replayFiles := []string{} + // Walk through directory + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip if not a file or doesn't have correct extension + if info.IsDir() || !strings.HasSuffix(path, extension) { + return nil + } + replayFiles = append(replayFiles, path) + return nil + }) + if err != nil { + return err + } + + // Create error channel and WaitGroup, increment wait group for each file, then wait for the waitgroup to finish + errChan := make(chan error, len(replayFiles)) + var wg sync.WaitGroup + + slog.Debug("Found replay files", "numFiles", len(replayFiles)) + for _, file := range replayFiles { + wg.Add(1) + + // Yay go concurrency! Huzzah! We can use this same method for replay parsing and output in the future + go func(inputFilepath string) { + defer wg.Done() + + replay, err := Parse(inputFilepath, true, false, isGzip) + if err != nil { + errChan <- fmt.Errorf("error parsing %s: %w", inputFilepath, err) + return + } + + playerNames := []string{} + for _, player := range replay.Players { + playerNames = append(playerNames, player.Name) + } + + // Create base filename with player names + baseFilename := strings.Join(playerNames, "_vs_") + + // Add prefix and suffix if provided + if prefix != "" { + baseFilename = prefix + baseFilename + } + if suffix != "" { + baseFilename = baseFilename + suffix + } + + // Add extension + filename := baseFilename + extension + newFilepath := filepath.Join(dir, filename) + + slog.Info("Renaming file", + "oldPath", filepath.Base(inputFilepath), + "newPath", filepath.Base(newFilepath), + ) + if err := os.Rename(inputFilepath, newFilepath); err != nil { + errChan <- fmt.Errorf("error renaming %s: %w", inputFilepath, err) + return + } + }(file) + } + + // Wait for all goroutines to complete + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return err + } + } + + return nil +} diff --git a/parser/types.go b/parser/types.go index 5d56bb2..4b96724 100644 --- a/parser/types.go +++ b/parser/types.go @@ -20,6 +20,12 @@ func (err NotL33t) Error() string { return string(err) } +type Vector3 struct { + X int32 + Y int32 + Z int32 +} + // =============================== // Node and header types // =============================== @@ -105,6 +111,7 @@ type GameCommand interface { PlayerId() int ByteLength() int GameTimeSecs() float64 + AffectsEAPM() bool } type BaseCommand struct { @@ -114,6 +121,7 @@ type BaseCommand struct { offsetEnd int byteLength int gameTimeSecs float64 + affectsEAPM bool } func (cmd BaseCommand) CommandType() int { @@ -136,6 +144,10 @@ func (cmd BaseCommand) GameTimeSecs() float64 { return cmd.gameTimeSecs } +func (cmd BaseCommand) AffectsEAPM() bool { + return cmd.affectsEAPM +} + type ResearchCommand struct { BaseCommand techId int32 @@ -157,6 +169,18 @@ type AutoqueueCommand struct { protoUnitId int32 } +type BuildCommand struct { + BaseCommand + protoBuildingId int32 + queued bool + location Vector3 +} + +type ProtoPowerCommand struct { + BaseCommand + protoPowerId int32 +} + type CommandList struct { entryIdx int offsetEnd int @@ -199,7 +223,7 @@ type XmbNode struct { // Replay formats, parser output, the human readable output, good for use in other applications // ============================================================================================= -type ReplayFormat struct { +type ReplayFormatted struct { MapName string BuildNumber int BuildString string @@ -222,6 +246,7 @@ type ReplayPlayer struct { RandomGod bool God string Winner bool + EAPM float64 MinorGods [3]string }