Skip to content

Commit 71f5b83

Browse files
authored
Merge pull request #374 from akiver/pov
feat: improve POV demos support
2 parents 1a9e4dd + 1351a45 commit 71f5b83

20 files changed

+344
-54
lines changed

docs/game-events.md

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
## Game events
2+
3+
List of game events that may be trigerred during parsing, some events are available only in GOTV and/or POV demos.
4+
5+
You can add a listener on the parser's event `GenericGameEvent` to listen to all events, example:
6+
7+
```go
8+
parser.RegisterEventHandler(func(event events.GenericGameEvent) {
9+
fmt.Println(event.Name, event.Data)
10+
})
11+
```
12+
13+
> **Warning**
14+
> It has been noticed that some demos may not fire events when it should. A noticable one is the `round_end` event.
15+
> If you encounter this problem it's probably not a parser bug but simply a demo with missing events.
16+
> As a workaround you may subscribe to properties update.
17+
> For example to detect rounds end you could subscribe to updates of the property `m_iRoundWinStatus` of the entity `CCSGameRulesProxy`.
18+
19+
✅ = Available, ❌ = Not available, ? = Not sure, need to be tested
20+
21+
| Event name | GOTV | POV |
22+
| ------------------------------- | ---- | --- |
23+
| ammo_pickup |||
24+
| announce_phase_end |||
25+
| begin_new_match |||
26+
| bomb_beep |||
27+
| bomb_begindefuse |||
28+
| bomb_beginplant |||
29+
| bomb_defused |||
30+
| bomb_dropped |||
31+
| bomb_exploded |||
32+
| bomb_pickup |||
33+
| bomb_planted |||
34+
| bot_takeover |||
35+
| buytime_ended |||
36+
| choppers_incoming_warning | ? ||
37+
| cs_intermission |||
38+
| cs_match_end_restart |||
39+
| cs_pre_restart |||
40+
| cs_round_final_beep |||
41+
| cs_round_start_beep |||
42+
| cs_win_panel_match |||
43+
| cs_win_panel_round |||
44+
| decoy_detonate |||
45+
| decoy_started |||
46+
| endmatch_cmm_start_reveal_items |||
47+
| enter_bombzone |||
48+
| enter_buyzone |||
49+
| entity_visible |||
50+
| exit_bombzone |||
51+
| exit_buyzone |||
52+
| firstbombs_incoming_warning | ? ||
53+
| flashbang_detonate |||
54+
| hegrenade_detonate |||
55+
| hltv_chase |||
56+
| hltv_fixed || ? |
57+
| hltv_message || ? |
58+
| hltv_status |||
59+
| hostage_follows |||
60+
| hostage_hurt |||
61+
| hostage_killed |||
62+
| hostage_rescued |||
63+
| hostage_rescued_all |||
64+
| hostname_changed |||
65+
| inferno_expire |||
66+
| inferno_startburn |||
67+
| inspect_weapon |||
68+
| item_equip |||
69+
| item_pickup |||
70+
| item_pickup_slerp |||
71+
| item_remove |||
72+
| jointeam_failed |||
73+
| other_death |||
74+
| player_blind |||
75+
| player_connect |||
76+
| player_connect_full |||
77+
| player_death |||
78+
| player_disconnect |||
79+
| player_falldamage |||
80+
| player_footstep |||
81+
| player_given_c4 |||
82+
| player_hurt |||
83+
| player_jump |||
84+
| player_changename |||
85+
| player_ping |||
86+
| player_ping_stop |||
87+
| player_spawn |||
88+
| player_spawned |||
89+
| player_team |||
90+
| round_announce_final |||
91+
| round_announce_last_round_half |||
92+
| round_announce_match_point |||
93+
| round_announce_match_start |||
94+
| round_announce_warmup |||
95+
| round_end |||
96+
| round_end_upload_stats |||
97+
| round_freeze_end |||
98+
| round_mvp |||
99+
| round_poststart |||
100+
| round_prestart |||
101+
| round_officially_ended |||
102+
| round_start |||
103+
| round_time_warning |||
104+
| server_cvar |||
105+
| show_survival_respawn_status | ? ||
106+
| smokegrenade_detonate |||
107+
| smokegrenade_expired |||
108+
| survival_paradrop_spawn | ? ||
109+
| switch_team |||
110+
| tournament_reward || ? |
111+
| vote_cast |||
112+
| weapon_fire |||
113+
| weapon_fire_on_empty |||
114+
| weapon_reload |||
115+
| weapon_zoom |||
116+
| weapon_zoom_rifle |||

internal/bitread/bitread.go

+22
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
smallBuffer = 512
1717
largeBuffer = 1024 * 128
1818
maxVarInt32Bytes = 5
19+
maxVarintBytes = 10
1920
)
2021

2122
// BitReader wraps github.com/markus-wa/gobitread.BitReader and provides additional functionality specific to CS:GO demos.
@@ -67,12 +68,33 @@ func (r *BitReader) ReadVarInt32() uint32 {
6768
return res
6869
}
6970

71+
// ReadVarInt64 reads a variable size unsigned int (max 64-bit).
72+
func (r *BitReader) ReadVarInt64() uint64 {
73+
var (
74+
res uint64
75+
b uint64 = 0x80
76+
)
77+
78+
for count := uint(0); b&0x80 != 0 && count != maxVarintBytes; count++ {
79+
b = uint64(r.ReadSingleByte())
80+
res |= (b & 0x7f) << (7 * count)
81+
}
82+
83+
return res
84+
}
85+
7086
// ReadSignedVarInt32 reads a variable size signed int (max 32-bit).
7187
func (r *BitReader) ReadSignedVarInt32() int32 {
7288
res := r.ReadVarInt32()
7389
return int32((res >> 1) ^ -(res & 1))
7490
}
7591

92+
// ReadSignedVarInt64 reads a variable size signed int (max 64-bit).
93+
func (r *BitReader) ReadSignedVarInt64() int64 {
94+
res := r.ReadVarInt64()
95+
return int64((res >> 1) ^ -(res & 1))
96+
}
97+
7698
// ReadUBitInt reads some kind of variable size uint.
7799
// Honestly, not quite sure how it works.
78100
func (r *BitReader) ReadUBitInt() uint {

pkg/demoinfocs/common/common_test.go

+20
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ func TestConvertSteamID64To32(t *testing.T) {
166166
assert.Equal(t, uint32(52686539), id)
167167
}
168168

169+
type fakeProp struct {
170+
propName string
171+
value st.PropertyValue
172+
}
173+
169174
type demoInfoProviderMock struct {
170175
tickRate float64
171176
ingameTick int
@@ -221,6 +226,21 @@ func entityWithProperty(propName string, value st.PropertyValue) *stfake.Entity
221226
return entity
222227
}
223228

229+
func entityWithProperties(properties []fakeProp) *stfake.Entity {
230+
entity := entityWithID(1)
231+
232+
for _, prop := range properties {
233+
property := new(stfake.Property)
234+
property.On("Value").Return(prop.value)
235+
236+
entity.On("Property", prop.propName).Return(property)
237+
entity.On("PropertyValue", prop.propName).Return(prop.value, true)
238+
entity.On("PropertyValueMust", prop.propName).Return(prop.value)
239+
}
240+
241+
return entity
242+
}
243+
224244
func entityWithoutProperty(propName string) *stfake.Entity {
225245
entity := entityWithID(1)
226246

pkg/demoinfocs/common/player.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ func (p *Player) SteamID32() uint32 {
5151
return ConvertSteamID64To32(p.SteamID64)
5252
}
5353

54-
// IsAlive returns true if the Hp of the player are > 0.
54+
// IsAlive returns true if the player is alive.
5555
func (p *Player) IsAlive() bool {
56-
return p.Health() > 0
56+
return p.Health() > 0 || getInt(p.Entity, "m_lifeState") == 0
5757
}
5858

5959
// IsBlinded returns true if the player is currently flashed.
@@ -298,7 +298,7 @@ func (p *Player) Position() r3.Vector {
298298

299299
// PositionEyes returns the player's position with the Z value at eye height.
300300
// This is what you get from cl_showpos 1.
301-
// See lso Position().
301+
// See also Position().
302302
func (p *Player) PositionEyes() r3.Vector {
303303
if p.Entity == nil {
304304
return r3.Vector{}

pkg/demoinfocs/common/player_test.go

+16-4
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,28 @@ func TestPlayerWeapons(t *testing.T) {
4242
func TestPlayerAlive(t *testing.T) {
4343
pl := newPlayer(0)
4444

45-
pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 100})
45+
pl.Entity = entityWithProperties([]fakeProp{
46+
{propName: "m_iHealth", value: st.PropertyValue{IntVal: 100}},
47+
{propName: "m_lifeState", value: st.PropertyValue{IntVal: 0}},
48+
})
4649
assert.Equal(t, true, pl.IsAlive(), "Should be alive")
4750

48-
pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 1})
51+
pl.Entity = entityWithProperties([]fakeProp{
52+
{propName: "m_iHealth", value: st.PropertyValue{IntVal: 1}},
53+
{propName: "m_lifeState", value: st.PropertyValue{IntVal: 0}},
54+
})
4955
assert.Equal(t, true, pl.IsAlive(), "Should be alive")
5056

51-
pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 0})
57+
pl.Entity = entityWithProperties([]fakeProp{
58+
{propName: "m_iHealth", value: st.PropertyValue{IntVal: 0}},
59+
{propName: "m_lifeState", value: st.PropertyValue{IntVal: 2}},
60+
})
5261
assert.Equal(t, false, pl.IsAlive(), "Should be dead")
5362

54-
pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: -10})
63+
pl.Entity = entityWithProperties([]fakeProp{
64+
{propName: "m_iHealth", value: st.PropertyValue{IntVal: -10}},
65+
{propName: "m_lifeState", value: st.PropertyValue{IntVal: 2}},
66+
})
5567
assert.Equal(t, false, pl.IsAlive(), "Should be dead")
5668
}
5769

pkg/demoinfocs/demoinfocs_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ func assertGolden(tb testing.TB, assertions *assert.Assertions, testCase string,
607607
}
608608

609609
func removePointers(s []byte) []byte {
610-
r := regexp.MustCompile(`\(0x[\da-f]{10}\)`)
610+
r := regexp.MustCompile(`\(0x[\da-f]+\)`)
611611

612612
return r.ReplaceAll(s, []byte("(non-nil)"))
613613
}

pkg/demoinfocs/events/events.go

+7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ import (
1818
// A frame can contain multiple ticks (usually 2 or 4) if the tv_snapshotrate differs from the tick-rate the game was played at.
1919
type FrameDone struct{}
2020

21+
// POVRecordingPlayerDetected signals that a player started recording the demo locally.
22+
// If this event is dispatched, it means it's a client-side (POV) demo.
23+
type POVRecordingPlayerDetected struct {
24+
PlayerSlot int
25+
PlayerInfo common.PlayerInfo
26+
}
27+
2128
// MatchStart signals that the match has started.
2229
type MatchStart struct{}
2330

pkg/demoinfocs/game_events.go

+7
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
124124
"bomb_planted": delayIfNoPlayers(geh.bombPlanted), // Plant finished
125125
"bot_takeover": delay(geh.botTakeover), // Bot got taken over
126126
"buytime_ended": nil, // Not actually end of buy time, seems to only be sent once per game at the start
127+
"choppers_incoming_warning": nil, // Helicopters are coming (Danger zone mode)
127128
"cs_intermission": nil, // Dunno, only in locally recorded (POV) demo
128129
"cs_match_end_restart": nil, // Yawn
129130
"cs_pre_restart": nil, // Not sure, doesn't seem to be important
@@ -140,6 +141,7 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
140141
"enter_buyzone": nil, // Dunno, only in locally recorded (POV) demo
141142
"exit_buyzone": nil, // Dunno, only in locally recorded (POV) demo
142143
"flashbang_detonate": geh.flashBangDetonate, // Flash exploded
144+
"firstbombs_incoming_warning": nil, // First wave artillery incoming (Danger zone mode)
143145
"hegrenade_detonate": geh.heGrenadeDetonate, // HE exploded
144146
"hostage_killed": geh.hostageKilled, // Hostage killed
145147
"hostage_hurt": geh.hostageHurt, // Hostage hurt
@@ -172,6 +174,8 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
172174
"player_spawn": nil, // Player spawn
173175
"player_spawned": nil, // Only present in locally recorded (POV) demos
174176
"player_given_c4": nil, // Dunno, only present in locally recorded (POV) demos
177+
"player_ping": nil, // When a player uses the "ping system" added with the operation Broken Fang, only present in locally recorded (POV) demos
178+
"player_ping_stop": nil, // When a player's ping expired, only present in locally recorded (POV) demos
175179

176180
// Player changed team. Delayed for two reasons
177181
// - team IDs of other players changing teams in the same tick might not have changed yet
@@ -192,10 +196,13 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
192196
"round_start": geh.roundStart, // Round started
193197
"round_time_warning": nil, // Round time warning
194198
"server_cvar": nil, // Dunno
199+
"show_survival_respawn_status": nil, // Dunno, (Danger zone mode)
200+
"survival_paradrop_spawn": nil, // A paradrop is coming (Danger zone mode)
195201
"smokegrenade_detonate": geh.smokeGrenadeDetonate, // Smoke popped
196202
"smokegrenade_expired": geh.smokeGrenadeExpired, // Smoke expired
197203
"switch_team": nil, // Dunno, only present in POV demos
198204
"tournament_reward": nil, // Dunno
205+
"vote_cast": nil, // Dunno, only present in POV demos
199206
"weapon_fire": delayIfNoPlayers(geh.weaponFire), // Weapon was fired
200207
"weapon_fire_on_empty": nil, // Sounds boring
201208
"weapon_reload": geh.weaponReload, // Weapon reloaded

pkg/demoinfocs/net_messages.go

+21-24
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package demoinfocs
33
import (
44
"bytes"
55
"encoding/binary"
6+
"fmt"
67

78
"github.com/markus-wa/ice-cipher-go/pkg/ice"
89
"google.golang.org/protobuf/proto"
@@ -19,34 +20,30 @@ func (p *parser) handlePacketEntities(pe *msg.CSVCMsg_PacketEntities) {
1920

2021
r := bit.NewSmallBitReader(bytes.NewReader(pe.EntityData))
2122

22-
currentEntity := -1
23-
for i := 0; i < int(pe.GetUpdatedEntries()); i++ {
24-
currentEntity += 1 + int(r.ReadUBitInt())
25-
26-
cmd := r.ReadBitsToByte(2)
27-
if cmd&1 == 0 {
28-
if cmd&2 != 0 {
29-
// Enter PVS
30-
if existing := p.gameState.entities[currentEntity]; existing != nil {
31-
// Sometimes entities don't get destroyed when they should be
32-
// For instance when a player is replaced by a BOT
33-
existing.Destroy()
34-
}
23+
entityIndex := -1
3524

36-
p.gameState.entities[currentEntity] = p.stParser.ReadEnterPVS(r, currentEntity)
37-
} else {
38-
// Delta Update
39-
if entity := p.gameState.entities[currentEntity]; entity != nil {
40-
entity.ApplyUpdate(r)
25+
for i := 0; i < int(pe.GetUpdatedEntries()); i++ {
26+
entityIndex += 1 + int(r.ReadUBitInt())
27+
28+
//nolint:nestif
29+
if r.ReadBit() {
30+
// FHDR_LEAVEPVS => LeavePVS
31+
if r.ReadBit() {
32+
// FHDR_LEAVEPVS | FHDR_DELETE => LeavePVS with force delete. Should never happen on full update
33+
if existingEntity := p.gameState.entities[entityIndex]; existingEntity != nil {
34+
existingEntity.Destroy()
35+
delete(p.gameState.entities, entityIndex)
4136
}
4237
}
38+
} else if r.ReadBit() {
39+
// FHDR_ENTERPVS => EnterPVS
40+
p.gameState.entities[entityIndex] = p.stParser.ReadEnterPVS(r, entityIndex, p.gameState.entities, p.recordingPlayerSlot)
4341
} else {
44-
if cmd&2 != 0 {
45-
// Leave PVS
46-
if entity := p.gameState.entities[currentEntity]; entity != nil {
47-
entity.Destroy()
48-
delete(p.gameState.entities, currentEntity)
49-
}
42+
// Delta update
43+
if p.gameState.entities[entityIndex] != nil {
44+
p.gameState.entities[entityIndex].ApplyUpdate(r)
45+
} else {
46+
panic(fmt.Sprintf("Entity with index %d doesn't exist but got an update", entityIndex))
5047
}
5148
}
5249
}

pkg/demoinfocs/parser.go

+6
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ type parser struct {
6464
err error // Contains a error that occurred during parsing if any
6565
errLock sync.Mutex // Used to sync up error mutations between parsing & handling go-routines
6666
decryptionKey []byte // Stored in `match730_*.dem.info` see MatchInfoDecryptionKey().
67+
/**
68+
* Set to the client slot of the recording player.
69+
* Always -1 for GOTV demos.
70+
*/
71+
recordingPlayerSlot int
6772

6873
// Additional fields, mainly caching & tracking things
6974

@@ -350,6 +355,7 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
350355
p.bombsiteA.index = -1
351356
p.bombsiteB.index = -1
352357
p.decryptionKey = config.NetMessageDecryptionKey
358+
p.recordingPlayerSlot = -1
353359

354360
dispatcherCfg := dp.Config{
355361
PanicHandler: func(v any) {

0 commit comments

Comments
 (0)