├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── LICENSE ├── README.md ├── _release ├── .gitignore └── release.sh ├── cmd └── screp │ └── screp.go ├── go.mod ├── go.sum ├── rep ├── commands.go ├── computed.go ├── doc.go ├── eapm-util.go ├── header.go ├── mapdata.go ├── repcmd │ ├── cmd.go │ ├── doc.go │ ├── hotkeytypes.go │ ├── latencies.go │ ├── leavereasons.go │ ├── orders.go │ ├── techs.go │ ├── types.go │ ├── units.go │ └── upgrades.go ├── repcore │ ├── doc.go │ ├── enums.go │ ├── ineffkind.go │ └── types.go ├── replay.go ├── replay_test.go └── shieldbattery.go └── repparser ├── repdecoder ├── doc.go ├── legacy.go ├── modern.go └── repdecoder.go ├── repparser.go └── slicereader.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: icza 2 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: "1.23" 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Andras Belicza 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # screp 2 | 3 | ![Build Status](https://github.com/icza/screp/actions/workflows/go.yml/badge.svg) 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/icza/screp.svg)](https://pkg.go.dev/github.com/icza/screp) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/icza/screp)](https://goreportcard.com/report/github.com/icza/screp) 6 | 7 | StarCraft: Brood War replay parser. 8 | 9 | The package is designed to be used by other packages or apps, and is safe for concurrent use. 10 | There is also an example CLI app that can be used standalone. 11 | 12 | Parses both "modern" (starting from 1.18) and "legacy" (pre 1.18) replays. 13 | 14 | _Check out the sister project to parse StarCraft II replays: [s2prot](https://github.com/icza/s2prot)_ 15 | 16 | ## Using the `screp` CLI app 17 | 18 | There is a command line application in the [cmd/screp](https://github.com/icza/screp/tree/master/cmd/screp) folder 19 | which can be used to parse and display information about a single replay file. 20 | 21 | The extracted data is displayed using JSON representation. 22 | 23 | Usage is as simple as: 24 | 25 | screp [FLAGS] repfile.rep 26 | 27 | Run with `-h` to see the list of available flags. 28 | 29 | Example to parse a file called `sample.rep`, and display replay header (included by default) 30 | and basic map data info (without tiles and resource location info): 31 | 32 | screp -map=true sample.rep 33 | 34 | Or simply: 35 | 36 | screp -map sample.rep 37 | 38 | There is also a handy `-overview` flag which displays an overview / summary about the rep in human readable format (no JSON): 39 | 40 | screp -overview sample.rep 41 | 42 | ## Installing the `screp` CLI app 43 | 44 | The easiest is to download the binary release prepared for your platform from the [Releases](https://github.com/icza/screp/releases) page. Extract the archive and start using `screp`. 45 | 46 | If you want to build `screp` from source, then simply clone the project and build the `cmd/screp` app: 47 | 48 | git clone https://github.com/icza/screp 49 | cd screp/cmd/screp 50 | go build 51 | 52 | This will create an executable binary in the `cmd/screp` folder, ready to run. 53 | 54 | ## Example projects using this 55 | 56 | - [RepMastered™](https://repmastered.icza.net) 57 | -------------------------------------------------------------------------------- /_release/.gitignore: -------------------------------------------------------------------------------- 1 | *.tar.gz 2 | *.zip 3 | -------------------------------------------------------------------------------- /_release/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script creates downloadable releases of the CLI app. 4 | 5 | APP_FOLDER="../cmd/screp" 6 | echo Using app folder: $APP_FOLDER 7 | 8 | # Acquire app name 9 | # Expected format: ` appName = "thename"` 10 | APP_NAME=$(more $APP_FOLDER/*.go | grep "appName\s*=" | cut -d '"' -f 2) 11 | if [ -z "$APP_NAME" ]; then 12 | echo Could not detect app name! 13 | exit 1 14 | fi 15 | echo Detected app name: $APP_NAME 16 | 17 | # Acquire app version 18 | # Expected format: ` appVersion = "theversion"` 19 | APP_VERSION=$(more $APP_FOLDER/*.go | grep "appVersion\s*=" | cut -d '"' -f 2) 20 | if [ -z "$APP_VERSION" ]; then 21 | echo Could not detect app version! 22 | exit 2 23 | fi 24 | echo Detected app version: $APP_VERSION 25 | 26 | START=$(date +%s) 27 | 28 | for REL_OS in linux windows darwin 29 | do 30 | for REL_ARCH in amd64 386 31 | do 32 | REL_NAME=$APP_NAME-$APP_VERSION-$REL_OS-$REL_ARCH 33 | if [ $REL_OS = "windows" ]; then 34 | REL_NAME=$REL_NAME.zip 35 | else 36 | REL_NAME=$REL_NAME.tar.gz 37 | fi 38 | echo Creating release $REL_NAME... 39 | rm $REL_NAME 2> /dev/null 40 | EXEC_NAME=$APP_NAME 41 | if [ $REL_OS = "windows" ]; then 42 | EXEC_NAME=$EXEC_NAME.exe 43 | fi 44 | 45 | GOOS=$REL_OS GOARCH=$REL_ARCH go build -o $EXEC_NAME $APP_FOLDER || exit 3 46 | 47 | if [ $REL_OS = "windows" ]; then 48 | zip -q $REL_NAME $EXEC_NAME 49 | else 50 | tar -zcf $REL_NAME $EXEC_NAME 51 | fi 52 | rm $EXEC_NAME 53 | done 54 | done 55 | 56 | END=$(date +%s) 57 | DIFF=$(echo "$END - $START" | bc) 58 | echo Done in $DIFF sec. 59 | -------------------------------------------------------------------------------- /cmd/screp/screp.go: -------------------------------------------------------------------------------- 1 | /* 2 | A simple CLI app to parse and display information about 3 | a StarCraft: Brood War replay passed as a CLI argument. 4 | */ 5 | package main 6 | 7 | import ( 8 | "crypto/md5" 9 | "crypto/sha1" 10 | "crypto/sha256" 11 | "crypto/sha512" 12 | "encoding/hex" 13 | "encoding/json" 14 | "flag" 15 | "fmt" 16 | "hash" 17 | "io" 18 | "os" 19 | "runtime" 20 | "strings" 21 | 22 | "github.com/icza/screp/rep" 23 | "github.com/icza/screp/repparser" 24 | ) 25 | 26 | const ( 27 | appName = "screp" 28 | appVersion = "v1.12.12" 29 | appAuthor = "Andras Belicza" 30 | appHome = "https://github.com/icza/screp" 31 | ) 32 | 33 | // Used exit codes 34 | const ( 35 | ExitCodeMissingArguments = 1 36 | ExitCodeFailedToParseReplay = 2 37 | ExitCodeFailedToCreateOutputFile = 3 38 | ExitCodeInvalidMapDataHash = 4 39 | ) 40 | 41 | const validMapDataHashes = "valid values are 'sha1', 'sha256', 'sha512', 'md5'" 42 | 43 | // Flag variables 44 | var ( 45 | version = flag.Bool("version", false, "print version info and exit") 46 | 47 | overview = flag.Bool("overview", false, "print replay overview in human-readable form (no JSON)\nother flags (except 'outFile') are ignored") 48 | header = flag.Bool("header", true, "print replay header") 49 | mapData = flag.Bool("map", false, "print map data") 50 | mapTiles = flag.Bool("maptiles", false, "print map data tiles; valid with 'map'") 51 | mapResLoc = flag.Bool("mapres", false, "print map data resource locations (minerals and geysers); valid with 'map'") 52 | mapGfx = flag.Bool("mapgfx", false, "print map graphics related data; valid with 'map'") 53 | cmds = flag.Bool("cmds", false, "print player commands") 54 | computed = flag.Bool("computed", true, "print computed / derived data") 55 | mapDataHash = flag.String("mapDataHash", "", "calculate and print the hash of map data section too using the given algorithm;\n"+validMapDataHashes) 56 | dumpMapData = flag.Bool("dumpMapData", false, "dump the raw map data (CHK) instead of JSON replay info\nuse it with the 'outfile' flag") 57 | stdin = flag.Bool("stdin", false, "read replay content from standard input instead of a file") 58 | outFile = flag.String("outfile", "", "optional output file name") 59 | 60 | indent = flag.Bool("indent", true, "use indentation when formatting output") 61 | ) 62 | 63 | func main() { 64 | flag.Parse() 65 | 66 | if *version { 67 | printVersion() 68 | return 69 | } 70 | 71 | args := flag.Args() 72 | if !*stdin && len(args) < 1 { 73 | printUsage() 74 | os.Exit(ExitCodeMissingArguments) 75 | } 76 | 77 | cfg := repparser.Config{ 78 | Commands: true, 79 | MapData: true, 80 | } 81 | 82 | var mapDataHasher hash.Hash 83 | if *mapDataHash != "" { 84 | cfg.Debug = true 85 | switch strings.ToLower(*mapDataHash) { 86 | case "md5": 87 | mapDataHasher = md5.New() 88 | case "sha1": 89 | mapDataHasher = sha1.New() 90 | case "sha256": 91 | mapDataHasher = sha256.New() 92 | case "sha512": 93 | mapDataHasher = sha512.New() 94 | default: 95 | fmt.Printf("Invalid mapDataHash: %v\n", *mapDataHash) 96 | fmt.Println(validMapDataHashes) 97 | os.Exit(ExitCodeInvalidMapDataHash) 98 | } 99 | } 100 | 101 | if *mapGfx { 102 | cfg.MapGraphics = true 103 | } 104 | 105 | if *dumpMapData { 106 | cfg.Debug = true 107 | } 108 | 109 | // Parse replay now 110 | var ( 111 | r *rep.Replay 112 | err error 113 | ) 114 | 115 | if *stdin { 116 | var data []byte 117 | data, err = io.ReadAll(os.Stdin) 118 | if err != nil { 119 | fmt.Printf("Failed to read from stdin: %v\n", err) 120 | os.Exit(ExitCodeFailedToParseReplay) 121 | } 122 | r, err = repparser.ParseConfig(data, cfg) 123 | } else { 124 | r, err = repparser.ParseFileConfig(args[0], cfg) 125 | } 126 | 127 | if err != nil { 128 | fmt.Printf("Failed to parse replay: %v\n", err) 129 | os.Exit(ExitCodeFailedToParseReplay) 130 | } 131 | 132 | var destination = os.Stdout 133 | 134 | if *outFile != "" { 135 | foutput, err := os.Create(*outFile) 136 | if err != nil { 137 | fmt.Printf("Failed to create output file: %v\n", err) 138 | os.Exit(ExitCodeFailedToCreateOutputFile) 139 | } 140 | defer func() { 141 | if err := foutput.Close(); err != nil { 142 | panic(err) 143 | } 144 | }() 145 | 146 | destination = foutput 147 | } 148 | 149 | if *overview { 150 | printOverview(destination, r) 151 | return 152 | } 153 | 154 | if *dumpMapData { 155 | if _, err := destination.Write(r.MapData.Debug.Data); err != nil { 156 | fmt.Printf("Failed to write map data: %v\n", err) 157 | } 158 | return 159 | } 160 | 161 | // custom holds any custom data we want in the output and is not part of rep.Replay 162 | custom := map[string]any{} 163 | 164 | if *computed { 165 | r.Compute() 166 | } 167 | 168 | if mapDataHasher != nil { 169 | mapDataHasher.Write(r.MapData.Debug.Data) 170 | custom["MapDataHash"] = hex.EncodeToString(mapDataHasher.Sum(nil)) 171 | } 172 | 173 | // Zero values in replay the user do not wish to see: 174 | if !*header { 175 | r.Header = nil 176 | } 177 | if !*mapData { 178 | r.MapData = nil 179 | } else { 180 | if !*mapTiles { 181 | r.MapData.Tiles = nil 182 | } 183 | if !*mapResLoc { 184 | r.MapData.MineralFields = nil 185 | r.MapData.Geysers = nil 186 | } 187 | } 188 | if !*cmds { 189 | r.Commands = nil 190 | } 191 | 192 | enc := json.NewEncoder(destination) 193 | 194 | if *indent { 195 | enc.SetIndent("", " ") 196 | } 197 | 198 | var valueToEncode any = r 199 | 200 | // If there are custom data, wrap (embed) the replay in a struct that holds the custom data too: 201 | if len(custom) > 0 { 202 | valueToEncode = struct { 203 | *rep.Replay 204 | Custom map[string]any 205 | }{r, custom} 206 | } 207 | 208 | if err := enc.Encode(valueToEncode); err != nil { 209 | fmt.Printf("Failed to encode output: %v\n", err) 210 | } 211 | } 212 | 213 | func printOverview(out *os.File, rep *rep.Replay) { 214 | rep.Compute() 215 | 216 | engine := rep.Header.Engine.ShortName 217 | if rep.Header.Version != "" { 218 | engine = engine + " " + rep.Header.Version 219 | } 220 | mapName := rep.MapData.Name 221 | if mapName == "" { 222 | mapName = rep.Header.Map // But revert to Header.Map if the latter is not available. 223 | } 224 | winner := "" 225 | if rep.Computed.WinnerTeam != 0 { 226 | winner = fmt.Sprint("Team ", rep.Computed.WinnerTeam) 227 | } 228 | 229 | fmt.Fprintln(out, "Engine :", engine) 230 | fmt.Fprintln(out, "Date :", rep.Header.StartTime.Format("2006-01-02 15:04:05 -07:00")) 231 | fmt.Fprintln(out, "Length :", rep.Header.Frames.String()) 232 | fmt.Fprintln(out, "Title :", rep.Header.Title) 233 | fmt.Fprintln(out, "Map :", mapName) 234 | fmt.Fprintln(out, "Type :", rep.Header.Type.Name) 235 | fmt.Fprintln(out, "Matchup :", rep.Header.Matchup()) 236 | fmt.Fprintln(out, "Winner :", winner) 237 | 238 | fmt.Fprintln(out, "Team R APM EAPM @ Name ") 239 | for i, p := range rep.Header.Players { 240 | pd := rep.Computed.PlayerDescs[i] 241 | mins := pd.LastCmdFrame.Duration().Minutes() 242 | var apm, eapm int 243 | if pd.CmdCount > 0 { 244 | apm = int(float64(pd.CmdCount)/mins + 0.5) 245 | } 246 | if pd.EffectiveCmdCount > 0 { 247 | eapm = int(float64(pd.EffectiveCmdCount)/mins + 0.5) 248 | } 249 | fmt.Fprintf(out, "%3d %s %4d %4d %2d %s\n", p.Team, p.Race.Name[:1], apm, eapm, pd.StartDirection, p.Name) 250 | } 251 | } 252 | 253 | func printVersion() { 254 | fmt.Println(appName, "version:", appVersion) 255 | fmt.Println("Parser version:", repparser.Version) 256 | fmt.Println("EAPM algorithm version:", rep.EAPMVersion) 257 | fmt.Println("Platform:", runtime.GOOS, runtime.GOARCH) 258 | fmt.Println("Built with:", runtime.Version()) 259 | fmt.Println("Author:", appAuthor) 260 | fmt.Println("Home page:", appHome) 261 | } 262 | 263 | func printUsage() { 264 | fmt.Println("Usage:") 265 | name := os.Args[0] 266 | fmt.Printf("\t%s [FLAGS] repfile.rep\n", name) 267 | fmt.Println("\tRun with '-h' to see a list of available flags.") 268 | } 269 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/icza/screp 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/icza/gox v0.2.0 9 | golang.org/x/text v0.25.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/icza/gox v0.2.0 h1:+0N8PCt9/QSx+k0dqe/wdlXJNR/haaPsPwrTJTNDeyk= 2 | github.com/icza/gox v0.2.0/go.mod h1:rVecw5Q6POJAWBcXgCZdAtwK/hmoNehxCkAP3sMnOIc= 3 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 4 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 5 | -------------------------------------------------------------------------------- /rep/commands.go: -------------------------------------------------------------------------------- 1 | // This file contains the types describing the players' commands. 2 | 3 | package rep 4 | 5 | import "github.com/icza/screp/rep/repcmd" 6 | 7 | // Commands contains the players' commands. 8 | type Commands struct { 9 | // Cmds is the commands of the players 10 | Cmds []repcmd.Cmd 11 | 12 | // ParseErrCmds is list of commands that failed to parse. 13 | // A parse error command may imply additional skipped (not recorded) commands 14 | // at the same frame. 15 | ParseErrCmds []*repcmd.ParseErrCmd 16 | 17 | // Debug holds optional debug info. 18 | Debug *CommandsDebug `json:"-"` 19 | } 20 | 21 | // CommandsDebug holds debug info for the commands section. 22 | type CommandsDebug struct { 23 | // Data is the raw, uncompressed data of the section. 24 | Data []byte 25 | } 26 | -------------------------------------------------------------------------------- /rep/computed.go: -------------------------------------------------------------------------------- 1 | // This file contains the types describing the computed / derived data. 2 | 3 | package rep 4 | 5 | import ( 6 | "github.com/icza/screp/rep/repcmd" 7 | "github.com/icza/screp/rep/repcore" 8 | ) 9 | 10 | // Computed contains computed, derived data from other parts of the replay. 11 | type Computed struct { 12 | // LeaveGameCmds of the players. 13 | LeaveGameCmds []*repcmd.LeaveGameCmd 14 | 15 | // ChatCmds is a collection of the received chat messages. 16 | ChatCmds []*repcmd.ChatCmd 17 | 18 | // WinnerTeam if can be detected by the "largest remaining team wins" 19 | // algorithm. It's 0 if winner team is unknown. 20 | WinnerTeam byte 21 | 22 | // PlayerID of the replay saver, if known 23 | RepSaverPlayerID *byte 24 | 25 | // PlayerDescs contains player descriptions in team order. 26 | PlayerDescs []*PlayerDesc 27 | 28 | // PIDPlayerDescs maps from player ID to PlayerDesc. 29 | // Note: all computer players have ID=255, so this won't be accurate for 30 | // computer players. 31 | PIDPlayerDescs map[byte]*PlayerDesc `json:"-"` 32 | } 33 | 34 | // PlayerDesc contains computed / derived data for a player. 35 | type PlayerDesc struct { 36 | // PlayerID this PlayerDesc belongs to. 37 | PlayerID byte 38 | 39 | // LastCmdFrame is the frame of the last command of the player. 40 | LastCmdFrame repcore.Frame 41 | 42 | // CmdCount is the number of commands of the player. 43 | CmdCount uint32 44 | 45 | // APM is the APM (Actions Per Minute) of the player. 46 | APM int32 47 | 48 | // EffectiveCmdCount is the number of effective commands of the player. 49 | EffectiveCmdCount uint32 50 | 51 | // EAPM is the EAPM (Effective Actions Per Minute) of the player. 52 | EAPM int32 53 | 54 | // StartLocation of the player 55 | StartLocation *repcore.Point 56 | 57 | // StartDirection is the direction of the start location of the player 58 | // compared to the center of the map, expressed using the clock, 59 | // e.g. 1 o'clock, 6 o'clock etc. 60 | StartDirection int32 61 | } 62 | 63 | // Redundancy returns the redundancy percent of the player's commands. 64 | // A command is redundant if its ineffective. 65 | func (pd *PlayerDesc) Redundancy() int { 66 | if pd.CmdCount == 0 { 67 | return 0 68 | } 69 | return int(float64(pd.CmdCount-pd.EffectiveCmdCount)*100/float64(pd.CmdCount) + 0.5) 70 | } 71 | -------------------------------------------------------------------------------- /rep/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Package rep models StarCraft: Brood War replays. 4 | 5 | Information sources: 6 | 7 | BWHF replay model: 8 | 9 | https://github.com/icza/bwhf/tree/master/src/hu/belicza/andras/bwhf/model 10 | 11 | BWAPI replay tool: 12 | 13 | https://github.com/bwapi/bwapi/tree/master/bwapi/libReplayTool 14 | 15 | */ 16 | package rep 17 | -------------------------------------------------------------------------------- /rep/eapm-util.go: -------------------------------------------------------------------------------- 1 | // This file contains the algorithm implementation for EAPM classification and calculation. 2 | 3 | package rep 4 | 5 | import ( 6 | "github.com/icza/screp/rep/repcmd" 7 | "github.com/icza/screp/rep/repcore" 8 | ) 9 | 10 | const ( 11 | // EAPMVersion is a Semver2 compatible version of the EAPM algorithm. 12 | EAPMVersion = "v1.0.5" 13 | ) 14 | 15 | // IsCmdEffective tells if a command is considered effective so it can be included in EAPM calculation. 16 | // 17 | // cmds must contain commands of the cmd's player only. It may be a partially filled slice, but must contain 18 | // the player's all commands up to the command in question: len(cmds) > i must hold. 19 | func IsCmdEffective(cmds []repcmd.Cmd, i int) bool { 20 | return CmdIneffKind(cmds, i) == repcore.IneffKindEffective 21 | } 22 | 23 | // CmdIneffKind returns the IneffKind classification of the given command. 24 | // 25 | // cmds must contain commands of the cmd's player only. It may be a partially filled slice, but must contain 26 | // the player's all commands up to the command in question: len(cmds) > i must hold. 27 | func CmdIneffKind(cmds []repcmd.Cmd, i int) repcore.IneffKind { 28 | if i == 0 { 29 | return repcore.IneffKindEffective // First command is effective whatever it is 30 | } 31 | 32 | // Try to "prove" command is ineffective. If we can't, it's effective. 33 | 34 | cmd := cmds[i] 35 | tid := cmd.BaseCmd().Type.ID 36 | 37 | // Unit queue overflow 38 | switch tid { 39 | case repcmd.TypeIDTrain, repcmd.TypeIDTrainFighter, repcmd.TypeIDCancelTrain: 40 | if countSameCmds(cmds, i, cmd) >= 6 { 41 | return repcore.IneffKindUnitQueueOverflow 42 | } 43 | } 44 | 45 | prevCmd := cmds[i-1] // i > 0 46 | prevTid := prevCmd.BaseCmd().Type.ID 47 | 48 | deltaFrame := cmd.BaseCmd().Frame - prevCmd.BaseCmd().Frame 49 | 50 | // Too fast cancel 51 | if deltaFrame <= 20 { 52 | switch { 53 | case (prevTid == repcmd.TypeIDTrain || prevTid == repcmd.TypeIDTrainFighter) && tid == repcmd.TypeIDCancelTrain: 54 | return repcore.IneffKindFastCancel 55 | case (prevTid == repcmd.TypeIDUnitMorph || prevTid == repcmd.TypeIDBuildingMorph) && tid == repcmd.TypeIDCancelMorph: 56 | return repcore.IneffKindFastCancel 57 | case prevTid == repcmd.TypeIDUpgrade && tid == repcmd.TypeIDCancelUpgrade: 58 | return repcore.IneffKindFastCancel 59 | case prevTid == repcmd.TypeIDTech && tid == repcmd.TypeIDCancelTech: 60 | return repcore.IneffKindFastCancel 61 | } 62 | } 63 | 64 | // Too fast repetition of certain commands in a short period of time 65 | // (regardless of their destinations, if destinations are different/far, then the first one was useless) 66 | if deltaFrame <= 10 && tid == prevTid { 67 | switch tid { 68 | case repcmd.TypeIDStop, repcmd.TypeIDHoldPosition, repcmd.VirtualTypeIDLand: 69 | return repcore.IneffKindFastRepetition 70 | case repcmd.TypeIDTargetedOrder, repcmd.TypeIDTargetedOrder121: 71 | oid, prevOid := cmd.(*repcmd.TargetedOrderCmd).Order.ID, prevCmd.(*repcmd.TargetedOrderCmd).Order.ID 72 | if oid == prevOid { 73 | if repcmd.IsOrderIDKindStop(oid) || repcmd.IsOrderIDKindAttack(oid) || repcmd.IsOrderIDKindHold(oid) { 74 | return repcore.IneffKindFastRepetition 75 | } 76 | switch oid { 77 | case repcmd.OrderIDMove, repcmd.OrderIDRallyPointUnit, repcmd.OrderIDRallyPointTile: 78 | return repcore.IneffKindFastRepetition 79 | } 80 | } 81 | } 82 | } 83 | 84 | // Too fast switch away from or reselecting the same selected unit = no use of selecting it. 85 | // By too fast I mean it's not even enough to check the units' state. 86 | if deltaFrame <= 8 && isSelectionChanger(cmd) && isSelectionChanger(prevCmd) { 87 | // Exclude double tapping the same hotkey: it's only ineffective if tapped more than 3 times 88 | // (double tapping is used to center the group) 89 | doubleTap := false 90 | if he, ok := cmd.(*repcmd.HotkeyCmd); ok { 91 | if he2, ok2 := prevCmd.(*repcmd.HotkeyCmd); ok2 { 92 | if he.Group == he2.Group { 93 | doubleTap = true 94 | // Is it repeated fast at least 3 times? 95 | if i >= 2 { 96 | prevPrevCmd := cmds[i-2] 97 | if he3, ok3 := prevPrevCmd.(*repcmd.HotkeyCmd); ok3 && 98 | he3.HotkeyType.ID == repcmd.HotkeyTypeIDSelect && he3.Group == he.Group && 99 | he2.Base.Frame-he3.Base.Frame <= 8 { 100 | return repcore.IneffKindFastReselection // Same hotkey (select) pressed at least 3 times 101 | } 102 | } 103 | } 104 | } 105 | } 106 | if !doubleTap { 107 | return repcore.IneffKindFastReselection 108 | } 109 | } 110 | 111 | // Repetition of certain commands without time restriction 112 | if tid == prevTid { 113 | switch tid { 114 | case repcmd.TypeIDUnitMorph, repcmd.TypeIDBuildingMorph, repcmd.TypeIDUpgrade, 115 | repcmd.TypeIDMergeArchon, repcmd.TypeIDMergeDarkArchon, repcmd.TypeIDLiftOff, 116 | repcmd.TypeIDCancelAddon, repcmd.TypeIDCancelBuild, repcmd.TypeIDCancelMorph, repcmd.TypeIDCancelNuke, 117 | repcmd.TypeIDCancelTech, repcmd.TypeIDCancelUpgrade: 118 | return repcore.IneffKindRepetition 119 | case repcmd.TypeIDBuild: 120 | // Only consider this ineffective if race is not Protoss: 121 | bc := cmd.(*repcmd.BuildCmd) 122 | if bc.Order != nil && bc.Order.ID != repcmd.OrderIDPlaceProtossBuilding { 123 | return repcore.IneffKindRepetition 124 | } 125 | } 126 | } 127 | 128 | // Repetition of the same hotkey assign or add 129 | if he, ok := cmd.(*repcmd.HotkeyCmd); ok && he.HotkeyType.ID != repcmd.HotkeyTypeIDSelect { 130 | if he2, ok2 := prevCmd.(*repcmd.HotkeyCmd); ok2 && he2.HotkeyType.ID == he.HotkeyType.ID { 131 | if he.Group == he2.Group { 132 | return repcore.IneffKindRepetitionHotkeyAddAssign 133 | } 134 | } 135 | } 136 | 137 | return repcore.IneffKindEffective // If we got this far, classify it as effective 138 | } 139 | 140 | // countSameCmds counts how many times the given command is repeated on the same selected units 141 | // without about 1 second. 142 | // 143 | // Counting is capped at 6: even if the command is repeated more times, 6 is returned. 144 | // 145 | // cmd must be cmds[i]. 146 | func countSameCmds(cmds []repcmd.Cmd, i int, cmd repcmd.Cmd) (count int) { 147 | baseCmd := cmd.BaseCmd() 148 | frameLimit := baseCmd.Frame - 25 // About 1 second 149 | 150 | for ; i >= 0; i-- { 151 | cmd2 := cmds[i] 152 | baseCmd2 := cmd2.BaseCmd() 153 | if baseCmd2.Frame < frameLimit { 154 | break 155 | } 156 | 157 | if baseCmd2.Type == baseCmd.Type { 158 | count++ 159 | if count == 6 { 160 | break 161 | } 162 | } else if isSelectionChanger(cmd2) { 163 | break 164 | } 165 | } 166 | 167 | return 168 | } 169 | 170 | // isSelectionChanger tells if the given command (may) change the current selection. 171 | func isSelectionChanger(cmd repcmd.Cmd) bool { 172 | switch cmd.BaseCmd().Type.ID { 173 | case repcmd.TypeIDSelect, repcmd.TypeIDSelectAdd, repcmd.TypeIDSelectRemove, 174 | repcmd.TypeIDSelect121, repcmd.TypeIDSelectAdd121, repcmd.TypeIDSelectRemove121: 175 | return true 176 | case repcmd.TypeIDHotkey: 177 | if cmd.(*repcmd.HotkeyCmd).HotkeyType.ID == repcmd.HotkeyTypeIDSelect { 178 | return true 179 | } 180 | } 181 | return false 182 | } 183 | -------------------------------------------------------------------------------- /rep/header.go: -------------------------------------------------------------------------------- 1 | // This file contains the types describing the replay header. 2 | 3 | package rep 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/icza/screp/rep/repcore" 11 | ) 12 | 13 | // Header models the replay header. 14 | type Header struct { 15 | // Engine used to play the game and save the replay 16 | Engine *repcore.Engine 17 | 18 | // Version contains information about the replay version. 19 | // Since version is not stored in replays, this only designates certain version ranges deducted from replay format. 20 | // Current possible values are: 21 | // - "-1.16": version is 1.16 or older 22 | // - "1.18-1.20": version is 1.18..1.20 23 | // - "1.21+": version is 1.21 or newer 24 | Version string 25 | 26 | // Frames is the number of frames. There are approximately ~23.81 frames in 27 | // a second. (1 frame = 0.042 second to be exact). 28 | Frames repcore.Frame 29 | 30 | // StartTime is the timestamp when the game started 31 | StartTime time.Time 32 | 33 | // Title is the game name / title 34 | Title string 35 | 36 | // RawTitle is the undecoded Title data. It may differ from Title if the latter is invalid UTF-8. 37 | RawTitle string `json:"-"` 38 | 39 | // Size of the map 40 | MapWidth, MapHeight uint16 41 | 42 | // AvailSlotsCount is the number of available slots 43 | AvailSlotsCount byte 44 | 45 | // Speed is the game speed 46 | Speed *repcore.Speed 47 | 48 | // Type is the game type 49 | Type *repcore.GameType 50 | 51 | // SubType indicates the size of the "Home" team. 52 | // For example, in case of 3v5 this is 3, in case of 7v1 this is 7. 53 | SubType uint16 54 | 55 | // Host is the game creator's name. 56 | Host string 57 | 58 | // RawHost is the undecoded Host data. It may differ from Host if the latter is invalid UTF-8. 59 | RawHost string `json:"-"` 60 | 61 | // Map name 62 | Map string 63 | 64 | // RawMap is the undecoded Map data. It may differ from Map if the latter is invalid UTF-8. 65 | RawMap string `json:"-"` 66 | 67 | // Slots contains all players of the game (including open/closed slots) 68 | Slots []*Player `json:"-"` 69 | 70 | // OrigPlayers contains the actual ("real") players of the game 71 | // in the order recorded in the replay. 72 | OrigPlayers []*Player `json:"-"` 73 | 74 | // Players contains the actual ("real") players of the game 75 | // in team order. 76 | Players []*Player 77 | 78 | // PIDPlayers maps from player ID to Player. 79 | // Note: all computer players have ID=255, so this won't be accurate for 80 | // computer players. 81 | PIDPlayers map[byte]*Player `json:"-"` 82 | 83 | // Debug holds optional debug info. 84 | Debug *HeaderDebug `json:"-"` 85 | } 86 | 87 | // Duration returns the game duration. 88 | func (h *Header) Duration() time.Duration { 89 | return h.Frames.Duration() 90 | } 91 | 92 | // MapSize returns the map size in widthxheight format, e.g. "64x64". 93 | func (h *Header) MapSize() string { 94 | return fmt.Sprint(h.MapWidth, "x", h.MapHeight) 95 | } 96 | 97 | // Matchup returns the matchup, the race letters of players in team order, 98 | // inserting 'v' between different teams, e.g. "PvT" or "PTZvZTP". 99 | // Observers are excluded from the matchup. 100 | func (h *Header) Matchup() string { 101 | m := make([]rune, 0, 9) 102 | first, prevTeam := true, byte(0) 103 | for _, p := range h.Players { 104 | if p.Observer { 105 | continue 106 | } 107 | if !first && p.Team != prevTeam { 108 | m = append(m, 'v') 109 | } 110 | m = append(m, p.Race.Letter) 111 | first, prevTeam = false, p.Team 112 | } 113 | return string(m) 114 | } 115 | 116 | // PlayerNames returns a comma separated list of player names in team order, 117 | // inserting " VS " between different teams. 118 | func (h *Header) PlayerNames() string { 119 | buf := &strings.Builder{} 120 | var prevTeam byte 121 | for i, p := range h.Players { 122 | if i > 0 { 123 | if p.Team != prevTeam { 124 | buf.WriteString(" VS ") 125 | } else { 126 | buf.WriteString(", ") 127 | } 128 | } 129 | buf.WriteString(p.Name) 130 | prevTeam = p.Team 131 | } 132 | return buf.String() 133 | } 134 | 135 | // Player represents a player of the game. 136 | type Player struct { 137 | // SlotID is the slot ID 138 | SlotID uint16 139 | 140 | // ID of the player. 141 | // Computer players all have ID=255. 142 | ID byte 143 | 144 | // Type is the player type 145 | Type *repcore.PlayerType 146 | 147 | // Race of the player 148 | Race *repcore.Race 149 | 150 | // Team of the player 151 | Team byte 152 | 153 | // Name of the player 154 | Name string 155 | 156 | // RawName is the undecoded Name data. It may differ from Name if the latter is invalid UTF-8. 157 | RawName string `json:"-"` 158 | 159 | // Color of the player 160 | Color *repcore.Color 161 | 162 | // Observer tells if the player only observes the game and should be excluded 163 | // from matchup. 164 | // This is not stored in replays, this is a calculated property. 165 | Observer bool 166 | } 167 | 168 | // HeaderDebug holds debug info for the header section. 169 | type HeaderDebug struct { 170 | // Data is the raw, uncompressed data of the section. 171 | Data []byte 172 | 173 | // Descriptor fields of the data 174 | Fields []*DebugFieldDescriptor 175 | } 176 | 177 | // DebugFieldDescriptor describes some arbitrary data in a byte slice. 178 | type DebugFieldDescriptor struct { 179 | Offset int // Offset of the data field 180 | Length int // Length of the data field in bytes 181 | Name string // Name of the data field 182 | } 183 | -------------------------------------------------------------------------------- /rep/mapdata.go: -------------------------------------------------------------------------------- 1 | // This file contains the types describing the map data. 2 | 3 | package rep 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // MapData describes the map and objects on it. 8 | type MapData struct { 9 | // Version of the map. 10 | // 0x2f: StarCraft beta 11 | // 0x3b: 1.00-1.03 StarCraft and above ("hybrid") 12 | // 0x3f: 1.04 StarCraft and above ("hybrid") 13 | // 0x40: StarCraft Remastered 14 | // 0xcd: Brood War 15 | // 0xce: Brood War Remastered 16 | Version uint16 17 | 18 | // TileSet defines the tile set used on the map. 19 | TileSet *repcore.TileSet 20 | 21 | TileSetMissing bool `json:"tileSetMissing,omitempty"` 22 | 23 | // Scenario name 24 | Name string 25 | 26 | // Scenario description 27 | Description string 28 | 29 | // PlayerOwners defines the player types (player owners). 30 | PlayerOwners []*repcore.PlayerOwner 31 | 32 | // PlayerSides defines the player sides (player races). 33 | PlayerSides []*repcore.PlayerSide 34 | 35 | // Tiles is the tile data of the map (within the tile set): width x height elements. 36 | // 1 Tile is 32 units (pixel) 37 | Tiles []uint16 `json:",omitempty"` 38 | 39 | // Mineral field locations on the map 40 | MineralFields []Resource `json:",omitempty"` 41 | 42 | // Geyser locations on the map 43 | Geysers []Resource `json:",omitempty"` 44 | 45 | // StartLocations on the map 46 | StartLocations []StartLocation 47 | 48 | // MapGraphics holds data for map image rendering. 49 | MapGraphics *MapGraphics `json:",omitempty"` 50 | 51 | // Debug holds optional debug info. 52 | Debug *MapDataDebug `json:"-"` 53 | } 54 | 55 | // MaxHumanPlayers returns the max number of human players on the map. 56 | func (md *MapData) MaxHumanPlayers() (count int) { 57 | for _, owner := range md.PlayerOwners { 58 | if owner == repcore.PlayerOwnerHumanOpenSlot { 59 | count++ 60 | } 61 | } 62 | return 63 | } 64 | 65 | // Resource describes a resource (mineral field of vespene geyser). 66 | type Resource struct { 67 | // Location of the resource 68 | repcore.Point 69 | 70 | // Amount of the resource 71 | Amount uint32 72 | } 73 | 74 | // StartLocation describes a player start location on the map 75 | type StartLocation struct { 76 | repcore.Point 77 | 78 | // SlotID of the owner of this start location; 79 | // Belongs to the Player with matching Player.SlotID 80 | SlotID byte 81 | } 82 | 83 | // MapDataDebug holds debug info for the map data section. 84 | type MapDataDebug struct { 85 | // Data is the raw, uncompressed data of the section. 86 | Data []byte 87 | } 88 | 89 | // MapGraphics holds info usually required only for map image rendering. 90 | type MapGraphics struct { 91 | // PlacedUnits contains all placed units on the map. 92 | // This includes mineral fields, geysers and startlocations too. 93 | // This also includes unit sprites. 94 | PlacedUnits []*PlacedUnit 95 | 96 | // Sprites contains additional visual sprites on the map. 97 | Sprites []*Sprite 98 | } 99 | 100 | type PlacedUnit struct { 101 | repcore.Point 102 | 103 | // UnitID is the unit id. This value is used in repcmd.Unit.UnitID. 104 | UnitID uint16 105 | 106 | // SlotID of the owner of this unit. 107 | // Belongs to the Player with matching Player.SlotID 108 | SlotID byte 109 | 110 | // ResourceAmount of if it's a resource 111 | ResourceAmount uint32 `json:",omitempty"` 112 | 113 | // Sprite tells if this unit is a sprite. 114 | Sprite bool `json:",omitempty"` 115 | } 116 | 117 | type Sprite struct { 118 | repcore.Point 119 | 120 | // SpriteID is the sprite id. 121 | SpriteID uint16 122 | } 123 | -------------------------------------------------------------------------------- /rep/repcmd/cmd.go: -------------------------------------------------------------------------------- 1 | // This file contains types that model the different commands. 2 | 3 | package repcmd 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/icza/screp/rep/repcore" 11 | ) 12 | 13 | // Bytes is a []byte that JSON-marshals itself as a number array. 14 | type Bytes []byte 15 | 16 | // MarshalJSON marshals the byte slice as a number array. 17 | func (bs Bytes) MarshalJSON() ([]byte, error) { 18 | if bs == nil { 19 | return []byte("null"), nil 20 | } 21 | 22 | buf := bytes.NewBuffer(make([]byte, 0, len(bs)*3)) 23 | buf.WriteByte('[') 24 | for i, v := range bs { 25 | if i > 0 { 26 | buf.WriteByte(',') 27 | } 28 | fmt.Fprint(buf, v) 29 | } 30 | buf.WriteByte(']') 31 | 32 | return buf.Bytes(), nil 33 | } 34 | 35 | // e creates a new Enum value. 36 | func e(name string) repcore.Enum { 37 | return repcore.Enum{Name: name} 38 | } 39 | 40 | // Cmd is the command interface. 41 | type Cmd interface { 42 | // Base returns the base command. 43 | BaseCmd() *Base 44 | 45 | // Params returns human-readable concrete command-specific parameters. 46 | Params(verbose bool) string 47 | } 48 | 49 | // Base is the base of all player commands. 50 | type Base struct { 51 | // Frame at which the command was issued 52 | Frame repcore.Frame 53 | 54 | // PlayerID this command was issued by 55 | PlayerID byte 56 | 57 | // Type of the command 58 | Type *Type 59 | 60 | // IneffKind classification of the command 61 | IneffKind repcore.IneffKind `json:",omitempty"` 62 | } 63 | 64 | // BaseCmd implements Cmd.BaseCmd(). 65 | func (b *Base) BaseCmd() *Base { 66 | return b 67 | } 68 | 69 | // Params implements Cmd.Params(). 70 | func (b *Base) Params(verbose bool) string { 71 | return "" 72 | } 73 | 74 | // c is a helper function to choose between 2 formats based on verbosity. 75 | func c(verbose bool, verboseFmt, nonVerboseFmt string) string { 76 | if verbose { 77 | return verboseFmt 78 | } 79 | return nonVerboseFmt 80 | } 81 | 82 | // ParseErrCmd represents a command where parsing error encountered. 83 | // It stores a reference to the preceding command for debugging purposes 84 | // (often a parse error is the result of improperly parsing the preceding command). 85 | type ParseErrCmd struct { 86 | *Base 87 | 88 | // PrevCmd is the command preceding the parse error command. 89 | PrevCmd Cmd 90 | } 91 | 92 | // Params implements Cmd.Params(). 93 | func (pec *ParseErrCmd) Params(verbose bool) string { 94 | prevBase := pec.PrevCmd.BaseCmd() 95 | return fmt.Sprintf( 96 | c(verbose, 97 | "PrevCmd: [Frame: %d, PlayerID: %d, Type: %s, Params: [%s]", 98 | "[%d, %d, %s, [%s]", 99 | ), 100 | prevBase.Frame, prevBase.PlayerID, prevBase.Type, pec.PrevCmd.Params(verbose), 101 | ) 102 | } 103 | 104 | // UnitTag itentifies a unit in the game (engine). Contains its in-game ID and 105 | // a recycle counter. 106 | type UnitTag uint16 107 | 108 | // Index returns the unit's tag index (in-game ID). 109 | func (ut UnitTag) Index() uint16 { 110 | return uint16(ut) & 0x7ff 111 | } 112 | 113 | // Recycle returns the tag resycle. 114 | func (ut UnitTag) Recycle() byte { 115 | return byte(uint16(ut) >> 12) 116 | } 117 | 118 | // Valid tells if this is a valid unit tag. 119 | func (ut UnitTag) Valid() bool { 120 | return ut != 0xffff 121 | } 122 | 123 | // GeneralCmd represents a general command whose parameters 124 | // are not handled / cared for. 125 | type GeneralCmd struct { 126 | *Base 127 | 128 | // Data is the "raw" parameters of the command. 129 | Data []byte 130 | } 131 | 132 | // Params implements Cmd.Params(). 133 | func (gc *GeneralCmd) Params(verbose bool) string { 134 | return fmt.Sprintf( 135 | c(verbose, 136 | "Data: [% x]", 137 | "[% x]", 138 | ), 139 | gc.Data, 140 | ) 141 | } 142 | 143 | // SelectCmd describes commands of types: TypeSelect, TypeSelectAdd, TypeSelectRemove 144 | type SelectCmd struct { 145 | *Base 146 | 147 | // UnitTags contains the unit tags involved in the select command. 148 | UnitTags []UnitTag 149 | } 150 | 151 | // Params implements Cmd.Params(). 152 | func (sc *SelectCmd) Params(verbose bool) string { 153 | return fmt.Sprintf( 154 | c(verbose, 155 | "UnitTags: %x", 156 | "%x", 157 | ), 158 | sc.UnitTags, 159 | ) 160 | } 161 | 162 | // BuildCmd describes a build command. Type: TypeBuild 163 | type BuildCmd struct { 164 | *Base 165 | 166 | // Order type 167 | Order *Order 168 | 169 | // Pos tells the point where the building is placed. 170 | Pos repcore.Point 171 | 172 | // Unit is the building issued to be built. 173 | Unit *Unit 174 | } 175 | 176 | // Params implements Cmd.Params(). 177 | func (bc *BuildCmd) Params(verbose bool) string { 178 | if verbose { 179 | return fmt.Sprintf("Order: %v, Pos: (%v), Unit: %v", bc.Order, bc.Pos, bc.Unit) 180 | } 181 | 182 | // Order is "redundant" (e.g. PlaceProtossBuilding, DroneStartBuild) 183 | return fmt.Sprintf("(%v), %v", bc.Pos, bc.Unit) 184 | } 185 | 186 | // GameSpeedCmd describes a set game speed command. Type: TypeGameSpeed 187 | type GameSpeedCmd struct { 188 | *Base 189 | 190 | // Speed is the new game speed. 191 | Speed *repcore.Speed 192 | } 193 | 194 | // Params implements Cmd.Params(). 195 | func (gc *GameSpeedCmd) Params(verbose bool) string { 196 | return fmt.Sprintf( 197 | c(verbose, 198 | "Speed: %v", 199 | "%v", 200 | ), 201 | gc.Speed, 202 | ) 203 | } 204 | 205 | // HotkeyCmd describes a hotkey command. Type: TypeHotkey 206 | type HotkeyCmd struct { 207 | *Base 208 | 209 | // HotkeyType is the type of the hotkey command 210 | // (named like this to avoid same name from Base.Type). 211 | HotkeyType *HotkeyType 212 | 213 | // Group (the "number"): 0..9. 214 | Group byte 215 | } 216 | 217 | // Params implements Cmd.Params(). 218 | func (hc *HotkeyCmd) Params(verbose bool) string { 219 | return fmt.Sprintf( 220 | c(verbose, 221 | "HotkeyType: %v, Group: %d", 222 | "%v, %d", 223 | ), 224 | hc.HotkeyType, hc.Group, 225 | ) 226 | } 227 | 228 | // LeaveGameCmd describes a leave game command. Type: TypeLeaveGame 229 | type LeaveGameCmd struct { 230 | *Base 231 | 232 | // Reasom why the player left. 233 | Reason *LeaveReason 234 | } 235 | 236 | // Params implements Cmd.Params(). 237 | func (lgc *LeaveGameCmd) Params(verbose bool) string { 238 | return fmt.Sprintf( 239 | c(verbose, 240 | "Reason: %v", 241 | "%v", 242 | ), lgc.Reason, 243 | ) 244 | } 245 | 246 | // TrainCmd describes a train command. Type: TypeTrain, TypeUnitMorph 247 | type TrainCmd struct { 248 | *Base 249 | 250 | // Unit is the trained unit. 251 | Unit *Unit 252 | } 253 | 254 | // Params implements Cmd.Params(). 255 | func (tc *TrainCmd) Params(verbose bool) string { 256 | return fmt.Sprintf( 257 | c(verbose, 258 | "Unit: %v", 259 | "%v", 260 | ), 261 | tc.Unit, 262 | ) 263 | } 264 | 265 | // QueueableCmd describes a generic command that holds whether it is queued. 266 | // Types: TypeStop, TypeReturnCargo, TypeUnloadAll, TypeHoldPosition, 267 | // TypeBurrow, TypeUnburrow, TypeSiege, TypeUnsiege, TypeCloack, TypeDecloack 268 | type QueueableCmd struct { 269 | *Base 270 | 271 | // Queued tells if the command is queued. If not, it's instant. 272 | Queued bool 273 | } 274 | 275 | // Params implements Cmd.Params(). 276 | func (qc *QueueableCmd) Params(verbose bool) string { 277 | if verbose { 278 | return fmt.Sprintf("Queued: %t", qc.Queued) 279 | } 280 | if qc.Queued { 281 | return "Queued" 282 | } 283 | return "" 284 | } 285 | 286 | // RightClickCmd represents a right click command. Type: TypeRightClick 287 | type RightClickCmd struct { 288 | *Base 289 | 290 | // Pos tells the right-clicked target point. 291 | Pos repcore.Point 292 | 293 | // UnitTag is the right-clicked unit's unit tag if it's valid. 294 | UnitTag UnitTag 295 | 296 | // Unit is the right-clicked unit (if UnitTag is valid). 297 | Unit *Unit 298 | 299 | // Queued tells if the command is queued. If not, it's instant. 300 | Queued bool 301 | } 302 | 303 | // Params implements Cmd.Params(). 304 | func (rcc *RightClickCmd) Params(verbose bool) string { 305 | if verbose { 306 | return fmt.Sprintf("Pos: (%v), UnitTag: %x, Unit: %v, Queued: %t", rcc.Pos, rcc.UnitTag, rcc.Unit, rcc.Queued) 307 | } 308 | 309 | b := &strings.Builder{} 310 | fmt.Fprintf(b, "(%v)", rcc.Pos) 311 | if rcc.UnitTag != 0 { 312 | fmt.Fprintf(b, ", %x", rcc.UnitTag) 313 | } 314 | if rcc.Unit.ID != UnitIDNone { 315 | fmt.Fprintf(b, ", %v", rcc.Unit) 316 | } 317 | if rcc.Queued { 318 | b.WriteString(", Queued") 319 | } 320 | return b.String() 321 | } 322 | 323 | // UnloadCmd describes an unload command. 324 | type UnloadCmd struct { 325 | *Base 326 | 327 | // UnitTag is the unloaded unit's tag if it's valid. 328 | UnitTag UnitTag 329 | } 330 | 331 | // Params implements Cmd.Params(). 332 | func (uc *UnloadCmd) Params(verbose bool) string { 333 | return fmt.Sprintf( 334 | c(verbose, 335 | " UnitTag: %x", 336 | "%x", 337 | ), 338 | uc.UnitTag, 339 | ) 340 | } 341 | 342 | // TargetedOrderCmd describes a targeted order command. Type: TypeTargetedOrder 343 | type TargetedOrderCmd struct { 344 | *Base 345 | 346 | // Pos tells the targeted order's target point. 347 | Pos repcore.Point 348 | 349 | // UnitTag is the targeted order's unit tag if it's valid. 350 | UnitTag UnitTag 351 | 352 | // Unit is the targeted order's unit (if UnitTag is valid). 353 | Unit *Unit 354 | 355 | // Order type 356 | Order *Order 357 | 358 | // Queued tells if the command is queued. If not, it's instant. 359 | Queued bool 360 | } 361 | 362 | // Params implements Cmd.Params(). 363 | func (toc *TargetedOrderCmd) Params(verbose bool) string { 364 | if verbose { 365 | return fmt.Sprintf("Pos: (%v), UnitTag: %x, Unit: %v, Order: %v, Queued: %t", toc.Pos, toc.UnitTag, toc.Unit, toc.Order, toc.Queued) 366 | } 367 | 368 | b := &strings.Builder{} 369 | fmt.Fprintf(b, "(%v)", toc.Pos) 370 | if toc.UnitTag != 0 { 371 | fmt.Fprintf(b, ", %x", toc.UnitTag) 372 | } 373 | if toc.Unit.ID != UnitIDNone { 374 | fmt.Fprintf(b, ", %v", toc.Unit) 375 | } 376 | fmt.Fprintf(b, ", %v", toc.Order) 377 | if toc.Queued { 378 | b.WriteString(", Queued") 379 | } 380 | return b.String() 381 | } 382 | 383 | // MinimapPingCmd describes a minimap ping command. Type: TypeMinimapPing 384 | type MinimapPingCmd struct { 385 | *Base 386 | 387 | // Pos tells the pinged location. 388 | Pos repcore.Point 389 | } 390 | 391 | // Params implements Cmd.Params(). 392 | func (mpc *MinimapPingCmd) Params(verbose bool) string { 393 | return fmt.Sprintf( 394 | c(verbose, 395 | "Pos: (%v)", 396 | "(%v)", 397 | ), 398 | mpc.Pos, 399 | ) 400 | } 401 | 402 | // ChatCmd describes an in-game receive chat command. Type: TypeChat 403 | // Owner of the command receives the message sent by the user identified by SenderSlotID. 404 | type ChatCmd struct { 405 | *Base 406 | 407 | // SenderSlotID tells the slot ID of the message sender. 408 | SenderSlotID byte 409 | 410 | // Message sent. 411 | Message string 412 | } 413 | 414 | // Params implements Cmd.Params(). 415 | func (cc *ChatCmd) Params(verbose bool) string { 416 | return fmt.Sprintf( 417 | c(verbose, 418 | "SenderSlotID: %d, Message: %q", 419 | "%d, %q", 420 | ), 421 | cc.SenderSlotID, cc.Message, 422 | ) 423 | } 424 | 425 | // VisionCmd describes the share vision command. Type: TypeIDVision 426 | type VisionCmd struct { 427 | *Base 428 | 429 | // SlotIDs lists slot IDs the owner shared shared vision with 430 | SlotIDs Bytes 431 | } 432 | 433 | // Params implements Cmd.Params(). 434 | func (vc *VisionCmd) Params(verbose bool) string { 435 | return fmt.Sprintf( 436 | c(verbose, 437 | "SlotIDs: %v", 438 | "%v", 439 | ), 440 | vc.SlotIDs, 441 | ) 442 | } 443 | 444 | // AllianceCmd describes the set alliance command. Type: TypeIDAlliance 445 | type AllianceCmd struct { 446 | *Base 447 | 448 | // SlotIDs lists slot IDs the owner is allied to. 449 | // It contains slot IDs in increasing order. 450 | SlotIDs Bytes 451 | 452 | // AlliedVictory tells if Allied Victory is set. 453 | AlliedVictory bool 454 | } 455 | 456 | // Params implements Cmd.Params(). 457 | func (ac *AllianceCmd) Params(verbose bool) string { 458 | if verbose { 459 | return fmt.Sprintf("SlotIDs: %v, AlliedVictory: %t", ac.SlotIDs, ac.AlliedVictory) 460 | } 461 | 462 | b := &strings.Builder{} 463 | fmt.Fprintf(b, "%v", ac.SlotIDs) 464 | if ac.AlliedVictory { 465 | b.WriteString(", AlliedVictory") 466 | } 467 | return b.String() 468 | } 469 | 470 | // CancelTrainCmd describes a cancel train command. Type: TypeCancelTrain 471 | type CancelTrainCmd struct { 472 | *Base 473 | 474 | // UnitTag is the cancelled unit tag. 475 | UnitTag UnitTag 476 | } 477 | 478 | // Params implements Cmd.Params(). 479 | func (ctc *CancelTrainCmd) Params(verbose bool) string { 480 | return fmt.Sprintf( 481 | c(verbose, 482 | "UnitTag: %x", 483 | "%x", 484 | ), 485 | ctc.UnitTag, 486 | ) 487 | } 488 | 489 | // BuildingMorphCmd describes a building morph command. Type: TypeBuildingMorph 490 | type BuildingMorphCmd struct { 491 | *Base 492 | 493 | // Unit is the unit to morph into (e.g. Lair from Hatchery). 494 | Unit *Unit 495 | } 496 | 497 | // Params implements Cmd.Params(). 498 | func (bmc *BuildingMorphCmd) Params(verbose bool) string { 499 | return fmt.Sprintf( 500 | c(verbose, 501 | "Unit: %v", 502 | "%v", 503 | ), 504 | bmc.Unit, 505 | ) 506 | } 507 | 508 | // LiftOffCmd describes a lift off command. Type: TypeLiftOff 509 | type LiftOffCmd struct { 510 | *Base 511 | 512 | // Pos tells the location of the lift off. 513 | Pos repcore.Point 514 | } 515 | 516 | // Params implements Cmd.Params(). 517 | func (loc *LiftOffCmd) Params(verbose bool) string { 518 | return fmt.Sprintf( 519 | c(verbose, 520 | "Pos: (%v)", 521 | "(%v)", 522 | ), loc.Pos, 523 | ) 524 | } 525 | 526 | // LandCmd describes a land command. Type: TypeBuild 527 | type LandCmd struct { 528 | *Base 529 | 530 | // Order type 531 | Order *Order 532 | 533 | // Pos tells the point where the building is landed. 534 | Pos repcore.Point 535 | 536 | // Unit is the building issued to be landed. 537 | Unit *Unit 538 | } 539 | 540 | // Params implements Cmd.Params(). 541 | func (bc *LandCmd) Params(verbose bool) string { 542 | if verbose { 543 | return fmt.Sprintf("Order: %v, Pos: (%v), Unit: %v", bc.Order, bc.Pos, bc.Unit) 544 | } 545 | 546 | // Order is "redundant" (it's always BuildingLand) 547 | return fmt.Sprintf("(%v), %v", bc.Pos, bc.Unit) 548 | } 549 | 550 | // TechCmd describes a tech (research) command. Type: TypeTech 551 | type TechCmd struct { 552 | *Base 553 | 554 | // Tech that was started. 555 | Tech *Tech 556 | } 557 | 558 | // Params implements Cmd.Params(). 559 | func (tc *TechCmd) Params(verbose bool) string { 560 | return fmt.Sprintf( 561 | c(verbose, 562 | "Tech: %v", 563 | "%v", 564 | ), 565 | tc.Tech, 566 | ) 567 | } 568 | 569 | // UpgradeCmd describes an upgrade command. Type: TypeUpgrade 570 | type UpgradeCmd struct { 571 | *Base 572 | 573 | // Upgrade that was started. 574 | Upgrade *Upgrade 575 | } 576 | 577 | // Params implements Cmd.Params(). 578 | func (uc *UpgradeCmd) Params(verbose bool) string { 579 | return fmt.Sprintf( 580 | c(verbose, 581 | "Upgrade: %v", 582 | "%v", 583 | ), uc.Upgrade, 584 | ) 585 | } 586 | 587 | // LatencyCmd describes a latency change command. Type: TypeLatency 588 | type LatencyCmd struct { 589 | *Base 590 | 591 | // Latency is the new latency. 592 | Latency *Latency 593 | } 594 | 595 | // Params implements Cmd.Params(). 596 | func (lc *LatencyCmd) Params(verbose bool) string { 597 | return fmt.Sprintf( 598 | c(verbose, 599 | "Latency: %v", 600 | "%v", 601 | ), lc.Latency, 602 | ) 603 | } 604 | -------------------------------------------------------------------------------- /rep/repcmd/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Package repcmd models player commands. 4 | 5 | */ 6 | package repcmd 7 | -------------------------------------------------------------------------------- /rep/repcmd/hotkeytypes.go: -------------------------------------------------------------------------------- 1 | // This file contains hotkey types. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // HotkeyType describes the hotkey type. 8 | type HotkeyType struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // HotkeyTypes is an enumeration of the possible hotkey types. 16 | var HotkeyTypes = []*HotkeyType{ 17 | {e("Assign"), 0x00}, 18 | {e("Select"), 0x01}, 19 | {e("Add"), 0x02}, 20 | } 21 | 22 | // HotkeyType IDs 23 | const ( 24 | HotkeyTypeIDAssign = 0x00 25 | HotkeyTypeIDSelect = 0x01 26 | HotkeyTypeIDAdd = 0x02 27 | ) 28 | 29 | // HotkeyTypeByID returns the HotkeyType for a given ID. 30 | // A new HotkeyType with Unknown name is returned if one is not found 31 | // for the given ID (preserving the unknown ID). 32 | func HotkeyTypeByID(ID byte) *HotkeyType { 33 | if int(ID) < len(HotkeyTypes) { 34 | return HotkeyTypes[ID] 35 | } 36 | return &HotkeyType{repcore.UnknownEnum(ID), ID} 37 | } 38 | -------------------------------------------------------------------------------- /rep/repcmd/latencies.go: -------------------------------------------------------------------------------- 1 | // This file contains latencies. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Latency describes the latency. 8 | type Latency struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // Latencies is an enumeration of the possible latencies. 16 | var Latencies = []*Latency{ 17 | {e("Low"), 0x00}, 18 | {e("High"), 0x01}, 19 | {e("Extra High"), 0x02}, 20 | } 21 | 22 | // LatencyTypeByID returns the Latency for a given ID. 23 | // A new Latency with Unknown name is returned if one is not found 24 | // for the given ID (preserving the unknown ID). 25 | func LatencyTypeByID(ID byte) *Latency { 26 | if int(ID) < len(Latencies) { 27 | return Latencies[ID] 28 | } 29 | return &Latency{repcore.UnknownEnum(ID), ID} 30 | } 31 | -------------------------------------------------------------------------------- /rep/repcmd/leavereasons.go: -------------------------------------------------------------------------------- 1 | // This file contains hotkey types. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // LeaveReason describes the leave reason. 8 | type LeaveReason struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // LeaveReasons is an enumeration of the possible leave reasons. 16 | var LeaveReasons = []*LeaveReason{ 17 | {e("Quit"), 0x01}, 18 | {e("Defeat"), 0x02}, 19 | {e("Victory"), 0x03}, 20 | {e("Finished"), 0x04}, 21 | {e("Draw"), 0x05}, 22 | {e("Dropped"), 0x06}, 23 | } 24 | 25 | // LeaveReasonByID returns the LeaveReason for a given ID. 26 | // A new LeaveReason with Unknown name is returned if one is not found 27 | // for the given ID (preserving the unknown ID). 28 | func LeaveReasonByID(ID byte) *LeaveReason { 29 | // Known reason IDs start from 1! 30 | if ID > 0 && int(ID) <= len(LeaveReasons) { 31 | return LeaveReasons[ID-1] 32 | } 33 | return &LeaveReason{repcore.UnknownEnum(ID), ID} 34 | } 35 | -------------------------------------------------------------------------------- /rep/repcmd/orders.go: -------------------------------------------------------------------------------- 1 | // This file contains unit orders. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Order describes the unit order. 8 | type Order struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // Orders is an enumeration of the possible unit orders. 16 | var Orders = []*Order{ 17 | {e("Die"), 0x00}, 18 | {e("Stop"), 0x01}, 19 | {e("Guard"), 0x02}, 20 | {e("PlayerGuard"), 0x03}, 21 | {e("TurretGuard"), 0x04}, 22 | {e("BunkerGuard"), 0x05}, 23 | {e("Move"), 0x06}, 24 | {e("ReaverStop"), 0x07}, 25 | {e("Attack1"), 0x08}, 26 | {e("Attack2"), 0x09}, 27 | {e("AttackUnit"), 0x0a}, 28 | {e("AttackFixedRange"), 0x0b}, 29 | {e("AttackTile"), 0x0c}, 30 | {e("Hover"), 0x0d}, 31 | {e("AttackMove"), 0x0e}, 32 | {e("InfestedCommandCenter"), 0x0f}, 33 | {e("UnusedNothing"), 0x10}, 34 | {e("UnusedPowerup"), 0x11}, 35 | {e("TowerGuard"), 0x12}, 36 | {e("TowerAttack"), 0x13}, 37 | {e("VultureMine"), 0x14}, 38 | {e("StayInRange"), 0x15}, 39 | {e("TurretAttack"), 0x16}, 40 | {e("Nothing"), 0x17}, 41 | {e("Unused_24"), 0x18}, 42 | {e("DroneStartBuild"), 0x19}, 43 | {e("DroneBuild"), 0x1a}, 44 | {e("CastInfestation"), 0x1b}, 45 | {e("MoveToInfest"), 0x1c}, 46 | {e("InfestingCommandCenter"), 0x1d}, 47 | {e("PlaceBuilding"), 0x1e}, 48 | {e("PlaceProtossBuilding"), 0x1f}, 49 | {e("CreateProtossBuilding"), 0x20}, 50 | {e("ConstructingBuilding"), 0x21}, 51 | {e("Repair"), 0x22}, 52 | {e("MoveToRepair"), 0x23}, 53 | {e("PlaceAddon"), 0x24}, 54 | {e("BuildAddon"), 0x25}, 55 | {e("Train"), 0x26}, 56 | {e("RallyPointUnit"), 0x27}, 57 | {e("RallyPointTile"), 0x28}, 58 | {e("ZergBirth"), 0x29}, 59 | {e("ZergUnitMorph"), 0x2a}, 60 | {e("ZergBuildingMorph"), 0x2b}, 61 | {e("IncompleteBuilding"), 0x2c}, 62 | {e("IncompleteMorphing"), 0x2d}, 63 | {e("BuildNydusExit"), 0x2e}, 64 | {e("EnterNydusCanal"), 0x2f}, 65 | {e("IncompleteWarping"), 0x30}, 66 | {e("Follow"), 0x31}, 67 | {e("Carrier"), 0x32}, 68 | {e("ReaverCarrierMove"), 0x33}, 69 | {e("CarrierStop"), 0x34}, 70 | {e("CarrierAttack"), 0x35}, 71 | {e("CarrierMoveToAttack"), 0x36}, 72 | {e("CarrierIgnore2"), 0x37}, 73 | {e("CarrierFight"), 0x38}, 74 | {e("CarrierHoldPosition"), 0x39}, 75 | {e("Reaver"), 0x3a}, 76 | {e("ReaverAttack"), 0x3b}, 77 | {e("ReaverMoveToAttack"), 0x3c}, 78 | {e("ReaverFight"), 0x3d}, 79 | {e("ReaverHoldPosition"), 0x3e}, 80 | {e("TrainFighter"), 0x3f}, 81 | {e("InterceptorAttack"), 0x40}, 82 | {e("ScarabAttack"), 0x41}, 83 | {e("RechargeShieldsUnit"), 0x42}, 84 | {e("RechargeShieldsBattery"), 0x43}, 85 | {e("ShieldBattery"), 0x44}, 86 | {e("InterceptorReturn"), 0x45}, 87 | {e("DroneLand"), 0x46}, 88 | {e("BuildingLand"), 0x47}, 89 | {e("BuildingLiftOff"), 0x48}, 90 | {e("DroneLiftOff"), 0x49}, 91 | {e("LiftingOff"), 0x4a}, 92 | {e("ResearchTech"), 0x4b}, 93 | {e("Upgrade"), 0x4c}, 94 | {e("Larva"), 0x4d}, 95 | {e("SpawningLarva"), 0x4e}, 96 | {e("Harvest1"), 0x4f}, 97 | {e("Harvest2"), 0x50}, 98 | {e("MoveToGas"), 0x51}, 99 | {e("WaitForGas"), 0x52}, 100 | {e("HarvestGas"), 0x53}, 101 | {e("ReturnGas"), 0x54}, 102 | {e("MoveToMinerals"), 0x55}, 103 | {e("WaitForMinerals"), 0x56}, 104 | {e("MiningMinerals"), 0x57}, 105 | {e("Harvest3"), 0x58}, 106 | {e("Harvest4"), 0x59}, 107 | {e("ReturnMinerals"), 0x5a}, 108 | {e("Interrupted"), 0x5b}, 109 | {e("EnterTransport"), 0x5c}, 110 | {e("PickupIdle"), 0x5d}, 111 | {e("PickupTransport"), 0x5e}, 112 | {e("PickupBunker"), 0x5f}, 113 | {e("Pickup4"), 0x60}, 114 | {e("PowerupIdle"), 0x61}, 115 | {e("Sieging"), 0x62}, 116 | {e("Unsieging"), 0x63}, 117 | {e("WatchTarget"), 0x64}, 118 | {e("InitCreepGrowth"), 0x65}, 119 | {e("SpreadCreep"), 0x66}, 120 | {e("StoppingCreepGrowth"), 0x67}, 121 | {e("GuardianAspect"), 0x68}, 122 | {e("ArchonWarp"), 0x69}, 123 | {e("CompletingArchonSummon"), 0x6a}, 124 | {e("HoldPosition"), 0x6b}, 125 | {e("QueenHoldPosition"), 0x6c}, 126 | {e("Cloak"), 0x6d}, 127 | {e("Decloak"), 0x6e}, 128 | {e("Unload"), 0x6f}, 129 | {e("MoveUnload"), 0x70}, 130 | {e("FireYamatoGun"), 0x71}, 131 | {e("MoveToFireYamatoGun"), 0x72}, 132 | {e("CastLockdown"), 0x73}, 133 | {e("Burrowing"), 0x74}, 134 | {e("Burrowed"), 0x75}, 135 | {e("Unburrowing"), 0x76}, 136 | {e("CastDarkSwarm"), 0x77}, 137 | {e("CastParasite"), 0x78}, 138 | {e("CastSpawnBroodlings"), 0x79}, 139 | {e("CastEMPShockwave"), 0x7a}, 140 | {e("NukeWait"), 0x7b}, 141 | {e("NukeTrain"), 0x7c}, 142 | {e("NukeLaunch"), 0x7d}, 143 | {e("NukePaint"), 0x7e}, 144 | {e("NukeUnit"), 0x7f}, 145 | {e("CastNuclearStrike"), 0x80}, 146 | {e("NukeTrack"), 0x81}, 147 | {e("InitializeArbiter"), 0x82}, 148 | {e("CloakNearbyUnits"), 0x83}, 149 | {e("PlaceMine"), 0x84}, 150 | {e("RightClickAction"), 0x85}, 151 | {e("SuicideUnit"), 0x86}, 152 | {e("SuicideLocation"), 0x87}, 153 | {e("SuicideHoldPosition"), 0x88}, 154 | {e("CastRecall"), 0x89}, 155 | {e("Teleport"), 0x8a}, 156 | {e("CastScannerSweep"), 0x8b}, 157 | {e("Scanner"), 0x8c}, 158 | {e("CastDefensiveMatrix"), 0x8d}, 159 | {e("CastPsionicStorm"), 0x8e}, 160 | {e("CastIrradiate"), 0x8f}, 161 | {e("CastPlague"), 0x90}, 162 | {e("CastConsume"), 0x91}, 163 | {e("CastEnsnare"), 0x92}, 164 | {e("CastStasisField"), 0x93}, 165 | {e("CastHallucination"), 0x94}, 166 | {e("Hallucination2"), 0x95}, 167 | {e("ResetCollision"), 0x96}, 168 | {e("ResetHarvestCollision"), 0x97}, 169 | {e("Patrol"), 0x98}, 170 | {e("CTFCOPInit"), 0x99}, 171 | {e("CTFCOPStarted"), 0x9a}, 172 | {e("CTFCOP2"), 0x9b}, 173 | {e("ComputerAI"), 0x9c}, 174 | {e("AtkMoveEP"), 0x9d}, 175 | {e("HarassMove"), 0x9e}, 176 | {e("AIPatrol"), 0x9f}, 177 | {e("GuardPost"), 0xa0}, 178 | {e("RescuePassive"), 0xa1}, 179 | {e("Neutral"), 0xa2}, 180 | {e("ComputerReturn"), 0xa3}, 181 | {e("InitializePsiProvider"), 0xa4}, 182 | {e("SelfDestructing"), 0xa5}, 183 | {e("Critter"), 0xa6}, 184 | {e("HiddenGun"), 0xa7}, 185 | {e("OpenDoor"), 0xa8}, 186 | {e("CloseDoor"), 0xa9}, 187 | {e("HideTrap"), 0xaa}, 188 | {e("RevealTrap"), 0xab}, 189 | {e("EnableDoodad"), 0xac}, 190 | {e("DisableDoodad"), 0xad}, 191 | {e("WarpIn"), 0xae}, 192 | {e("Medic"), 0xaf}, 193 | {e("MedicHeal"), 0xb0}, 194 | {e("HealMove"), 0xb1}, 195 | {e("MedicHoldPosition"), 0xb2}, 196 | {e("MedicHealToIdle"), 0xb3}, 197 | {e("CastRestoration"), 0xb4}, 198 | {e("CastDisruptionWeb"), 0xb5}, 199 | {e("CastMindControl"), 0xb6}, 200 | {e("DarkArchonMeld"), 0xb7}, 201 | {e("CastFeedback"), 0xb8}, 202 | {e("CastOpticalFlare"), 0xb9}, 203 | {e("CastMaelstrom"), 0xba}, 204 | {e("JunkYardDog"), 0xbb}, 205 | {e("Fatal"), 0xbc}, 206 | {e("None"), 0xbd}, 207 | } 208 | 209 | // OrderByID returns the Order for a given ID. 210 | // A new Order with Unknown name is returned if one is not found 211 | // for the given ID (preserving the unknown ID). 212 | func OrderByID(ID byte) *Order { 213 | if int(ID) < len(Orders) { 214 | return Orders[ID] 215 | } 216 | return &Order{repcore.UnknownEnum(ID), ID} 217 | } 218 | 219 | // Order IDs 220 | const ( 221 | OrderIDStop = 0x01 222 | OrderIDMove = 0x06 223 | OrderIDReaverStop = 0x07 224 | OrderIDAttack1 = 0x08 225 | OrderIDAttack2 = 0x09 226 | OrderIDAttackUnit = 0x0a 227 | OrderIDAttackFixedRange = 0x0b 228 | OrderIDAttackTile = 0x0c 229 | OrderIDAttackMove = 0x0e 230 | OrderIDPlaceProtossBuilding = 0x1f 231 | OrderIDRallyPointUnit = 0x27 232 | OrderIDRallyPointTile = 0x28 233 | OrderIDCarrierStop = 0x34 234 | OrderIDCarrierAttack = 0x35 235 | OrderIDCarrierHoldPosition = 0x39 236 | OrderIDReaverHoldPosition = 0x3e 237 | OrderIDReaverAttack = 0x3b 238 | OrderIDBuildingLand = 0x47 239 | OrderIDHoldPosition = 0x6b 240 | OrderIDQueenHoldPosition = 0x6c 241 | OrderIDUnload = 0x6f 242 | OrderIDMoveUnload = 0x70 243 | OrderIDNukeLaunch = 0x7d 244 | OrderIDCastRecall = 0x89 245 | OrderIDCastScannerSweep = 0x8b 246 | OrderIDMedicHoldPosition = 0xb2 247 | ) 248 | 249 | // IsOrderIDKindStop tells if the given order ID is one of the stop orders. 250 | func IsOrderIDKindStop(orderID byte) bool { 251 | switch orderID { 252 | case OrderIDStop, OrderIDReaverStop, OrderIDCarrierStop: 253 | return true 254 | } 255 | return false 256 | } 257 | 258 | // IsOrderIDKindHold tells if the given order ID is one of the hold orders. 259 | func IsOrderIDKindHold(orderID byte) bool { 260 | switch orderID { 261 | case OrderIDHoldPosition, OrderIDCarrierHoldPosition, OrderIDReaverHoldPosition, 262 | OrderIDQueenHoldPosition, OrderIDMedicHoldPosition: 263 | return true 264 | } 265 | return false 266 | } 267 | 268 | // IsOrderIDKindAttack tells if the given order ID is one of the attack orders. 269 | func IsOrderIDKindAttack(orderID byte) bool { 270 | switch orderID { 271 | case OrderIDAttack1, OrderIDAttack2, OrderIDAttackUnit, OrderIDAttackFixedRange, 272 | OrderIDAttackMove, OrderIDCarrierAttack, OrderIDReaverAttack: 273 | return true 274 | } 275 | return false 276 | } 277 | -------------------------------------------------------------------------------- /rep/repcmd/techs.go: -------------------------------------------------------------------------------- 1 | // This file contains techs. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Tech describes the tech (research). 8 | type Tech struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // Techs is an enumeration of the possible techs. 16 | var Techs = []*Tech{ 17 | {e("Stim Packs"), 0x00}, 18 | {e("Lockdown"), 0x01}, 19 | {e("EMP Shockwave"), 0x02}, 20 | {e("Spider Mines"), 0x03}, 21 | {e("Scanner Sweep"), 0x04}, 22 | {e("Tank Siege Mode"), 0x05}, 23 | {e("Defensive Matrix"), 0x06}, 24 | {e("Irradiate"), 0x07}, 25 | {e("Yamato Gun"), 0x08}, 26 | {e("Cloaking Field"), 0x09}, 27 | {e("Personnel Cloaking"), 0x0a}, 28 | {e("Burrowing"), 0x0b}, 29 | {e("Infestation"), 0x0c}, 30 | {e("Spawn Broodlings"), 0x0d}, 31 | {e("Dark Swarm"), 0x0e}, 32 | {e("Plague"), 0x0f}, 33 | {e("Consume"), 0x10}, 34 | {e("Ensnare"), 0x11}, 35 | {e("Parasite"), 0x12}, 36 | {e("Psionic Storm"), 0x13}, 37 | {e("Hallucination"), 0x14}, 38 | {e("Recall"), 0x15}, 39 | {e("Stasis Field"), 0x16}, 40 | {e("Archon Warp"), 0x17}, 41 | {e("Restoration"), 0x18}, 42 | {e("Disruption Web"), 0x19}, 43 | {e("Unused 26"), 0x1a}, 44 | {e("Mind Control"), 0x1b}, 45 | {e("Dark Archon Meld"), 0x1c}, 46 | {e("Feedback"), 0x1d}, 47 | {e("Optical Flare"), 0x1e}, 48 | {e("Maelstrom"), 0x1f}, 49 | {e("Lurker Aspect"), 0x20}, 50 | {e("Unused 33"), 0x21}, 51 | {e("Healing"), 0x22}, 52 | } 53 | 54 | // TechByID returns the Tech for a given ID. 55 | // A new Tech with Unknown name is returned if one is not found 56 | // for the given ID (preserving the unknown ID). 57 | func TechByID(ID byte) *Tech { 58 | if int(ID) < len(Techs) { 59 | return Techs[ID] 60 | } 61 | return &Tech{repcore.UnknownEnum(ID), ID} 62 | } 63 | -------------------------------------------------------------------------------- /rep/repcmd/types.go: -------------------------------------------------------------------------------- 1 | // This file contains the command types. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Type IDs of command types 8 | const ( 9 | TypeIDKeepAlive byte = 0x05 10 | TypeIDSaveGame byte = 0x06 11 | TypeIDLoadGame byte = 0x07 12 | TypeIDRestartGame byte = 0x08 13 | TypeIDSelect byte = 0x09 14 | TypeIDSelectAdd byte = 0x0a 15 | TypeIDSelectRemove byte = 0x0b 16 | TypeIDBuild byte = 0x0c 17 | TypeIDVision byte = 0x0d 18 | TypeIDAlliance byte = 0x0e 19 | TypeIDGameSpeed byte = 0x0f 20 | TypeIDPause byte = 0x10 21 | TypeIDResume byte = 0x11 22 | TypeIDCheat byte = 0x12 23 | TypeIDHotkey byte = 0x13 24 | TypeIDRightClick byte = 0x14 25 | TypeIDTargetedOrder byte = 0x15 26 | TypeIDCancelBuild byte = 0x18 27 | TypeIDCancelMorph byte = 0x19 28 | TypeIDStop byte = 0x1a 29 | TypeIDCarrierStop byte = 0x1b 30 | TypeIDReaverStop byte = 0x1c 31 | TypeIDOrderNothing byte = 0x1d 32 | TypeIDReturnCargo byte = 0x1e 33 | TypeIDTrain byte = 0x1f 34 | TypeIDCancelTrain byte = 0x20 35 | TypeIDCloack byte = 0x21 36 | TypeIDDecloack byte = 0x22 37 | TypeIDUnitMorph byte = 0x23 38 | TypeIDUnsiege byte = 0x25 39 | TypeIDSiege byte = 0x26 40 | TypeIDTrainFighter byte = 0x27 // Build interceptor / scarab 41 | TypeIDUnloadAll byte = 0x28 42 | TypeIDUnload byte = 0x29 43 | TypeIDMergeArchon byte = 0x2a 44 | TypeIDHoldPosition byte = 0x2b 45 | TypeIDBurrow byte = 0x2c 46 | TypeIDUnburrow byte = 0x2d 47 | TypeIDCancelNuke byte = 0x2e 48 | TypeIDLiftOff byte = 0x2f 49 | TypeIDTech byte = 0x30 50 | TypeIDCancelTech byte = 0x31 51 | TypeIDUpgrade byte = 0x32 52 | TypeIDCancelUpgrade byte = 0x33 53 | TypeIDCancelAddon byte = 0x34 54 | TypeIDBuildingMorph byte = 0x35 55 | TypeIDStim byte = 0x36 56 | TypeIDSync byte = 0x37 57 | TypeIDVoiceEnable byte = 0x38 58 | TypeIDVoiceDisable byte = 0x39 59 | TypeIDVoiceSquelch byte = 0x3a 60 | TypeIDVoiceUnsquelch byte = 0x3b 61 | TypeIDStartGame byte = 0x3c 62 | TypeIDDownloadPercentage byte = 0x3d 63 | TypeIDChangeGameSlot byte = 0x3e 64 | TypeIDNewNetPlayer byte = 0x3f 65 | TypeIDJoinedGame byte = 0x40 66 | TypeIDChangeRace byte = 0x41 67 | TypeIDTeamGameTeam byte = 0x42 68 | TypeIDUMSTeam byte = 0x43 69 | TypeIDMeleeTeam byte = 0x44 70 | TypeIDSwapPlayers byte = 0x45 71 | TypeIDSavedData byte = 0x48 72 | TypeIDBriefingStart byte = 0x54 73 | TypeIDLatency byte = 0x55 74 | TypeIDReplaySpeed byte = 0x56 75 | TypeIDLeaveGame byte = 0x57 76 | TypeIDMinimapPing byte = 0x58 77 | TypeIDMergeDarkArchon byte = 0x5a 78 | TypeIDMakeGamePublic byte = 0x5b 79 | TypeIDChat byte = 0x5c 80 | TypeIDRightClick121 byte = 0x60 81 | TypeIDTargetedOrder121 byte = 0x61 82 | TypeIDUnload121 byte = 0x62 83 | TypeIDSelect121 byte = 0x63 84 | TypeIDSelectAdd121 byte = 0x64 85 | TypeIDSelectRemove121 byte = 0x65 86 | ) 87 | 88 | // Virtual Type IDs of command types 89 | const ( 90 | VirtualTypeIDLand byte = 0xfe // Recorded as TypeIDBuild but the meaning (and text) is fundamentally different 91 | ) 92 | 93 | // Type describes the command type. 94 | type Type struct { 95 | repcore.Enum 96 | 97 | // ID as it appears in replays 98 | ID byte 99 | } 100 | 101 | // Types is an enumeration of the possible command types 102 | var Types = []*Type{ 103 | {e("Keep Alive"), TypeIDKeepAlive}, 104 | {e("Save Game"), TypeIDSaveGame}, 105 | {e("Load Game"), TypeIDLoadGame}, 106 | {e("Restart Game"), TypeIDRestartGame}, 107 | {e("Select"), TypeIDSelect}, 108 | {e("Select Add"), TypeIDSelectAdd}, 109 | {e("Select Remove"), TypeIDSelectRemove}, 110 | {e("Build"), TypeIDBuild}, 111 | {e("Vision"), TypeIDVision}, 112 | {e("Alliance"), TypeIDAlliance}, 113 | {e("Game Speed"), TypeIDGameSpeed}, 114 | {e("Pause"), TypeIDPause}, 115 | {e("Resume"), TypeIDResume}, 116 | {e("Cheat"), TypeIDCheat}, 117 | {e("Hotkey"), TypeIDHotkey}, 118 | {e("Right Click"), TypeIDRightClick}, 119 | {e("Targeted Order"), TypeIDTargetedOrder}, 120 | {e("Cancel Build"), TypeIDCancelBuild}, 121 | {e("Cancel Morph"), TypeIDCancelMorph}, 122 | {e("Stop"), TypeIDStop}, 123 | {e("Carrier Stop"), TypeIDCarrierStop}, 124 | {e("Reaver Stop"), TypeIDReaverStop}, 125 | {e("Order Nothing"), TypeIDOrderNothing}, 126 | {e("Return Cargo"), TypeIDReturnCargo}, 127 | {e("Train"), TypeIDTrain}, 128 | {e("Cancel Train"), TypeIDCancelTrain}, 129 | {e("Cloack"), TypeIDCloack}, 130 | {e("Decloack"), TypeIDDecloack}, 131 | {e("Unit Morph"), TypeIDUnitMorph}, 132 | {e("Unsiege"), TypeIDUnsiege}, 133 | {e("Siege"), TypeIDSiege}, 134 | {e("Train Fighter"), TypeIDTrainFighter}, // Build interceptor / scarab 135 | {e("Unload All"), TypeIDUnloadAll}, 136 | {e("Unload"), TypeIDUnload}, 137 | {e("Merge Archon"), TypeIDMergeArchon}, 138 | {e("Hold Position"), TypeIDHoldPosition}, 139 | {e("Burrow"), TypeIDBurrow}, 140 | {e("Unburrow"), TypeIDUnburrow}, 141 | {e("Cancel Nuke"), TypeIDCancelNuke}, 142 | {e("Lift Off"), TypeIDLiftOff}, 143 | {e("Tech"), TypeIDTech}, 144 | {e("Cancel Tech"), TypeIDCancelTech}, 145 | {e("Upgrade"), TypeIDUpgrade}, 146 | {e("Cancel Upgrade"), TypeIDCancelUpgrade}, 147 | {e("Cancel Addon"), TypeIDCancelAddon}, 148 | {e("Building Morph"), TypeIDBuildingMorph}, 149 | {e("Stim"), TypeIDStim}, 150 | {e("Sync"), TypeIDSync}, 151 | {e("Voice Enable"), TypeIDVoiceEnable}, 152 | {e("Voice Disable"), TypeIDVoiceDisable}, 153 | {e("Voice Squelch"), TypeIDVoiceSquelch}, 154 | {e("Voice Unsquelch"), TypeIDVoiceUnsquelch}, 155 | {e("[Lobby] Start Game"), TypeIDStartGame}, 156 | {e("[Lobby] Download Percentage"), TypeIDDownloadPercentage}, 157 | {e("[Lobby] Change Game Slot"), TypeIDChangeGameSlot}, 158 | {e("[Lobby] New Net Player"), TypeIDNewNetPlayer}, 159 | {e("[Lobby] Joined Game"), TypeIDJoinedGame}, 160 | {e("[Lobby] Change Race"), TypeIDChangeRace}, 161 | {e("[Lobby] Team Game Team"), TypeIDTeamGameTeam}, 162 | {e("[Lobby] UMS Team"), TypeIDUMSTeam}, 163 | {e("[Lobby] Melee Team"), TypeIDMeleeTeam}, 164 | {e("[Lobby] Swap Players"), TypeIDSwapPlayers}, 165 | {e("[Lobby] Saved Data"), TypeIDSavedData}, 166 | {e("Briefing Start"), TypeIDBriefingStart}, 167 | {e("Latency"), TypeIDLatency}, 168 | {e("Replay Speed"), TypeIDReplaySpeed}, 169 | {e("Leave Game"), TypeIDLeaveGame}, 170 | {e("Minimap Ping"), TypeIDMinimapPing}, 171 | {e("Merge Dark Archon"), TypeIDMergeDarkArchon}, 172 | {e("Make Game Public"), TypeIDMakeGamePublic}, 173 | {e("Chat"), TypeIDChat}, 174 | {e("Right Click"), TypeIDRightClick121}, 175 | {e("Targeted Order"), TypeIDTargetedOrder121}, 176 | {e("Unload"), TypeIDUnload121}, 177 | {e("Select"), TypeIDSelect121}, 178 | {e("Select Add"), TypeIDSelectAdd121}, 179 | {e("Select Remove"), TypeIDSelectRemove121}, 180 | 181 | {e("Land"), VirtualTypeIDLand}, 182 | } 183 | 184 | // Named command types 185 | var ( 186 | TypeKeepAlive = Types[0] 187 | TypeSaveGame = Types[1] 188 | TypeLoadGame = Types[2] 189 | TypeRestartGame = Types[3] 190 | TypeSelect = Types[4] 191 | TypeSelectAdd = Types[5] 192 | TypeSelectRemove = Types[6] 193 | TypeBuild = Types[7] 194 | TypeVision = Types[8] 195 | TypeAlliance = Types[9] 196 | TypeGameSpeed = Types[10] 197 | TypePause = Types[11] 198 | TypeResume = Types[12] 199 | TypeCheat = Types[13] 200 | TypeHotkey = Types[14] 201 | TypeRightClick = Types[15] 202 | TypeTargetedOrder = Types[16] 203 | TypeCancelBuild = Types[17] 204 | TypeCancelMorph = Types[18] 205 | TypeStop = Types[19] 206 | TypeCarrierStop = Types[20] 207 | TypeReaverStop = Types[21] 208 | TypeOrderNothing = Types[22] 209 | TypeReturnCargo = Types[23] 210 | TypeTrain = Types[24] 211 | TypeCancelTrain = Types[25] 212 | TypeCloack = Types[26] 213 | TypeDecloack = Types[27] 214 | TypeUnitMorph = Types[28] 215 | TypeUnsiege = Types[29] 216 | TypeSiege = Types[30] 217 | TypeTrainFighter = Types[31] // Build interceptor / scarab 218 | TypeUnloadAll = Types[32] 219 | TypeUnload = Types[33] 220 | TypeMergeArchon = Types[34] 221 | TypeHoldPosition = Types[35] 222 | TypeBurrow = Types[36] 223 | TypeUnburrow = Types[37] 224 | TypeCancelNuke = Types[38] 225 | TypeLiftOff = Types[39] 226 | TypeTech = Types[40] 227 | TypeCancelTech = Types[41] 228 | TypeUpgrade = Types[42] 229 | TypeCancelUpgrade = Types[43] 230 | TypeCancelAddon = Types[44] 231 | TypeBuildingMorph = Types[45] 232 | TypeStim = Types[46] 233 | TypeSync = Types[47] 234 | TypeVoiceEnable = Types[48] 235 | TypeVoiceDisable = Types[49] 236 | TypeVoiceSquelch = Types[50] 237 | TypeVoiceUnsquelch = Types[51] 238 | TypeStartGame = Types[52] 239 | TypeDownloadPercentage = Types[53] 240 | TypeChangeGameSlot = Types[54] 241 | TypeNewNetPlayer = Types[55] 242 | TypeJoinedGame = Types[56] 243 | TypeChangeRace = Types[57] 244 | TypeTeamGameTeam = Types[58] 245 | TypeUMSTeam = Types[59] 246 | TypeMeleeTeam = Types[60] 247 | TypeSwapPlayers = Types[61] 248 | TypeSavedData = Types[62] 249 | TypeBriefingStart = Types[63] 250 | TypeLatency = Types[64] 251 | TypeReplaySpeed = Types[65] 252 | TypeLeaveGame = Types[66] 253 | TypeMinimapPing = Types[67] 254 | TypeMergeDarkArchon = Types[68] 255 | TypeMakeGamePublic = Types[69] 256 | TypeChat = Types[70] 257 | TypeRightClick121 = Types[71] 258 | TypeTargetedOrder121 = Types[72] 259 | TypeUnload121 = Types[73] 260 | TypeSelect121 = Types[74] 261 | TypeSelectAdd121 = Types[75] 262 | TypeSelectRemove121 = Types[76] 263 | 264 | TypeLand = Types[77] 265 | ) 266 | 267 | // typeIDType maps from type ID to type. 268 | var typeIDType = map[byte]*Type{} 269 | 270 | func init() { 271 | for _, t := range Types { 272 | typeIDType[t.ID] = t 273 | } 274 | } 275 | 276 | // TypeByID returns the Type for a given ID. 277 | // A new Type with Unknown name is returned if one is not found 278 | // for the given ID (preserving the unknown ID). 279 | func TypeByID(ID byte) *Type { 280 | if t := typeIDType[ID]; t != nil { 281 | return t 282 | } 283 | return &Type{repcore.UnknownEnum(ID), ID} 284 | } 285 | -------------------------------------------------------------------------------- /rep/repcmd/units.go: -------------------------------------------------------------------------------- 1 | // This file contains the units. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Unit describes the unit. 8 | type Unit struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID uint16 13 | } 14 | 15 | // Units is an enumeration of the possible units 16 | var Units = []*Unit{ 17 | {e("Marine"), 0x00}, 18 | {e("Ghost"), 0x01}, 19 | {e("Vulture"), 0x02}, 20 | {e("Goliath"), 0x03}, 21 | {e("Goliath Turret"), 0x04}, 22 | {e("Siege Tank (Tank Mode)"), 0x05}, 23 | {e("Siege Tank Turret (Tank Mode)"), 0x06}, 24 | {e("SCV"), 0x07}, 25 | {e("Wraith"), 0x08}, 26 | {e("Science Vessel"), 0x09}, 27 | {e("Gui Motang (Firebat)"), 0x0A}, 28 | {e("Dropship"), 0x0B}, 29 | {e("Battlecruiser"), 0x0C}, 30 | {e("Spider Mine"), 0x0D}, 31 | {e("Nuclear Missile"), 0x0E}, 32 | {e("Terran Civilian"), 0x0F}, 33 | {e("Sarah Kerrigan (Ghost)"), 0x10}, 34 | {e("Alan Schezar (Goliath)"), 0x11}, 35 | {e("Alan Schezar Turret"), 0x12}, 36 | {e("Jim Raynor (Vulture)"), 0x13}, 37 | {e("Jim Raynor (Marine)"), 0x14}, 38 | {e("Tom Kazansky (Wraith)"), 0x15}, 39 | {e("Magellan (Science Vessel)"), 0x16}, 40 | {e("Edmund Duke (Tank Mode)"), 0x17}, 41 | {e("Edmund Duke Turret (Tank Mode)"), 0x18}, 42 | {e("Edmund Duke (Siege Mode)"), 0x19}, 43 | {e("Edmund Duke Turret (Siege Mode)"), 0x1A}, 44 | {e("Arcturus Mengsk (Battlecruiser)"), 0x1B}, 45 | {e("Hyperion (Battlecruiser)"), 0x1C}, 46 | {e("Norad II (Battlecruiser)"), 0x1D}, 47 | {e("Terran Siege Tank (Siege Mode)"), 0x1E}, 48 | {e("Siege Tank Turret (Siege Mode)"), 0x1F}, 49 | {e("Firebat"), 0x20}, 50 | {e("Scanner Sweep"), 0x21}, 51 | {e("Medic"), 0x22}, 52 | {e("Larva"), 0x23}, 53 | {e("Egg"), 0x24}, 54 | {e("Zergling"), 0x25}, 55 | {e("Hydralisk"), 0x26}, 56 | {e("Ultralisk"), 0x27}, 57 | {e("Drone"), 0x29}, 58 | {e("Overlord"), 0x2A}, 59 | {e("Mutalisk"), 0x2B}, 60 | {e("Guardian"), 0x2C}, 61 | {e("Queen"), 0x2D}, 62 | {e("Defiler"), 0x2E}, 63 | {e("Scourge"), 0x2F}, 64 | {e("Torrasque (Ultralisk)"), 0x30}, 65 | {e("Matriarch (Queen)"), 0x31}, 66 | {e("Infested Terran"), 0x32}, 67 | {e("Infested Kerrigan (Infested Terran)"), 0x33}, 68 | {e("Unclean One (Defiler)"), 0x34}, 69 | {e("Hunter Killer (Hydralisk)"), 0x35}, 70 | {e("Devouring One (Zergling)"), 0x36}, 71 | {e("Kukulza (Mutalisk)"), 0x37}, 72 | {e("Kukulza (Guardian)"), 0x38}, 73 | {e("Yggdrasill (Overlord)"), 0x39}, 74 | {e("Valkyrie"), 0x3A}, 75 | {e("Mutalisk Cocoon"), 0x3B}, 76 | {e("Corsair"), 0x3C}, 77 | {e("Dark Templar"), 0x3D}, 78 | {e("Devourer"), 0x3E}, 79 | {e("Dark Archon"), 0x3F}, 80 | {e("Probe"), 0x40}, 81 | {e("Zealot"), 0x41}, 82 | {e("Dragoon"), 0x42}, 83 | {e("High Templar"), 0x43}, 84 | {e("Archon"), 0x44}, 85 | {e("Shuttle"), 0x45}, 86 | {e("Scout"), 0x46}, 87 | {e("Arbiter"), 0x47}, 88 | {e("Carrier"), 0x48}, 89 | {e("Interceptor"), 0x49}, 90 | {e("Protoss Dark Templar (Hero)"), 0x4A}, 91 | {e("Zeratul (Dark Templar)"), 0x4B}, 92 | {e("Tassadar/Zeratul (Archon)"), 0x4C}, 93 | {e("Fenix (Zealot)"), 0x4D}, 94 | {e("Fenix (Dragoon)"), 0x4E}, 95 | {e("Tassadar (Templar)"), 0x4F}, 96 | {e("Mojo (Scout)"), 0x50}, 97 | {e("Warbringer (Reaver)"), 0x51}, 98 | {e("Gantrithor (Carrier)"), 0x52}, 99 | {e("Reaver"), 0x53}, 100 | {e("Observer"), 0x54}, 101 | {e("Scarab"), 0x55}, 102 | {e("Danimoth (Arbiter)"), 0x56}, 103 | {e("Aldaris (Templar)"), 0x57}, 104 | {e("Artanis (Scout)"), 0x58}, 105 | {e("Rhynadon (Badlands Critter)"), 0x59}, 106 | {e("Bengalaas (Jungle Critter)"), 0x5A}, 107 | {e("Cargo Ship (Unused)"), 0x5B}, 108 | {e("Mercenary Gunship (Unused)"), 0x5C}, 109 | {e("Scantid (Desert Critter)"), 0x5D}, 110 | {e("Kakaru (Twilight Critter)"), 0x5E}, 111 | {e("Ragnasaur (Ashworld Critter)"), 0x5F}, 112 | {e("Ursadon (Ice World Critter)"), 0x60}, 113 | {e("Lurker Egg"), 0x61}, 114 | {e("Raszagal (Corsair)"), 0x62}, 115 | {e("Samir Duran (Ghost)"), 0x63}, 116 | {e("Alexei Stukov (Ghost)"), 0x64}, 117 | {e("Map Revealer"), 0x65}, 118 | {e("Gerard DuGalle (BattleCruiser)"), 0x66}, 119 | {e("Lurker"), 0x67}, 120 | {e("Infested Duran (Infested Terran)"), 0x68}, 121 | {e("Disruption Web"), 0x69}, 122 | {e("Command Center"), 0x6A}, 123 | {e("ComSat"), 0x6B}, 124 | {e("Nuclear Silo"), 0x6C}, 125 | {e("Supply Depot"), 0x6D}, 126 | {e("Refinery"), 0x6E}, 127 | {e("Barracks"), 0x6F}, 128 | {e("Academy"), 0x70}, 129 | {e("Factory"), 0x71}, 130 | {e("Starport"), 0x72}, 131 | {e("Control Tower"), 0x73}, 132 | {e("Science Facility"), 0x74}, 133 | {e("Covert Ops"), 0x75}, 134 | {e("Physics Lab"), 0x76}, 135 | {e("Machine Shop"), 0x78}, 136 | {e("Repair Bay (Unused)"), 0x79}, 137 | {e("Engineering Bay"), 0x7A}, 138 | {e("Armory"), 0x7B}, 139 | {e("Missile Turret"), 0x7C}, 140 | {e("Bunker"), 0x7D}, 141 | {e("Norad II (Crashed)"), 0x7E}, 142 | {e("Ion Cannon"), 0x7F}, 143 | {e("Uraj Crystal"), 0x80}, 144 | {e("Khalis Crystal"), 0x81}, 145 | {e("Infested CC"), 0x82}, 146 | {e("Hatchery"), 0x83}, 147 | {e("Lair"), 0x84}, 148 | {e("Hive"), 0x85}, 149 | {e("Nydus Canal"), 0x86}, 150 | {e("Hydralisk Den"), 0x87}, 151 | {e("Defiler Mound"), 0x88}, 152 | {e("Greater Spire"), 0x89}, 153 | {e("Queens Nest"), 0x8A}, 154 | {e("Evolution Chamber"), 0x8B}, 155 | {e("Ultralisk Cavern"), 0x8C}, 156 | {e("Spire"), 0x8D}, 157 | {e("Spawning Pool"), 0x8E}, 158 | {e("Creep Colony"), 0x8F}, 159 | {e("Spore Colony"), 0x90}, 160 | {e("Unused Zerg Building1"), 0x91}, 161 | {e("Sunken Colony"), 0x92}, 162 | {e("Zerg Overmind (With Shell)"), 0x93}, 163 | {e("Overmind"), 0x94}, 164 | {e("Extractor"), 0x95}, 165 | {e("Mature Chrysalis"), 0x96}, 166 | {e("Cerebrate"), 0x97}, 167 | {e("Cerebrate Daggoth"), 0x98}, 168 | {e("Unused Zerg Building2"), 0x99}, 169 | {e("Nexus"), 0x9A}, 170 | {e("Robotics Facility"), 0x9B}, 171 | {e("Pylon"), 0x9C}, 172 | {e("Assimilator"), 0x9D}, 173 | {e("Unused Protoss Building1"), 0x9E}, 174 | {e("Observatory"), 0x9F}, 175 | {e("Gateway"), 0xA0}, 176 | {e("Unused Protoss Building2"), 0xA1}, 177 | {e("Photon Cannon"), 0xA2}, 178 | {e("Citadel of Adun"), 0xA3}, 179 | {e("Cybernetics Core"), 0xA4}, 180 | {e("Templar Archives"), 0xA5}, 181 | {e("Forge"), 0xA6}, 182 | {e("Stargate"), 0xA7}, 183 | {e("Stasis Cell/Prison"), 0xA8}, 184 | {e("Fleet Beacon"), 0xA9}, 185 | {e("Arbiter Tribunal"), 0xAA}, 186 | {e("Robotics Support Bay"), 0xAB}, 187 | {e("Shield Battery"), 0xAC}, 188 | {e("Khaydarin Crystal Formation"), 0xAD}, 189 | {e("Protoss Temple"), 0xAE}, 190 | {e("Xel'Naga Temple"), 0xAF}, 191 | {e("Mineral Field (Type 1)"), 0xB0}, 192 | {e("Mineral Field (Type 2)"), 0xB1}, 193 | {e("Mineral Field (Type 3)"), 0xB2}, 194 | {e("Cave (Unused)"), 0xB3}, 195 | {e("Cave-in (Unused)"), 0xB4}, 196 | {e("Cantina (Unused)"), 0xB5}, 197 | {e("Mining Platform (Unused)"), 0xB6}, 198 | {e("Independent Command Center (Unused)"), 0xB7}, 199 | {e("Independent Starport (Unused)"), 0xB8}, 200 | {e("Independent Jump Gate (Unused)"), 0xB9}, 201 | {e("Ruins (Unused)"), 0xBA}, 202 | {e("Khaydarin Crystal Formation (Unused)"), 0xBB}, 203 | {e("Vespene Geyser"), 0xBC}, 204 | {e("Warp Gate"), 0xBD}, 205 | {e("Psi Disrupter"), 0xBE}, 206 | {e("Zerg Marker"), 0xBF}, 207 | {e("Terran Marker"), 0xC0}, 208 | {e("Protoss Marker"), 0xC1}, 209 | {e("Zerg Beacon"), 0xC2}, 210 | {e("Terran Beacon"), 0xC3}, 211 | {e("Protoss Beacon"), 0xC4}, 212 | {e("Zerg Flag Beacon"), 0xC5}, 213 | {e("Terran Flag Beacon"), 0xC6}, 214 | {e("Protoss Flag Beacon"), 0xC7}, 215 | {e("Power Generator"), 0xC8}, 216 | {e("Overmind Cocoon"), 0xC9}, 217 | {e("Dark Swarm"), 0xCA}, 218 | {e("Floor Missile Trap"), 0xCB}, 219 | {e("Floor Hatch (Unused)"), 0xCC}, 220 | {e("Left Upper Level Door"), 0xCD}, 221 | {e("Right Upper Level Door"), 0xCE}, 222 | {e("Left Pit Door"), 0xCF}, 223 | {e("Right Pit Door"), 0xD0}, 224 | {e("Floor Gun Trap"), 0xD1}, 225 | {e("Left Wall Missile Trap"), 0xD2}, 226 | {e("Left Wall Flame Trap"), 0xD3}, 227 | {e("Right Wall Missile Trap"), 0xD4}, 228 | {e("Right Wall Flame Trap"), 0xD5}, 229 | {e("Start Location"), 0xD6}, 230 | {e("Flag"), 0xD7}, 231 | {e("Young Chrysalis"), 0xD8}, 232 | {e("Psi Emitter"), 0xD9}, 233 | {e("Data Disc"), 0xDA}, 234 | {e("Khaydarin Crystal"), 0xDB}, 235 | {e("Mineral Cluster Type 1"), 0xDC}, 236 | {e("Mineral Cluster Type 2"), 0xDD}, 237 | {e("Protoss Vespene Gas Orb Type 1"), 0xDE}, 238 | {e("Protoss Vespene Gas Orb Type 2"), 0xDF}, 239 | {e("Zerg Vespene Gas Sac Type 1"), 0xE0}, 240 | {e("Zerg Vespene Gas Sac Type 2"), 0xE1}, 241 | {e("Terran Vespene Gas Tank Type 1"), 0xE2}, 242 | {e("Terran Vespene Gas Tank Type 2"), 0xE3}, 243 | {e("None"), 0xE4}, 244 | } 245 | 246 | // unitIDUnit maps from unit ID to unit. 247 | var unitIDUnit = map[uint16]*Unit{} 248 | 249 | func init() { 250 | for _, u := range Units { 251 | unitIDUnit[u.ID] = u 252 | } 253 | } 254 | 255 | // Unit IDs 256 | const ( 257 | // Critters 258 | UnitIDRhynadon = 0x59 259 | UnitIDBengalaas = 0x5a 260 | UnitIDScantid = 0x5d 261 | UnitIDKakaru = 0x5e 262 | UnitIDRagnasaur = 0x5f 263 | UnitIDUrsadon = 0x60 264 | 265 | UnitIDCommandCenter = 0x6A 266 | UnitIDComSat = 0x6B 267 | UnitIDNuclearSilo = 0x6C 268 | UnitIDSupplyDepot = 0x6D 269 | UnitIDRefinery = 0x6E 270 | UnitIDBarracks = 0x6F 271 | UnitIDAcademy = 0x70 272 | UnitIDFactory = 0x71 273 | UnitIDStarport = 0x72 274 | UnitIDControlTower = 0x73 275 | UnitIDScienceFacility = 0x74 276 | UnitIDCovertOps = 0x75 277 | UnitIDPhysicsLab = 0x76 278 | UnitIDMachineShop = 0x78 279 | UnitIDEngineeringBay = 0x7A 280 | UnitIDArmory = 0x7B 281 | UnitIDMissileTurret = 0x7C 282 | UnitIDBunker = 0x7D 283 | 284 | UnitIDInfestedCC = 0x82 285 | UnitIDHatchery = 0x83 286 | UnitIDLair = 0x84 287 | UnitIDHive = 0x85 288 | UnitIDNydusCanal = 0x86 289 | UnitIDHydraliskDen = 0x87 290 | UnitIDDefilerMound = 0x88 291 | UnitIDGreaterSpire = 0x89 292 | UnitIDQueensNest = 0x8A 293 | UnitIDEvolutionChamber = 0x8B 294 | UnitIDUltraliskCavern = 0x8C 295 | UnitIDSpire = 0x8D 296 | UnitIDSpawningPool = 0x8E 297 | UnitIDCreepColony = 0x8F 298 | UnitIDSporeColony = 0x90 299 | UnitIDSunkenColony = 0x92 300 | UnitIDExtractor = 0x95 301 | 302 | UnitIDNexus = 0x9A 303 | UnitIDRoboticsFacility = 0x9B 304 | UnitIDPylon = 0x9C 305 | UnitIDAssimilator = 0x9D 306 | UnitIDObservatory = 0x9F 307 | UnitIDGateway = 0xA0 308 | UnitIDPhotonCannon = 0xA2 309 | UnitIDCitadelOfAdun = 0xA3 310 | UnitIDCyberneticsCore = 0xA4 311 | UnitIDTemplarArchives = 0xA5 312 | UnitIDForge = 0xA6 313 | UnitIDStargate = 0xA7 314 | UnitIDFleetBeacon = 0xA9 315 | UnitIDArbiterTribunal = 0xAA 316 | UnitIDRoboticsSupportBay = 0xAB 317 | UnitIDShieldBattery = 0xAC 318 | 319 | UnitIDMineralField1 = 0xB0 320 | UnitIDMineralField2 = 0xB1 321 | UnitIDMineralField3 = 0xB2 322 | UnitIDVespeneGeyser = 0xBC 323 | UnitIDStartLocation = 0xD6 324 | 325 | UnitIDNone = 0xE4 326 | ) 327 | 328 | // UnitByID returns the Unit for a given ID. 329 | // A new Unit with Unknown name is returned if one is not found 330 | // for the given ID (preserving the unknown ID). 331 | func UnitByID(ID uint16) *Unit { 332 | if u := unitIDUnit[ID]; u != nil { 333 | return u 334 | } 335 | return &Unit{repcore.UnknownEnum(ID), ID} 336 | } 337 | 338 | // unitIDRace maps from unit ID to owner race. 339 | var unitIDRace = map[uint16]*repcore.Race{ 340 | UnitIDCommandCenter: repcore.RaceTerran, 341 | UnitIDComSat: repcore.RaceTerran, 342 | UnitIDNuclearSilo: repcore.RaceTerran, 343 | UnitIDSupplyDepot: repcore.RaceTerran, 344 | UnitIDRefinery: repcore.RaceTerran, 345 | UnitIDBarracks: repcore.RaceTerran, 346 | UnitIDAcademy: repcore.RaceTerran, 347 | UnitIDFactory: repcore.RaceTerran, 348 | UnitIDStarport: repcore.RaceTerran, 349 | UnitIDControlTower: repcore.RaceTerran, 350 | UnitIDScienceFacility: repcore.RaceTerran, 351 | UnitIDCovertOps: repcore.RaceTerran, 352 | UnitIDPhysicsLab: repcore.RaceTerran, 353 | UnitIDMachineShop: repcore.RaceTerran, 354 | UnitIDEngineeringBay: repcore.RaceTerran, 355 | UnitIDArmory: repcore.RaceTerran, 356 | UnitIDMissileTurret: repcore.RaceTerran, 357 | UnitIDBunker: repcore.RaceTerran, 358 | 359 | UnitIDInfestedCC: repcore.RaceZerg, 360 | UnitIDHatchery: repcore.RaceZerg, 361 | UnitIDLair: repcore.RaceZerg, 362 | UnitIDHive: repcore.RaceZerg, 363 | UnitIDNydusCanal: repcore.RaceZerg, 364 | UnitIDHydraliskDen: repcore.RaceZerg, 365 | UnitIDDefilerMound: repcore.RaceZerg, 366 | UnitIDGreaterSpire: repcore.RaceZerg, 367 | UnitIDQueensNest: repcore.RaceZerg, 368 | UnitIDEvolutionChamber: repcore.RaceZerg, 369 | UnitIDUltraliskCavern: repcore.RaceZerg, 370 | UnitIDSpire: repcore.RaceZerg, 371 | UnitIDSpawningPool: repcore.RaceZerg, 372 | UnitIDCreepColony: repcore.RaceZerg, 373 | UnitIDSporeColony: repcore.RaceZerg, 374 | UnitIDSunkenColony: repcore.RaceZerg, 375 | UnitIDExtractor: repcore.RaceZerg, 376 | 377 | UnitIDNexus: repcore.RaceProtoss, 378 | UnitIDRoboticsFacility: repcore.RaceProtoss, 379 | UnitIDPylon: repcore.RaceProtoss, 380 | UnitIDAssimilator: repcore.RaceProtoss, 381 | UnitIDObservatory: repcore.RaceProtoss, 382 | UnitIDGateway: repcore.RaceProtoss, 383 | UnitIDPhotonCannon: repcore.RaceProtoss, 384 | UnitIDCitadelOfAdun: repcore.RaceProtoss, 385 | UnitIDCyberneticsCore: repcore.RaceProtoss, 386 | UnitIDTemplarArchives: repcore.RaceProtoss, 387 | UnitIDForge: repcore.RaceProtoss, 388 | UnitIDStargate: repcore.RaceProtoss, 389 | UnitIDFleetBeacon: repcore.RaceProtoss, 390 | UnitIDArbiterTribunal: repcore.RaceProtoss, 391 | UnitIDRoboticsSupportBay: repcore.RaceProtoss, 392 | UnitIDShieldBattery: repcore.RaceProtoss, 393 | } 394 | 395 | // RaceOfUnitID returns the owner race of the unit given by its ID. 396 | // Returns nil if owner is unknown. 397 | // Currently only building units are recognized. 398 | func RaceOfUnitID(ID uint16) *repcore.Race { 399 | if r := unitIDRace[ID]; r != nil { 400 | return r 401 | } 402 | return nil 403 | } 404 | -------------------------------------------------------------------------------- /rep/repcmd/upgrades.go: -------------------------------------------------------------------------------- 1 | // This file contains upgrades. 2 | 3 | package repcmd 4 | 5 | import "github.com/icza/screp/rep/repcore" 6 | 7 | // Upgrade describes the upgrade. 8 | type Upgrade struct { 9 | repcore.Enum 10 | 11 | // ID as it appears in replays 12 | ID byte 13 | } 14 | 15 | // Upgrades is an enumeration of the possible upgrades. 16 | var Upgrades = []*Upgrade{ 17 | {e("Terran Infantry Armor"), 0x00}, 18 | {e("Terran Vehicle Plating"), 0x01}, 19 | {e("Terran Ship Plating"), 0x02}, 20 | {e("Zerg Carapace"), 0x03}, 21 | {e("Zerg Flyer Carapace"), 0x04}, 22 | {e("Protoss Ground Armor"), 0x05}, 23 | {e("Protoss Air Armor"), 0x06}, 24 | {e("Terran Infantry Weapons"), 0x07}, 25 | {e("Terran Vehicle Weapons"), 0x08}, 26 | {e("Terran Ship Weapons"), 0x09}, 27 | {e("Zerg Melee Attacks"), 0x0A}, 28 | {e("Zerg Missile Attacks"), 0x0B}, 29 | {e("Zerg Flyer Attacks"), 0x0C}, 30 | {e("Protoss Ground Weapons"), 0x0D}, 31 | {e("Protoss Air Weapons"), 0x0E}, 32 | {e("Protoss Plasma Shields"), 0x0F}, 33 | {e("U-238 Shells (Marine Range)"), 0x10}, 34 | {e("Ion Thrusters (Vulture Speed)"), 0x11}, 35 | {e("Titan Reactor (Science Vessel Energy)"), 0x13}, 36 | {e("Ocular Implants (Ghost Sight)"), 0x14}, 37 | {e("Moebius Reactor (Ghost Energy)"), 0x15}, 38 | {e("Apollo Reactor (Wraith Energy)"), 0x16}, 39 | {e("Colossus Reactor (Battle Cruiser Energy)"), 0x17}, 40 | {e("Ventral Sacs (Overlord Transport)"), 0x18}, 41 | {e("Antennae (Overlord Sight)"), 0x19}, 42 | {e("Pneumatized Carapace (Overlord Speed)"), 0x1A}, 43 | {e("Metabolic Boost (Zergling Speed)"), 0x1B}, 44 | {e("Adrenal Glands (Zergling Attack)"), 0x1C}, 45 | {e("Muscular Augments (Hydralisk Speed)"), 0x1D}, 46 | {e("Grooved Spines (Hydralisk Range)"), 0x1E}, 47 | {e("Gamete Meiosis (Queen Energy)"), 0x1F}, 48 | {e("Defiler Energy"), 0x20}, 49 | {e("Singularity Charge (Dragoon Range)"), 0x21}, 50 | {e("Leg Enhancement (Zealot Speed)"), 0x22}, 51 | {e("Scarab Damage"), 0x23}, 52 | {e("Reaver Capacity"), 0x24}, 53 | {e("Gravitic Drive (Shuttle Speed)"), 0x25}, 54 | {e("Sensor Array (Observer Sight)"), 0x26}, 55 | {e("Gravitic Booster (Observer Speed)"), 0x27}, 56 | {e("Khaydarin Amulet (Templar Energy)"), 0x28}, 57 | {e("Apial Sensors (Scout Sight)"), 0x29}, 58 | {e("Gravitic Thrusters (Scout Speed)"), 0x2A}, 59 | {e("Carrier Capacity"), 0x2B}, 60 | {e("Khaydarin Core (Arbiter Energy)"), 0x2C}, 61 | {e("Argus Jewel (Corsair Energy)"), 0x2F}, 62 | {e("Argus Talisman (Dark Archon Energy)"), 0x31}, 63 | {e("Caduceus Reactor (Medic Energy)"), 0x33}, 64 | {e("Chitinous Plating (Ultralisk Armor)"), 0x34}, 65 | {e("Anabolic Synthesis (Ultralisk Speed)"), 0x35}, 66 | {e("Charon Boosters (Goliath Range)"), 0x36}, 67 | } 68 | 69 | // upgradeIDUpgrade maps from upgrade ID to upgrade. 70 | var upgradeIDUpgrade = map[byte]*Upgrade{} 71 | 72 | func init() { 73 | for _, u := range Upgrades { 74 | upgradeIDUpgrade[u.ID] = u 75 | } 76 | } 77 | 78 | // UpgradeByID returns the Upgrade for a given ID. 79 | // A new Upgrade with Unknown name is returned if one is not found 80 | // for the given ID (preserving the unknown ID). 81 | func UpgradeByID(ID byte) *Upgrade { 82 | if u := upgradeIDUpgrade[ID]; u != nil { 83 | return u 84 | } 85 | return &Upgrade{repcore.UnknownEnum(ID), ID} 86 | } 87 | -------------------------------------------------------------------------------- /rep/repcore/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Package repcore contains core types and utilities used for modeling 4 | StarCraft: Brood War replays. 5 | 6 | */ 7 | package repcore 8 | -------------------------------------------------------------------------------- /rep/repcore/enums.go: -------------------------------------------------------------------------------- 1 | // This file contains general enum types. 2 | 3 | package repcore 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | ) 9 | 10 | // Enum is the base / common part of enum types. 11 | type Enum struct { 12 | // Name of the entity 13 | Name string 14 | } 15 | 16 | // String returns the string representation of the enum (the name). 17 | // Defined with value receiver so this gets called even if a non-pointer is used. 18 | func (e Enum) String() string { 19 | return e.Name 20 | } 21 | 22 | // UnknownEnum constructs a new Enum for an unknown entity with a name: 23 | // 24 | // "Unknown 0xID" 25 | // 26 | // ID must be an integer number. 27 | func UnknownEnum(ID any) Enum { 28 | return Enum{fmt.Sprintf("Unknown 0x%x", ID)} 29 | } 30 | 31 | // Engine is the StarCraft engine / extension. 32 | type Engine struct { 33 | Enum 34 | 35 | // ID as it appears in replays 36 | ID byte 37 | 38 | // ShortName is a shorter name 39 | ShortName string 40 | } 41 | 42 | // Engines is an enumeration of the possible engines 43 | var Engines = []*Engine{ 44 | {Enum{"StarCraft"}, 0x00, "SC"}, 45 | {Enum{"Brood War"}, 0x01, "BW"}, 46 | } 47 | 48 | // Named engines 49 | var ( 50 | EngineStarCraft = Engines[0] 51 | EngineBroodWar = Engines[1] 52 | ) 53 | 54 | // EngineByID returns the Engine for a given ID. 55 | // A new Engine with Unknown name is returned if one is not found 56 | // for the given ID (preserving the unknown ID). 57 | func EngineByID(ID byte) *Engine { 58 | if int(ID) < len(Engines) { 59 | return Engines[ID] 60 | } 61 | return &Engine{UnknownEnum(ID), ID, "Unk"} 62 | } 63 | 64 | // Speed is the game speed. 65 | type Speed struct { 66 | Enum 67 | 68 | // ID as it appears in replays 69 | ID byte 70 | } 71 | 72 | // Speeds is an enumeration of the possible speeds 73 | var Speeds = []*Speed{ 74 | {Enum{"Slowest"}, 0x00}, 75 | {Enum{"Slower"}, 0x01}, 76 | {Enum{"Slow"}, 0x02}, 77 | {Enum{"Normal"}, 0x03}, 78 | {Enum{"Fast"}, 0x04}, 79 | {Enum{"Faster"}, 0x05}, 80 | {Enum{"Fastest"}, 0x06}, 81 | } 82 | 83 | // Named speeds 84 | var ( 85 | SpeedSlowest = Speeds[0] 86 | SpeedSlower = Speeds[1] 87 | SpeedSlow = Speeds[2] 88 | SpeedNormal = Speeds[3] 89 | SpeedFast = Speeds[4] 90 | SpeedFaster = Speeds[5] 91 | SpeedFastest = Speeds[6] 92 | ) 93 | 94 | // SpeedByID returns the Speed for a given ID. 95 | // A new Speed with Unknown name is returned if one is not found 96 | // for the given ID (preserving the unknown ID). 97 | func SpeedByID(ID byte) *Speed { 98 | if int(ID) < len(Speeds) { 99 | return Speeds[ID] 100 | } 101 | return &Speed{UnknownEnum(ID), ID} 102 | } 103 | 104 | // GameType is the game type. 105 | type GameType struct { 106 | Enum 107 | 108 | // ID as it appears in replays 109 | ID uint16 110 | 111 | // ShortName is a shorter name 112 | ShortName string 113 | } 114 | 115 | // GameTypes is an enumeration of the possible game types 116 | var GameTypes = []*GameType{ 117 | {Enum{"None"}, 0x00, "None"}, 118 | {Enum{"Custom"}, 0x01, "Custom"}, // Warcraft III 119 | {Enum{"Melee"}, 0x02, "Melee"}, 120 | {Enum{"Free For All"}, 0x03, "FFA"}, 121 | {Enum{"One on One"}, 0x04, "1on1"}, 122 | {Enum{"Capture The Flag"}, 0x05, "CTF"}, 123 | {Enum{"Greed"}, 0x06, "Greed"}, 124 | {Enum{"Slaughter"}, 0x07, "Slaughter"}, 125 | {Enum{"Sudden Death"}, 0x08, "Sudden Death"}, 126 | {Enum{"Ladder"}, 0x09, "Ladder"}, 127 | {Enum{"Use map settings"}, 0x0a, "UMS"}, 128 | {Enum{"Team Melee"}, 0x0b, "Team Melee"}, 129 | {Enum{"Team Free For All"}, 0x0c, "Team FFA"}, 130 | {Enum{"Team Capture The Flag"}, 0x0d, "Team CTF"}, 131 | {UnknownEnum(0x0e), 0x0e, "Unk"}, 132 | {Enum{"Top vs Bottom"}, 0x0f, "TvB"}, 133 | {Enum{"Iron Man Ladder"}, 0x10, "Iron Man Ladder"}, // Warcraft II 134 | } 135 | 136 | // Named valid game types 137 | var ( 138 | GameTypeNone = GameTypes[0] 139 | _ = GameTypes[1] // GameTypeCustom 140 | GameTypeMelee = GameTypes[2] 141 | GameTypeFFA = GameTypes[3] 142 | GameType1on1 = GameTypes[4] 143 | GameTypeCTF = GameTypes[5] 144 | GameTypeGreed = GameTypes[6] 145 | GameTypeSlaughter = GameTypes[7] 146 | GameTypeSuddenDeath = GameTypes[8] 147 | GameTypeLadder = GameTypes[9] 148 | GameTypeUMS = GameTypes[10] 149 | GameTypeTeamMelee = GameTypes[11] 150 | GameTypeTeamFFA = GameTypes[12] 151 | GameTypeTeamCTF = GameTypes[13] 152 | _ = GameTypes[14] // GameTypeUnknown 153 | GameTypeTvB = GameTypes[15] 154 | GameTypeIronManLadder = GameTypes[16] 155 | ) 156 | 157 | // GameTypeByID returns the GameType for a given ID. 158 | // A new GameType with Unknown name is returned if one is not found 159 | // for the given ID (preserving the unknown ID). 160 | func GameTypeByID(ID uint16) *GameType { 161 | if int(ID) < len(GameTypes) { 162 | return GameTypes[ID] 163 | } 164 | return &GameType{UnknownEnum(ID), ID, "Unk"} 165 | } 166 | 167 | // PlayerType describes a player (slot) type. 168 | type PlayerType struct { 169 | Enum 170 | 171 | // ID as it appears in replays 172 | ID byte 173 | } 174 | 175 | // PlayerTypes is an enumeration of the possible player types 176 | var PlayerTypes = []*PlayerType{ 177 | {Enum{"Inactive"}, 0x00}, 178 | {Enum{"Computer"}, 0x01}, 179 | {Enum{"Human"}, 0x02}, 180 | {Enum{"Rescue Passive"}, 0x03}, 181 | {Enum{"(Unused)"}, 0x04}, 182 | {Enum{"Computer Controlled"}, 0x05}, 183 | {Enum{"Open"}, 0x06}, 184 | {Enum{"Neutral"}, 0x07}, 185 | {Enum{"Closed"}, 0x08}, 186 | } 187 | 188 | // Named player types 189 | var ( 190 | PlayerTypeInactive = PlayerTypes[0] 191 | PlayerTypeComputer = PlayerTypes[1] 192 | PlayerTypeHuman = PlayerTypes[2] 193 | PlayerTypeRescuePassive = PlayerTypes[3] 194 | PlayerTypeUnused = PlayerTypes[4] 195 | PlayerTypeComputerControlled = PlayerTypes[5] 196 | PlayerTypeOpen = PlayerTypes[6] 197 | PlayerTypeNeutral = PlayerTypes[7] 198 | PlayerTypeClosed = PlayerTypes[8] 199 | ) 200 | 201 | // PlayerTypeByID returns the PlayerType for a given ID. 202 | // A new PlayerType with Unknown name is returned if one is not found 203 | // for the given ID (preserving the unknown ID). 204 | func PlayerTypeByID(ID byte) *PlayerType { 205 | if int(ID) < len(PlayerTypes) { 206 | return PlayerTypes[ID] 207 | } 208 | return &PlayerType{UnknownEnum(ID), ID} 209 | } 210 | 211 | // Race describes a race. 212 | type Race struct { 213 | Enum 214 | 215 | // ID as it appears in replays 216 | ID byte 217 | 218 | // ShortName is a shorter name 219 | ShortName string 220 | 221 | // Letter is the letter of the race (first letter of its name) 222 | Letter rune 223 | } 224 | 225 | // Races is an enumeration of the possible races 226 | var Races = []*Race{ 227 | {Enum{"Zerg"}, 0x00, "zerg", 'Z'}, 228 | {Enum{"Terran"}, 0x01, "ran", 'T'}, 229 | {Enum{"Protoss"}, 0x02, "toss", 'P'}, 230 | } 231 | 232 | // Named races 233 | var ( 234 | RaceZerg = Races[0] 235 | RaceTerran = Races[1] 236 | RaceProtoss = Races[2] 237 | ) 238 | 239 | // RaceByID returns the Race for a given ID. 240 | // A new Race with Unknown name is returned if one is not found 241 | // for the given ID (preserving the unknown ID). 242 | func RaceByID(ID byte) *Race { 243 | if int(ID) < len(Races) { 244 | return Races[ID] 245 | } 246 | return &Race{UnknownEnum(ID), ID, "Unk", 'U'} 247 | } 248 | 249 | // Color describes a color. 250 | type Color struct { 251 | Enum 252 | 253 | // ID as it appears in replays 254 | ID uint32 255 | 256 | // RGB is the red, green, blue component of the color 257 | RGB uint32 258 | 259 | // footprint is the footprint of the color in the player colors section. 260 | footprint []byte 261 | } 262 | 263 | // Colors is an enumeration of the possible colors 264 | var Colors = []*Color{ 265 | {Enum{"Red"}, 0x00, 0xf40404, []byte{0xf5, 0xf4, 0x74, 0x3f, 0x81, 0x80, 0x80, 0x3c, 0x81, 0x80, 0x80, 0x3c, 0x00, 0x00, 0x80, 0x3f}}, 266 | {Enum{"Blue"}, 0x01, 0x0c48cc, []byte{0xc1, 0xc0, 0x40, 0x3d, 0x91, 0x90, 0x90, 0x3e, 0xcd, 0xcc, 0x4c, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 267 | {Enum{"Teal"}, 0x02, 0x2cb494, []byte{0xb1, 0xb0, 0x30, 0x3e, 0xb5, 0xb4, 0x34, 0x3f, 0x95, 0x94, 0x14, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 268 | {Enum{"Purple"}, 0x03, 0x88409c, []byte{0x89, 0x88, 0x08, 0x3f, 0x81, 0x80, 0x80, 0x3e, 0x9d, 0x9c, 0x1c, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 269 | {Enum{"Orange"}, 0x04, 0xf88c14, []byte{0xf9, 0xf8, 0x78, 0x3f, 0x8d, 0x8c, 0x0c, 0x3f, 0xa1, 0xa0, 0xa0, 0x3d, 0x00, 0x00, 0x80, 0x3f}}, 270 | {Enum{"Brown"}, 0x05, 0x703014, []byte{0xe1, 0xe0, 0xe0, 0x3e, 0xc1, 0xc0, 0x40, 0x3e, 0xa1, 0xa0, 0xa0, 0x3d, 0x00, 0x00, 0x80, 0x3f}}, 271 | {Enum{"White"}, 0x06, 0xcce0d0, []byte{0xcd, 0xcc, 0x4c, 0x3f, 0xe1, 0xe0, 0x60, 0x3f, 0xd1, 0xd0, 0x50, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 272 | {Enum{"Yellow"}, 0x07, 0xfcfc38, []byte{0xfd, 0xfc, 0x7c, 0x3f, 0xfd, 0xfc, 0x7c, 0x3f, 0xe1, 0xe0, 0x60, 0x3e, 0x00, 0x00, 0x80, 0x3f}}, 273 | {Enum{"Green"}, 0x08, 0x088008, []byte{0x81, 0x80, 0x00, 0x3d, 0x81, 0x80, 0x00, 0x3f, 0x81, 0x80, 0x00, 0x3d, 0x00, 0x00, 0x80, 0x3f}}, 274 | {Enum{"Pale Yellow"}, 0x09, 0xfcfc7c, []byte{0xfd, 0xfc, 0x7c, 0x3f, 0xfd, 0xfc, 0x7c, 0x3f, 0xf9, 0xf8, 0xf8, 0x3e, 0x00, 0x00, 0x80, 0x3f}}, 275 | {Enum{"Tan"}, 0x0a, 0xecc4b0, []byte{0xed, 0xec, 0x6c, 0x3f, 0xc5, 0xc4, 0x44, 0x3f, 0xb1, 0xb0, 0x30, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 276 | {Enum{"Aqua"}, 0x0b, 0x4068d4, nil}, 277 | {Enum{"Pale Green"}, 0x0c, 0x74a47c, []byte{0xe9, 0xe8, 0xe8, 0x3e, 0xa5, 0xa4, 0x24, 0x3f, 0xf9, 0xf8, 0xf8, 0x3e, 0x00, 0x00, 0x80, 0x3f}}, 278 | {Enum{"Blueish Grey"}, 0x0d, 0x9090b8, []byte{0xe5, 0xe4, 0xe4, 0x3e, 0x91, 0x90, 0x10, 0x3f, 0xb9, 0xb8, 0x38, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 279 | {Enum{"Pale Yellow2"}, 0x0e, 0xfcfc7c, nil}, 280 | {Enum{"Cyan"}, 0x0f, 0x00e4fc, []byte{0x00, 0x00, 0x00, 0x00, 0xe5, 0xe4, 0x64, 0x3f, 0xfd, 0xfc, 0x7c, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 281 | {Enum{"Pink"}, 0x10, 0xffc4e4, []byte{0x00, 0x00, 0x80, 0x3f, 0xc5, 0xc4, 0x44, 0x3f, 0xe5, 0xe4, 0x64, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 282 | {Enum{"Olive"}, 0x11, 0x787800, []byte{0x81, 0x80, 0x00, 0x3f, 0x81, 0x80, 0x00, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f}}, 283 | {Enum{"Lime"}, 0x12, 0xd2f53c, []byte{0xd3, 0xd2, 0x52, 0x3f, 0xf6, 0xf5, 0x75, 0x3f, 0xf1, 0xf0, 0x70, 0x3e, 0x00, 0x00, 0x80, 0x3f}}, 284 | {Enum{"Navy"}, 0x13, 0x0000e6, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0x80, 0x00, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 285 | {Enum{"Dark Aqua"}, 0x14, 0x4068d4, []byte{0x81, 0x80, 0x80, 0x3e, 0xd1, 0xd0, 0xd0, 0x3e, 0xd5, 0xd4, 0x54, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 286 | {Enum{"Magenta"}, 0x15, 0xf032e6, []byte{0xf1, 0xf0, 0x70, 0x3f, 0xc9, 0xc8, 0x48, 0x3e, 0xe7, 0xe6, 0x66, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 287 | {Enum{"Grey"}, 0x16, 0x808080, []byte{0x81, 0x80, 0x00, 0x3f, 0x81, 0x80, 0x00, 0x3f, 0x81, 0x80, 0x00, 0x3f, 0x00, 0x00, 0x80, 0x3f}}, 288 | {Enum{"Black"}, 0x17, 0x3c3c3c, []byte{0xf1, 0xf0, 0x70, 0x3e, 0xf1, 0xf0, 0x70, 0x3e, 0xf1, 0xf0, 0x70, 0x3e, 0x00, 0x00, 0x80, 0x3f}}, 289 | } 290 | 291 | // Named colors 292 | var ( 293 | ColorRed = Colors[0] 294 | ColorBlue = Colors[1] 295 | ColorTeal = Colors[2] 296 | ColorPurple = Colors[3] 297 | ColorOrange = Colors[4] 298 | ColorBrown = Colors[5] 299 | ColorWhite = Colors[6] 300 | ColorYellow = Colors[7] 301 | ColorGreen = Colors[8] 302 | ColorPaleYellow = Colors[9] 303 | ColorTan = Colors[10] 304 | ColorAqua = Colors[11] 305 | ColorPaleGreen = Colors[12] 306 | ColorBlueishGrey = Colors[13] 307 | ColorPaleYellow2 = Colors[14] // Same as the other with same name 308 | ColorCyan = Colors[15] 309 | ColorPink = Colors[16] 310 | ColorOlive = Colors[17] 311 | ColorLime = Colors[18] 312 | ColorNavy = Colors[19] 313 | ColorDarkAqua = Colors[20] 314 | ColorMagenta = Colors[21] 315 | ColorGrey = Colors[22] 316 | ColorBlack = Colors[23] 317 | ) 318 | 319 | // ColorByID returns the Color for a given ID. 320 | // A new Color with Unknown name is returned if one is not found 321 | // for the given ID (preserving the unknown ID). 322 | func ColorByID(ID uint32) *Color { 323 | if int(ID) < len(Colors) { 324 | return Colors[ID] 325 | } 326 | return &Color{UnknownEnum(ID), ID, 0, nil} 327 | } 328 | 329 | // footprintFirstByteColors groups colors by the first byte of their footprints. 330 | var footprintFirstByteColors = map[byte][]*Color{} 331 | 332 | func init() { 333 | for _, c := range Colors { 334 | if len(c.footprint) == 0 { 335 | continue 336 | } 337 | footprintFirstByteColors[c.footprint[0]] = append(footprintFirstByteColors[c.footprint[0]], c) 338 | } 339 | } 340 | 341 | // ColorByFootprint returns the Color for a given footprint. 342 | // nil is returned if one is not found for the given footprint. 343 | func ColorByFootprint(footprint []byte) *Color { 344 | if len(footprint) > 0 { 345 | for _, c := range footprintFirstByteColors[footprint[0]] { 346 | if bytes.Equal(c.footprint, footprint) { 347 | return c 348 | } 349 | } 350 | } 351 | 352 | return nil 353 | } 354 | 355 | // TileSet describes a tile set. 356 | type TileSet struct { 357 | Enum 358 | 359 | // ID as it appears in replays 360 | ID uint16 361 | } 362 | 363 | // TileSets is an enumeration of the possible tile sets 364 | var TileSets = []*TileSet{ 365 | {Enum{"Badlands"}, 0x00}, 366 | {Enum{"Space Platform"}, 0x01}, 367 | {Enum{"Installation"}, 0x02}, 368 | {Enum{"Ashworld"}, 0x03}, 369 | {Enum{"Jungle"}, 0x04}, 370 | {Enum{"Desert"}, 0x05}, 371 | {Enum{"Arctic"}, 0x06}, 372 | {Enum{"Twilight"}, 0x07}, 373 | } 374 | 375 | // Named tile sets 376 | var ( 377 | TileSetBadlands = TileSets[0] 378 | TileSetSpacePlatform = TileSets[1] 379 | TileSetInstallation = TileSets[2] 380 | TileSetAshworld = TileSets[3] 381 | TileSetJungle = TileSets[4] 382 | TileSetDesert = TileSets[5] 383 | TileSetArctic = TileSets[6] 384 | TileSetTwilight = TileSets[7] 385 | ) 386 | 387 | // TileSetByID returns the TileSet for a given ID. 388 | // A new TileSet with Unknown name is returned if one is not found 389 | // for the given ID (preserving the unknown ID). 390 | func TileSetByID(ID uint16) *TileSet { 391 | if int(ID) < len(TileSets) { 392 | return TileSets[ID] 393 | } 394 | return &TileSet{UnknownEnum(ID), ID} 395 | } 396 | 397 | // PlayerOwner describes a player owner. 398 | type PlayerOwner struct { 399 | Enum 400 | 401 | // ID as it appears in replays 402 | ID uint8 403 | } 404 | 405 | // PlayerOwners is an enumeration of the possible player owners 406 | var PlayerOwners = []*PlayerOwner{ 407 | {Enum{"Inactive"}, 0x00}, 408 | {Enum{"Computer (game)"}, 0x01}, 409 | {Enum{"Occupied by Human Player"}, 0x02}, 410 | {Enum{"Rescue Passive"}, 0x03}, 411 | {Enum{"Unused"}, 0x04}, 412 | {Enum{"Computer"}, 0x05}, 413 | {Enum{"Human (Open Slot)"}, 0x06}, 414 | {Enum{"Neutral"}, 0x07}, 415 | {Enum{"Closed slot"}, 0x08}, 416 | } 417 | 418 | // Named player owners 419 | var ( 420 | PlayerOwnerInactive = PlayerOwners[0] 421 | PlayerOwnerComputerGame = PlayerOwners[1] 422 | PlayerOwnerOccupiedByHumanPlayer = PlayerOwners[2] 423 | PlayerOwnerRescuePassive = PlayerOwners[3] 424 | PlayerOwnerUnused = PlayerOwners[4] 425 | PlayerOwnerComputer = PlayerOwners[5] 426 | PlayerOwnerHumanOpenSlot = PlayerOwners[6] 427 | PlayerOwnerNeutral = PlayerOwners[7] 428 | PlayerOwnerClosedSlot = PlayerOwners[8] 429 | ) 430 | 431 | // PlayerOwnerByID returns the PlayerOwner for a given ID. 432 | // A new PlayerOwner with Unknown name is returned if one is not found 433 | // for the given ID (preserving the unknown ID). 434 | func PlayerOwnerByID(ID uint8) *PlayerOwner { 435 | if int(ID) < len(PlayerOwners) { 436 | return PlayerOwners[ID] 437 | } 438 | return &PlayerOwner{UnknownEnum(ID), ID} 439 | } 440 | 441 | // PlayerSide describes a player side (race). 442 | type PlayerSide struct { 443 | Enum 444 | 445 | // ID as it appears in replays 446 | ID uint8 447 | } 448 | 449 | // PlayerSides is an enumeration of the possible player sides 450 | var PlayerSides = []*PlayerSide{ 451 | {Enum{"Zerg"}, 0x00}, 452 | {Enum{"Terran"}, 0x01}, 453 | {Enum{"Protoss"}, 0x02}, 454 | {Enum{"Invalid (Independent)"}, 0x03}, 455 | {Enum{"Invalid (Neutral)"}, 0x04}, 456 | {Enum{"User Selectable"}, 0x05}, 457 | {Enum{"Random (Forced)"}, 0x06}, // Acts as a selected race 458 | {Enum{"Inactive"}, 0x07}, 459 | } 460 | 461 | // Named player sides 462 | var ( 463 | PlayerSideZerg = PlayerSides[0] 464 | PlayerSideTerran = PlayerSides[1] 465 | PlayerSideProtoss = PlayerSides[2] 466 | PlayerSideInvalidIndependent = PlayerSides[3] 467 | PlayerSideInvalidNeutral = PlayerSides[4] 468 | PlayerSideUserSelectable = PlayerSides[5] 469 | PlayerSideRandomForced = PlayerSides[6] 470 | PlayerSideInactive = PlayerSides[7] 471 | ) 472 | 473 | // PlayerSideByID returns the PlayerSide for a given ID. 474 | // A new PlayerSide with Unknown name is returned if one is not found 475 | // for the given ID (preserving the unknown ID). 476 | func PlayerSideByID(ID uint8) *PlayerSide { 477 | if int(ID) < len(PlayerSides) { 478 | return PlayerSides[ID] 479 | } 480 | return &PlayerSide{UnknownEnum(ID), ID} 481 | } 482 | -------------------------------------------------------------------------------- /rep/repcore/ineffkind.go: -------------------------------------------------------------------------------- 1 | package repcore 2 | 3 | // IneffKind classifies commands if and why they are ineffective. 4 | type IneffKind byte 5 | 6 | const ( 7 | // IneffKindEffective means the command is considered effective. 8 | IneffKindEffective IneffKind = iota 9 | 10 | // IneffKindUnitQueueOverflow means the command is ineffective due to unit queue overflow 11 | IneffKindUnitQueueOverflow 12 | 13 | // IneffKindFastCancel means the command is ineffective due to too fast cancel 14 | IneffKindFastCancel 15 | 16 | // IneffKindFastRepetition means the command is ineffective due to too fast repetition 17 | IneffKindFastRepetition 18 | 19 | // IneffKindFastReselection means the command is ineffective due to too fast selection change 20 | // or reselection 21 | IneffKindFastReselection 22 | 23 | // IneffKindRepetition means the command is ineffective due to repetition 24 | IneffKindRepetition 25 | 26 | // IneffKindRepetitionHotkeyAddAssign means the command is ineffective due to 27 | // repeating the same hotkey add or assign 28 | IneffKindRepetitionHotkeyAddAssign 29 | ) 30 | 31 | var ineffKindStrings = []string{ 32 | IneffKindEffective: "effective", 33 | IneffKindUnitQueueOverflow: "unit queue overflow", 34 | IneffKindFastCancel: "too fast cancel", 35 | IneffKindFastRepetition: "too fast repetition", 36 | IneffKindFastReselection: "too fast selection change or reselection", 37 | IneffKindRepetition: "repetition", 38 | IneffKindRepetitionHotkeyAddAssign: "repeptition of the same hotkey add or assign", 39 | } 40 | 41 | // Effective tells if the IneffKind represents Effective, that is, 42 | // it's equal to IneffKindEffective. 43 | func (k IneffKind) Effective() bool { 44 | return k == IneffKindEffective 45 | } 46 | 47 | // String returns a short string description. 48 | func (k IneffKind) String() string { 49 | return ineffKindStrings[k] 50 | } 51 | -------------------------------------------------------------------------------- /rep/repcore/types.go: -------------------------------------------------------------------------------- 1 | // This file contains general types. 2 | 3 | package repcore 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // Frame is the basic time unit in StarCraft. 11 | // There are approximately ~23.81 frames in a second; 12 | // 1 frame = 0.042 second = 42 ms to be exact. 13 | type Frame int32 14 | 15 | // Seconds returns the time equivalent to the frames in seconds. 16 | func (f Frame) Seconds() float64 { 17 | return float64(f.Milliseconds()) / 1000 18 | } 19 | 20 | // Milliseconds returns the time equivalent to the frames in milliseconds. 21 | func (f Frame) Milliseconds() int64 { 22 | return int64(f) * 42 23 | } 24 | 25 | // Duration returns the frame as a time.Duration value. 26 | func (f Frame) Duration() time.Duration { 27 | return time.Millisecond * time.Duration(f.Milliseconds()) 28 | } 29 | 30 | // String returns a human-friendly mm:ss representation, e.g. "03:12", 31 | // or if the frame represents bigger than an hour: "1:02:03". 32 | func (f Frame) String() string { 33 | sec := f.Milliseconds() / 1000 34 | min := sec / 60 35 | if min < 60 { 36 | return fmt.Sprintf("%02d:%02d", min, sec%60) 37 | } 38 | return fmt.Sprintf("%d:%02d:%02d", min/60, min%60, sec%60) 39 | } 40 | 41 | // Duration2Frame converts a Duration value to Frame. 42 | func Duration2Frame(d time.Duration) Frame { 43 | return Frame(d.Milliseconds() / 42) 44 | } 45 | 46 | // Point describes a point in the map. 47 | type Point struct { 48 | // X and Y coordinates of the point 49 | // 1 Tile is 32 units (pixel) 50 | X, Y uint16 51 | } 52 | 53 | // String returns a string representation of the point in the format: 54 | // 55 | // "x=X, y=Y" 56 | func (p Point) String() string { 57 | return fmt.Sprint("x=", p.X, ", y=", p.Y) 58 | } 59 | -------------------------------------------------------------------------------- /rep/replay.go: -------------------------------------------------------------------------------- 1 | // This file contains the Replay type and its components which model a complete 2 | // SC:BW replay. 3 | 4 | package rep 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "math" 10 | "slices" 11 | "sort" 12 | "strings" 13 | "time" 14 | 15 | "github.com/icza/gox/stringsx" 16 | "github.com/icza/screp/rep/repcmd" 17 | "github.com/icza/screp/rep/repcore" 18 | "github.com/icza/screp/repparser/repdecoder" 19 | ) 20 | 21 | // Replay models an SC:BW replay. 22 | type Replay struct { 23 | // Stored here for decoding purposes only. 24 | RepFormat repdecoder.RepFormat `json:"-"` 25 | 26 | // Header of the replay 27 | Header *Header 28 | 29 | // Commands of the players 30 | Commands *Commands 31 | 32 | // MapData describes the map and objects on it 33 | MapData *MapData 34 | 35 | // Computed contains data that is computed / derived from other parts of the 36 | // replay. 37 | Computed *Computed 38 | 39 | // ShieldBattery holds info if game was played on ShieldBattery 40 | ShieldBattery *ShieldBattery `json:",omitempty"` 41 | } 42 | 43 | // Set of lowered and cleaned map names that use the UMS random teams feature. 44 | // Transformation on map names to obtain keys: 45 | // 46 | // strings.TrimSpace(strings.ToLower(stringsx.Clean(mapName))) 47 | var exactUMSTeamsAIMaps = map[string]bool{ 48 | "hunters kespa soulclan ai": true, 49 | ":da hunters ai": true, 50 | "(xb2) big game hunters": true, 51 | "(xsc) big game hunters": true, 52 | "big game hunters =c.r=": true, 53 | "big game hunters": true, // Multiple BGH versions have random team assignment, always try if UMS 54 | } 55 | 56 | // Compute creates and computes the Computed field. 57 | func (r *Replay) Compute() { 58 | if r.Computed != nil { 59 | return 60 | } 61 | 62 | players := r.Header.Players 63 | numPlayers := len(players) 64 | 65 | c := &Computed{ 66 | PlayerDescs: make([]*PlayerDesc, numPlayers), 67 | PIDPlayerDescs: make(map[byte]*PlayerDesc, numPlayers), 68 | } 69 | r.Computed = c 70 | 71 | for i, p := range players { 72 | pd := &PlayerDesc{ 73 | PlayerID: p.ID, 74 | } 75 | c.PlayerDescs[i] = pd 76 | c.PIDPlayerDescs[p.ID] = pd 77 | } 78 | 79 | if r.Commands != nil { 80 | // We need to gather player's commands separately for EAPM calculation. 81 | // We could use a map, mapping from pid to player's commands, but then when building it, 82 | // we would have to always reassign the slice. Instead we use a pointer to a wrapper struct: 83 | type pidCmdsWrapper struct { 84 | cmds []repcmd.Cmd 85 | } 86 | pidCmdsWrappers := make(map[byte]*pidCmdsWrapper, numPlayers) 87 | pidBuilds := make(map[byte]int, numPlayers) // Build commands count per player 88 | for _, p := range players { 89 | pidCmdsWrappers[p.ID] = &pidCmdsWrapper{ 90 | cmds: make([]repcmd.Cmd, 0, len(r.Commands.Cmds)/numPlayers), // Estimate even cmd distribution for fewer reallocations 91 | } 92 | } 93 | 94 | cmds := r.Commands.Cmds 95 | for _, cmd := range cmds { 96 | // Observers' commands (e.g. chat) have PlayerID starting with 128 (2nd obs 129 etc.) 97 | // We don't have PlayerDescs for them, so must check: 98 | baseCmd := cmd.BaseCmd() 99 | if pd := c.PIDPlayerDescs[baseCmd.PlayerID]; pd != nil { 100 | pd.CmdCount++ 101 | pidCmdsWrapper := pidCmdsWrappers[baseCmd.PlayerID] 102 | pidCmdsWrapper.cmds = append(pidCmdsWrapper.cmds, cmd) 103 | baseCmd.IneffKind = CmdIneffKind(pidCmdsWrapper.cmds, len(pidCmdsWrapper.cmds)-1) 104 | if baseCmd.IneffKind.Effective() { 105 | pd.EffectiveCmdCount++ 106 | } 107 | } 108 | switch x := cmd.(type) { 109 | case *repcmd.LeaveGameCmd: 110 | c.LeaveGameCmds = append(c.LeaveGameCmds, x) 111 | case *repcmd.ChatCmd: 112 | c.ChatCmds = append(c.ChatCmds, x) 113 | case *repcmd.BuildCmd: 114 | pidBuilds[baseCmd.PlayerID]++ 115 | } 116 | } 117 | 118 | // Detect replay saver: 119 | // Replay saver is the one who receives the chat messages. 120 | // (Note chat is saved since patch 1.16, released on 2008-11-25.) 121 | if len(c.ChatCmds) > 0 { 122 | c.RepSaverPlayerID = &c.ChatCmds[0].PlayerID 123 | } 124 | 125 | // Search for last commands: 126 | // Make a local copy of the PIDPlayerDescs map to keep track of 127 | // players we still need this info for: 128 | pidPlayerDescs := make(map[byte]*PlayerDesc, numPlayers) 129 | for pid, pd := range c.PIDPlayerDescs { 130 | // Only include players that do have commands: 131 | if pd.CmdCount > 0 { 132 | pidPlayerDescs[pid] = pd 133 | } 134 | } 135 | for i := len(cmds) - 1; i >= 0; i-- { 136 | cmd := cmds[i] 137 | baseCmd := cmd.BaseCmd() 138 | pd := pidPlayerDescs[baseCmd.PlayerID] 139 | if pd == nil { 140 | continue 141 | } 142 | if baseCmd.Frame > r.Header.Frames || baseCmd.Frame < 0 { 143 | // Bad parsing or corrupted replay may result in invalid frames, 144 | // do not use such a bad frame. 145 | continue 146 | } 147 | pd.LastCmdFrame = baseCmd.Frame 148 | // Optimization: If this was the last player, break: 149 | if len(pidPlayerDescs) == 1 { 150 | break 151 | } 152 | delete(pidPlayerDescs, pd.PlayerID) 153 | } 154 | 155 | // Calculate APMs and EAPMs: 156 | for _, pd := range c.PlayerDescs { 157 | if pd.LastCmdFrame == 0 { 158 | continue 159 | } 160 | mins := pd.LastCmdFrame.Duration().Minutes() 161 | pd.APM = int32(float64(pd.CmdCount)/mins + 0.5) 162 | pd.EAPM = int32(float64(pd.EffectiveCmdCount)/mins + 0.5) 163 | } 164 | 165 | switch r.Header.Type { 166 | 167 | case repcore.GameTypeUMS: 168 | mapName := r.Header.Map 169 | if r.MapData != nil { 170 | mapName = r.MapData.Name 171 | } 172 | // counter-examples: " \aai \x04hunters \x02remastered \x062.0", "\x03(XB2)\x06 Big Game Hunters", "Big Game Hungers " 173 | mapName = strings.TrimSpace(strings.ToLower(stringsx.Clean(mapName))) 174 | // "[ai]" maps are special, we can do better than in general: 175 | switch { 176 | 177 | case exactUMSTeamsAIMaps[mapName] || 178 | strings.HasPrefix(mapName, "王牌猎人") || strings.HasPrefix(mapName, "j_big game hunters") || 179 | strings.Contains(mapName, "宏图") || // "grand plan"; e.g. "South Korea's grand plan" (韩国宏图) or "中国宏图" ("China's grand plan") 180 | strings.Contains(mapName, "随机分组") || // "random grouping" 181 | strings.Contains(mapName, "[ai]") || strings.Contains(mapName, "ai hunters") || strings.Contains(mapName, "bgh random teams") || strings.Contains(mapName, "big game hunters [r]") || 182 | strings.Contains(mapName, "new super random team") || strings.Contains(mapName, "new super ◆random team") || strings.Contains(mapName, "fa§te§t random team") || 183 | strings.Contains(mapName, "random forces"): 184 | r.detectObservers(pidBuilds, obsProfileUMSAI) 185 | r.computeUMSTeamsAI() 186 | 187 | default: 188 | r.computeUMSTeams() 189 | } 190 | 191 | case repcore.GameTypeMelee: 192 | r.detectObservers(pidBuilds, obsProfileMelee) 193 | r.computeMeleeTeams() 194 | } 195 | 196 | r.computeWinners() 197 | } 198 | 199 | if r.MapData != nil { 200 | // 1 tile is 32 pixels, so half is x*16: 201 | cx, cy := float64(r.Header.MapWidth*16), float64(r.Header.MapHeight*16) 202 | // Lookup start location of players 203 | sls := r.MapData.StartLocations 204 | for i, p := range players { 205 | for j := range sls { 206 | if p.SlotID == uint16(sls[j].SlotID) { 207 | pt := &sls[j].Point 208 | c.PlayerDescs[i].StartLocation = pt 209 | // Map Y coordinate grows from top to bottom: 210 | c.PlayerDescs[i].StartDirection = angleToClock( 211 | math.Atan2(cy-float64(pt.Y), float64(pt.X)-cx), 212 | ) 213 | break 214 | } 215 | } 216 | } 217 | } 218 | } 219 | 220 | // computeUMSTeams computes the teams in UMS games. 221 | // 222 | // Handles a special case: 1v1 game with observers. 223 | // Rules to detect this case: 224 | // 225 | // -there are only 2 human players on team 1, having train or build commands 226 | // -all other players are on a different team, and they have no train nor build commands 227 | // 228 | // If this case is detected, the players on team 1 are split into team 1 and 2, 229 | // and all players (observers) on the (original) team 2 are assiged to team 3, and marked as observers. 230 | func (r *Replay) computeUMSTeams() { 231 | // We'll have to check player commands later, so if it's not parsed, don't waste any time: 232 | if r.Commands == nil { 233 | return 234 | } 235 | 236 | players := r.Header.Players 237 | if len(players) < 2 { 238 | return 239 | } 240 | 241 | playerCandidateIDs, obsCandidateIDs := map[byte]bool{}, map[byte]bool{} 242 | 243 | for i, p := range players { 244 | if p.Type != repcore.PlayerTypeHuman { 245 | return // Non-human involved, don't get involved! 246 | } 247 | if i < 2 { // candidates for 1v1 players 248 | if p.Team != 1 { 249 | return 250 | } 251 | playerCandidateIDs[p.ID] = true 252 | } else { // candidates for observers 253 | if p.Team == 1 { 254 | return 255 | } 256 | obsCandidateIDs[p.ID] = true 257 | } 258 | } 259 | 260 | // Check if player candidates have train or build commands, and obs candidates don't. 261 | playerTrainBuildCount := 0 262 | noObsCandidates := len(obsCandidateIDs) == 0 263 | 264 | cmdLoop: 265 | for _, cmd := range r.Commands.Cmds { 266 | switch cmd.(type) { 267 | case *repcmd.TrainCmd, *repcmd.BuildCmd: 268 | if playerCandidateIDs[cmd.BaseCmd().PlayerID] { 269 | playerTrainBuildCount++ 270 | if noObsCandidates { 271 | break cmdLoop // We got what we want, no obs candidates, no need to continue 272 | } 273 | } else if obsCandidateIDs[cmd.BaseCmd().PlayerID] { 274 | return // An obs candidate have a train or build command, this is not the special case we're looking for 275 | } 276 | } 277 | } 278 | 279 | if playerTrainBuildCount == 0 { 280 | return // Player candidates have no train nor build commands, this is not the special case we're looking for 281 | } 282 | 283 | // Special case detected, proceed to re-teaming. 284 | 285 | // 1v1 players 286 | players[0].Team = 1 287 | players[1].Team = 2 288 | 289 | // Observers 290 | for _, p := range players[2:] { 291 | p.Team = 3 292 | p.Observer = true 293 | } 294 | } 295 | 296 | // computeUMSTeamsAI computes the teams in UMS AI games. 297 | // 298 | // Maps having "[AI]" in their name are special: they create random teams after start, 299 | // with optional observers. Random team arragement usually happens 18 seconds after game start. 300 | // Commands selecting teams are not recorded, but since teams are created randomly, players very often check alliance 301 | // to see who their allies are. This reasults in Alliance commands recording the team setup at the time 302 | // of the checks. We will use these to detect teams. 303 | // There is no guarantee players check alliance and they may also change the (initial) alliance arranged by the map. 304 | // 305 | // Different AI maps handle observers differently. 306 | // Some set alliance from players to observers too (observers will be in team 1 and team 2 too), and observers are allied with each other only. 307 | // Other AI maps set alliance only between team members and observers separately. 308 | // Observers may also be allied with all players / slots. 309 | // 310 | // Alliance commands in the first 115 seconds are checked; if they consistently denote 2 teams (and an optional observer team), 311 | // players are assigned team 1 and 2 respectively (and observers are assigned team 3, and marked as observers). 312 | // 313 | // As a special / fallback case, if only player(s) from one team checked alliance 314 | // (which results in a single known team), and the remaining non-obs players are of the same count, 315 | // accept this as the team setup: known team vs the rest as the other team. 316 | // 317 | // If teams can be computed, also rearranges Header.Players and Computed.PlayerDescs 318 | // according to new teams. 319 | func (r *Replay) computeUMSTeamsAI() { 320 | // We'll have to check player commands later, so if it's not parsed, don't waste any time: 321 | if r.Commands == nil { 322 | return 323 | } 324 | 325 | players := r.Header.Players 326 | if len(players) < 2 { 327 | return 328 | } 329 | 330 | // Only compute if we don't yet have team info (if all teams are the same): 331 | var nonObsPlayer *Player 332 | for _, p := range players { 333 | if p.Observer { 334 | continue 335 | } 336 | if nonObsPlayer == nil { 337 | nonObsPlayer = p 338 | } else { 339 | if p.Team != nonObsPlayer.Team { 340 | return 341 | } 342 | } 343 | } 344 | 345 | // Set of slotIDs belonging to players (non-observers): 346 | playerSlotIDs := map[byte]bool{} 347 | for _, p := range players { 348 | if !p.Observer { 349 | playerSlotIDs[byte(p.SlotID)] = true 350 | } 351 | } 352 | filterOutObserverSlotIDs := func(slotIDs []byte) (result []byte) { 353 | for _, slotID := range slotIDs { 354 | if playerSlotIDs[slotID] { 355 | result = append(result, slotID) 356 | } 357 | } 358 | return 359 | } 360 | 361 | // Slot IDs of player's last Alliance commands, observers filtered out: 362 | pidSlotIDs := map[byte][]byte{} 363 | 364 | // If there are 2 players only, it's unlikely they'll check alliances and thus below team detection would fail. 365 | // To make it still work, initialize with self-alliance: 366 | if len(playerSlotIDs) == 2 { 367 | for _, p := range players { 368 | if !p.Observer { 369 | pidSlotIDs[p.ID] = []byte{byte(p.SlotID)} 370 | } 371 | } 372 | } 373 | 374 | // Stop after ~115 seconds: use the "initial" teams 375 | frameMaxLimit := repcore.Duration2Frame(115 * time.Second) 376 | frameMinLimit := repcore.Duration2Frame(18 * time.Second) 377 | for _, cmd := range r.Commands.Cmds { 378 | if cmd.BaseCmd().Frame > frameMaxLimit { 379 | break 380 | } 381 | if ac, ok := cmd.(*repcmd.AllianceCmd); ok { 382 | if p := r.Header.PIDPlayers[ac.PlayerID]; p != nil && p.Observer { 383 | continue 384 | } 385 | filteredSlotIDs := filterOutObserverSlotIDs(ac.SlotIDs) // Note: first filter because on "BGH Random Teams" this also includes the obs computer! 386 | if len(filteredSlotIDs) == 1 && cmd.BaseCmd().Frame < frameMinLimit { 387 | continue // Random team arrangement has likely not done, do not count! 388 | } 389 | pidSlotIDs[ac.PlayerID] = filteredSlotIDs 390 | } 391 | } 392 | 393 | // Since observers are filtered out, there should be exactly 2 teams, with equal size, 394 | // disjunct players. And all other players must be observers. 395 | 396 | // We use the string representation of the slots as the virtual team ID 397 | // which will be something like "[0 2 3]" 398 | virtualTeamIDSlotIDs := map[string][]byte{} 399 | for _, slotIDs := range pidSlotIDs { 400 | if len(slotIDs) == 0 { 401 | continue 402 | } 403 | virtualID := fmt.Sprint(slotIDs) 404 | virtualTeamIDSlotIDs[virtualID] = slotIDs 405 | } 406 | if len(virtualTeamIDSlotIDs) != 2 { 407 | // Not 2 teams exactly. 408 | // Check for the "special / fallback case": if only player(s) from one team checked alliance 409 | // (which results in a single known team), and the remaining non-obs players are of the same count, 410 | // accept this as the team setup: known team vs the rest as the other team. 411 | specialCase := false 412 | if len(virtualTeamIDSlotIDs) == 1 { 413 | // Need the one element from the map, build a set from it for easy lookup. 414 | knownTeamSlotIDs := map[byte]bool{} 415 | for _, slotIDs := range virtualTeamIDSlotIDs { 416 | for _, slotID := range slotIDs { 417 | knownTeamSlotIDs[slotID] = true 418 | } 419 | } 420 | // Collect remaining non-obs slotIDs 421 | remainingSlotIDs := make([]byte, 0, len(players)) 422 | for _, p := range players { 423 | if p.Observer || knownTeamSlotIDs[byte(p.SlotID)] { 424 | continue 425 | } 426 | remainingSlotIDs = append(remainingSlotIDs, byte(p.SlotID)) 427 | } 428 | if len(knownTeamSlotIDs) == len(remainingSlotIDs) { 429 | // Hooray! Sort slot IDs and add as a virtual team: 430 | specialCase = true 431 | slices.Sort(remainingSlotIDs) 432 | virtualTeamIDSlotIDs[fmt.Sprint(remainingSlotIDs)] = remainingSlotIDs 433 | } 434 | } 435 | if !specialCase { 436 | return 437 | } 438 | } 439 | 440 | var team1SlotIDs, team2SlotIDs []byte 441 | for _, slotIDs := range virtualTeamIDSlotIDs { 442 | if team1SlotIDs == nil { 443 | team1SlotIDs = slotIDs 444 | } else { 445 | team2SlotIDs = slotIDs 446 | } 447 | } 448 | // Use consistent team order (order by first slot ID): 449 | if team2SlotIDs[0] < team1SlotIDs[0] { 450 | team1SlotIDs, team2SlotIDs = team2SlotIDs, team1SlotIDs 451 | } 452 | // Check if teams are disjuct: 453 | for _, slotIDA := range team1SlotIDs { 454 | if bytes.IndexByte(team2SlotIDs, slotIDA) >= 0 { 455 | return // slotIDA is in both teams 456 | } 457 | } 458 | // Check if all non-observers are in one of the 2 teams: 459 | slotIDTeams := map[byte]byte{} 460 | for _, slotID := range team1SlotIDs { 461 | slotIDTeams[slotID] = 1 462 | } 463 | for _, slotID := range team2SlotIDs { 464 | slotIDTeams[slotID] = 2 465 | } 466 | if len(playerSlotIDs) != len(slotIDTeams) { 467 | return // Not all player assigned to team 1 or 2 468 | } 469 | 470 | // Assign new teams 471 | for _, p := range players { 472 | if p.Observer { 473 | p.Team = 3 474 | } else { 475 | p.Team = slotIDTeams[byte(p.SlotID)] 476 | } 477 | } 478 | 479 | // Re-sort Header.Players and Computed.PlayerDescs 480 | r.rearrangePlayers() 481 | } 482 | 483 | // obsProfile holds data for observer rules in different scenarios. 484 | type obsProfile struct { 485 | apmLimit int32 // Human obs must be below this APM limit 486 | buildCmdsLimit int // Human obs must be below this build command limit 487 | earlyLeaveFrame repcore.Frame // consider early leavers as observer 488 | computer bool // Classify computer as observer (BGH Random Teams map) 489 | } 490 | 491 | var ( 492 | obsProfileMelee = &obsProfile{apmLimit: 25, buildCmdsLimit: 5} 493 | obsProfileUMSAI = &obsProfile{apmLimit: 40, buildCmdsLimit: 2, earlyLeaveFrame: repcore.Duration2Frame(18 * time.Second), computer: true} 494 | ) 495 | 496 | // detectObservers detects observers based on the given obs profile. 497 | func (r *Replay) detectObservers(pidBuilds map[byte]int, obsProf *obsProfile) { 498 | c := r.Computed 499 | 500 | // Criteria for observers: 501 | // - Human 502 | // and 503 | // - APM < obsProf.apmLimit 504 | // - Has less than obsProf.buildCmdsLimit build commands 505 | // or 506 | // - obsProf.earlyLeaveFrame is not zero and the player left earlier 507 | 508 | numObs := 0 509 | for i, p := range r.Header.Players { 510 | if p.Type == repcore.PlayerTypeHuman && 511 | (c.PlayerDescs[i].APM < obsProf.apmLimit && pidBuilds[p.ID] < obsProf.buildCmdsLimit || 512 | obsProf.earlyLeaveFrame > 0 && c.PlayerDescs[i].LastCmdFrame < obsProf.earlyLeaveFrame) || 513 | (obsProf.computer && p.Type == repcore.PlayerTypeComputer) { 514 | p.Observer = true 515 | numObs++ 516 | } 517 | } 518 | 519 | // If less than 2 non-obs players remained, undo: 520 | if len(r.Header.Players)-numObs < 2 { 521 | for _, p := range r.Header.Players { 522 | p.Observer = false 523 | } 524 | } 525 | } 526 | 527 | // computeMeleeTeams computes the teams in melee games based on player Alliance commands. 528 | // 529 | // If teams can be computed, also rearranges Header.Players and Computed.PlayerDescs 530 | // according to new teams. 531 | func (r *Replay) computeMeleeTeams() { 532 | // We'll have to check player commands later, so if it's not parsed, don't waste any time: 533 | if r.Commands == nil { 534 | return 535 | } 536 | 537 | players := r.Header.Players 538 | if len(players) < 2 { 539 | return 540 | } 541 | 542 | // Only compute if we don't yet have team info (if all teams are the same): 543 | var nonObsPlayer *Player 544 | for _, p := range players { 545 | if p.Observer { 546 | continue 547 | } 548 | if nonObsPlayer == nil { 549 | nonObsPlayer = p 550 | } else { 551 | if p.Team != nonObsPlayer.Team { 552 | return 553 | } 554 | } 555 | } 556 | 557 | // NOTE: all computers have pid=255, but since they don't set alliance 558 | // and they can't be allied with, they won't cause trouble. 559 | // Only when their team is set, don't try set teams of "faulty" teammates. 560 | 561 | pidSlotIDs := map[byte][]byte{} 562 | // By default all players are allied to themselves only: 563 | for _, p := range players { 564 | if p.Observer { 565 | continue 566 | } 567 | pidSlotIDs[p.ID] = []byte{byte(p.SlotID)} 568 | } 569 | 570 | // Stop after ~90 seconds: use the "initial" teams 571 | frameLimit := repcore.Duration2Frame(90 * time.Second) 572 | for _, cmd := range r.Commands.Cmds { 573 | if cmd.BaseCmd().Frame > frameLimit { 574 | break 575 | } 576 | if ac, ok := cmd.(*repcmd.AllianceCmd); ok { 577 | if p := r.Header.PIDPlayers[ac.PlayerID]; p != nil && p.Observer { 578 | continue 579 | } 580 | pidSlotIDs[ac.PlayerID] = ac.SlotIDs 581 | } 582 | } 583 | 584 | // Check if set alliances are consistent: 585 | // For each A=>B alliance there must be a B=>A 586 | // Build maps for fast lookups: 587 | slotIDPlayers := map[byte]*Player{} 588 | for _, p := range players { 589 | if !p.Observer { 590 | slotIDPlayers[byte(p.SlotID)] = p 591 | } 592 | } 593 | slotIDSlotIDs := map[byte][]byte{} 594 | for pid, slotIDs := range pidSlotIDs { 595 | if p := r.Header.PIDPlayers[pid]; p != nil { 596 | slotIDSlotIDs[byte(p.SlotID)] = slotIDs 597 | } 598 | } 599 | // Now check the consistency: 600 | for pid, slotIDs := range pidSlotIDs { 601 | p := r.Header.PIDPlayers[pid] 602 | if p == nil { 603 | continue 604 | } 605 | slotIDA := byte(p.SlotID) 606 | for _, slotIDB := range slotIDs { 607 | if slotIDA == slotIDB { 608 | continue 609 | } 610 | if p := slotIDPlayers[slotIDB]; p == nil || p.Observer { 611 | continue 612 | } 613 | // There is a slotIDA => slotIDB alliance, there must be a slotIDB => slotIDA: 614 | found := false 615 | for _, slotIDC := range slotIDSlotIDs[slotIDB] { 616 | if slotIDC == slotIDA { 617 | // found! 618 | found = true 619 | break 620 | } 621 | } 622 | if !found { 623 | // Alliance is inconsistent, do not change teams: 624 | return 625 | } 626 | } 627 | } 628 | 629 | // Found matching alliances! Assign new teams. 630 | // Start clean: 631 | for _, p := range players { 632 | p.Team = 0 633 | } 634 | team := byte(1) 635 | for _, p := range players { 636 | if p.Observer { 637 | continue // We handle observers last 638 | } 639 | if p.Team != 0 { 640 | continue // Already assigned 641 | } 642 | p.Team = team 643 | if p.Type != repcore.PlayerTypeComputer { // pidSlotIDs is not valid for computers. 644 | // All teammates get the same team 645 | for _, slotID := range pidSlotIDs[p.ID] { 646 | if p := slotIDPlayers[slotID]; p != nil && !p.Observer { 647 | p.Team = team 648 | } 649 | } 650 | } 651 | team++ 652 | } 653 | // Last assign highest team to observers: 654 | for _, p := range players { 655 | if p.Observer { 656 | p.Team = team 657 | } 658 | } 659 | 660 | // Re-sort Header.Players and Computed.PlayerDescs 661 | r.rearrangePlayers() 662 | } 663 | 664 | // rearrangePlayers rearranges Header.Players and Computed.PlayerDescs to be in "team order". 665 | // Teams may be assigned / changed by team detection algorithms, this helper function 666 | // rearranges the players so the order will be in team-order. 667 | func (r *Replay) rearrangePlayers() { 668 | players := r.Header.Players 669 | pds := r.Computed.PlayerDescs 670 | 671 | // Re-sort Header.Players and Computed.PlayerDescs 672 | type wrapper struct { 673 | p *Player 674 | pd *PlayerDesc 675 | } 676 | 677 | ws := make([]wrapper, len(players)) 678 | for i, p := range players { 679 | ws[i] = wrapper{p: p, pd: pds[i]} 680 | } 681 | 682 | sort.SliceStable(ws, func(i, j int) bool { 683 | return ws[i].p.Team < ws[j].p.Team 684 | }) 685 | 686 | for i := range ws { 687 | players[i] = ws[i].p 688 | pds[i] = ws[i].pd 689 | } 690 | } 691 | 692 | // computeWinners attempts to compute winners using "largest remaining team wins" principle. 693 | func (r *Replay) computeWinners() { 694 | // Situation: game result (winners / losers) is not recorded in replays. 695 | // We try to determine the winners based on the "largest remaining team wins" principle. 696 | // The essence of this is to process Leave game commands and track remaining team sizes. 697 | // Problems: 698 | // -Leave game commands are not recorded for computers 699 | // -Leave game commands are not recorded for the replay saver 700 | 701 | c := r.Computed 702 | 703 | // Keep track of team sizes and computer counts: 704 | nonObsPlayersCount := 0 705 | teamSizes := map[byte]int{} // Excluding computers 706 | teamCompsCount := map[byte]int{} // Including only computers 707 | 708 | for _, p := range r.Header.Players { 709 | if !p.Observer { 710 | if p.Type == repcore.PlayerTypeComputer { 711 | teamCompsCount[p.Team]++ 712 | } else { 713 | teamSizes[p.Team]++ 714 | } 715 | nonObsPlayersCount++ 716 | } 717 | } 718 | 719 | // If there is a team full of only computers, we can't detect winners. 720 | for team := range teamCompsCount { 721 | if teamSizes[team] == 0 { 722 | return // This team only consists of computers 723 | } 724 | } 725 | 726 | // Computers never leave, so use only non-computer sizes (teamSizes) ongoing. 727 | 728 | // Keep only leave game commands of non-observers, which matters if / when we check the last of them. 729 | leaveGameCmds := make([]*repcmd.LeaveGameCmd, 0, len(c.LeaveGameCmds)+1) 730 | for _, lgcmd := range c.LeaveGameCmds { 731 | if p := r.Header.PIDPlayers[lgcmd.PlayerID]; p != nil { 732 | if !p.Observer { 733 | leaveGameCmds = append(leaveGameCmds, lgcmd) 734 | } 735 | } 736 | } 737 | 738 | // There is no Leave game command recorded for the replay saver. 739 | // If we know the replay saver, "simulate" a leave game command 740 | // for him/her as the last leave game command. 741 | if c.RepSaverPlayerID != nil { 742 | // rep saver might be an observer, so must check if there's a player for him/her: 743 | if repSaver := r.Header.PIDPlayers[*c.RepSaverPlayerID]; repSaver != nil && !repSaver.Observer { 744 | // Add virutal leave game cmd 745 | leaveGameCmds = append(leaveGameCmds, &repcmd.LeaveGameCmd{ 746 | Base: &repcmd.Base{ 747 | PlayerID: repSaver.ID, // Only PlayerID is needed / used 748 | }, 749 | }) 750 | } 751 | } 752 | 753 | for _, lgcmd := range leaveGameCmds { 754 | // lgcmd.PlayerID exists in PIDPlayers, was checked when assembled leaveGameCmds 755 | teamSizes[r.Header.PIDPlayers[lgcmd.PlayerID].Team]-- 756 | } 757 | 758 | if len(teamSizes) < 2 || // There are no multiple teams 759 | len(leaveGameCmds) == 0 { // There were no Leave game commands, not even a "virtual" one, 760 | // we just don't know who the winners are. 761 | return 762 | } 763 | 764 | // Complete winners detection: largest remaining team wins 765 | maxTeam, maxSize := byte(0), -1 766 | for team, size := range teamSizes { 767 | if size > maxSize { 768 | maxTeam, maxSize = team, size 769 | } 770 | } 771 | // Are winners detectable? 772 | if maxSize > 0 { 773 | // Is there only one team with max size? 774 | count := 0 775 | for _, size := range teamSizes { 776 | if size == maxSize { 777 | count++ 778 | } 779 | } 780 | if count == 1 { 781 | // We have our winners! 782 | c.WinnerTeam = maxTeam 783 | return 784 | } 785 | } 786 | 787 | // There is no single largest team. 788 | // If there are multiple teams (not just one), and if all (non-obs) players left (we have a leave game command for all), 789 | // declare the last leaver's team the winner team. 790 | // Often this happens if an observer saves the replay, and he/she is the one last leaving (there's no leave game command for observers). 791 | if len(leaveGameCmds) == nonObsPlayersCount { 792 | playerID := leaveGameCmds[len(leaveGameCmds)-1].PlayerID 793 | c.WinnerTeam = r.Header.PIDPlayers[playerID].Team 794 | return 795 | } 796 | } 797 | 798 | // angleToClock converts an angle given in radian to an hour clock value 799 | // in the range of 1..12. 800 | // 801 | // Examples: 802 | // - PI/2 => 12 (o'clock) 803 | // - 0 => 3 (o'clock) 804 | // - PI => 9 (o'clock) 805 | func angleToClock(angle float64) int32 { 806 | // The algorithm below computes clock value in the range of 0..11 where 807 | // 0 corresponds to 12. 808 | 809 | // 1 hour is PI/6 angle range 810 | const oneHour = math.Pi / 6 811 | 812 | // Shift by 3:30 (0 or 12 o-clock starts at 11:30) 813 | // and invert direction (clockwise): 814 | angle = -angle + oneHour*3.5 815 | 816 | // Put in range of 0..2*PI 817 | for angle < 0 { 818 | angle += oneHour * 12 819 | } 820 | for angle >= oneHour*12 { 821 | angle -= oneHour * 12 822 | } 823 | 824 | // And convert to a clock value: 825 | hour := int32(angle / oneHour) 826 | if hour == 0 { 827 | return 12 828 | } 829 | return hour 830 | } 831 | -------------------------------------------------------------------------------- /rep/replay_test.go: -------------------------------------------------------------------------------- 1 | package rep 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | ) 7 | 8 | func TestAngleToClock(t *testing.T) { 9 | cases := []struct { 10 | angle float64 11 | clock int32 12 | }{ 13 | {0, 3}, 14 | {math.Pi / 2, 12}, 15 | {math.Pi, 9}, 16 | {math.Pi * 3 / 2, 6}, 17 | 18 | {0 + math.Pi*6, 3}, 19 | {0 - math.Pi*6, 3}, 20 | 21 | {math.Pi/2 + math.Pi/13, 12}, 22 | {math.Pi/2 - math.Pi/13, 12}, 23 | } 24 | 25 | for _, c := range cases { 26 | if got := angleToClock(c.angle); got != c.clock { 27 | t.Errorf("Expected: %v, got: %v", c.clock, got) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /rep/shieldbattery.go: -------------------------------------------------------------------------------- 1 | // This file contains the types describing info parsed from the ShieldBattery custom section. 2 | 3 | package rep 4 | 5 | // ShieldBattery models the data parsed from the ShieldBattery custom section. 6 | type ShieldBattery struct { 7 | StarCraftExeBuild uint32 8 | ShieldBatteryVersion string 9 | GameID string 10 | } 11 | -------------------------------------------------------------------------------- /repparser/repdecoder/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Package repdecoder implements decoding StarCraft Brood War replay files (*.rep). 4 | 5 | SC BW replays are basically divided into 2 types: 6 | 7 | - modern (starting from 1.18) 8 | 9 | - legacy (pre 1.18) 10 | 11 | The type detection and utilization of the proper decoder is automatic 12 | and transparent to the package user. 13 | 14 | */ 15 | package repdecoder 16 | -------------------------------------------------------------------------------- /repparser/repdecoder/legacy.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This file implements decoding the legacy (pre 1.18) replay format. 4 | It partially implements reading PKWARE Data Compressed data, 5 | tailored to the needs of parsing StarCraft: Brood War replay files (*.rep). 6 | 7 | 8 | The algorithm comes from JCA's bwreplib. 9 | Rewrite and optimization for Go: Andras Belicza 10 | 11 | 12 | Information sources: 13 | 14 | BWHF replay parser: 15 | https://github.com/icza/bwhf/blob/master/src/hu/belicza/andras/bwhf/control/BinReplayUnpacker.java 16 | 17 | 18 | Zadislav Zezula: 19 | https://github.com/ladislav-zezula/StormLib/blob/master/src/pklib/explode.c 20 | 21 | 22 | */ 23 | 24 | package repdecoder 25 | 26 | import "io" 27 | 28 | var off507120 = []byte{ // length = 0x40 29 | 0x02, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x06, 30 | 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 31 | 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x07, 0x07, 32 | 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 33 | 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 34 | 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 35 | 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 36 | 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 37 | } 38 | 39 | var off507160 = []byte{ // length = 0x40, com1 40 | 0x03, 0x0D, 0x05, 0x19, 0x09, 0x11, 0x01, 0x3E, 41 | 0x1E, 0x2E, 0x0E, 0x36, 0x16, 0x26, 0x06, 0x3A, 42 | 0x1A, 0x2A, 0x0A, 0x32, 0x12, 0x22, 0x42, 0x02, 43 | 0x7C, 0x3C, 0x5C, 0x1C, 0x6C, 0x2C, 0x4C, 0x0C, 44 | 0x74, 0x34, 0x54, 0x14, 0x64, 0x24, 0x44, 0x04, 45 | 0x78, 0x38, 0x58, 0x18, 0x68, 0x28, 0x48, 0x08, 46 | 0xF0, 0x70, 0xB0, 0x30, 0xD0, 0x50, 0x90, 0x10, 47 | 0xE0, 0x60, 0xA0, 0x20, 0xC0, 0x40, 0x80, 0x00, 48 | } 49 | 50 | var off5071A0 = []byte{ // length = 0x10 51 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 52 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 53 | } 54 | 55 | var off5071B0 = []byte{ // length = 0x20 56 | 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 57 | 0x04, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 58 | 0x08, 0x00, 0x0A, 0x00, 0x0E, 0x00, 0x16, 0x00, 59 | 0x26, 0x00, 0x46, 0x00, 0x86, 0x00, 0x06, 0x01, 60 | } 61 | 62 | var off5071D0 = []byte{ // length = 0x10 63 | 0x03, 0x02, 0x03, 0x03, 0x04, 0x04, 0x04, 0x05, 64 | 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x07, 0x07, 65 | } 66 | 67 | var off5071E0 = []byte{ // length = 0x10, com1 68 | 0x05, 0x03, 0x01, 0x06, 0x0A, 0x02, 0x0C, 0x14, 69 | 0x04, 0x18, 0x08, 0x30, 0x10, 0x20, 0x40, 0x00, 70 | } 71 | 72 | // legacyDecoder is the Decoder implementation for legacy replays. 73 | type legacyDecoder struct { 74 | decoder 75 | 76 | // esi struct used in decoding several sections 77 | esi esi 78 | } 79 | 80 | type replayEnc struct { 81 | src []byte 82 | m04 int32 83 | m08 []byte 84 | m0C int32 85 | m10 int32 86 | m14 int32 87 | } 88 | 89 | // zeroedEsiData is an array that remains untouched (zeroed) so the esi.data 90 | // slice field can easily and efficiently be zeroed by copying this over 91 | var zeroedEsiData [0x3114 + 0x20]byte // allocates 0x30 extra bytes in the beginning, but we ignore those 92 | 93 | type esi struct { 94 | m00 int32 95 | m04 int32 96 | m08 int32 97 | m0C int32 98 | m10 int32 99 | m14 int32 100 | m18 int32 101 | m1C int32 102 | m20 int32 103 | m24 replayEnc 104 | m28 int32 105 | m2C int32 106 | data []byte 107 | } 108 | 109 | func (d *legacyDecoder) Section(size int32) (result []byte, sectionID int32, err error) { 110 | var count int32 111 | if count, result, err = d.sectionHeader(size); result != nil || err != nil { 112 | return 113 | } 114 | 115 | var n, length2, m1C, m20, resultOffset int32 116 | result = make([]byte, size) 117 | 118 | d.initEsi() 119 | rep := &d.esi.m24 120 | 121 | buf := d.buf 122 | bufLen := int32(len(buf)) 123 | for ; n < count; n, m1C, m20 = n+1, m1C+bufLen, m20+length2 { 124 | var length int32 // compressed length of the chunk 125 | if length, err = d.readInt32(); err != nil { 126 | return nil, sectionID, err 127 | } 128 | if length > size-m20 { 129 | return nil, sectionID, ErrMismatchedSection 130 | } 131 | 132 | if _, err = io.ReadFull(d.r, result[resultOffset:resultOffset+length]); err != nil { 133 | return 134 | } 135 | 136 | if length == min(size-m1C, bufLen) { 137 | continue 138 | } 139 | 140 | rep.src = make([]byte, length) 141 | copy(rep.src, result[resultOffset:]) 142 | rep.m04 = 0 143 | rep.m08 = buf 144 | rep.m0C = 0 145 | rep.m10 = length 146 | rep.m14 = bufLen 147 | 148 | if d.repSection() == 0 && rep.m0C <= bufLen { 149 | length2 = rep.m0C 150 | } else { 151 | length2 = 0 152 | } 153 | if length2 == 0 || length2 > size { 154 | return nil, sectionID, ErrMismatchedSection 155 | } 156 | 157 | copy(result[resultOffset:], buf[:length2]) 158 | resultOffset += length2 159 | } 160 | 161 | return result, sectionID, nil 162 | } 163 | 164 | // initEsi initializes (zeroes) the esi struct. 165 | func (d *legacyDecoder) initEsi() { 166 | if d.esi.data == nil { 167 | // If this is the first call, we create and slice a new array: 168 | var data [len(zeroedEsiData)]byte // zeroed 169 | d.esi.data = data[:] 170 | // esi.m24 is a struct, its zero value is good. 171 | } else { 172 | // Else we copy over the zeroed slice: 173 | copy(d.esi.data, zeroedEsiData[:]) 174 | // zero esi.m24 by assigning a new, zero-value struct 175 | d.esi.m24 = replayEnc{} 176 | } 177 | } 178 | 179 | // repSection decodes the esi.m24 (replayEnc) field. 180 | func (d *legacyDecoder) repSection() int32 { 181 | esi := &d.esi 182 | 183 | esi.m1C = 0x800 184 | esi.m20 = d.esi28(0x2234, esi.m1C) 185 | if esi.m20 <= 4 { 186 | return 3 187 | } 188 | rep := &d.esi.m24 189 | esi.m04 = int32(rep.src[0]) 190 | esi.m0C = int32(rep.src[1]) 191 | esi.m14 = int32(rep.src[2]) 192 | esi.m18 = 0 193 | esi.m1C = 3 194 | if esi.m0C < 4 || esi.m0C > 6 { 195 | return 1 196 | } 197 | esi.m10 = 1<= 0; n-- { 218 | for x, y = int32(str[n]), 1<= 0x305 { 232 | break 233 | } 234 | if length >= 0x100 { // decode region of size length -0xFE 235 | length -= 0xFE 236 | tmp := d.function2(length) 237 | if tmp == 0 { 238 | length = 0x306 239 | break 240 | } 241 | for length > 0 { 242 | esi.data[0x30+esi.m08] = esi.data[0x30+esi.m08-tmp] 243 | esi.m08++ 244 | length-- 245 | } 246 | } else { 247 | // just copy the character 248 | esi.data[0x30+esi.m08] = byte(length) 249 | esi.m08++ 250 | } 251 | if esi.m08 < 0x2000 { 252 | continue 253 | } 254 | d.esi2C(0x1030, 0x1000) 255 | copy(esi.data[0x30:0x30+esi.m08-0x1000], esi.data[0x1030:]) 256 | esi.m08 -= 0x1000 257 | } 258 | d.esi2C(0x1030, esi.m08-0x1000) 259 | 260 | return length 261 | } 262 | 263 | func (d *legacyDecoder) function1() int32 { 264 | esi := &d.esi 265 | 266 | var x, result int32 267 | 268 | // esi.m14 is odd 269 | if (1 & esi.m14) != 0 { 270 | if d.common(1) { 271 | return 0x306 272 | } 273 | result = int32(esi.data[0x2B34+(esi.m14&0xff)]) 274 | if d.common(int32(esi.data[0x30F4+result])) { 275 | return 0x306 276 | } 277 | if esi.data[0x3104+result] != 0 { 278 | x = ((1 << (esi.data[0x3104+result] & 0xff)) - 1) & esi.m14 279 | if d.common(int32(esi.data[0x3104+result])) && (result+x) != 0x10E { 280 | return 0x306 281 | } 282 | result = (int32(esi.data[0x3114+2*result+1]) << 8) | int32(esi.data[0x3114+2*result]) // memcpy(&result, &myesi->m3114[2*result], 2); 283 | result += x 284 | } 285 | return result + 0x100 286 | } 287 | // esi.m14 is even 288 | if d.common(1) { 289 | return 0x306 290 | } 291 | if esi.m04 == 0 { 292 | result = esi.m14 & 0xff 293 | if d.common(8) { 294 | return 0x306 295 | } 296 | return result 297 | } 298 | if (esi.m14 & 0xff) == 0 { 299 | if d.common(8) { 300 | return 0x306 301 | } 302 | result = int32(esi.data[0x2EB4+(esi.m14&0xff)]) 303 | } else { 304 | result = int32(esi.data[0x2C34+(esi.m14&0xff)]) 305 | if result == 0xFF { 306 | if (esi.m14 & 0x3F) == 0 { 307 | if d.common(6) { 308 | return 0x306 309 | } 310 | result = int32(esi.data[0x2C34+(esi.m14&0x7F)]) 311 | } else { 312 | if d.common(4) { 313 | return 0x306 314 | } 315 | result = int32(esi.data[0x2D34+(esi.m14&0xFF)]) 316 | } 317 | } 318 | } 319 | if d.common(int32(esi.data[0x2FB4+result])) { 320 | return 0x306 321 | } 322 | return result 323 | } 324 | 325 | func (d *legacyDecoder) function2(length int32) int32 { 326 | esi := &d.esi 327 | 328 | tmp := int32(esi.data[0x2A34+esi.m14&0xff]) 329 | if d.common(int32(esi.data[0x30B4+tmp])) { 330 | return 0 331 | } 332 | if length != 2 { 333 | tmp <<= byte(esi.m0C) 334 | tmp |= esi.m14 & esi.m10 335 | if d.common(esi.m0C) { 336 | return 0 337 | } 338 | } else { 339 | tmp <<= 2 340 | tmp |= esi.m14 & 3 341 | if d.common(2) { 342 | return 0 343 | } 344 | } // A38 345 | 346 | return tmp + 1 347 | } 348 | 349 | func (d *legacyDecoder) common(count int32) bool { 350 | esi := &d.esi 351 | 352 | if esi.m18 < count { 353 | esi.m14 >>= byte(esi.m18) 354 | if esi.m1C == esi.m20 { 355 | esi.m20 = d.esi28(0x2234, 0x800) 356 | if esi.m20 == 0 { 357 | return true 358 | } 359 | esi.m1C = 0 360 | } 361 | tmp := int32(esi.data[0x2234+esi.m1C]) 362 | tmp <<= 8 363 | esi.m1C++ 364 | tmp |= esi.m14 365 | esi.m14 = tmp 366 | tmp >>= uint32(count - esi.m18&0xff) 367 | esi.m14 = tmp 368 | esi.m18 += 8 - count 369 | } else { 370 | esi.m18 -= count 371 | esi.m14 >>= byte(count) 372 | } 373 | 374 | return false 375 | } 376 | 377 | func (d *legacyDecoder) esi28(dstPos, length int32) int32 { 378 | rep := &d.esi.m24 379 | 380 | length = min(rep.m10-rep.m04, length) 381 | copy(d.esi.data[dstPos:], rep.src[rep.m04:rep.m04+length]) 382 | rep.m04 += length 383 | return length 384 | } 385 | 386 | func (d *legacyDecoder) esi2C(srcPos, length int32) { 387 | rep := &d.esi.m24 388 | 389 | if rep.m0C+length <= rep.m14 { 390 | copy(rep.m08[rep.m0C:], d.esi.data[srcPos:srcPos+length]) 391 | } 392 | rep.m0C += length 393 | } 394 | 395 | // min returns the smaller of 2 int32 values. 396 | func min(a, b int32) int32 { 397 | if a < b { 398 | return a 399 | } 400 | return b 401 | } 402 | -------------------------------------------------------------------------------- /repparser/repdecoder/modern.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This file implements decoding the modern (starting from 1.18) replay format. 4 | 5 | */ 6 | 7 | package repdecoder 8 | 9 | import ( 10 | "bytes" 11 | "compress/zlib" 12 | "io" 13 | ) 14 | 15 | // modernDecoder is the Decoder implementation for modern replays. 16 | type modernDecoder struct { 17 | decoder 18 | } 19 | 20 | var knownModernSectionIDSizeHints = map[int32]int32{ 21 | 1313426259: 0x15e0, // "SKIN" 22 | 1398033740: 0x1c, // "LMTS" 23 | 1481197122: 0x08, // "BFIX" 24 | 1380729667: 0xc0, // "CCLR" 25 | 1195787079: 0x19, // "GCFG" 26 | } 27 | 28 | func (d *modernDecoder) Section(size int32) (result []byte, sectionID int32, err error) { 29 | if d.sectionsCounter > 5 { 30 | // These are the sections added in modern replays. 31 | if sectionID, err = d.readInt32(); err != nil { // This is the StrID of the section 32 | return 33 | } 34 | var rawSize int32 35 | if rawSize, err = d.readInt32(); err != nil { // raw, remaining section size 36 | return 37 | } 38 | 39 | sizeHint := knownModernSectionIDSizeHints[sectionID] 40 | if sizeHint == 0 { 41 | // It's not a known, SCR section, but some custom section. 42 | // Don't assume anything about its format, return the raw data: 43 | result = make([]byte, rawSize) 44 | _, err = io.ReadFull(d.r, result) 45 | return 46 | } 47 | size = sizeHint 48 | } 49 | 50 | var count int32 51 | if count, result, err = d.sectionHeader(size); result != nil || err != nil { 52 | return 53 | } 54 | 55 | resBuf := bytes.NewBuffer(make([]byte, 0, size)) 56 | 57 | var zr io.ReadCloser // zlib reader 58 | 59 | for ; count > 0; count-- { 60 | var length int32 // compressed length of the chunk 61 | if length, err = d.readInt32(); err != nil { 62 | return 63 | } 64 | 65 | if int32(len(d.buf)) < length { 66 | d.buf = make([]byte, length) 67 | } 68 | compressed := d.buf[:length] 69 | if _, err = io.ReadFull(d.r, compressed); err != nil { 70 | return nil, sectionID, err 71 | } 72 | if length > 4 && compressed[0] == 0x78 { // Is it compressed? (0x78 zlib magic) 73 | if resetter, ok := zr.(zlib.Resetter); ok { 74 | err = resetter.Reset(bytes.NewBuffer(compressed), nil) 75 | } else { 76 | zr, err = zlib.NewReader(bytes.NewBuffer(compressed)) 77 | if zr != nil { 78 | defer zr.Close() 79 | } 80 | } 81 | if err != nil { 82 | return nil, sectionID, err 83 | } 84 | if _, err = io.Copy(resBuf, zr); err != nil { 85 | return nil, sectionID, err 86 | } 87 | } else { 88 | // it's not compressed 89 | if _, err = resBuf.Write(compressed); err != nil { 90 | return 91 | } 92 | } 93 | } 94 | 95 | return resBuf.Bytes(), sectionID, nil 96 | } 97 | -------------------------------------------------------------------------------- /repparser/repdecoder/repdecoder.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This file contains the interface to the replay decoder, and common parts 4 | of the 2 types (legacy and modern). 5 | 6 | Information sources: 7 | 8 | BWHF replay parser: 9 | https://github.com/icza/bwhf/blob/master/src/hu/belicza/andras/bwhf/control/BinReplayUnpacker.java 10 | 11 | */ 12 | 13 | package repdecoder 14 | 15 | import ( 16 | "bytes" 17 | "encoding/binary" 18 | "errors" 19 | "fmt" 20 | "io" 21 | "os" 22 | ) 23 | 24 | var ( 25 | // ErrMismatchedSection is returned by Decoder.Section() if the section size is not the expected one 26 | ErrMismatchedSection = errors.New("mismatched section") 27 | 28 | // ErrNoMoreSections is returned by Decoder.NewSection() if there are no more sections. 29 | ErrNoMoreSections = errors.New("no more sections") 30 | ) 31 | 32 | // Decoder wraps a Section method for decoding a section of a given size. 33 | type Decoder interface { 34 | // RepFormat returns the replay format 35 | RepFormat() RepFormat 36 | 37 | // NewSection must be called between sections. 38 | // ErrNoMoreSections is returned if the replay has no more sections. 39 | NewSection() error 40 | 41 | // Section decodes a section of the given size. 42 | Section(size int32) (data []byte, sectionID int32, err error) 43 | 44 | // Close closes the decoder, releases any associated resources. 45 | io.Closer 46 | } 47 | 48 | // NewFromFile creates a new Decoder that reads and decompresses data form a 49 | // file. 50 | func NewFromFile(name string) (d Decoder, err error) { 51 | var f *os.File 52 | f, err = os.Open(name) 53 | if err != nil { 54 | return 55 | } 56 | 57 | defer func() { 58 | if err != nil { 59 | f.Close() 60 | } 61 | }() 62 | 63 | stat, err := f.Stat() 64 | if err != nil { 65 | return 66 | } 67 | 68 | if stat.IsDir() { 69 | return nil, fmt.Errorf("not a file: %s", name) 70 | } 71 | 72 | var rf RepFormat 73 | if stat.Size() >= 30 { 74 | fileHeader := make([]byte, 30) 75 | if _, err = io.ReadFull(f, fileHeader); err != nil { 76 | return 77 | } 78 | rf = detectRepFormat(fileHeader) 79 | if _, err = f.Seek(0, io.SeekStart); err != nil { 80 | return 81 | } 82 | } 83 | 84 | return newDecoder(f, rf), nil 85 | } 86 | 87 | // New creates a new Decoder that reads and decompresses data from the 88 | // given byte slice. 89 | func New(repData []byte) Decoder { 90 | rf := RepFormatUnknown 91 | if len(repData) >= 30 { 92 | rf = detectRepFormat(repData[:30]) 93 | } 94 | 95 | return newDecoder(bytes.NewBuffer(repData), rf) 96 | } 97 | 98 | // RepFormat identifies the replay format 99 | type RepFormat int 100 | 101 | // Possible values of repFormat 102 | const ( 103 | RepFormatUnknown RepFormat = iota // Unknown replay format 104 | RepFormatLegacy // Legacy replay format (pre 1.18) 105 | RepFormatModern // Modern replay format (1.18 - 1.20) 106 | RepFormatModern121 // Modern 1.21 replay format (starting from 1.21) 107 | ) 108 | 109 | // detectRepFormat detects the replay format based on the file header 110 | // (the initial bytes of the binary replay). 111 | // Information used from the header includes the replay ID section's data 112 | // (which is 4 bytes, starting at offset 12), and the first bytes of the compressed 113 | // data block of the Header section (which starts at offset 28). 114 | // If the compressed data block starts with the magic of the valid zlib header, 115 | // it is modern. If it is modern, the replay ID data decides which version. 116 | func detectRepFormat(fileHeader []byte) RepFormat { 117 | if len(fileHeader) < 30 { 118 | return RepFormatUnknown 119 | } 120 | 121 | // legacy and pre 1.21 modern replays have replay ID data "reRS". 122 | // Starting from 1.21, replay ID data is "seRS". 123 | if fileHeader[12] == 's' { 124 | return RepFormatModern121 125 | } 126 | 127 | // It's pre 1.21, check if legacy: 128 | 129 | // Now only checking first byte of the compressed data block. 130 | // 2nd would be 131 | // 0x01 no compression 132 | // 0x5E level 1..5 133 | // 0x9C level 6 (default compression?) 134 | // 0xDA level 7..9 135 | if fileHeader[28] != 0x78 { 136 | return RepFormatLegacy 137 | } 138 | 139 | return RepFormatModern 140 | } 141 | 142 | // newDecoder creates a new Decoder that reads and decompresses data from the given Reader. 143 | // The source is treated as a modern replay if modern is true, else as a 144 | // legacy replay. 145 | func newDecoder(r io.Reader, rf RepFormat) Decoder { 146 | dec := decoder{ 147 | r: r, 148 | rf: rf, 149 | int32Buf: make([]byte, 4), 150 | buf: make([]byte, 0x2000), // 8 KB buffer 151 | } 152 | 153 | switch rf { 154 | case RepFormatModern, RepFormatModern121: 155 | return &modernDecoder{ 156 | decoder: dec, 157 | } 158 | default: 159 | return &legacyDecoder{ 160 | decoder: dec, 161 | } 162 | } 163 | } 164 | 165 | // decoder is the Decoder base (incomplete) implementation. 166 | // Contains common parts of the 2 replay types. 167 | type decoder struct { 168 | // r is the source of replay data 169 | r io.Reader 170 | 171 | // rf identifiers the rep format 172 | rf RepFormat 173 | 174 | // sectionsCounter tells how many sections have been read 175 | sectionsCounter int 176 | 177 | // intBuf is a general buffer for reading an int32 value 178 | int32Buf []byte 179 | 180 | // buf is a general buffer (re)used in decoding several sections 181 | buf []byte 182 | } 183 | 184 | func (d *decoder) RepFormat() RepFormat { 185 | return d.rf 186 | } 187 | 188 | // readInt32 reads an int32 from the underlying Reader. 189 | func (d *decoder) readInt32() (n int32, err error) { 190 | if _, err = io.ReadFull(d.r, d.int32Buf); err != nil { 191 | return 192 | } 193 | 194 | n = int32(binary.LittleEndian.Uint32(d.int32Buf)) 195 | return 196 | } 197 | 198 | func (d *decoder) NewSection() (err error) { 199 | d.sectionsCounter++ 200 | 201 | switch d.rf { 202 | case RepFormatLegacy: 203 | if d.sectionsCounter == 5 { 204 | return ErrNoMoreSections // Legacy replays only have 4 sections 205 | } 206 | case RepFormatModern121: 207 | // There is a 4-byte encoded length between sections: 208 | if d.sectionsCounter == 2 { 209 | if _, err = d.readInt32(); err != nil { 210 | return 211 | } 212 | } 213 | } 214 | 215 | return 216 | } 217 | 218 | // sectionHeader reads the section header. 219 | func (d *decoder) sectionHeader(size int32) (count int32, result []byte, err error) { 220 | if size == 0 { 221 | result = []byte{} 222 | return 223 | } 224 | 225 | // checksum, we're not checking it 226 | if _, err = d.readInt32(); err != nil { 227 | return 228 | } 229 | 230 | // number of chunks the section data is split into 231 | count, err = d.readInt32() 232 | 233 | return 234 | } 235 | 236 | // Close closes the underlying io.Reader if it implements io.Closer. 237 | func (d *decoder) Close() error { 238 | if closer, ok := d.r.(io.Closer); ok { 239 | return closer.Close() 240 | } 241 | return nil 242 | } 243 | -------------------------------------------------------------------------------- /repparser/repparser.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package repparser implements StarCraft: Brood War replay parsing. 3 | 4 | The package is safe for concurrent use. 5 | 6 | Information sources: 7 | 8 | BWHF replay parser: 9 | 10 | https://github.com/icza/bwhf/tree/master/src/hu/belicza/andras/bwhf/control 11 | 12 | BWAPI replay parser: 13 | 14 | https://github.com/bwapi/bwapi/tree/master/bwapi/libReplayTool 15 | 16 | https://github.com/bwapi/bwapi/tree/master/bwapi/include/BWAPI 17 | 18 | https://github.com/bwapi/bwapi/tree/master/bwapi/PKLib 19 | 20 | Command models: 21 | 22 | https://github.com/icza/bwhf/blob/master/src/hu/belicza/andras/bwhf/model/Action.java 23 | 24 | https://github.com/bwapi/bwapi/tree/master/bwapi/libReplayTool 25 | 26 | jssuh replay parser: 27 | 28 | https://github.com/neivv/jssuh 29 | 30 | Map Data format: 31 | 32 | https://www.starcraftai.com/wiki/CHK_Format 33 | 34 | http://www.staredit.net/wiki/index.php/Scenario.chk 35 | 36 | http://blog.naver.com/PostView.nhn?blogId=wisdomswrap&logNo=60119755717&parentCategoryNo=&categoryNo=19&viewDate=&isShowPopularPosts=false&from=postView 37 | 38 | https://github.com/ShieldBattery/bw-chk/blob/master/index.js 39 | */ 40 | package repparser 41 | 42 | import ( 43 | "bytes" 44 | "encoding/binary" 45 | "errors" 46 | "fmt" 47 | "io" 48 | "log" 49 | "runtime" 50 | "sort" 51 | "time" 52 | "unicode/utf8" 53 | 54 | "github.com/icza/screp/rep" 55 | "github.com/icza/screp/rep/repcmd" 56 | "github.com/icza/screp/rep/repcore" 57 | "github.com/icza/screp/repparser/repdecoder" 58 | "golang.org/x/text/encoding/korean" 59 | ) 60 | 61 | const ( 62 | // Version is a Semver2 compatible version of the parser. 63 | Version = "v1.12.12" 64 | ) 65 | 66 | var ( 67 | // ErrNotReplayFile indicates the given file (or reader) is not a valid 68 | // replay file 69 | ErrNotReplayFile = errors.New("not a replay file") 70 | 71 | // ErrParsing indicates that an unexpected error occurred, which may be 72 | // due to corrupt / invalid replay file, or some implementation error. 73 | ErrParsing = errors.New("parsing") 74 | ) 75 | 76 | // Config holds parser configuration. 77 | type Config struct { 78 | // Commands tells if the commands section is to be parsed 79 | Commands bool 80 | 81 | // MapData tells if the map data section is to be parsed 82 | MapData bool 83 | 84 | // Debug tells if debug and replay internal binaries is to be retained in the returned Replay. 85 | Debug bool 86 | 87 | // MapGraphics tells if map data usually required for map image rendering is to be parsed. 88 | // MapData must be parsed too. 89 | MapGraphics bool 90 | 91 | // Custom logger to use to report parsing errors. 92 | // If nil, the default logger of the log package will be used. 93 | // To suppress logs, use a new logger directed to io.Discard, e.g.: 94 | // discardLogger := log.New(io.Discard, "", 0) 95 | Logger *log.Logger 96 | 97 | _ struct{} // To prevent unkeyed literals 98 | } 99 | 100 | // ParseFile parses all sections from an SC:BW replay file. 101 | func ParseFile(name string) (r *rep.Replay, err error) { 102 | return ParseFileConfig(name, Config{Commands: true, MapData: true}) 103 | } 104 | 105 | // ParseFileSections parses an SC:BW replay file. 106 | // Parsing commands and map data sections depends on the given parameters. 107 | // Replay ID and header sections are always parsed. 108 | // 109 | // Deprecated: Use ParseFileConfig() instead. 110 | func ParseFileSections(name string, commands, mapData bool) (r *rep.Replay, err error) { 111 | return ParseFileConfig(name, Config{Commands: commands, MapData: mapData}) 112 | } 113 | 114 | // ParseFileConfig parses an SC:BW replay file based on the given parser configuration. 115 | // Replay ID and header sections are always parsed. 116 | func ParseFileConfig(name string, cfg Config) (r *rep.Replay, err error) { 117 | dec, err := repdecoder.NewFromFile(name) 118 | if err != nil { 119 | return nil, err 120 | } 121 | defer dec.Close() 122 | 123 | return parseProtected(dec, cfg) 124 | } 125 | 126 | // Parse parses all sections of an SC:BW replay from the given byte slice. 127 | // Map graphics related info is not parsed (see Config.MapGraphics). 128 | func Parse(repData []byte) (*rep.Replay, error) { 129 | return ParseConfig(repData, Config{Commands: true, MapData: true}) 130 | } 131 | 132 | // ParseSections parses an SC:BW replay from the given byte slice. 133 | // Parsing commands and map data sections depends on the given parameters. 134 | // Replay ID and header sections are always parsed. 135 | // 136 | // Deprecated: Use ParseConfig() instead. 137 | func ParseSections(repData []byte, commands, mapData bool) (*rep.Replay, error) { 138 | return ParseConfig(repData, Config{Commands: commands, MapData: mapData}) 139 | } 140 | 141 | // ParseConfig parses an SC:BW replay from the given byte sice based on the given parser configuration. 142 | // Replay ID and header sections are always parsed. 143 | func ParseConfig(repData []byte, cfg Config) (*rep.Replay, error) { 144 | dec := repdecoder.New(repData) 145 | defer dec.Close() 146 | 147 | return parseProtected(dec, cfg) 148 | } 149 | 150 | // parseProtected calls parse(), but protects the function call from panics, 151 | // in which case it returns ErrParsing. 152 | func parseProtected(dec repdecoder.Decoder, cfg Config) (r *rep.Replay, err error) { 153 | // Make sure cfg.Logger is not nil, in one place: 154 | if cfg.Logger == nil { 155 | cfg.Logger = log.Default() 156 | } 157 | 158 | // Input is untrusted data, protect the parsing logic. 159 | // It also protects against implementation bugs. 160 | defer func() { 161 | if r := recover(); r != nil { 162 | cfg.Logger.Printf("Parsing error: %v", r) 163 | buf := make([]byte, 2000) 164 | n := runtime.Stack(buf, false) 165 | cfg.Logger.Printf("Stack: %s", buf[:n]) 166 | err = ErrParsing 167 | } 168 | }() 169 | 170 | return parse(dec, cfg) 171 | } 172 | 173 | // Section describes a Section of the replay. 174 | type Section struct { 175 | // ID of the section 176 | ID int 177 | 178 | // Size of the uncompressed section in bytes; 179 | // 0 means the Size has to be read as a section of 4 bytes 180 | Size int32 181 | 182 | // ParseFunc defines the function responsible to process (parse / interpret) 183 | // the section's data. 184 | ParseFunc func(data []byte, r *rep.Replay, cfg Config) error 185 | 186 | // Optional section string ID 187 | StrID string 188 | } 189 | 190 | // Sections describes the subsequent Sections of replays 191 | var Sections = []*Section{ 192 | {ID: 0, Size: 0x04, ParseFunc: parseReplayID}, 193 | {ID: 1, Size: 0x279, ParseFunc: parseHeader}, 194 | {ID: 2, Size: 0, ParseFunc: parseCommands}, 195 | {ID: 3, Size: 0, ParseFunc: parseMapData}, 196 | {ID: 4, Size: 0x300, ParseFunc: parsePlayerNames}, 197 | } 198 | 199 | // ModernSections holds custom sections added in Remastered, and also custom sections 200 | // added by 3rd party vendors. 201 | var ModernSections = map[int32]*Section{ 202 | 1313426259: {ID: 5, Size: 0x15e0, ParseFunc: parseSkin, StrID: "SKIN"}, 203 | 1398033740: {ID: 6, Size: 0x1c, ParseFunc: parseLmts, StrID: "LMTS"}, 204 | 1481197122: {ID: 7, Size: 0x08, ParseFunc: parseBfix, StrID: "BFIX"}, 205 | 1380729667: {ID: 8, Size: 0xc0, ParseFunc: parsePlayerColors, StrID: "CCLR"}, 206 | 1195787079: {ID: 9, Size: 0x19, ParseFunc: parseGcfg, StrID: "GCFG"}, 207 | 208 | // ShieldBattery's custom section 209 | 1952539219: {ID: 10, Size: 0, ParseFunc: parseShieldBatterySection, StrID: "Sbat"}, 210 | } 211 | 212 | // Named sections 213 | var ( 214 | SectionReplayID = Sections[0] 215 | SectionHeader = Sections[1] 216 | SectionCommands = Sections[2] 217 | SectionMapData = Sections[3] 218 | SectionPlayerNames = Sections[4] 219 | ) 220 | 221 | // parse parses an SC:BW replay using the given Decoder. 222 | func parse(dec repdecoder.Decoder, cfg Config) (*rep.Replay, error) { 223 | r := new(rep.Replay) 224 | r.RepFormat = dec.RepFormat() 225 | 226 | // We have to read all sections, some data (e.g. player colors) are positioned after map data. 227 | 228 | // A replay is a sequence of sections: 229 | for sectionCounter := 0; ; sectionCounter++ { 230 | if err := dec.NewSection(); err != nil { 231 | if err == repdecoder.ErrNoMoreSections { 232 | break 233 | } 234 | return nil, fmt.Errorf("Decoder.NewSection() error: %w", err) 235 | } 236 | 237 | var s *Section 238 | var size int32 239 | if sectionCounter < len(Sections) { 240 | s = Sections[sectionCounter] 241 | 242 | // Determine section size: 243 | size = s.Size 244 | if size == 0 { 245 | sizeData, _, err := dec.Section(4) 246 | if err != nil { 247 | return nil, fmt.Errorf("Decoder.Section() error when reading size: %w", err) 248 | } 249 | size = int32(binary.LittleEndian.Uint32(sizeData)) 250 | } 251 | } 252 | 253 | // Read section data 254 | data, sectionID, err := dec.Section(size) 255 | if err != nil { 256 | if s != nil && s.ID == SectionReplayID.ID { 257 | err = ErrNotReplayFile // In case of Replay ID section return special error 258 | } 259 | if err == io.EOF { 260 | break // New sections with StrID are optional 261 | } 262 | if sectionCounter >= len(Sections) { 263 | // If we got "enough" info, just log the error: 264 | cfg.Logger.Printf("Warning: Decoder.Section() error: %v", err) 265 | break 266 | } 267 | return nil, fmt.Errorf("Decoder.Section() error: %w", err) 268 | } 269 | 270 | if s == nil { 271 | s = ModernSections[sectionID] 272 | if s == nil { 273 | // Unknown section, just skip it: 274 | idBytes := make([]byte, 4) 275 | binary.LittleEndian.PutUint32(idBytes, uint32(sectionID)) 276 | cfg.Logger.Printf("Unknown modern section ID: %s", idBytes) 277 | continue 278 | } 279 | } 280 | 281 | // Need to process? 282 | switch { 283 | case s == SectionCommands && !cfg.Commands: 284 | case s == SectionMapData && !cfg.MapData: 285 | default: 286 | // Process section data 287 | if err = s.ParseFunc(data, r, cfg); err != nil { 288 | return nil, fmt.Errorf("ParseFunc() error (sectionID: %d): %v", s.ID, err) 289 | } 290 | } 291 | } 292 | 293 | // Modern sections may or may not exist. Remastered's modern sections are in fixed order, 294 | // but we don't rely on it. 295 | 296 | return r, nil 297 | } 298 | 299 | // repIDs is the possible valid content of the Replay ID section 300 | var repIDs = [][]byte{ 301 | []byte("seRS"), // Starting from 1.21 302 | []byte("reRS"), // Up until 1.20. 303 | } 304 | 305 | // parseReplayID processes the replay ID data. 306 | func parseReplayID(data []byte, r *rep.Replay, cfg Config) (err error) { 307 | for _, repID := range repIDs { 308 | if bytes.Equal(data, repID) { 309 | return 310 | } 311 | } 312 | 313 | return ErrNotReplayFile 314 | } 315 | 316 | var headerFields = []*rep.DebugFieldDescriptor{ 317 | {Offset: 0x00, Length: 1, Name: "Engine"}, 318 | {Offset: 0x01, Length: 4, Name: "Frames"}, 319 | {Offset: 0x08, Length: 8, Name: "Start time"}, 320 | {Offset: 0x18, Length: 28, Name: "Title"}, 321 | {Offset: 0x34, Length: 2, Name: "Map width"}, 322 | {Offset: 0x36, Length: 2, Name: "Map height"}, 323 | {Offset: 0x39, Length: 1, Name: "Available slots count"}, 324 | {Offset: 0x3a, Length: 1, Name: "Speed"}, 325 | {Offset: 0x3c, Length: 2, Name: "Type"}, 326 | {Offset: 0x3e, Length: 2, Name: "SubType"}, 327 | {Offset: 0x48, Length: 24, Name: "Host"}, 328 | {Offset: 0x61, Length: 26, Name: "Map"}, 329 | {Offset: 0xa1, Length: 432, Name: "Player structs (12)"}, 330 | {Offset: 0xa1, Length: 36, Name: "Player 1 struct"}, 331 | {Offset: 0xa1, Length: 2, Name: "Player 1 slot ID"}, 332 | {Offset: 0xa1 + 4, Length: 1, Name: "Player 1 ID"}, 333 | {Offset: 0xa1 + 8, Length: 1, Name: "Player 1 type"}, 334 | {Offset: 0xa1 + 9, Length: 1, Name: "Player 1 race"}, 335 | {Offset: 0xa1 + 10, Length: 1, Name: "Player 1 team"}, 336 | {Offset: 0xa1 + 11, Length: 25, Name: "Player 1 name"}, 337 | {Offset: 0xa1 + 36, Length: 36, Name: "Player 2 struct"}, 338 | {Offset: 0x251, Length: 8 * 4, Name: "Player colors (8)"}, 339 | {Offset: 0x251, Length: 4, Name: "Player 1 color"}, 340 | {Offset: 0x251 + 4, Length: 4, Name: "Player 2 color"}, 341 | } 342 | 343 | // parseHeader processes the replay header data. 344 | func parseHeader(data []byte, r *rep.Replay, cfg Config) error { 345 | bo := binary.LittleEndian // ByteOrder reader: little-endian 346 | 347 | h := new(rep.Header) 348 | r.Header = h 349 | if cfg.Debug { 350 | h.Debug = &rep.HeaderDebug{ 351 | Data: data, 352 | Fields: headerFields, 353 | } 354 | } 355 | 356 | // Fill Version: 357 | switch r.RepFormat { 358 | case repdecoder.RepFormatModern121: 359 | r.Header.Version = "1.21+" 360 | case repdecoder.RepFormatLegacy: 361 | r.Header.Version = "-1.16" 362 | case repdecoder.RepFormatModern: 363 | r.Header.Version = "1.18-1.20" 364 | } 365 | 366 | h.Engine = repcore.EngineByID(data[0x00]) 367 | h.Frames = repcore.Frame(bo.Uint32(data[0x01:])) 368 | h.StartTime = time.Unix(int64(bo.Uint32(data[0x08:])), 0) // replay stores seconds since EPOCH 369 | // SC:R uses UTF-8 always (except the map data section which may come from an external source or from the "past"). 370 | // The game UI allows longer title than what fits into its space in the header. If longer, SC simply "cuts" it, 371 | // even in the middle of a multi-byte UTF-8 sequence :S 372 | // This may result in reading invalid UTF-8 title data, even though it was generated using UTF-8, 373 | // and hence must be decoded as such. 374 | if r.RepFormat == repdecoder.RepFormatLegacy { 375 | h.Title, h.RawTitle = cString(data[0x18 : 0x18+28]) 376 | } else { 377 | h.Title, h.RawTitle = cStringUTF8(data[0x18 : 0x18+28]) 378 | } 379 | h.MapWidth = bo.Uint16(data[0x34:]) 380 | h.MapHeight = bo.Uint16(data[0x36:]) 381 | h.AvailSlotsCount = data[0x39] 382 | h.Speed = repcore.SpeedByID(data[0x3a]) 383 | h.Type = repcore.GameTypeByID(bo.Uint16(data[0x3c:])) 384 | h.SubType = bo.Uint16(data[0x3e:]) 385 | h.Host, h.RawHost = cString(data[0x48 : 0x48+24]) 386 | h.Map, h.RawMap = cString(data[0x61 : 0x61+26]) 387 | 388 | // Parse players 389 | const ( 390 | slotsCount = 12 391 | maxPlayers = 8 392 | ) 393 | h.PIDPlayers = make(map[byte]*rep.Player, slotsCount) 394 | h.Slots = make([]*rep.Player, slotsCount) 395 | playerStructs := data[0xa1 : 0xa1+432] 396 | for i := range h.Slots { 397 | p := new(rep.Player) 398 | h.Slots[i] = p 399 | ps := playerStructs[i*36 : i*36+432/slotsCount] 400 | p.SlotID = bo.Uint16(ps) 401 | p.ID = ps[4] 402 | p.Type = repcore.PlayerTypeByID(ps[8]) 403 | p.Race = repcore.RaceByID(ps[9]) 404 | p.Team = ps[10] 405 | p.Name, p.RawName = cString(ps[11 : 11+25]) 406 | 407 | if i < maxPlayers { 408 | p.Color = repcore.ColorByID(bo.Uint32(data[0x251+i*4:])) 409 | } 410 | 411 | // Filter real players: 412 | if p.Name != "" { 413 | h.OrigPlayers = append(h.OrigPlayers, p) 414 | h.PIDPlayers[p.ID] = p 415 | } 416 | } 417 | 418 | // If game type is melee or OneOnOne, all players' teams may be set to 0 or 1. 419 | // Heuristic improvements: If 2 players only and their teams are the same, change teams to 1 and 2, 420 | // and so matchup will be e.g. ZvT instead of ZT, 421 | // and winner detection can also work (because teams will be different). 422 | if (h.Type == repcore.GameTypeMelee || h.Type == repcore.GameType1on1) && len(h.OrigPlayers) == 2 && 423 | h.OrigPlayers[0].Team == h.OrigPlayers[1].Team { 424 | h.OrigPlayers[0].Team = 1 425 | h.OrigPlayers[1].Team = 2 426 | } 427 | // Also if game type is FFA, teams are set to 0. 428 | // Assign teams incrementing from 1. 429 | if h.Type == repcore.GameTypeFFA { 430 | for i, p := range h.OrigPlayers { 431 | p.Team = byte(i + 1) 432 | } 433 | } 434 | 435 | // Fill Players in team order: 436 | h.Players = make([]*rep.Player, len(h.OrigPlayers)) 437 | copy(h.Players, h.OrigPlayers) 438 | sort.SliceStable(h.Players, func(i int, j int) bool { 439 | return h.Players[i].Team < h.Players[j].Team 440 | }) 441 | 442 | return nil 443 | } 444 | 445 | // parseCommands processes the players' commands data. 446 | func parseCommands(data []byte, r *rep.Replay, cfg Config) error { 447 | bo := binary.LittleEndian // ByteOrder reader: little-endian 448 | 449 | _ = bo 450 | cs := new(rep.Commands) 451 | r.Commands = cs 452 | if cfg.Debug { 453 | cs.Debug = &rep.CommandsDebug{Data: data} 454 | } 455 | 456 | for sr, size := (sliceReader{b: data}), uint32(len(data)); sr.pos < size; { 457 | frame := sr.getUint32() 458 | 459 | // Command block in this frame 460 | cmdBlockSize := sr.getByte() // cmd block size (remaining) 461 | cmdBlockEndPos := sr.pos + uint32(cmdBlockSize) // Cmd block end position 462 | 463 | for sr.pos < cmdBlockEndPos { 464 | parseOk := true 465 | 466 | var cmd repcmd.Cmd 467 | base := &repcmd.Base{ 468 | Frame: repcore.Frame(frame), 469 | } 470 | base.PlayerID = sr.getByte() 471 | base.Type = repcmd.TypeByID(sr.getByte()) 472 | 473 | switch base.Type.ID { // Try to list in frequency order: 474 | 475 | case repcmd.TypeIDRightClick: 476 | rccmd := &repcmd.RightClickCmd{Base: base} 477 | rccmd.Pos.X = sr.getUint16() 478 | rccmd.Pos.Y = sr.getUint16() 479 | rccmd.UnitTag = repcmd.UnitTag(sr.getUint16()) 480 | rccmd.Unit = repcmd.UnitByID(sr.getUint16()) 481 | rccmd.Queued = sr.getByte() != 0 482 | cmd = rccmd 483 | 484 | case repcmd.TypeIDSelect, repcmd.TypeIDSelectAdd, repcmd.TypeIDSelectRemove: 485 | count := sr.getByte() 486 | selectCmd := &repcmd.SelectCmd{ 487 | Base: base, 488 | UnitTags: make([]repcmd.UnitTag, count), 489 | } 490 | for i := byte(0); i < count; i++ { 491 | selectCmd.UnitTags[i] = repcmd.UnitTag(sr.getUint16()) 492 | } 493 | cmd = selectCmd 494 | 495 | case repcmd.TypeIDHotkey: 496 | hotkeyCmd := &repcmd.HotkeyCmd{Base: base} 497 | hotkeyCmd.HotkeyType = repcmd.HotkeyTypeByID(sr.getByte()) 498 | hotkeyCmd.Group = sr.getByte() 499 | cmd = hotkeyCmd 500 | 501 | case repcmd.TypeIDTrain, repcmd.TypeIDUnitMorph: 502 | cmd = &repcmd.TrainCmd{ 503 | Base: base, 504 | Unit: repcmd.UnitByID(sr.getUint16()), 505 | } 506 | 507 | case repcmd.TypeIDTargetedOrder: 508 | tocmd := &repcmd.TargetedOrderCmd{Base: base} 509 | tocmd.Pos.X = sr.getUint16() 510 | tocmd.Pos.Y = sr.getUint16() 511 | tocmd.UnitTag = repcmd.UnitTag(sr.getUint16()) 512 | tocmd.Unit = repcmd.UnitByID(sr.getUint16()) 513 | tocmd.Order = repcmd.OrderByID(sr.getByte()) 514 | tocmd.Queued = sr.getByte() != 0 515 | cmd = tocmd 516 | 517 | case repcmd.TypeIDBuild: 518 | buildCmd := &repcmd.BuildCmd{Base: base} 519 | buildCmd.Order = repcmd.OrderByID(sr.getByte()) 520 | buildCmd.Pos.X = sr.getUint16() 521 | buildCmd.Pos.Y = sr.getUint16() 522 | buildCmd.Unit = repcmd.UnitByID(sr.getUint16()) 523 | if buildCmd.Order.ID == repcmd.OrderIDBuildingLand { 524 | // It's actually a Land command: 525 | landCmd := (*repcmd.LandCmd)(buildCmd) // Fields are identical, we may simply convert it 526 | landCmd.Base.Type = repcmd.TypeLand 527 | cmd = landCmd 528 | } else { 529 | // It's truly a build command 530 | cmd = buildCmd 531 | } 532 | 533 | case repcmd.TypeIDStop, repcmd.TypeIDBurrow, repcmd.TypeIDUnburrow, 534 | repcmd.TypeIDReturnCargo, repcmd.TypeIDHoldPosition, repcmd.TypeIDUnloadAll, 535 | repcmd.TypeIDUnsiege, repcmd.TypeIDSiege, repcmd.TypeIDCloack, repcmd.TypeIDDecloack: 536 | cmd = &repcmd.QueueableCmd{ 537 | Base: base, 538 | Queued: sr.getByte() != 0, 539 | } 540 | 541 | case repcmd.TypeIDLeaveGame: 542 | cmd = &repcmd.LeaveGameCmd{ 543 | Base: base, 544 | Reason: repcmd.LeaveReasonByID(sr.getByte()), 545 | } 546 | 547 | case repcmd.TypeIDMinimapPing: 548 | pingCmd := &repcmd.MinimapPingCmd{Base: base} 549 | pingCmd.Pos.X = sr.getUint16() 550 | pingCmd.Pos.Y = sr.getUint16() 551 | cmd = pingCmd 552 | 553 | case repcmd.TypeIDChat: 554 | chatCmd := &repcmd.ChatCmd{Base: base} 555 | chatCmd.SenderSlotID = sr.getByte() 556 | chatCmd.Message, _ = cString(sr.readSlice(80)) 557 | cmd = chatCmd 558 | 559 | case repcmd.TypeIDVision: 560 | data := sr.getUint16() 561 | visionCmd := &repcmd.VisionCmd{ 562 | Base: base, 563 | } 564 | // There is 1 bit for each slot, 0x01: shared vision for that slot 565 | for i := byte(0); i < 12; i++ { 566 | if data&0x01 != 0 { 567 | visionCmd.SlotIDs = append(visionCmd.SlotIDs, i) 568 | } 569 | data >>= 1 570 | } 571 | cmd = visionCmd 572 | 573 | case repcmd.TypeIDAlliance: 574 | data := sr.getUint32() 575 | allianceCmd := &repcmd.AllianceCmd{ 576 | Base: base, 577 | } 578 | // There are 2 bits for each slot, 0x00: not allied, 0x1: allied, 0x02: allied victory 579 | for i := byte(0); i < 11; i++ { // only 11 slots, 12th is always 0x01 or 0x02 580 | if x := data & 0x03; x != 0 { 581 | allianceCmd.SlotIDs = append(allianceCmd.SlotIDs, i) 582 | if x == 2 { 583 | allianceCmd.AlliedVictory = true 584 | } 585 | } 586 | data >>= 2 587 | } 588 | cmd = allianceCmd 589 | 590 | case repcmd.TypeIDGameSpeed: 591 | cmd = &repcmd.GameSpeedCmd{ 592 | Base: base, 593 | Speed: repcore.SpeedByID(sr.getByte()), 594 | } 595 | 596 | case repcmd.TypeIDCancelTrain: 597 | cmd = &repcmd.CancelTrainCmd{ 598 | Base: base, 599 | UnitTag: repcmd.UnitTag(sr.getUint16()), 600 | } 601 | 602 | case repcmd.TypeIDUnload: 603 | cmd = &repcmd.UnloadCmd{ 604 | Base: base, 605 | UnitTag: repcmd.UnitTag(sr.getUint16()), 606 | } 607 | 608 | case repcmd.TypeIDLiftOff: 609 | liftOffCmd := &repcmd.LiftOffCmd{Base: base} 610 | liftOffCmd.Pos.X = sr.getUint16() 611 | liftOffCmd.Pos.Y = sr.getUint16() 612 | cmd = liftOffCmd 613 | 614 | case repcmd.TypeIDTech: 615 | cmd = &repcmd.TechCmd{ 616 | Base: base, 617 | Tech: repcmd.TechByID(sr.getByte()), 618 | } 619 | 620 | case repcmd.TypeIDUpgrade: 621 | cmd = &repcmd.UpgradeCmd{ 622 | Base: base, 623 | Upgrade: repcmd.UpgradeByID(sr.getByte()), 624 | } 625 | 626 | case repcmd.TypeIDBuildingMorph: 627 | cmd = &repcmd.BuildingMorphCmd{ 628 | Base: base, 629 | Unit: repcmd.UnitByID(sr.getUint16()), 630 | } 631 | 632 | case repcmd.TypeIDLatency: 633 | cmd = &repcmd.LatencyCmd{ 634 | Base: base, 635 | Latency: repcmd.LatencyTypeByID(sr.getByte()), 636 | } 637 | 638 | case repcmd.TypeIDCheat: 639 | cmd = &repcmd.GeneralCmd{ 640 | Base: base, 641 | Data: sr.readSlice(4), 642 | } 643 | 644 | case repcmd.TypeIDSaveGame, repcmd.TypeIDLoadGame: 645 | count := sr.getUint32() 646 | sr.pos += count 647 | 648 | // NO ADDITIONAL DATA: 649 | 650 | case repcmd.TypeIDKeepAlive: 651 | case repcmd.TypeIDRestartGame: 652 | case repcmd.TypeIDPause: 653 | case repcmd.TypeIDResume: 654 | case repcmd.TypeIDCancelBuild: 655 | case repcmd.TypeIDCancelMorph: 656 | case repcmd.TypeIDCarrierStop: 657 | case repcmd.TypeIDReaverStop: 658 | case repcmd.TypeIDOrderNothing: 659 | case repcmd.TypeIDTrainFighter: 660 | case repcmd.TypeIDMergeArchon: 661 | case repcmd.TypeIDCancelNuke: 662 | case repcmd.TypeIDCancelTech: 663 | case repcmd.TypeIDCancelUpgrade: 664 | case repcmd.TypeIDCancelAddon: 665 | case repcmd.TypeIDStim: 666 | case repcmd.TypeIDVoiceEnable: 667 | case repcmd.TypeIDVoiceDisable: 668 | case repcmd.TypeIDStartGame: 669 | case repcmd.TypeIDBriefingStart: 670 | case repcmd.TypeIDMergeDarkArchon: 671 | case repcmd.TypeIDMakeGamePublic: 672 | 673 | // DON'T CARE COMMANDS: 674 | 675 | case repcmd.TypeIDSync: 676 | sr.pos += 6 677 | case repcmd.TypeIDVoiceSquelch: 678 | sr.pos++ 679 | case repcmd.TypeIDVoiceUnsquelch: 680 | sr.pos++ 681 | case repcmd.TypeIDDownloadPercentage: 682 | sr.pos++ 683 | case repcmd.TypeIDChangeGameSlot: 684 | sr.pos += 5 685 | case repcmd.TypeIDNewNetPlayer: 686 | sr.pos += 7 687 | case repcmd.TypeIDJoinedGame: 688 | sr.pos += 17 689 | case repcmd.TypeIDChangeRace: 690 | sr.pos += 2 691 | case repcmd.TypeIDTeamGameTeam: 692 | sr.pos++ 693 | case repcmd.TypeIDUMSTeam: 694 | sr.pos++ 695 | case repcmd.TypeIDMeleeTeam: 696 | sr.pos += 2 697 | case repcmd.TypeIDSwapPlayers: 698 | sr.pos += 2 699 | case repcmd.TypeIDSavedData: 700 | sr.pos += 12 701 | case repcmd.TypeIDReplaySpeed: 702 | sr.pos += 9 703 | 704 | // New commands introduced in 1.21 705 | 706 | case repcmd.TypeIDRightClick121: 707 | rccmd := &repcmd.RightClickCmd{Base: base} 708 | rccmd.Pos.X = sr.getUint16() 709 | rccmd.Pos.Y = sr.getUint16() 710 | rccmd.UnitTag = repcmd.UnitTag(sr.getUint16()) 711 | sr.getUint16() // Unknown, always 0? 712 | rccmd.Unit = repcmd.UnitByID(sr.getUint16()) 713 | rccmd.Queued = sr.getByte() != 0 714 | cmd = rccmd 715 | 716 | case repcmd.TypeIDTargetedOrder121: 717 | tocmd := &repcmd.TargetedOrderCmd{Base: base} 718 | tocmd.Pos.X = sr.getUint16() 719 | tocmd.Pos.Y = sr.getUint16() 720 | tocmd.UnitTag = repcmd.UnitTag(sr.getUint16()) 721 | sr.getUint16() // Unknown, always 0? 722 | tocmd.Unit = repcmd.UnitByID(sr.getUint16()) 723 | tocmd.Order = repcmd.OrderByID(sr.getByte()) 724 | tocmd.Queued = sr.getByte() != 0 725 | cmd = tocmd 726 | 727 | case repcmd.TypeIDUnload121: 728 | ucmd := &repcmd.UnloadCmd{Base: base} 729 | ucmd.UnitTag = repcmd.UnitTag(sr.getUint16()) 730 | sr.getUint16() // Unknown, always 0? 731 | cmd = ucmd 732 | 733 | case repcmd.TypeIDSelect121, repcmd.TypeIDSelectAdd121, repcmd.TypeIDSelectRemove121: 734 | count := sr.getByte() 735 | selectCmd := &repcmd.SelectCmd{ 736 | Base: base, 737 | UnitTags: make([]repcmd.UnitTag, count), 738 | } 739 | for i := byte(0); i < count; i++ { 740 | selectCmd.UnitTags[i] = repcmd.UnitTag(sr.getUint16()) 741 | sr.getUint16() // Unknown, always 0? 742 | } 743 | cmd = selectCmd 744 | 745 | default: 746 | // We don't know how to parse this command, we have to skip 747 | // to the end of the command block 748 | // (potentially skipping additional commands...) 749 | var remBytes []byte 750 | if sr.pos <= cmdBlockEndPos && cmdBlockEndPos <= uint32(len(sr.b)) { // Due to "bad" parsing these must be checked... 751 | remBytes = sr.b[sr.pos:cmdBlockEndPos] 752 | } 753 | cfg.Logger.Printf("skipping typeID: %#v, frame: %d, playerID: %d, remaining bytes: %d [% x]\n", base.Type.ID, base.Frame, base.PlayerID, cmdBlockEndPos-sr.pos, remBytes) 754 | pec := &repcmd.ParseErrCmd{Base: base} 755 | if len(cs.Cmds) > 0 { 756 | pec.PrevCmd = cs.Cmds[len(cs.Cmds)-1] 757 | } 758 | cs.ParseErrCmds = append(cs.ParseErrCmds, pec) 759 | sr.pos = cmdBlockEndPos 760 | parseOk = false 761 | } 762 | 763 | if parseOk { 764 | if cmd == nil { 765 | cs.Cmds = append(cs.Cmds, base) 766 | } else { 767 | cs.Cmds = append(cs.Cmds, cmd) 768 | } 769 | } 770 | } 771 | 772 | sr.pos = cmdBlockEndPos 773 | } 774 | 775 | return nil 776 | } 777 | 778 | // parseMapData processes the map data data. 779 | func parseMapData(data []byte, r *rep.Replay, cfg Config) error { 780 | md := new(rep.MapData) 781 | r.MapData = md 782 | if cfg.Debug { 783 | md.Debug = &rep.MapDataDebug{Data: data} 784 | } 785 | if cfg.MapGraphics { 786 | md.MapGraphics = &rep.MapGraphics{} 787 | } 788 | 789 | // Even though "ERA " section is mandatory, I've seen reps where it was missing. 790 | // TileSet may be cruitial for some apps, let's ensure it doesn't remain nil. 791 | // Somewhat arbitrary default: 792 | md.TileSet = repcore.TileSetTwilight 793 | md.TileSetMissing = true 794 | 795 | var ( 796 | scenarioNameIdx uint16 // String index 797 | scenarioDescriptionIdx uint16 // String index 798 | stringsData []byte 799 | extendedStringsData bool 800 | ) 801 | 802 | // Map data section is a sequence of sub-sections: 803 | for sr, size := (sliceReader{b: data}), uint32(len(data)); sr.pos < size; { 804 | id := sr.getString(4) 805 | // Seen examples where a "final" UPUS section following UPRP section had only 1 byte hereon, so check: 806 | if sr.pos+4 >= size { 807 | break 808 | } 809 | ssSize := sr.getUint32() // sub-section size (remaining) 810 | ssEndPos := sr.pos + ssSize // sub-section end position 811 | 812 | switch id { 813 | case "VER ": 814 | md.Version = sr.getUint16() 815 | case "ERA ": // Tile set sub-section 816 | md.TileSet = repcore.TileSetByID(sr.getUint16() & 0x07) 817 | md.TileSetMissing = false 818 | case "DIM ": // Dimension sub-section 819 | // If map has a non-standard size, the replay header contains 820 | // invalid map size, this is the correct one. 821 | width := sr.getUint16() 822 | height := sr.getUint16() 823 | if width <= 256 && height <= 256 { 824 | if width > r.Header.MapWidth { 825 | r.Header.MapWidth = width 826 | } 827 | if height > r.Header.MapHeight { 828 | r.Header.MapHeight = height 829 | } 830 | } 831 | case "OWNR": // StarCraft Player Types 832 | count := uint32(12) // 12 bytes, 1 for each player 833 | if count > ssSize { 834 | count = ssSize 835 | } 836 | owners := sr.readSlice(count) 837 | md.PlayerOwners = make([]*repcore.PlayerOwner, len(owners)) 838 | for i, id := range owners { 839 | md.PlayerOwners[i] = repcore.PlayerOwnerByID(id) 840 | } 841 | case "SIDE": // Player races 842 | count := uint32(12) // 12 bytes, 1 for each player 843 | if count > ssSize { 844 | count = ssSize 845 | } 846 | sides := sr.readSlice(count) 847 | md.PlayerSides = make([]*repcore.PlayerSide, len(sides)) 848 | for i, id := range sides { 849 | md.PlayerSides[i] = repcore.PlayerSideByID(id) 850 | } 851 | case "MTXM": // Tile sub-section 852 | // map_width*map_height (a tile is an uint16 value) 853 | maxI := ssSize / 2 854 | // Note: Sometimes map is broken into multiple sections. 855 | // The first one is the biggest (whole map size), 856 | // but the beginning of map is empty. The subsequent MTXM 857 | // sub-sections will fill the whole at the beginning. 858 | // An example was found when the first MTXM section was only 859 | // 8 elements, and the next was the whole map, beginning also filled. 860 | // Therefore if currently allocated Tile is small, a new one is allocated. 861 | if len(md.Tiles) < int(maxI) { 862 | md.Tiles = make([]uint16, maxI) 863 | } 864 | for i := uint32(0); i < maxI; i++ { 865 | md.Tiles[i] = sr.getUint16() 866 | } 867 | case "UNIT": // Placed units 868 | for sr.pos+36 <= ssEndPos { // Loop while we have a complete unit 869 | unitEndPos := sr.pos + 36 // 36 bytes for each unit 870 | 871 | sr.pos += 4 // uint32 unit class instance ("serial number") 872 | x := sr.getUint16() 873 | y := sr.getUint16() 874 | unitID := sr.getUint16() 875 | sr.pos += 2 // uint16 Type of relation to another building (i.e. add-on, nydus link) 876 | sr.pos += 2 // uint16 Flags of special properties (e.g. cloacked, burrowed etc.) 877 | sr.pos += 2 // uint16 valid elements flag 878 | ownerID := sr.getByte() // 0-based SlotID 879 | sr.pos++ // Hit points % (1-100) 880 | sr.pos++ // Shield points % (1-100) 881 | sr.pos++ // Energy points % (1-100) 882 | resAmount := sr.getUint32() // Resource amount 883 | 884 | switch unitID { 885 | case repcmd.UnitIDMineralField1, repcmd.UnitIDMineralField2, repcmd.UnitIDMineralField3: 886 | md.MineralFields = append(md.MineralFields, rep.Resource{Point: repcore.Point{X: x, Y: y}, Amount: resAmount}) 887 | case repcmd.UnitIDVespeneGeyser: 888 | md.Geysers = append(md.Geysers, rep.Resource{Point: repcore.Point{X: x, Y: y}, Amount: resAmount}) 889 | case repcmd.UnitIDStartLocation: 890 | md.StartLocations = append(md.StartLocations, 891 | rep.StartLocation{Point: repcore.Point{X: x, Y: y}, SlotID: ownerID}, 892 | ) 893 | } 894 | 895 | if cfg.MapGraphics { 896 | md.MapGraphics.PlacedUnits = append(md.MapGraphics.PlacedUnits, &rep.PlacedUnit{ 897 | Point: repcore.Point{X: x, Y: y}, 898 | UnitID: unitID, 899 | SlotID: ownerID, 900 | ResourceAmount: resAmount, 901 | }) 902 | } 903 | 904 | // Skip unprocessed unit data: 905 | sr.pos = unitEndPos 906 | } 907 | case "THG2": // StarCraft Sprites 908 | if cfg.MapGraphics { 909 | for sr.pos+10 <= ssEndPos { // Loop while we have a complete sprite 910 | spriteEndPos := sr.pos + 10 // 10 bytes for each sprite 911 | 912 | spriteID := sr.getUint16() 913 | x := sr.getUint16() 914 | y := sr.getUint16() 915 | ownerID := sr.getByte() // 0-based SlotID 916 | sr.pos++ // Unused 917 | flags := sr.getUint16() 918 | if flags&0x1000 == 0 { 919 | // It's actually a unit 920 | md.MapGraphics.PlacedUnits = append(md.MapGraphics.PlacedUnits, &rep.PlacedUnit{ 921 | Point: repcore.Point{X: x, Y: y}, 922 | UnitID: spriteID, 923 | SlotID: ownerID, 924 | Sprite: true, 925 | }) 926 | } else { 927 | // It really is a sprite 928 | md.MapGraphics.Sprites = append(md.MapGraphics.Sprites, &rep.Sprite{ 929 | Point: repcore.Point{X: x, Y: y}, 930 | SpriteID: spriteID, 931 | }) 932 | } 933 | 934 | // Skip unprocessed sprite data: 935 | sr.pos = spriteEndPos 936 | } 937 | } 938 | case "SPRP": // Scenario properties 939 | // Strings section might be after this, so we just record the string indices for now: 940 | scenarioNameIdx = sr.getUint16() 941 | scenarioDescriptionIdx = sr.getUint16() 942 | case "STR ": // String data 943 | // There might be multiple "STR " sections, subsequent sections overwrite the 944 | // beginning of earlier sections. 945 | stringsStart := int(sr.pos) 946 | // count := sr.getUint16() // Number of following offsets (uint16 values) 947 | if len(stringsData) < int(ssEndPos)-stringsStart { 948 | stringsData = make([]byte, int(ssEndPos)-stringsStart) 949 | } 950 | copy(stringsData, data[stringsStart:ssEndPos]) 951 | case "STRx": // Extended String data 952 | // This section is identical to "STR " except that all uint16 values are uint32 values. 953 | stringsStart := int(sr.pos) 954 | // count := sr.getUint32() // Number of following offsets (uint32 values) 955 | if len(stringsData) < int(ssEndPos)-stringsStart { 956 | stringsData = make([]byte, int(ssEndPos)-stringsStart) 957 | } 958 | copy(stringsData, data[stringsStart:ssEndPos]) 959 | extendedStringsData = true 960 | } 961 | 962 | // Part or all of the sub-section might be unprocessed, skip the unprocessed bytes 963 | sr.pos = ssEndPos 964 | } 965 | 966 | // Get a string from the strings identified by its index. 967 | getString := func(idx uint16) string { 968 | if idx == 0 { 969 | return "" 970 | } 971 | var offsetSize uint32 972 | if extendedStringsData { 973 | offsetSize = 4 974 | } else { 975 | offsetSize = 2 976 | } 977 | pos := uint32(idx) * offsetSize // idx is 1-based (0th offset is not included), but stringsData contains the offsets count too 978 | if int(pos+offsetSize-1) >= len(stringsData) { 979 | cfg.Logger.Printf("Invalid strings index: %d, map: %s", idx, r.Header.Map) 980 | return "" 981 | } 982 | var offset uint32 983 | if extendedStringsData { 984 | offset = (&sliceReader{b: stringsData, pos: pos}).getUint32() 985 | } else { 986 | offset = uint32((&sliceReader{b: stringsData, pos: pos}).getUint16()) 987 | } 988 | if int(offset) >= len(stringsData) { 989 | cfg.Logger.Printf("Invalid strings offset: %d, strings index: %d, map: %s", offset, idx, r.Header.Map) 990 | return "" 991 | } 992 | s, _ := cString(stringsData[offset:]) 993 | return s 994 | } 995 | 996 | md.Name = getString(scenarioNameIdx) 997 | md.Description = getString(scenarioDescriptionIdx) 998 | 999 | return nil 1000 | } 1001 | 1002 | // parsePlayerNames processes the player names data. 1003 | func parsePlayerNames(data []byte, r *rep.Replay, cfg Config) error { 1004 | // Note: these player names parse well even when decoding is unknown in header 1005 | // (are these always UTF-8?) 1006 | for i, p := range r.Header.Slots { 1007 | pos := i * 96 1008 | if pos+96 > len(data) { 1009 | break 1010 | } 1011 | 1012 | if p.Type != repcore.PlayerTypeInactive { 1013 | name, orig := cString(data[pos : pos+96]) 1014 | if name != "" { 1015 | p.Name, p.RawName = name, orig 1016 | } 1017 | } 1018 | } 1019 | 1020 | return nil 1021 | } 1022 | 1023 | // parseSkin processes the skin data. 1024 | func parseSkin(data []byte, r *rep.Replay, cfg Config) error { 1025 | // TODO 0x15e0 bytes of data 1026 | return nil 1027 | } 1028 | 1029 | // parseLmts processes the lmts data. 1030 | func parseLmts(data []byte, r *rep.Replay, cfg Config) error { 1031 | // TODO 0x1c bytes of data 1032 | 1033 | // bo := binary.LittleEndian // ByteOrder reader: little-endian 1034 | // bo.Uint32(data[0x0:]) // Images limit 1035 | // bo.Uint32(data[0x4:]) // Sprites limit 1036 | // bo.Uint32(data[0x8:]) // Lone limit 1037 | // bo.Uint32(data[0x0c:]) // Units limit 1038 | // bo.Uint32(data[0x10:]) // Bullets limit 1039 | // bo.Uint32(data[0x14:]) // Orders limit 1040 | // bo.Uint32(data[0x18:]) // Fog sprites limit 1041 | 1042 | return nil 1043 | } 1044 | 1045 | // parseBfix processes the bfix data. 1046 | func parseBfix(data []byte, r *rep.Replay, cfg Config) error { 1047 | // TODO 0x08 bytes of data 1048 | return nil 1049 | } 1050 | 1051 | // parseGcfg processes the gcfg data. 1052 | func parseGcfg(data []byte, r *rep.Replay, cfg Config) error { 1053 | // TODO 0x19 bytes of data 1054 | return nil 1055 | } 1056 | 1057 | // parsePlayerColors processes the player colors data. 1058 | func parsePlayerColors(data []byte, r *rep.Replay, cfg Config) error { 1059 | // 16 bytes footprint for all colors. 1060 | for i, p := range r.Header.Slots { 1061 | pos := i * 16 1062 | if pos+16 > len(data) { 1063 | break 1064 | } 1065 | if c := repcore.ColorByFootprint(data[pos : pos+16]); c != nil { 1066 | p.Color = c 1067 | } 1068 | } 1069 | 1070 | return nil 1071 | } 1072 | 1073 | // parseShieldBatterySection processes the ShieldBattery data. 1074 | func parseShieldBatterySection(data []byte, r *rep.Replay, cfg Config) error { 1075 | // info source: 1076 | // https://github.com/ShieldBattery/ShieldBattery/blob/master/game/src/replay.rs#L62-L80 1077 | // https://github.com/ShieldBattery/ShieldBattery/blob/master/app/replays/parse-shieldbattery-replay.ts 1078 | 1079 | if len(data) < 0x56 { 1080 | // 0x56 bytes is the size of SB's first version of the section. 1081 | return nil // Unknown format 1082 | } 1083 | 1084 | bo := binary.LittleEndian // ByteOrder reader: little-endian 1085 | 1086 | sb := new(rep.ShieldBattery) 1087 | r.ShieldBattery = sb 1088 | 1089 | formatVersion := bo.Uint16(data) 1090 | 1091 | sb.StarCraftExeBuild = bo.Uint32(data[0x01:]) 1092 | sb.ShieldBatteryVersion, _ = cString(data[0x06:0x16]) 1093 | 1094 | // 0x16 - 0x1a: team_game_main_players 1095 | // 0x1a - 0x26: starting_races 1096 | 1097 | gameID := data[0x26:0x36] 1098 | sb.GameID = fmt.Sprintf("%x-%x-%x-%x-%x", gameID[:4], gameID[4:6], gameID[6:8], gameID[8:10], gameID[10:]) 1099 | 1100 | if formatVersion >= 0x01 { 1101 | // 0x56 - 0x58: game_logic_version 1102 | } 1103 | 1104 | return nil 1105 | } 1106 | 1107 | var koreanDecoder = korean.EUCKR.NewDecoder() 1108 | 1109 | // cString returns a 0x00 byte terminated string from the given buffer. 1110 | // If the string is not valid UTF-8, tries to decode it as EUC-KR (also known as Code Page 949). 1111 | // Returns both the decoded and the original string. 1112 | func cString(data []byte) (s string, orig string) { 1113 | // Find 0x00 byte: 1114 | for i, ch := range data { 1115 | if ch == 0 { 1116 | data = data[:i] // excludes terminating 0x00 1117 | 1118 | if !utf8.Valid(data) { 1119 | // Try korean 1120 | if krdata, err := koreanDecoder.Bytes(data); err == nil { 1121 | return string(krdata), string(data) 1122 | } 1123 | } 1124 | break // Either UTF-8 or custom decoding failed 1125 | } 1126 | } 1127 | 1128 | // Return data as string. 1129 | // We end up here if: 1130 | // - no terminating 0 char found, 1131 | // - or string is valid UTF-8, 1132 | // - or it is invalid but custom decoding failed 1133 | // Either way: 1134 | s = string(data) 1135 | return s, s 1136 | } 1137 | 1138 | // cStringUTF8 returns a 0x00 byte terminated string from the given buffer, 1139 | // always using UTF-8 encoding. 1140 | // If the data is invalid UTF-8, invalid sequences will be removed from it. 1141 | // 1142 | // Returns both the decoded and the original string. 1143 | func cStringUTF8(data []byte) (s string, orig string) { 1144 | // Find 0x00 byte: 1145 | for i, ch := range data { 1146 | if ch == 0 { 1147 | data = data[:i] // excludes terminating 0x00 1148 | break 1149 | } 1150 | } 1151 | 1152 | if !utf8.Valid(data) { 1153 | return string(bytes.ToValidUTF8(data, nil)), string(data) 1154 | } 1155 | 1156 | s = string(data) 1157 | return s, s 1158 | } 1159 | -------------------------------------------------------------------------------- /repparser/slicereader.go: -------------------------------------------------------------------------------- 1 | // This file contains a slice reader which aids reading data from a byte slice. 2 | 3 | package repparser 4 | 5 | import "encoding/binary" 6 | 7 | // sliceReader aids reading data from a byte slice 8 | type sliceReader struct { 9 | // b is the byte slice to read from 10 | b []byte 11 | 12 | // pos is the index of the next byte to read 13 | pos uint32 14 | } 15 | 16 | // getByte returns the next byte. 17 | func (sr *sliceReader) getByte() (r byte) { 18 | r, sr.pos = sr.b[sr.pos], sr.pos+1 19 | return 20 | } 21 | 22 | // getUint16 returns the next 2 bytes as an uint16 value. 23 | func (sr *sliceReader) getUint16() (r uint16) { 24 | r, sr.pos = binary.LittleEndian.Uint16(sr.b[sr.pos:]), sr.pos+2 25 | return 26 | } 27 | 28 | // getUint32 returns the next 4 bytes as an uint32 value. 29 | func (sr *sliceReader) getUint32() (r uint32) { 30 | r, sr.pos = binary.LittleEndian.Uint32(sr.b[sr.pos:]), sr.pos+4 31 | return 32 | } 33 | 34 | // getString returns the next size bytes as a string. 35 | func (sr *sliceReader) getString(size uint32) (r string) { 36 | r, sr.pos = string(sr.b[sr.pos:sr.pos+size]), sr.pos+size 37 | return 38 | } 39 | 40 | // readSlice returns a the next size bytes as a slice. 41 | func (sr *sliceReader) readSlice(size uint32) (r []byte) { 42 | r = make([]byte, size) 43 | sr.pos += uint32(copy(r, sr.b[sr.pos:])) 44 | return 45 | } 46 | --------------------------------------------------------------------------------