Starcraft II Demo Fetcher
This is the raw code that I did in like what… 20 minutes (okay, maybe a bit more for the v1)
version 1.0
package main
import (
"fmt"
"html"
"image/color"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/icza/s2prot/rep"
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
)
func main() {
var repPath, choice1 string
fmt.Println("Enter the replay path: ")
fmt.Scan(&repPath)
fmt.Println("Do you want to auto-rename ALL of the replays (WARNING : this will create a new folder within the first one… without sub-folders) [y/N]?")
fmt.Scan(&choice1)
renameFlag := strings.EqualFold(choice1, "y")
startTimer := time.Now()
err := filepath.WalkDir(repPath, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() && filepath.Ext(path) == ".SC2Replay" {
fmt.Printf("\nProcessing: %v\n", path)
scanReplay(path)
if renameFlag {
moveAndRename(path)
}
}
return err
})
if err != nil {
log.Fatalf("\nError: %v", err)
}
fmt.Printf("Scanned in : %v", time.Since(startTimer))
}
func scanReplay(replayPath string) {
r, err := rep.NewFromFile(replayPath)
if err != nil {
fmt.Printf("\nError: %v\n", err)
return
}
defer r.Close()
var playerList []string
for _, pl := range r.Details.Players() {
playerList = append(playerList, pl.Name)
}
// APM Graph
func() {
var eventList = map[int]string{
1: "UnitBorn",
4: "UnitTypeChange",
5: "Upgrade",
6: "UnitInit",
27: "Cmd",
28: "SelectionDelta",
29: "ControlGroupUpdate",
49: "CameraUpdate",
104: "CmdUpdateTargetPoint",
105: "CmdUpdateTargetUnit",
}
p := plot.New()
p.Title.Text = "APM Graph" + " -- " + r.Details.Matchup() + " - " + r.Details.Time().String() + " - " + r.Details.Title() + strings.Join(playerList, "_")
p.X.Label.Text = "Time"
p.X.Max = float64(r.Header.Loops())
p.Y.Label.Text = "Actions"
p.Y.AutoRescale = true
var loopsPerMinute int64 = 1344 // 22.4 actions per second * 60 seconds
for pID, player := range r.Details.Players() {
pt := plotter.XYs{}
var actionLoops []int64
for _, event := range r.GameEvts {
if eventList[event.EvtType.ID] == event.EvtType.Name && event.Loop() != 0 && event.UserID() == player.WorkingSetSlotID() {
actionLoops = append(actionLoops, event.Loop())
}
}
for _, track := range r.TrackerEvts.Evts {
if eventList[track.EvtType.ID] == track.EvtType.Name && track.Loop() != 0 && track.UserID() == player.WorkingSetSlotID() {
actionLoops = append(actionLoops, track.Loop())
}
}
slices.Sort(actionLoops)
if len(actionLoops) > 0 {
numBuckets := int(actionLoops[len(actionLoops)-1]/loopsPerMinute) + 1
counts := make([]int, numBuckets)
for _, loop := range actionLoops {
bucketIdx := loop / loopsPerMinute
counts[int(bucketIdx)]++
}
for i, c := range counts {
pt = append(pt, plotter.XY{
X: float64(i) * float64(loopsPerMinute),
Y: float64(c),
})
}
}
line, err := plotter.NewLine(pt)
if err != nil {
fmt.Printf("\tError: %v\n", err)
}
line.LineStyle.Color = setColor(pID)
p.Add(line)
p.Legend.Add(player.Name, line)
}
errSav := p.Save(12*vg.Inch, 12*vg.Inch, "APMGRAPH--"+r.Details.Title()+"-"+r.Details.Matchup()+"-"+r.Details.Time().String()+".svg")
if errSav != nil {
fmt.Printf("\tError: %v\n", errSav)
}
}()
// ControlGroup graph
func() {
p := plot.New()
p.Title.Text = "Control Group Graph" + " -- " + r.Details.Matchup() + " - " + r.Details.Time().String() + " - " + r.Details.Title() + strings.Join(playerList, "_")
p.X.Label.Text = "Time"
p.X.AutoRescale = true
p.Y.Label.Text = "Group"
p.Y.Min = 0
p.Y.Max = 9
p.Y.Tick.Marker = plot.ConstantTicks([]plot.Tick{
{Value: 0, Label: "0"},
{Value: 1, Label: "1"},
{Value: 2, Label: "2"},
{Value: 3, Label: "3"},
{Value: 4, Label: "4"},
{Value: 5, Label: "5"},
{Value: 6, Label: "6"},
{Value: 7, Label: "7"},
{Value: 8, Label: "8"},
{Value: 9, Label: "9"},
})
for pID, player := range r.Details.Players() {
pt := plotter.XYs{}
for _, event := range r.GameEvts {
if event.UserID() == player.WorkingSetSlotID() && event.Stringv("evtTypeName") == "ControlGroupUpdate" {
if event.Int("controlGroupUpdate") != event.Int("controlGroupIndex") {
pt = append(pt, plotter.XY{X: float64(event.Loop()), Y: float64(event.Int("controlGroupIndex"))})
}
if event.Int("controlGroupUpdate") == event.Int("controlGroupIndex") {
pt = append(pt, plotter.XY{X: float64(event.Loop()), Y: float64(event.Int("controlGroupUpdate"))})
}
}
}
scatter, err := plotter.NewScatter(pt)
if err != nil {
fmt.Printf("\tError: %v\n", err)
}
scatter.GlyphStyle.Color = setColor(pID)
scatter.GlyphStyle.Radius = vg.Points(3)
p.Add(scatter)
p.Legend.Add(player.Name, scatter)
}
errSav := p.Save(12*vg.Inch, 6*vg.Inch, "ControlGroupGRAPH--"+r.Details.Title()+"-"+r.Details.Matchup()+"-"+r.Details.Time().String()+".svg")
if errSav != nil {
fmt.Printf("\tError: %v\n", errSav)
}
}()
// Build Order graph
// func() {
// var unitFilter = map[string]bool{}
// for _, player := range r.Details.Players() {
// for _, track := range r.TrackerEvts.Evts {
// if track.UserID() == player.WorkingSetSlotID() && track.Loop() != 0 {
// switch track.EvtType.ID {
// case rep.TrackerEvtIDUnitBorn:
// }
// }
// }
// }
// }()
}
func setColor(playerID int) color.RGBA {
switch playerID {
case 0:
return color.RGBA{R: 255, G: 0, B: 0, A: 255}
case 1:
return color.RGBA{R: 0, G: 255, B: 0, A: 255}
case 2:
return color.RGBA{R: 0, G: 0, B: 255, A: 255}
case 3:
return color.RGBA{R: 255, G: 255, B: 0, A: 255}
case 4:
return color.RGBA{R: 255, G: 0, B: 255, A: 255}
case 5:
return color.RGBA{R: 0, G: 255, B: 255, A: 255}
case 6:
return color.RGBA{R: 255, G: 255, B: 255, A: 255}
case 7:
return color.RGBA{R: 128, G: 128, B: 128, A: 255}
}
return color.RGBA{R: 255, G: 255, B: 255, A: 128}
}
func moveAndRename(replayPath string) {
dir := filepath.Dir(replayPath)
ext := filepath.Ext(replayPath)
r, err := rep.NewFromFile(replayPath)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
var result string
var playerList []string
for pID, player := range r.Details.Players() {
cleanName := html.UnescapeString(player.Name)
cleanName = strings.NewReplacer("<", "", ">", "").Replace(cleanName)
playerList = append(playerList, cleanName)
if pID == 0 && player.WorkingSetSlotID() == int64(pID) {
result = player.Result().String()
}
}
newName := fmt.Sprintf(
"%s-%s_%s_%s-%s%s",
result,
strings.Join(playerList, "-"),
r.Details.Matchup(),
r.Details.Title(),
r.Details.Time().Format("2006-01-02 15:04:05"),
ext,
)
newName = sanitizeFilename(newName)
newPath := filepath.Join(dir, newName)
if newPath == replayPath {
fmt.Printf("Error: %v\n", err)
return
}
os.Rename(replayPath, newPath)
fmt.Printf("Renamed : %s -> %s\n", filepath.Base(replayPath), newName)
}
func sanitizeFilename(name string) string {
var invalidChars = regexp.MustCompile(`[\\/:*?"<>|]`)
return invalidChars.ReplaceAllString(name, "_")
}
version 0.1b
package main
import (
"fmt"
"io/fs"
"log"
"path/filepath"
"time"
"github.com/icza/s2prot"
"github.com/icza/s2prot/rep"
)
func main() {
var repPath string
fmt.Print("Enter the replay folder path: ")
fmt.Scan(&repPath)
startTime := time.Now()
cleanRepPath := filepath.Clean(repPath)
err := filepath.WalkDir(cleanRepPath, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() && filepath.Ext(path) == ".SC2Replay" {
scanReplay(path)
}
return err
})
if err != nil {
log.Fatalf("Cannot enter the folder: %v\n", err)
}
initFetcher()
fmt.Printf("It took %v to process", time.Since(startTime))
}
func scanReplay(rPath string) {
r, err := rep.NewFromFile(rPath)
if err != nil {
fmt.Printf("Cannot scan the file: %v\n", err)
return
}
fmt.Printf("BaseBuild : %v\n", r.Header.BaseBuild())
fmt.Printf("Loops : %v\n", r.Header.Loops())
fmt.Printf("Map Name : %v\n", r.Details.Title())
fmt.Printf("Map Time : %v\n", r.Details.Time())
fmt.Printf("Speed : %v\n", r.Details.GameSpeed())
fmt.Printf("Matchup : %v\n", r.Details.Matchup())
fmt.Printf("Length : %v\n", r.Header.Duration())
fmt.Printf("Game Events : %v\n", len(r.GameEvts))
fmt.Printf("Message Events: %v\n", len(r.MessageEvts))
fmt.Printf("Tracker Events: %v\n", len(r.TrackerEvts.Evts))
fmt.Println("Players :")
for id, player := range r.Details.Players() {
pName := player.Name
pRace := player.RaceString()
pResult := player.Result()
pHandicap := player.Handicap()
fmt.Printf("Player: %-20s, Race: %v, Handicap: %v, APM (Metadata): %v, APM (Computed): %v, MMR: %v, Result: %s\n", pName, pRace, pHandicap, FetchMeta("APM", id, r), ComputeAPM(id, r), FetchMeta("MMR", id, r), pResult)
fmt.Println("Build Order:")
FetchOrder("bo", id, r)
fmt.Println("Group Order:")
FetchOrder("go", id, r)
}
}
func FetchOrder(option string, id int, file *rep.Rep) {
switch option {
case "bo":
for _, track := range file.TrackerEvts.Evts {
var IgnoreList = map[string]bool{
"Larva": true,
"Broodling": true,
"Interceptor": true,
}
if track.Int("controlPlayerId")-1 == int64(id) && track.Loop() != 0 && !IgnoreList[track.Stringv("unitTypeName")] {
var uName string
switch track.EvtType.ID {
case 1:
uName = track.Stringv("unitTypeName")
case 6:
uName = track.Stringv("unitTypeName")
}
fmt.Printf("\t\tUnit: %-20s, Loop: %v, Time: %s\n", uName, track.Loop(), FormatTime(ComputeTime(track.Loop(), file.Header.Loops(), file.Header.Duration())))
}
}
case "go":
var added int64
for _, event := range file.GameEvts {
if event.Int("userId") == int64(id) {
switch event.EvtType.ID {
case rep.GmEIdCtrlGroupUpdate:
fmt.Printf("\t\tGroup: %v, Loop: %v, Time: %s\n", event.Int("controlGroupIndex"), event.Loop(), FormatTime(ComputeTime(event.Loop(), file.Header.Loops(), file.Header.Duration())))
case rep.GmEIdSelDelta:
selDelta := event.Structv("delta")
added += func() int64 {
var Count int64
for _, sg := range selDelta.Array("addSubgroups") {
subgroup := sg.(s2prot.Struct)
Count = subgroup.Int("count")
}
return Count
}()
}
}
}
fmt.Printf("\n\t\tAssignations: %v\n", added)
}
}
func ComputeAPM(pID int, file *rep.Rep) int {
var action float64
var ActionList = map[int]string{
1: "UnitBorn",
4: "UnitTypeChange",
5: "Upgrade",
6: "UnitInit",
27: "Cmd",
28: "SelectionDelta",
29: "ControlGroupUpdate",
49: "CameraUpdate",
105: "CmdUpdateTargetUnit",
104: "CmdUpdateTargetPoint",
}
for _, event := range file.GameEvts {
if event.UserID() == int64(pID) && ActionList[event.EvtType.ID] == event.EvtType.Name {
action++
}
}
for _, track := range file.TrackerEvts.Evts {
if track.UserID() == int64(pID) && ActionList[track.EvtType.ID] == track.EvtType.Name {
action++
}
}
if action > 0 && file.Header.Duration() > 0 {
action /= file.Header.Duration().Minutes()
return int(action)
}
return -1
}
func FetchMeta(option string, pID int, file *rep.Rep) int {
for _, md := range file.Metadata.Players() {
if md.PlayerID()-1 == int64(pID) {
switch option {
case "APM":
return int(md.APM())
case "MMR":
return int(md.MMR())
}
}
}
return -1
}
func ComputeTime(loop int64, totalLoops int64, totalDuration time.Duration) time.Duration {
SecondsPerLoop := totalDuration.Seconds() / float64(totalLoops)
elapsedSec := float64(loop) * SecondsPerLoop
return time.Duration(elapsedSec * float64(time.Second))
}
func FormatTime(dur time.Duration) string {
total := int(dur.Seconds())
return fmt.Sprintf("%02d:%02d", total/60, total%60)
}
Return to the lobby