Skip to content

Commit b9e66f9

Browse files
authored
Merge pull request #663 from markus-wa/various-fixes
Various fixes: correctness, new events/getters, and doc corrections
2 parents 422d96d + 3da8b43 commit b9e66f9

25 files changed

Lines changed: 817 additions & 113 deletions

‎docs/game-events.md‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,35 @@ parser.RegisterEventHandler(func(event events.GenericGameEvent) {
1818
1919
✅ = Available, ❌ = Not available, ? = Not sure, need to be tested
2020

21+
> **Provenance**
22+
> This table was originally authored against **CS:GO** demos (Jan 2023) and is not generated. Most
23+
> rows have never been re-measured for CS2. The rows verified against CS2 broadcast-GOTV so far are
24+
> the item-lifecycle / per-action corrections noted below (`item_remove`, `player_falldamage`,
25+
> `player_ping`/`player_ping_stop`, `defuser_*`, and the per-action cohort); treat the remaining rows
26+
> as CS:GO-era carry-over pending re-measurement, and the POV column as untested for CS2.
27+
28+
> **Note (CS2 broadcast-GOTV vs match-server SourceTV)**
29+
> The GOTV column can differ between the two GOTV flavors. In CS2 **broadcast-GOTV** (e.g. HLTV)
30+
> demos a family of item-lifecycle / per-action events does not fire even when otherwise available:
31+
> `item_remove`, `defuser_dropped`, `defuser_pickup` and `player_falldamage` never fire, and
32+
> `bomb_beginplant` / `bomb_begindefuse` fire only in match-server SourceTV recordings (marked ✅
33+
> above), not in broadcast-GOTV. Drop / pickup / ownership are still recoverable from the
34+
> entity-property layer — weapon `m_hOwnerEntity` and pawn `m_bPawnHasDefuser` transitions fire
35+
> abundantly. Conversely `player_ping` / `player_ping_stop` appear in broadcast-GOTV but are
36+
> **absent on match-server SourceTV** (advertised in the event list, never emitted) - the reverse of
37+
> the rest of the family, so a consumer validating on a match-server demo would wrongly conclude
38+
> pings don't exist in CS2.
39+
40+
> **Note (per-action event cohort in broadcast-GOTV)**
41+
> Broadcast-GOTV carries aggregate/director events (`player_sound`, `entity_killed`, `player_ping`,
42+
> `hltv_*`) but drops the raw per-action cohort (`player_footstep`, `player_jump`, `weapon_reload`,
43+
> `item_equip`, `weapon_zoom`, `player_blind`, `bomb_beginplant`). This is not governed by a `tv_*`
44+
> ConVar — `tv_transmitall` controls entity transmission, not the game-event stream, and is already
45+
> `1` in demos where the cohort is still absent; the cohort's absence is a property of the
46+
> game-event networking layer, so it cannot be re-enabled server-side for broadcast-GOTV. Most of
47+
> the signal is still recoverable from the entity/net-prop layer. Server ConVars themselves are
48+
> readable via the `events.ConVarsUpdated` event and `GameState.ConVars()`.
49+
2150
| Event name | GOTV | POV |
2251
| ------------------------------- | ---- | --- |
2352
| ammo_pickup | ❌ | ✅ |
@@ -43,6 +72,8 @@ parser.RegisterEventHandler(func(event events.GenericGameEvent) {
4372
| cs_win_panel_round | ✅ | ✅ |
4473
| decoy_detonate | ✅ | ✅ |
4574
| decoy_started | ✅ | ✅ |
75+
| defuser_dropped | ❌ | ? |
76+
| defuser_pickup | ❌ | ? |
4677
| endmatch_cmm_start_reveal_items | ✅ | ❌ |
4778
| enter_bombzone | ❌ | ✅ |
4879
| enter_buyzone | ❌ | ✅ |
@@ -68,22 +99,22 @@ parser.RegisterEventHandler(func(event events.GenericGameEvent) {
6899
| item_equip | ✅ | ❌ |
69100
| item_pickup | ✅ | ❌ |
70101
| item_pickup_slerp | ❌ | ✅ |
71-
| item_remove | ✅ | ❌ |
102+
| item_remove | ❌ | ❌ |
72103
| jointeam_failed | ❌ | ✅ |
73104
| other_death | ✅ | ✅ |
74105
| player_blind | ✅ | ❌ |
75106
| player_connect | ✅ | ✅ |
76107
| player_connect_full | ✅ | ✅ |
77108
| player_death | ✅ | ✅ |
78109
| player_disconnect | ✅ | ✅ |
79-
| player_falldamage | ✅ | ❌ |
110+
| player_falldamage | ❌ | ❌ |
80111
| player_footstep | ✅ | ❌ |
81112
| player_given_c4 | ❌ | ✅ |
82113
| player_hurt | ✅ | ✅ |
83114
| player_jump | ✅ | ❌ |
84115
| player_changename | ✅ | ✅ |
85-
| player_ping | ❌ | ✅ |
86-
| player_ping_stop | ❌ | ✅ |
116+
| player_ping | ✅ | ✅ |
117+
| player_ping_stop | ✅ | ✅ |
87118
| player_spawn | ✅ | ✅ |
88119
| player_spawned | ❌ | ✅ |
89120
| player_team | ✅ | ✅ |

‎examples/mocking/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func TestCollectKills(t *testing.T) {
5656
}
5757

5858
func kill(wep common.EquipmentElement) events.Kill {
59-
eq := common.NewEquipment(wep)
59+
eq := common.NewEquipment(wep, 1)
6060
return events.Kill{
6161
Killer: new(common.Player),
6262
Weapon: &eq,

‎examples/mocking/mocking_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestCollectKills(t *testing.T) {
2929
}
3030

3131
func kill(wep common.EquipmentType) events.Kill {
32-
eq := common.NewEquipment(wep)
32+
eq := common.NewEquipment(wep, 1)
3333
return events.Kill{
3434
Killer: new(common.Player),
3535
Weapon: eq,

‎pkg/demoinfocs/common/common.go‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package common
33

44
import (
55
"fmt"
6-
"math/rand"
76
"strconv"
87
"strings"
98
"time"
@@ -53,6 +52,10 @@ func getVelocityComponent(entity st.Entity, component string) (float32, bool) {
5352

5453
// Velocity returns the projectile's velocity.
5554
// Returns a zero vector if the velocity is not networked.
55+
//
56+
// Note: on CS2 broadcast/GOTV demos the m_vecVelocity property is not present on grenade
57+
// projectile entities and this returns a zero vector. The throw kinematics are instead captured
58+
// at entity creation via m_vInitialVelocity / m_vInitialPosition; use those for release velocity.
5659
func (g *GrenadeProjectile) Velocity() r3.Vector {
5760
x, okX := getVelocityComponent(g.Entity, "m_vecX")
5861
y, okY := getVelocityComponent(g.Entity, "m_vecY")
@@ -70,17 +73,24 @@ func (g *GrenadeProjectile) Velocity() r3.Vector {
7073
}
7174

7275
// UniqueID returns the unique id of the grenade.
73-
// The unique id is a random int generated internally by this library and can be used to differentiate
74-
// grenades from each other. This is needed because demo-files reuse entity ids.
76+
// The id is generated internally by this library and can be used to differentiate grenades from
77+
// each other. This is needed because demo-files reuse entity ids.
78+
//
79+
// The id is unique within the Parser instance that created the grenade; ids from different Parser
80+
// instances (e.g. concurrently or sequentially parsed demos) may collide, so don't key data
81+
// across demos by it.
7582
func (g *GrenadeProjectile) UniqueID() int64 {
7683
return g.uniqueID
7784
}
7885

7986
// NewGrenadeProjectile creates a grenade projectile and sets the Unique-ID.
8087
//
88+
// The id must come from the creating Parser's unique-id source so that it is unique within that
89+
// parse and reproducible across parses of the same demo.
90+
//
8191
// Intended for internal use only.
82-
func NewGrenadeProjectile() *GrenadeProjectile {
83-
return &GrenadeProjectile{uniqueID: rand.Int63()} //nolint:gosec
92+
func NewGrenadeProjectile(id int64) *GrenadeProjectile {
93+
return &GrenadeProjectile{uniqueID: id}
8494
}
8595

8696
// Bomb tracks the bomb's position, and the player carrying it, if any.

‎pkg/demoinfocs/common/common_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
)
1313

1414
func TestGrenadeProjectileUniqueID(t *testing.T) {
15-
assert.NotEqual(t, NewGrenadeProjectile().UniqueID(), NewGrenadeProjectile().UniqueID(), "UniqueIDs of different grenade projectiles should be different")
15+
assert.NotEqual(t, NewGrenadeProjectile(1).UniqueID(), NewGrenadeProjectile(2).UniqueID(), "UniqueIDs of different grenade projectiles should be different")
1616
}
1717

1818
func TestGrenadeProjectile_Velocity(t *testing.T) {

‎pkg/demoinfocs/common/equipment.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package common
22

33
import (
4+
"encoding/binary"
45
"strings"
56

67
"github.com/oklog/ulid/v2"
@@ -340,7 +341,8 @@ func (e *Equipment) Class() EquipmentClass {
340341
// UniqueID2 returns a unique id of the equipment element that can be sorted efficiently.
341342
// UniqueID2 is a value generated internally by this library and can be used to differentiate
342343
// equipment from each other. This is needed because demo-files reuse entity ids.
343-
// Unlike UniqueID, UniqueID2 is guaranteed to be unique.
344+
// The id is unique within the Parser instance that created the equipment; ids from different
345+
// Parser instances may collide, so don't key data across demos by it.
344346
func (e *Equipment) UniqueID2() ulid.ULID {
345347
return e.uniqueID2
346348
}
@@ -395,9 +397,15 @@ func (e *Equipment) ZoomLevel() ZoomLevel {
395397
return ZoomLevel(value.Int())
396398
}
397399

398-
// AmmoReserve returns the ammo left available for reloading.
400+
// AmmoReserve returns the reserve ammo available for reloading.
399401
// Returns CWeaponCSBase.m_iPrimaryReserveAmmoCount for most weapons and 'Owner.AmmoLeft[AmmoType] - 1' for grenades.
400402
// Use AmmoInMagazine() + AmmoReserve() to quickly get the amount of grenades a player owns.
403+
//
404+
// Note: the unit is era-dependent. The March 2026 CS2 reload patch
405+
// (https://steamcommunity.com/games/CSGO/announcements/detail/532126482488623354) changed reserve
406+
// ammo to reserve magazines, so on post-patch demos this is a magazine count (e.g. AK-47 = 3,
407+
// MP9 = 2) rather than a round count; pre-patch demos still report rounds. The value itself is
408+
// correct for both - only the interpretation differs by demo era.
401409
func (e *Equipment) AmmoReserve() int {
402410
if e.Entity == nil {
403411
return 0
@@ -444,9 +452,17 @@ func (e *Equipment) Silenced() bool {
444452

445453
// NewEquipment creates a new Equipment and sets the UniqueID.
446454
//
455+
// The id must come from the creating Parser's unique-id source. It is encoded into the ULID, which
456+
// keeps UniqueID2 unique within the parse, lexicographically sortable (in creation order) and
457+
// reproducible across parses of the same demo - unlike ulid.Make(), which embeds wall-clock time
458+
// plus entropy and changes every parse.
459+
//
447460
// Intended for internal use only.
448-
func NewEquipment(wep EquipmentType) *Equipment {
449-
return &Equipment{Type: wep, uniqueID2: ulid.Make()}
461+
func NewEquipment(wep EquipmentType, id int64) *Equipment {
462+
var uid ulid.ULID
463+
binary.BigEndian.PutUint64(uid[:8], uint64(id))
464+
465+
return &Equipment{Type: wep, uniqueID2: uid}
450466
}
451467

452468
var equipmentToAlternative = map[EquipmentType]EquipmentType{

‎pkg/demoinfocs/common/equipment_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ func TestWeirdCS2Values(t *testing.T) {
3838
}
3939

4040
func TestEquipment_Class(t *testing.T) {
41-
assert.Equal(t, EqClassUnknown, NewEquipment(EqUnknown).Class(), "EqUnknown should have the class EqClassUnknown")
42-
assert.Equal(t, EqClassPistols, NewEquipment(EqP2000).Class(), "EqP2000 should have the class EqClassPistols")
43-
assert.Equal(t, EqClassPistols, NewEquipment(EqRevolver).Class(), "EqRevolver should have the class EqClassPistols")
44-
assert.Equal(t, EqClassRifle, NewEquipment(EqG3SG1).Class(), "EqG3SG1 should have the class EqClassRifle")
41+
assert.Equal(t, EqClassUnknown, NewEquipment(EqUnknown, 1).Class(), "EqUnknown should have the class EqClassUnknown")
42+
assert.Equal(t, EqClassPistols, NewEquipment(EqP2000, 1).Class(), "EqP2000 should have the class EqClassPistols")
43+
assert.Equal(t, EqClassPistols, NewEquipment(EqRevolver, 1).Class(), "EqRevolver should have the class EqClassPistols")
44+
assert.Equal(t, EqClassRifle, NewEquipment(EqG3SG1, 1).Class(), "EqG3SG1 should have the class EqClassRifle")
4545
}
4646

4747
func TestEquipment_UniqueID(t *testing.T) {
48-
assert.NotEqual(t, NewEquipment(EqAK47).UniqueID2(), NewEquipment(EqAK47).UniqueID2(), "UniqueIDs of different equipment instances should be different")
48+
assert.NotEqual(t, NewEquipment(EqAK47, 1).UniqueID2(), NewEquipment(EqAK47, 2).UniqueID2(), "UniqueIDs of different equipment instances should be different")
4949
}
5050

5151
func TestEquipment_AmmoInMagazine(t *testing.T) {

‎pkg/demoinfocs/common/inferno.go‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package common
22

33
import (
44
"fmt"
5-
"math/rand"
65
"sort"
76

87
"github.com/golang/geo/r2"
@@ -39,8 +38,11 @@ type Fires struct {
3938
}
4039

4140
// UniqueID returns the unique id of the inferno.
42-
// The unique id is a random int generated internally by this library and can be used to differentiate
43-
// infernos from each other. This is needed because demo-files reuse entity ids.
41+
// The id is generated internally by this library and can be used to differentiate infernos from
42+
// each other. This is needed because demo-files reuse entity ids.
43+
//
44+
// The id is unique within the Parser instance that created the inferno; ids from different Parser
45+
// instances may collide, so don't key data across demos by it.
4446
func (inf *Inferno) UniqueID() int64 {
4547
return inf.uniqueID
4648
}
@@ -62,6 +64,9 @@ func (inf *Inferno) Thrower() *Player {
6264
func (inf *Inferno) Fires() Fires {
6365
entity := inf.Entity
6466
origin := entity.Position()
67+
// m_fireCount is the total number of fire positions ever spawned for this inferno, not the number
68+
// currently burning - it only ever increases and never returns to 0. Use Fires().Active() (gated
69+
// on m_bFireIsBurning) for the fires burning right now.
6570
nFires := entity.PropertyValueMust("m_fireCount").Int()
6671
fires := make([]Fire, 0, nFires)
6772
iFormat := "%04d"
@@ -204,11 +209,14 @@ func convexHull(pointCloud []r3.Vector) quickhull.ConvexHull {
204209

205210
// NewInferno creates a inferno and sets the Unique-ID.
206211
//
212+
// The id must come from the creating Parser's unique-id source so that it is unique within that
213+
// parse and reproducible across parses of the same demo.
214+
//
207215
// Intended for internal use only.
208-
func NewInferno(demoInfoProvider demoInfoProvider, entity st.Entity, thrower *Player) *Inferno {
216+
func NewInferno(demoInfoProvider demoInfoProvider, entity st.Entity, thrower *Player, id int64) *Inferno {
209217
return &Inferno{
210218
Entity: entity,
211-
uniqueID: rand.Int63(), //nolint:gosec
219+
uniqueID: id,
212220
demoInfoProvider: demoInfoProvider,
213221
thrower: thrower,
214222
}

‎pkg/demoinfocs/common/inferno_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
func TestInferno_UniqueID(t *testing.T) {
1515
entity := new(stfake.Entity)
16-
assert.NotEqual(t, NewInferno(nil, entity, nil).UniqueID(), NewInferno(nil, entity, nil).UniqueID(), "UniqueIDs of different infernos should be different")
16+
assert.NotEqual(t, NewInferno(nil, entity, nil, 1).UniqueID(), NewInferno(nil, entity, nil, 2).UniqueID(), "UniqueIDs of different infernos should be different")
1717
}
1818

1919
func TestFires_Active(t *testing.T) {
@@ -119,7 +119,7 @@ func TestInferno_Thrower(t *testing.T) {
119119
playersByHandle: map[uint64]*Player{1: player},
120120
}
121121

122-
assert.Equal(t, player, NewInferno(provider, entity, nil).Thrower())
122+
assert.Equal(t, player, NewInferno(provider, entity, nil, 1).Thrower())
123123
}
124124

125125
func TestInferno_List(t *testing.T) {

‎pkg/demoinfocs/common/player.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Player struct {
2020
Inventory map[int]*Equipment // All weapons / equipment the player is currently carrying. See also Weapons().
2121
EntityID int // Usually the same as Entity.ID() but may be different between player death and re-spawn.
2222
Entity st.Entity // May be nil between player-death and re-spawn
23-
FlashDuration float32 // Blindness duration from the flashbang currently affecting the player (seconds)
23+
FlashDuration float32 // Effective blindness duration from the flashbang currently affecting the player (seconds). The engine's flash-affected window (e.g. for assist attribution) runs ~1.4x longer; there is no continuous remaining-intensity netprop.
2424
FlashTick int // In-game tick at which the player was last flashed
2525
TeamState *TeamState // When keeping the reference make sure you notice when the player changes teams
2626
Team Team // Team identifier for the player (e.g. TeamTerrorists or TeamCounterTerrorists).
@@ -408,8 +408,11 @@ func (p *Player) ViewDirectionY() float32 {
408408
return 0
409409
}
410410

411-
// Velocity returns the player's velocity in game units per second.
412-
// Note: available only with demos after the AnimGraph 2 update! Otherwise this returns a zero vector.
411+
// Velocity returns the player's velocity in units per second.
412+
//
413+
// Note: on CS2 GOTV demos - both broadcast-GOTV and match-server SourceTV - the m_vecVelocity
414+
// property is not networked on player pawns and this returns a zero vector; it is populated only on
415+
// POV / match-making demos. For GOTV, derive velocity from the position delta between ticks instead.
413416
func (p *Player) Velocity() r3.Vector {
414417
pawnEntity := p.PlayerPawnEntity()
415418
if pawnEntity == nil {

0 commit comments

Comments
 (0)