From 3b52b7c306c2dc9b29f006939356b090af61e9d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 22:07:52 +0000 Subject: [PATCH 1/3] Initial plan From 90d70946ac01a8104618d070f0d85b9c28ffdd00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 22:14:42 +0000 Subject: [PATCH 2/3] Optimize parser performance with reduced allocations and computations Co-authored-by: sparkoo <1160903+sparkoo@users.noreply.github.com> --- parser/pkg/log/logger.go | 98 ++--- parser/pkg/parser/bomb.go | 15 +- parser/pkg/parser/map.go | 116 ++--- parser/pkg/parser/parser.go | 846 ++++++++++++++++++------------------ 4 files changed, 548 insertions(+), 527 deletions(-) diff --git a/parser/pkg/log/logger.go b/parser/pkg/log/logger.go index 11948a5..6eb8782 100644 --- a/parser/pkg/log/logger.go +++ b/parser/pkg/log/logger.go @@ -1,49 +1,49 @@ -package log - -import ( - "csgo-2d-demo-player/conf" - "fmt" - - "go.uber.org/zap" -) - -var logger *zap.Logger - -func Init(mode conf.Mode) { - switch mode { - case conf.MODE_DEV: - logger = zap.Must(zap.NewDevelopment()) - logger.Info("initialized development logger") - case conf.MODE_PROD: - logger = zap.Must(zap.NewProduction()) - logger.Info("initialized production logger") - default: - panic("unknown mode") - } -} - -func Close() { - errClose := logger.Sync() - if errClose != nil { - panic(errClose) - } -} - -func L() *zap.Logger { - if logger == nil { - Init(conf.MODE_DEV) - } - return logger -} - -func Print(msg string, err error) { - L().Info(msg, zap.Error(err)) -} - -func Printf(msg string, args ...any) { - L().Info(fmt.Sprintf(msg, args...)) -} - -func Println(msg string, err error) { - L().Info(msg, zap.Error(err)) -} +package log + +import ( + "csgo-2d-demo-player/conf" + "fmt" + + "go.uber.org/zap" +) + +var logger *zap.Logger + +func Init(mode conf.Mode) { + switch mode { + case conf.MODE_DEV: + logger = zap.Must(zap.NewDevelopment()) + logger.Info("initialized development logger") + case conf.MODE_PROD: + logger = zap.Must(zap.NewProduction()) + logger.Info("initialized production logger") + default: + panic("unknown mode") + } +} + +func Close() { + errClose := logger.Sync() + if errClose != nil { + panic(errClose) + } +} + +func L() *zap.Logger { + if logger == nil { + Init(conf.MODE_DEV) + } + return logger +} + +func Print(msg string, err error) { + L().Info(msg, zap.Error(err)) +} + +func Printf(msg string, args ...any) { + L().Info(fmt.Sprintf(msg, args...)) +} + +func Println(msg string, err error) { + L().Info(msg, zap.Error(err)) +} diff --git a/parser/pkg/parser/bomb.go b/parser/pkg/parser/bomb.go index e9afac9..ad6f46a 100644 --- a/parser/pkg/parser/bomb.go +++ b/parser/pkg/parser/bomb.go @@ -57,19 +57,20 @@ func (b *bombHandler) tick() { // because there is no event that tells us bomb is in zero state (in game, not planted), we detect // bomb movement by calculating distance between frames. this happens at the end of the round when bomb was // previously in different state (planted, exploded) - bombPos := b.parser.GameState().Bomb().Position() - bombPos.Z = 0 - oldBombPos := b.position - oldBombPos.Z = 0 - distance := bombPos.Distance(oldBombPos) + newBombPos := b.parser.GameState().Bomb().Position() - if distance > distanceDelta && + // Calculate 2D distance without creating new vectors + dx := newBombPos.X - b.position.X + dy := newBombPos.Y - b.position.Y + distance := dx*dx + dy*dy // Use squared distance to avoid sqrt + + if distance > (distanceDelta*distanceDelta) && (b.state == message.Bomb_Planted || b.state == message.Bomb_Explode || b.state == message.Bomb_Defused) { //log.Printf("bomb movement detected '%v', state back to ZERO from '%v'", distance, b.state) b.state = message.Bomb_Zero } - b.position = b.parser.GameState().Bomb().Position() + b.position = newBombPos } func (b *bombHandler) message(mapCS *MapCS) *message.Bomb { diff --git a/parser/pkg/parser/map.go b/parser/pkg/parser/map.go index 8b361ad..8cc67aa 100644 --- a/parser/pkg/parser/map.go +++ b/parser/pkg/parser/map.go @@ -1,58 +1,58 @@ -// Package metadata provides metadata and utility functions, -// like translations from ingame coordinates to radar image pixels (see also /assets/maps directory). -package parser - -import ( - "github.com/golang/geo/r2" -) - -// MapCS represents a CS:GO map. It contains information required to translate -// in-game world coordinates to coordinates relative to (0, 0) on the provided map-overviews (radar images). -type MapCS struct { - Name string - PZero r2.Point - Scale float64 -} - -// Translate translates in-game world-relative coordinates to (0, 0) relative coordinates. -func (m MapCS) Translate(x, y float64) (float64, float64) { - return x - m.PZero.X, m.PZero.Y - y -} - -// TranslateScale translates and scales in-game world-relative coordinates to (0, 0) relative coordinates. -// The outputs are pixel coordinates for the radar images found in the maps folder. -func (m MapCS) TranslateScale(x, y float64) (float64, float64) { - x, y = m.Translate(x, y) - return x / m.Scale, y / m.Scale -} - -// MapNameToMap translates a map name to a Map. -var MapNameToMap = make(map[string]MapCS) - -// makeMap creates a map stuct initialized with the given parameters. -func makeMap(name string, x, y, scale float64) MapCS { - m := MapCS{Name: name, PZero: r2.Point{X: x, Y: y}, Scale: scale} - - MapNameToMap[name] = m - - return m -} - -// Pre-defined map translations. -// see "steamapps/common/Counter-Strike Global Offensive/csgo/resource/overviews/*.txt" -var ( - MapDeAncient = makeMap("de_ancient", -2953, 2164, 5) - MapDeAnubis = makeMap("de_anubis", -2796, 3328, 5.22) - MapDeCache = makeMap("de_cache", -2000, 3250, 5.5) - MapDeCanals = makeMap("de_canals", -2496, 1792, 4) - MapDeCbble = makeMap("de_cbble", -3840, 3072, 6) - MapDeDust2 = makeMap("de_dust2", -2476, 3239, 4.4) - MapDeInferno = makeMap("de_inferno", -2087, 3870, 4.9) - MapDeMirage = makeMap("de_mirage", -3230, 1713, 5) - MapDeNuke = makeMap("de_nuke", -3453, 2887, 7) - MapDeOverpass = makeMap("de_overpass", -4831, 1781, 5.2) - MapDeTrain = makeMap("de_train", -2477, 2392, 4.7) - MapDeVertigo = makeMap("de_vertigo", -3168, 1762, 4) - MapCsAgency = makeMap("cs_agency", -2947, 2492, 5) - MapCsOffice = makeMap("cs_office", -1838, 1858, 4.1) -) +// Package metadata provides metadata and utility functions, +// like translations from ingame coordinates to radar image pixels (see also /assets/maps directory). +package parser + +import ( + "github.com/golang/geo/r2" +) + +// MapCS represents a CS:GO map. It contains information required to translate +// in-game world coordinates to coordinates relative to (0, 0) on the provided map-overviews (radar images). +type MapCS struct { + Name string + PZero r2.Point + Scale float64 +} + +// Translate translates in-game world-relative coordinates to (0, 0) relative coordinates. +func (m MapCS) Translate(x, y float64) (float64, float64) { + return x - m.PZero.X, m.PZero.Y - y +} + +// TranslateScale translates and scales in-game world-relative coordinates to (0, 0) relative coordinates. +// The outputs are pixel coordinates for the radar images found in the maps folder. +func (m MapCS) TranslateScale(x, y float64) (float64, float64) { + x, y = m.Translate(x, y) + return x / m.Scale, y / m.Scale +} + +// MapNameToMap translates a map name to a Map. +var MapNameToMap = make(map[string]MapCS) + +// makeMap creates a map stuct initialized with the given parameters. +func makeMap(name string, x, y, scale float64) MapCS { + m := MapCS{Name: name, PZero: r2.Point{X: x, Y: y}, Scale: scale} + + MapNameToMap[name] = m + + return m +} + +// Pre-defined map translations. +// see "steamapps/common/Counter-Strike Global Offensive/csgo/resource/overviews/*.txt" +var ( + MapDeAncient = makeMap("de_ancient", -2953, 2164, 5) + MapDeAnubis = makeMap("de_anubis", -2796, 3328, 5.22) + MapDeCache = makeMap("de_cache", -2000, 3250, 5.5) + MapDeCanals = makeMap("de_canals", -2496, 1792, 4) + MapDeCbble = makeMap("de_cbble", -3840, 3072, 6) + MapDeDust2 = makeMap("de_dust2", -2476, 3239, 4.4) + MapDeInferno = makeMap("de_inferno", -2087, 3870, 4.9) + MapDeMirage = makeMap("de_mirage", -3230, 1713, 5) + MapDeNuke = makeMap("de_nuke", -3453, 2887, 7) + MapDeOverpass = makeMap("de_overpass", -4831, 1781, 5.2) + MapDeTrain = makeMap("de_train", -2477, 2392, 4.7) + MapDeVertigo = makeMap("de_vertigo", -3168, 1762, 4) + MapCsAgency = makeMap("cs_agency", -2947, 2492, 5) + MapCsOffice = makeMap("cs_office", -1838, 1858, 4.1) +) diff --git a/parser/pkg/parser/parser.go b/parser/pkg/parser/parser.go index 51d8122..48ed336 100644 --- a/parser/pkg/parser/parser.go +++ b/parser/pkg/parser/parser.go @@ -1,413 +1,433 @@ -package parser - -import ( - "csgo-2d-demo-player/pkg/log" - "csgo-2d-demo-player/pkg/message" - "fmt" - "io" - "sort" - "time" - - "github.com/golang/geo/r3" - dem "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs" - "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/common" - "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/events" - demsg "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/msg" - "go.uber.org/zap" -) - -var zeroVector = r3.Vector{ - X: 0, - Y: 0, - Z: 0, -} - -const velocityDelta = 0.000001 //nolint:golint,unused // unused now - -type RoundTimer struct { - lastRoundStart time.Duration -} - -func Parse(demoFile io.Reader, handler func(msg *message.Message, state dem.GameState)) error { - parser := dem.NewParser(demoFile) - defer func() { - if err := parser.Close(); err != nil { - log.L().Error("failed to close parser", zap.Error(err)) - } - }() - - matchErr := parseMatch(parser, handler) - if matchErr != nil { - return matchErr - } - - return nil -} - -func parseMatch(parser dem.Parser, handler func(msg *message.Message, state dem.GameState)) error { - parseTimer := time.Now() - gameStarted := false - var mapCS MapCS - - parser.RegisterNetMessageHandler(func(e *demsg.CSVCMsg_ServerInfo) { - mapCS = MapNameToMap[e.GetMapName()] - }) - - // parse one frame to have something - if more, err := parser.ParseNextFrame(); !more || err != nil { - return err - } - - readyForNewRound := false - roundMessage := message.NewRound(parser.CurrentFrame()) - currentRoundTimer := RoundTimer{ - lastRoundStart: parser.CurrentTime(), - } - - bombH := newBombHandler(parser) - - parser.RegisterEventHandler(func(ge events.GrenadeEventIf) { - msg := handleGrenadeEvent(ge, &mapCS, NewRoundMessage(parser)) - roundMessage.Add(msg) - }) - - parser.RegisterEventHandler(func(e events.WeaponFire) { - msg := handleWeaponFireEvent(e, &mapCS, NewRoundMessage(parser)) - roundMessage.Add(msg) - }) - - parser.RegisterEventHandler(func(e events.Kill) { - // log.Printf("r: '%d', '%+v'", parser.GameState().TotalRoundsPlayed(), e) - frag := &message.Frag{ - Weapon: convertWeapon(e.Weapon.Type), - } - if e.Victim != nil { - frag.VictimName = e.Victim.Name - frag.VictimTeam = team(e.Victim.Team) - } - if e.Killer != nil { - frag.KillerName = e.Killer.Name - frag.KillerTeam = team(e.Killer.Team) - } - - roundMessage.Add(&message.Message{ - MsgType: message.Message_FragType, - Tick: int32(parser.CurrentFrame()), - Frag: frag, - }) - }) - parser.RegisterEventHandler(func(e events.RoundEnd) { - //log.Printf("round end '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) - roundMessage.Winner = team(e.Winner) - }) - parser.RegisterEventHandler(func(e events.RoundEndOfficial) { - //log.Printf("round end offic '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) - roundMessage.RoundTookSeconds = int32((parser.CurrentTime() - currentRoundTimer.lastRoundStart).Seconds()) - roundMessage.RoundNo = int32(parser.GameState().TotalRoundsPlayed()) - roundMessage.EndTick = int32(parser.CurrentFrame()) - msg := &message.Message{ - MsgType: message.Message_RoundType, - Tick: int32(parser.CurrentFrame()), - Round: roundMessage, - } - //log.Printf("sending round, messages '%v', roundNo '%v' T [%v : %v] CT", len(msg.Round.Ticks), msg.Round.RoundNo, msg.Round.TeamState.TScore, msg.Round.TeamState.CTScore) - handler(msg, parser.GameState()) - }) - parser.RegisterEventHandler(func(e events.GamePhaseChanged) { - if e.NewGamePhase == common.GamePhaseGameEnded { - //log.Printf("sending last round ? tick '%v' time '%v', winner '%v'", parser.CurrentFrame(), parser.CurrentTime(), roundMessage.Winner) - roundMessage.RoundTookSeconds = int32((parser.CurrentTime() - currentRoundTimer.lastRoundStart).Seconds()) - roundMessage.RoundNo = int32(parser.GameState().TotalRoundsPlayed() + 1) - roundMessage.EndTick = int32(parser.CurrentFrame()) - msg := &message.Message{ - MsgType: message.Message_RoundType, - Tick: int32(parser.CurrentFrame()), - Round: roundMessage, - } - //log.Printf("sending round, messages '%v', roundNo '%v' T [%v : %v] CT", len(msg.Round.Ticks), msg.Round.RoundNo, msg.Round.TeamState.TScore, msg.Round.TeamState.CTScore) - handler(msg, parser.GameState()) - } - }) - parser.RegisterEventHandler(func(e events.RoundStart) { - readyForNewRound = true - }) - - bombH.registerEvents() - - parser.RegisterEventHandler(func(e events.RoundFreezetimeEnd) { - //log.Printf("freezetime end '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) - - if readyForNewRound { - readyForNewRound = false - roundMessage = message.NewRound(parser.CurrentFrame()) - currentRoundTimer.lastRoundStart = parser.CurrentTime() - roundMessage.TeamState = message.CreateTeamUpdateMessage(parser.GameState()) - roundMessage.FreezetimeEndTick = int32(parser.CurrentFrame()) - } - - if !gameStarted { - handler(&message.Message{ - MsgType: message.Message_InitType, - Tick: int32(parser.CurrentFrame()), - Init: &message.Init{ - MapName: mapCS.Name, - CTName: parser.GameState().TeamCounterTerrorists().ClanName(), - TName: parser.GameState().TeamTerrorists().ClanName(), - }, - }, parser.GameState()) - gameStarted = true - } - }) - - for { - more, err := parser.ParseNextFrame() - if err != nil { - return err - } - if !more { - log.L().Info("demo parsed", zap.Duration("took", time.Since(parseTimer))) - handler(&message.Message{ - MsgType: message.Message_DemoEndType, - Tick: int32(parser.CurrentFrame()), - Init: &message.Init{ - MapName: mapCS.Name, - CTName: parser.GameState().TeamCounterTerrorists().ClanName(), - TName: parser.GameState().TeamTerrorists().ClanName(), - }}, parser.GameState()) - return nil - } - if !gameStarted { - continue - } - - // if parser.CurrentFrame()%1024 == 0 { - // progressWholePercent := int32(math.Round(float64(parser.Progress()) * 100)) - // handler(&message.Message{ - // MsgType: message.Message_ProgressType, - // Tick: int32(parser.CurrentFrame()), - // Progress: &message.Progress{ - // Progress: progressWholePercent, - // Message: "Loading match ...", - // }, - // }, parser.GameState()) - // } - - if parser.CurrentFrame()%16 == 0 { - roundTime := parser.CurrentTime() - currentRoundTimer.lastRoundStart - minutes := int(roundTime.Minutes()) - roundMessage.Add(&message.Message{ - MsgType: message.Message_TimeUpdateType, - Tick: int32(parser.CurrentFrame()), - RoundTime: &message.RoundTime{ - RoundTime: fmt.Sprintf("%d:%02d", minutes, int(roundTime.Seconds())-(60*minutes)), - }, - }) - } - - bombH.tick() - - if parser.CurrentFrame()%4 != 0 { - roundMessage.Add(&message.Message{ - MsgType: message.Message_EmptyType, - Tick: int32(parser.CurrentFrame()), - }) - continue - } - - roundMessage.Add(createTickStateMessage(parser.GameState(), &mapCS, parser, bombH)) - } -} - -func handleGrenadeEvent(ge events.GrenadeEventIf, mapCS *MapCS, msg *message.Message) *message.Message { - x, y := translatePosition(ge.Base().Position, mapCS) - switch ge.(type) { - case events.FlashExplode, events.HeExplode: - msg.MsgType = message.Message_GrenadeEventType - msg.GrenadeEvent = &message.Grenade{ - Id: int32(ge.Base().GrenadeEntityID), - Kind: WeaponsEqType[ge.Base().Grenade.Type], - X: x, - Y: y, - Z: ge.Base().Position.Z, - Action: "explode", - } - return msg - } - return nil -} - -func handleWeaponFireEvent(e events.WeaponFire, mapCS *MapCS, msg *message.Message) *message.Message { - if c := e.Weapon.Class(); c == common.EqClassPistols || c == common.EqClassSMG || c == common.EqClassHeavy || c == common.EqClassRifle { - x, y := translatePosition(e.Shooter.Position(), mapCS) - msg.MsgType = message.Message_ShotType - msg.Shot = &message.Shot{ - PlayerId: int32(e.Shooter.UserID), - X: x, - Y: y, - Rotation: -(e.Shooter.ViewDirectionX() - 90.0), - } - return msg - } - return nil -} - -func NewRoundMessage(parser dem.Parser) *message.Message { - return &message.Message{ - Tick: int32(parser.CurrentFrame()), - } -} - -func createTickStateMessage(tick dem.GameState, mapCS *MapCS, parser dem.Parser, bombH *bombHandler) *message.Message { - msgPlayers := make([]*message.Player, 0) - for _, p := range tick.TeamTerrorists().Members() { - msgPlayers = append(msgPlayers, transformPlayer(p, mapCS)) - } - for _, p := range tick.TeamCounterTerrorists().Members() { - msgPlayers = append(msgPlayers, transformPlayer(p, mapCS)) - } - sort.Slice(msgPlayers, func(i, j int) bool { - return msgPlayers[i].PlayerId < msgPlayers[j].PlayerId - }) - - nades := make([]*message.Grenade, 0) - for _, g := range tick.GrenadeProjectiles() { - var action string - if g.WeaponInstance.Type == common.EqHE { - // HE for some reason keep on map longer. we want to remove them after they explode - if exploded, ok := g.Entity.PropertyValue("m_nExplodeEffectIndex"); ok && exploded.UInt64() > 0 { - continue - } - } - if g.WeaponInstance.Type == common.EqSmoke { - if exploded, ok := g.Entity.PropertyValue("m_bDidSmokeEffect"); ok && exploded.BoolVal() { - action = "explode" - } - } - // if g.WeaponInstance.Type == common.EqDecoy { - // TODO: fix decoy firing when not moving - // vel := g.Velocity() - // if vel.Distance(zeroVector) <= velocityDelta { - // action = "explode" - // } - // } - x, y := translatePosition(g.Position(), mapCS) - nades = append(nades, &message.Grenade{ - Id: int32(g.Entity.ID()), - Kind: WeaponsEqType[g.WeaponInstance.Type], - X: x, - Y: y, - Z: g.Position().Z, - Action: action, - }) - } - - for _, inferno := range tick.Infernos() { - for _, fire := range inferno.Fires().Active().ConvexHull3D().Vertices { - x, y := translatePosition(fire, mapCS) - dist := int(fire.Distance(zeroVector) * 100_000) - nades = append(nades, &message.Grenade{ - Id: int32(inferno.Entity.ID() + dist), - Kind: "fire", - X: x, - Y: y, - Z: fire.Z, - Action: "explode", - }) - } - } - - sort.Slice(nades, func(i, j int) bool { - return nades[i].Id < nades[j].Id - }) - - return &message.Message{ - MsgType: message.Message_TickStateUpdate, - Tick: int32(parser.CurrentFrame()), - TickState: &message.TickState{ - Players: msgPlayers, - Nades: nades, - Bomb: bombH.message(mapCS), - }, - } -} - -func transformPlayer(p *common.Player, mapCS *MapCS) *message.Player { - x, y := translatePosition(p.Position(), mapCS) - player := &message.Player{ - PlayerId: int32(p.UserID), - Name: p.Name, - Team: team(p.Team), - X: x, - Y: y, - Z: p.Position().Z, - Rotation: -(p.ViewDirectionX() - 90.0), - Alive: p.IsAlive(), - Flashed: p.IsBlinded(), - Hp: int32(p.Health()), - Armor: int32(p.Armor()), - Helmet: p.HasHelmet(), - Defuse: p.HasDefuseKit(), - Money: int32(p.Money()), - } - - if w := p.ActiveWeapon(); w != nil { - player.Weapon = convertWeapon(w.Type) - } - - //TODO: Grenades should have priority left to right flash > he > smoke > molotov/inc > decoy - for _, w := range p.Weapons() { - if w.Class() == common.EqClassUnknown { - // we don't know what this is, nothing to do here - log.L().Debug("unknown eq", zap.Any("weapon", w)) - continue - } - weaponString := convertWeapon(w.Type) - switch w.Class() { - case common.EqClassSMG, common.EqClassHeavy, common.EqClassRifle: - player.Primary = weaponString - player.PrimaryAmmoMagazine = int32(w.AmmoInMagazine()) - player.PrimaryAmmoReserve = int32(w.AmmoReserve()) - case common.EqClassPistols: - player.Secondary = weaponString - player.SecondaryAmmoMagazine = int32(w.AmmoInMagazine()) - player.SecondaryAmmoReserve = int32(w.AmmoReserve()) - case common.EqClassGrenade: - for gi := 0; gi < w.AmmoInMagazine()+w.AmmoReserve(); gi++ { - player.Grenades = append(player.Grenades, weaponString) - } - case common.EqClassEquipment: - switch w.Type { - case common.EqBomb: - player.Bomb = true - case common.EqKnife: - case common.EqZeus: - default: - log.Printf("what is this ? '%+v'\n", w) - } - } - } - sort.Slice(player.Grenades, func(i, j int) bool { - return player.Grenades[i] < player.Grenades[j] - }) - - return player -} - -func translatePosition(position r3.Vector, mapCS *MapCS) (float64, float64) { - x, y := mapCS.TranslateScale(position.X, position.Y) - x = x / 1024 * 100 - y = y / 1024 * 100 - return x, y -} - -func team(team common.Team) string { - switch team { - case common.TeamCounterTerrorists: - return "CT" - case common.TeamTerrorists: - return "T" - default: - log.Printf("I don't know the team '%v'. Should not get here, but apparently it sometimes happen that spectators wins the round.", team) - } - return "" -} +package parser + +import ( + "csgo-2d-demo-player/pkg/log" + "csgo-2d-demo-player/pkg/message" + "fmt" + "io" + "sort" + "time" + + "github.com/golang/geo/r3" + dem "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs" + "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/common" + "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/events" + demsg "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/msg" + "go.uber.org/zap" +) + +var zeroVector = r3.Vector{ + X: 0, + Y: 0, + Z: 0, +} + +const velocityDelta = 0.000001 //nolint:golint,unused // unused now + +type RoundTimer struct { + lastRoundStart time.Duration +} + +func Parse(demoFile io.Reader, handler func(msg *message.Message, state dem.GameState)) error { + parser := dem.NewParser(demoFile) + defer func() { + if err := parser.Close(); err != nil { + log.L().Error("failed to close parser", zap.Error(err)) + } + }() + + matchErr := parseMatch(parser, handler) + if matchErr != nil { + return matchErr + } + + return nil +} + +func parseMatch(parser dem.Parser, handler func(msg *message.Message, state dem.GameState)) error { + parseTimer := time.Now() + gameStarted := false + var mapCS MapCS + + parser.RegisterNetMessageHandler(func(e *demsg.CSVCMsg_ServerInfo) { + mapCS = MapNameToMap[e.GetMapName()] + }) + + // parse one frame to have something + if more, err := parser.ParseNextFrame(); !more || err != nil { + return err + } + + readyForNewRound := false + roundMessage := message.NewRound(parser.CurrentFrame()) + currentRoundTimer := RoundTimer{ + lastRoundStart: parser.CurrentTime(), + } + + bombH := newBombHandler(parser) + + parser.RegisterEventHandler(func(ge events.GrenadeEventIf) { + msg := handleGrenadeEvent(ge, &mapCS, parser.CurrentFrame()) + if msg != nil { + roundMessage.Add(msg) + } + }) + + parser.RegisterEventHandler(func(e events.WeaponFire) { + msg := handleWeaponFireEvent(e, &mapCS, parser.CurrentFrame()) + if msg != nil { + roundMessage.Add(msg) + } + }) + + parser.RegisterEventHandler(func(e events.Kill) { + // log.Printf("r: '%d', '%+v'", parser.GameState().TotalRoundsPlayed(), e) + frag := &message.Frag{ + Weapon: convertWeapon(e.Weapon.Type), + } + if e.Victim != nil { + frag.VictimName = e.Victim.Name + frag.VictimTeam = team(e.Victim.Team) + } + if e.Killer != nil { + frag.KillerName = e.Killer.Name + frag.KillerTeam = team(e.Killer.Team) + } + + roundMessage.Add(&message.Message{ + MsgType: message.Message_FragType, + Tick: int32(parser.CurrentFrame()), + Frag: frag, + }) + }) + parser.RegisterEventHandler(func(e events.RoundEnd) { + //log.Printf("round end '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) + roundMessage.Winner = team(e.Winner) + }) + parser.RegisterEventHandler(func(e events.RoundEndOfficial) { + //log.Printf("round end offic '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) + roundMessage.RoundTookSeconds = int32((parser.CurrentTime() - currentRoundTimer.lastRoundStart).Seconds()) + roundMessage.RoundNo = int32(parser.GameState().TotalRoundsPlayed()) + roundMessage.EndTick = int32(parser.CurrentFrame()) + msg := &message.Message{ + MsgType: message.Message_RoundType, + Tick: int32(parser.CurrentFrame()), + Round: roundMessage, + } + //log.Printf("sending round, messages '%v', roundNo '%v' T [%v : %v] CT", len(msg.Round.Ticks), msg.Round.RoundNo, msg.Round.TeamState.TScore, msg.Round.TeamState.CTScore) + handler(msg, parser.GameState()) + }) + parser.RegisterEventHandler(func(e events.GamePhaseChanged) { + if e.NewGamePhase == common.GamePhaseGameEnded { + //log.Printf("sending last round ? tick '%v' time '%v', winner '%v'", parser.CurrentFrame(), parser.CurrentTime(), roundMessage.Winner) + roundMessage.RoundTookSeconds = int32((parser.CurrentTime() - currentRoundTimer.lastRoundStart).Seconds()) + roundMessage.RoundNo = int32(parser.GameState().TotalRoundsPlayed() + 1) + roundMessage.EndTick = int32(parser.CurrentFrame()) + msg := &message.Message{ + MsgType: message.Message_RoundType, + Tick: int32(parser.CurrentFrame()), + Round: roundMessage, + } + //log.Printf("sending round, messages '%v', roundNo '%v' T [%v : %v] CT", len(msg.Round.Ticks), msg.Round.RoundNo, msg.Round.TeamState.TScore, msg.Round.TeamState.CTScore) + handler(msg, parser.GameState()) + } + }) + parser.RegisterEventHandler(func(e events.RoundStart) { + readyForNewRound = true + }) + + bombH.registerEvents() + + parser.RegisterEventHandler(func(e events.RoundFreezetimeEnd) { + //log.Printf("freezetime end '%+v' tick '%v' time '%v'", e, parser.CurrentFrame(), parser.CurrentTime()) + + if readyForNewRound { + readyForNewRound = false + roundMessage = message.NewRound(parser.CurrentFrame()) + currentRoundTimer.lastRoundStart = parser.CurrentTime() + roundMessage.TeamState = message.CreateTeamUpdateMessage(parser.GameState()) + roundMessage.FreezetimeEndTick = int32(parser.CurrentFrame()) + } + + if !gameStarted { + handler(&message.Message{ + MsgType: message.Message_InitType, + Tick: int32(parser.CurrentFrame()), + Init: &message.Init{ + MapName: mapCS.Name, + CTName: parser.GameState().TeamCounterTerrorists().ClanName(), + TName: parser.GameState().TeamTerrorists().ClanName(), + }, + }, parser.GameState()) + gameStarted = true + } + }) + + for { + more, err := parser.ParseNextFrame() + if err != nil { + return err + } + if !more { + log.L().Info("demo parsed", zap.Duration("took", time.Since(parseTimer))) + handler(&message.Message{ + MsgType: message.Message_DemoEndType, + Tick: int32(parser.CurrentFrame()), + Init: &message.Init{ + MapName: mapCS.Name, + CTName: parser.GameState().TeamCounterTerrorists().ClanName(), + TName: parser.GameState().TeamTerrorists().ClanName(), + }}, parser.GameState()) + return nil + } + if !gameStarted { + continue + } + + // if parser.CurrentFrame()%1024 == 0 { + // progressWholePercent := int32(math.Round(float64(parser.Progress()) * 100)) + // handler(&message.Message{ + // MsgType: message.Message_ProgressType, + // Tick: int32(parser.CurrentFrame()), + // Progress: &message.Progress{ + // Progress: progressWholePercent, + // Message: "Loading match ...", + // }, + // }, parser.GameState()) + // } + + if parser.CurrentFrame()%16 == 0 { + roundTime := parser.CurrentTime() - currentRoundTimer.lastRoundStart + totalSeconds := int(roundTime.Seconds()) + minutes := totalSeconds / 60 + seconds := totalSeconds - (minutes * 60) + roundMessage.Add(&message.Message{ + MsgType: message.Message_TimeUpdateType, + Tick: int32(parser.CurrentFrame()), + RoundTime: &message.RoundTime{ + RoundTime: fmt.Sprintf("%d:%02d", minutes, seconds), + }, + }) + } + + bombH.tick() + + if parser.CurrentFrame()%4 != 0 { + roundMessage.Add(&message.Message{ + MsgType: message.Message_EmptyType, + Tick: int32(parser.CurrentFrame()), + }) + continue + } + + roundMessage.Add(createTickStateMessage(parser.GameState(), &mapCS, parser, bombH)) + } +} + +func handleGrenadeEvent(ge events.GrenadeEventIf, mapCS *MapCS, currentFrame int) *message.Message { + switch ge.(type) { + case events.FlashExplode, events.HeExplode: + x, y := translatePosition(ge.Base().Position, mapCS) + base := ge.Base() + return &message.Message{ + MsgType: message.Message_GrenadeEventType, + Tick: int32(currentFrame), + GrenadeEvent: &message.Grenade{ + Id: int32(base.GrenadeEntityID), + Kind: WeaponsEqType[base.Grenade.Type], + X: x, + Y: y, + Z: base.Position.Z, + Action: "explode", + }, + } + } + return nil +} + +func handleWeaponFireEvent(e events.WeaponFire, mapCS *MapCS, currentFrame int) *message.Message { + if c := e.Weapon.Class(); c == common.EqClassPistols || c == common.EqClassSMG || c == common.EqClassHeavy || c == common.EqClassRifle { + x, y := translatePosition(e.Shooter.Position(), mapCS) + return &message.Message{ + MsgType: message.Message_ShotType, + Tick: int32(currentFrame), + Shot: &message.Shot{ + PlayerId: int32(e.Shooter.UserID), + X: x, + Y: y, + Rotation: -(e.Shooter.ViewDirectionX() - 90.0), + }, + } + } + return nil +} + +func createTickStateMessage(tick dem.GameState, mapCS *MapCS, parser dem.Parser, bombH *bombHandler) *message.Message { + // Pre-allocate slice with known capacity to reduce allocations + tMembers := tick.TeamTerrorists().Members() + ctMembers := tick.TeamCounterTerrorists().Members() + msgPlayers := make([]*message.Player, 0, len(tMembers)+len(ctMembers)) + + for _, p := range tMembers { + msgPlayers = append(msgPlayers, transformPlayer(p, mapCS)) + } + for _, p := range ctMembers { + msgPlayers = append(msgPlayers, transformPlayer(p, mapCS)) + } + sort.Slice(msgPlayers, func(i, j int) bool { + return msgPlayers[i].PlayerId < msgPlayers[j].PlayerId + }) + + // Pre-allocate grenades slice + grenadeProjectiles := tick.GrenadeProjectiles() + infernos := tick.Infernos() + // Estimate capacity: grenades + approximate fire entities + estimatedNades := len(grenadeProjectiles) + len(infernos)*10 + nades := make([]*message.Grenade, 0, estimatedNades) + + for _, g := range grenadeProjectiles { + var action string + weaponType := g.WeaponInstance.Type + + if weaponType == common.EqHE { + // HE for some reason keep on map longer. we want to remove them after they explode + if exploded, ok := g.Entity.PropertyValue("m_nExplodeEffectIndex"); ok && exploded.UInt64() > 0 { + continue + } + } else if weaponType == common.EqSmoke { + if exploded, ok := g.Entity.PropertyValue("m_bDidSmokeEffect"); ok && exploded.BoolVal() { + action = "explode" + } + } + // if weaponType == common.EqDecoy { + // TODO: fix decoy firing when not moving + // vel := g.Velocity() + // if vel.Distance(zeroVector) <= velocityDelta { + // action = "explode" + // } + // } + x, y := translatePosition(g.Position(), mapCS) + nades = append(nades, &message.Grenade{ + Id: int32(g.Entity.ID()), + Kind: WeaponsEqType[weaponType], + X: x, + Y: y, + Z: g.Position().Z, + Action: action, + }) + } + + for _, inferno := range infernos { + for _, fire := range inferno.Fires().Active().ConvexHull3D().Vertices { + x, y := translatePosition(fire, mapCS) + dist := int(fire.Distance(zeroVector) * 100_000) + nades = append(nades, &message.Grenade{ + Id: int32(inferno.Entity.ID() + dist), + Kind: "fire", + X: x, + Y: y, + Z: fire.Z, + Action: "explode", + }) + } + } + + sort.Slice(nades, func(i, j int) bool { + return nades[i].Id < nades[j].Id + }) + + return &message.Message{ + MsgType: message.Message_TickStateUpdate, + Tick: int32(parser.CurrentFrame()), + TickState: &message.TickState{ + Players: msgPlayers, + Nades: nades, + Bomb: bombH.message(mapCS), + }, + } +} + +func transformPlayer(p *common.Player, mapCS *MapCS) *message.Player { + x, y := translatePosition(p.Position(), mapCS) + player := &message.Player{ + PlayerId: int32(p.UserID), + Name: p.Name, + Team: team(p.Team), + X: x, + Y: y, + Z: p.Position().Z, + Rotation: -(p.ViewDirectionX() - 90.0), + Alive: p.IsAlive(), + Flashed: p.IsBlinded(), + Hp: int32(p.Health()), + Armor: int32(p.Armor()), + Helmet: p.HasHelmet(), + Defuse: p.HasDefuseKit(), + Money: int32(p.Money()), + } + + if w := p.ActiveWeapon(); w != nil { + player.Weapon = convertWeapon(w.Type) + } + + // Pre-allocate grenades slice - max grenades per player is typically 4 + weapons := p.Weapons() + player.Grenades = make([]string, 0, 4) + + //TODO: Grenades should have priority left to right flash > he > smoke > molotov/inc > decoy + for _, w := range weapons { + if w.Class() == common.EqClassUnknown { + // we don't know what this is, nothing to do here + log.L().Debug("unknown eq", zap.Any("weapon", w)) + continue + } + weaponString := convertWeapon(w.Type) + switch w.Class() { + case common.EqClassSMG, common.EqClassHeavy, common.EqClassRifle: + player.Primary = weaponString + player.PrimaryAmmoMagazine = int32(w.AmmoInMagazine()) + player.PrimaryAmmoReserve = int32(w.AmmoReserve()) + case common.EqClassPistols: + player.Secondary = weaponString + player.SecondaryAmmoMagazine = int32(w.AmmoInMagazine()) + player.SecondaryAmmoReserve = int32(w.AmmoReserve()) + case common.EqClassGrenade: + for gi := 0; gi < w.AmmoInMagazine()+w.AmmoReserve(); gi++ { + player.Grenades = append(player.Grenades, weaponString) + } + case common.EqClassEquipment: + switch w.Type { + case common.EqBomb: + player.Bomb = true + case common.EqKnife: + case common.EqZeus: + default: + log.Printf("what is this ? '%+v'\n", w) + } + } + } + sort.Slice(player.Grenades, func(i, j int) bool { + return player.Grenades[i] < player.Grenades[j] + }) + + return player +} + +func translatePosition(position r3.Vector, mapCS *MapCS) (float64, float64) { + // Combine translation and scaling operations + x := (position.X - mapCS.PZero.X) / mapCS.Scale * 0.09765625 // 100/1024 = 0.09765625 + y := (mapCS.PZero.Y - position.Y) / mapCS.Scale * 0.09765625 + return x, y +} + +func team(team common.Team) string { + switch team { + case common.TeamCounterTerrorists: + return "CT" + case common.TeamTerrorists: + return "T" + default: + log.Printf("I don't know the team '%v'. Should not get here, but apparently it sometimes happen that spectators wins the round.", team) + } + return "" +} From 766fbfadebc4e2c057405ac386c073d7de03c914 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 22:17:00 +0000 Subject: [PATCH 3/3] Add performance benchmarks for optimized parser functions Co-authored-by: sparkoo <1160903+sparkoo@users.noreply.github.com> --- parser/pkg/parser/parser_bench_test.go | 31 ++++++++++++++++++++++++++ parser/pkg/parser/wasmparser_test.go | 1 + 2 files changed, 32 insertions(+) create mode 100644 parser/pkg/parser/parser_bench_test.go diff --git a/parser/pkg/parser/parser_bench_test.go b/parser/pkg/parser/parser_bench_test.go new file mode 100644 index 0000000..a3addbb --- /dev/null +++ b/parser/pkg/parser/parser_bench_test.go @@ -0,0 +1,31 @@ +package parser + +import ( + "testing" + + "github.com/golang/geo/r3" + "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/common" +) + +// BenchmarkTranslatePosition tests the performance of position translation +func BenchmarkTranslatePosition(b *testing.B) { + mapCS := MapDeMirage + position := r3.Vector{X: 1000.0, Y: 2000.0, Z: 100.0} + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, _ = translatePosition(position, &mapCS) + } +} + +// BenchmarkConvertWeapon tests the performance of weapon conversion +func BenchmarkConvertWeapon(b *testing.B) { + b.Run("Known Weapon", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = convertWeapon(common.EqAK47) + } + }) +} diff --git a/parser/pkg/parser/wasmparser_test.go b/parser/pkg/parser/wasmparser_test.go index b9892f5..4f078be 100644 --- a/parser/pkg/parser/wasmparser_test.go +++ b/parser/pkg/parser/wasmparser_test.go @@ -46,6 +46,7 @@ func BenchmarkParseDemo(b *testing.B) { } defer func() { _ = demoFile.Close() }() + b.ReportAllocs() for b.Loop() { _, err := demoFile.Seek(0, 0) if err != nil {