Repository: markus-wa/demoinfocs-golang
Pull request #374 (2023-01-22 12:03:37)

feat: improve POV demos support
Hi!

This PR improves POV demos support.
The main problem was entities being fully re-created instead of updated when they left and re-entered PVS.
It was noticeable with player entities since they enter/leave PVS frequently with client demos.

Changes:
- Re-use existing entities when they enter PVS if possible based on their serial number
- Add int64 props decoder (got some with Danger mode demos)
- Use the proper prop in `player.Position()` (`cslocaldata.m_vecOrigin` vs `csnonlocaldata.m_vecOrigin`)
- Add a `parser.IsPOV` function
- Make `player.isAlive()` more accurate by checking the value of the prop `m_lifeState`
- Add more known game events
- Add a doc about game events

Running the following sample now outputs:
```
Player entity created GOTV 0
Player entity created TaRiK 3
Player entity created SiBaby 3
Player entity created stew 3
Player entity created valens 3
Player entity created unknown 3
Player entity created Metin 3
Player entity created sharof0444 3
Player entity created 1foxey 3
Player entity created sherpss 3
Player entity created cook 3
Player entity created Dan 0
Player entity created Seth 0
Player entity created Xander 0
```

Before this PR it would spam with entity creation.

```go
package main

import (
	"fmt"
	"os"

	demoinfocs "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs"
	events "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/events"
	st "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables"
)

func main() {
	f, err := os.Open("../../test/cs-demos/set/POV-dust2-faceit-game_6-30.dem")
	checkError(err)

	defer f.Close()

	p := demoinfocs.NewParser(f)
	defer p.Close()

	header, err := p.ParseHeader()
	checkError(err)
	fmt.Println("Map:", header.MapName)

	p.RegisterEventHandler(func(events.DataTablesParsed) {
		p.ServerClasses().FindByName("CCSPlayer").OnEntityCreated(func(ent st.Entity) {
			player := p.GameState().Participants().FindByHandle(ent.ID())
			fmt.Println("Player entity created", player.Name, player.Team)
		})
	})

	err = p.ParseToEnd()
	checkError(err)
}

func checkError(err error) {
	if err != nil {
		panic(err)
	}
}
```

Thank you


Patch:
diff --git a/docs/game-events.md b/docs/game-events.md
new file mode 100644
index 0000000..f52423d
--- /dev/null
+++ b/docs/game-events.md
@@ -0,0 +1,116 @@
+## Game events
+
+List of game events that may be trigerred during parsing, some events are available only in GOTV and/or POV demos.
+
+You can add a listener on the parser's event `GenericGameEvent` to listen to all events, example:
+
+```go
+parser.RegisterEventHandler(func(event events.GenericGameEvent) {
+    fmt.Println(event.Name, event.Data)
+})
+```
+
+> **Warning**
+> It has been noticed that some demos may not fire events when it should. A noticable one is the `round_end` event.
+> If you encounter this problem it's probably not a parser bug but simply a demo with missing events.
+> As a workaround you may subscribe to properties update.
+> For example to detect rounds end you could subscribe to updates of the property `m_iRoundWinStatus` of the entity `CCSGameRulesProxy`.
+
+✅ = Available, ❌ = Not available, ? = Not sure, need to be tested
+
+| Event name                      | GOTV | POV |
+| ------------------------------- | ---- | --- |
+| ammo_pickup                     | ❌   | ✅  |
+| announce_phase_end              | ✅   | ✅  |
+| begin_new_match                 | ✅   | ✅  |
+| bomb_beep                       | ✅   | ❌  |
+| bomb_begindefuse                | ✅   | ❌  |
+| bomb_beginplant                 | ✅   | ❌  |
+| bomb_defused                    | ✅   | ✅  |
+| bomb_dropped                    | ✅   | ✅  |
+| bomb_exploded                   | ✅   | ✅  |
+| bomb_pickup                     | ✅   | ✅  |
+| bomb_planted                    | ✅   | ✅  |
+| bot_takeover                    | ✅   | ✅  |
+| buytime_ended                   | ✅   | ❌  |
+| choppers_incoming_warning       | ?    | ✅  |
+| cs_intermission                 | ❌   | ✅  |
+| cs_match_end_restart            | ✅   | ✅  |
+| cs_pre_restart                  | ✅   | ✅  |
+| cs_round_final_beep             | ✅   | ✅  |
+| cs_round_start_beep             | ✅   | ✅  |
+| cs_win_panel_match              | ✅   | ✅  |
+| cs_win_panel_round              | ✅   | ✅  |
+| decoy_detonate                  | ✅   | ✅  |
+| decoy_started                   | ✅   | ✅  |
+| endmatch_cmm_start_reveal_items | ✅   | ❌  |
+| enter_bombzone                  | ❌   | ✅  |
+| enter_buyzone                   | ❌   | ✅  |
+| entity_visible                  | ❌   | ✅  |
+| exit_bombzone                   | ❌   | ✅  |
+| exit_buyzone                    | ❌   | ✅  |
+| firstbombs_incoming_warning     | ?    | ✅  |
+| flashbang_detonate              | ✅   | ✅  |
+| hegrenade_detonate              | ✅   | ✅  |
+| hltv_chase                      | ✅   | ❌  |
+| hltv_fixed                      | ✅   | ?   |
+| hltv_message                    | ✅   | ?   |
+| hltv_status                     | ❌   | ✅  |
+| hostage_follows                 | ❌   | ✅  |
+| hostage_hurt                    | ✅   | ✅  |
+| hostage_killed                  | ✅   | ✅  |
+| hostage_rescued                 | ✅   | ✅  |
+| hostage_rescued_all             | ✅   | ✅  |
+| hostname_changed                | ❌   | ✅  |
+| inferno_expire                  | ✅   | ✅  |
+| inferno_startburn               | ✅   | ✅  |
+| inspect_weapon                  | ❌   | ✅  |
+| item_equip                      | ✅   | ❌  |
+| item_pickup                     | ✅   | ❌  |
+| item_pickup_slerp               | ❌   | ✅  |
+| item_remove                     | ✅   | ❌  |
+| jointeam_failed                 | ❌   | ✅  |
+| other_death                     | ✅   | ✅  |
+| player_blind                    | ✅   | ❌  |
+| player_connect                  | ✅   | ✅  |
+| player_connect_full             | ✅   | ✅  |
+| player_death                    | ✅   | ✅  |
+| player_disconnect               | ✅   | ✅  |
+| player_falldamage               | ✅   | ❌  |
+| player_footstep                 | ✅   | ❌  |
+| player_given_c4                 | ❌   | ✅  |
+| player_hurt                     | ✅   | ✅  |
+| player_jump                     | ✅   | ❌  |
+| player_changename               | ✅   | ✅  |
+| player_ping                     | ❌   | ✅  |
+| player_ping_stop                | ❌   | ✅  |
+| player_spawn                    | ✅   | ✅  |
+| player_spawned                  | ❌   | ✅  |
+| player_team                     | ✅   | ✅  |
+| round_announce_final            | ✅   | ✅  |
+| round_announce_last_round_half  | ✅   | ✅  |
+| round_announce_match_point      | ✅   | ✅  |
+| round_announce_match_start      | ✅   | ✅  |
+| round_announce_warmup           | ✅   | ✅  |
+| round_end                       | ✅   | ✅  |
+| round_end_upload_stats          | ❌   | ✅  |
+| round_freeze_end                | ✅   | ✅  |
+| round_mvp                       | ✅   | ✅  |
+| round_poststart                 | ✅   | ❌  |
+| round_prestart                  | ✅   | ❌  |
+| round_officially_ended          | ✅   | ✅  |
+| round_start                     | ✅   | ✅  |
+| round_time_warning              | ✅   | ✅  |
+| server_cvar                     | ✅   | ✅  |
+| show_survival_respawn_status    | ?    | ✅  |
+| smokegrenade_detonate           | ✅   | ✅  |
+| smokegrenade_expired            | ✅   | ✅  |
+| survival_paradrop_spawn         | ?    | ✅  |
+| switch_team                     | ❌   | ✅  |
+| tournament_reward               | ✅   | ?   |
+| vote_cast                       | ❌   | ✅  |
+| weapon_fire                     | ✅   | ✅  |
+| weapon_fire_on_empty            | ✅   | ❌  |
+| weapon_reload                   | ✅   | ❌  |
+| weapon_zoom                     | ✅   | ❌  |
+| weapon_zoom_rifle               | ❌   | ✅  |
diff --git a/internal/bitread/bitread.go b/internal/bitread/bitread.go
index 0544e78..df10814 100644
--- a/internal/bitread/bitread.go
+++ b/internal/bitread/bitread.go
@@ -16,6 +16,7 @@ const (
 	smallBuffer      = 512
 	largeBuffer      = 1024 * 128
 	maxVarInt32Bytes = 5
+	maxVarintBytes   = 10
 )
 
 // 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 {
 	return res
 }
 
+// ReadVarInt64 reads a variable size unsigned int (max 64-bit).
+func (r *BitReader) ReadVarInt64() uint64 {
+	var (
+		res uint64
+		b   uint64 = 0x80
+	)
+
+	for count := uint(0); b&0x80 != 0 && count != maxVarintBytes; count++ {
+		b = uint64(r.ReadSingleByte())
+		res |= (b & 0x7f) << (7 * count)
+	}
+
+	return res
+}
+
 // ReadSignedVarInt32 reads a variable size signed int (max 32-bit).
 func (r *BitReader) ReadSignedVarInt32() int32 {
 	res := r.ReadVarInt32()
 	return int32((res >> 1) ^ -(res & 1))
 }
 
+// ReadSignedVarInt64 reads a variable size signed int (max 64-bit).
+func (r *BitReader) ReadSignedVarInt64() int64 {
+	res := r.ReadVarInt64()
+	return int64((res >> 1) ^ -(res & 1))
+}
+
 // ReadUBitInt reads some kind of variable size uint.
 // Honestly, not quite sure how it works.
 func (r *BitReader) ReadUBitInt() uint {
diff --git a/pkg/demoinfocs/common/player.go b/pkg/demoinfocs/common/player.go
index a0b1e16..8241348 100644
--- a/pkg/demoinfocs/common/player.go
+++ b/pkg/demoinfocs/common/player.go
@@ -51,9 +51,9 @@ func (p *Player) SteamID32() uint32 {
 	return ConvertSteamID64To32(p.SteamID64)
 }
 
-// IsAlive returns true if the Hp of the player are > 0.
+// IsAlive returns true if the player is alive.
 func (p *Player) IsAlive() bool {
-	return p.Health() > 0
+	return p.Health() > 0 || getInt(p.Entity, "m_lifeState") == 0
 }
 
 // IsBlinded returns true if the player is currently flashed.
@@ -298,7 +298,7 @@ func (p *Player) Position() r3.Vector {
 
 // PositionEyes returns the player's position with the Z value at eye height.
 // This is what you get from cl_showpos 1.
-// See lso Position().
+// See also Position().
 func (p *Player) PositionEyes() r3.Vector {
 	if p.Entity == nil {
 		return r3.Vector{}
diff --git a/pkg/demoinfocs/events/events.go b/pkg/demoinfocs/events/events.go
index 2a0cf77..dc5f5b1 100644
--- a/pkg/demoinfocs/events/events.go
+++ b/pkg/demoinfocs/events/events.go
@@ -18,6 +18,13 @@ import (
 // A frame can contain multiple ticks (usually 2 or 4) if the tv_snapshotrate differs from the tick-rate the game was played at.
 type FrameDone struct{}
 
+// POVRecordingPlayerDetected signals that a player started recording the demo locally.
+// If this event is dispatched, it means it's a client-side (POV) demo.
+type POVRecordingPlayerDetected struct {
+	PlayerSlot int
+	PlayerInfo common.PlayerInfo
+}
+
 // MatchStart signals that the match has started.
 type MatchStart struct{}
 
diff --git a/pkg/demoinfocs/game_events.go b/pkg/demoinfocs/game_events.go
index c66041c..0d42881 100644
--- a/pkg/demoinfocs/game_events.go
+++ b/pkg/demoinfocs/game_events.go
@@ -124,6 +124,7 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
 		"bomb_planted":                    delayIfNoPlayers(geh.bombPlanted),     // Plant finished
 		"bot_takeover":                    delay(geh.botTakeover),                // Bot got taken over
 		"buytime_ended":                   nil,                                   // Not actually end of buy time, seems to only be sent once per game at the start
+		"choppers_incoming_warning":       nil,                                   // Helicopters are coming (Danger zone mode)
 		"cs_intermission":                 nil,                                   // Dunno, only in locally recorded (POV) demo
 		"cs_match_end_restart":            nil,                                   // Yawn
 		"cs_pre_restart":                  nil,                                   // Not sure, doesn't seem to be important
@@ -140,6 +141,7 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
 		"enter_buyzone":                   nil,                                   // Dunno, only in locally recorded (POV) demo
 		"exit_buyzone":                    nil,                                   // Dunno, only in locally recorded (POV) demo
 		"flashbang_detonate":              geh.flashBangDetonate,                 // Flash exploded
+		"firstbombs_incoming_warning":     nil,                                   // First wave artillery incoming (Danger zone mode)
 		"hegrenade_detonate":              geh.heGrenadeDetonate,                 // HE exploded
 		"hostage_killed":                  geh.hostageKilled,                     // Hostage killed
 		"hostage_hurt":                    geh.hostageHurt,                       // Hostage hurt
@@ -172,6 +174,8 @@ func newGameEventHandler(parser *parser, ignoreBombsiteIndexNotFound bool) gameE
 		"player_spawn":                    nil,                                   // Player spawn
 		"player_spawned":                  nil,                                   // Only present in locally recorded (POV) demos
 		"player_given_c4":                 nil,                                   // Dunno, only present in locally recorded (POV) demos
+		"player_ping":                     nil,                                   // When a player uses the "ping system" added with the operation Broken Fang, only present in locally recorded (POV) demos
+		"player_ping_stop":                nil,                                   // When a player's ping expired, only present in locally recorded (POV) demos
 
 		// Player changed team. Delayed for two reasons
 		// - 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
 		"round_start":                    geh.roundStart,                   // Round started
 		"round_time_warning":             nil,                              // Round time warning
 		"server_cvar":                    nil,                              // Dunno
+		"show_survival_respawn_status":   nil,                              // Dunno, (Danger zone mode)
+		"survival_paradrop_spawn":        nil,                              // A paradrop is coming (Danger zone mode)
 		"smokegrenade_detonate":          geh.smokeGrenadeDetonate,         // Smoke popped
 		"smokegrenade_expired":           geh.smokeGrenadeExpired,          // Smoke expired
 		"switch_team":                    nil,                              // Dunno, only present in POV demos
 		"tournament_reward":              nil,                              // Dunno
+		"vote_cast":                      nil,                              // Dunno, only present in POV demos
 		"weapon_fire":                    delayIfNoPlayers(geh.weaponFire), // Weapon was fired
 		"weapon_fire_on_empty":           nil,                              // Sounds boring
 		"weapon_reload":                  geh.weaponReload,                 // Weapon reloaded
diff --git a/pkg/demoinfocs/net_messages.go b/pkg/demoinfocs/net_messages.go
index 7ba763a..b112798 100644
--- a/pkg/demoinfocs/net_messages.go
+++ b/pkg/demoinfocs/net_messages.go
@@ -3,6 +3,7 @@ package demoinfocs
 import (
 	"bytes"
 	"encoding/binary"
+	"fmt"
 
 	"github.com/markus-wa/ice-cipher-go/pkg/ice"
 	"google.golang.org/protobuf/proto"
@@ -19,34 +20,30 @@ func (p *parser) handlePacketEntities(pe *msg.CSVCMsg_PacketEntities) {
 
 	r := bit.NewSmallBitReader(bytes.NewReader(pe.EntityData))
 
-	currentEntity := -1
-	for i := 0; i < int(pe.GetUpdatedEntries()); i++ {
-		currentEntity += 1 + int(r.ReadUBitInt())
-
-		cmd := r.ReadBitsToByte(2)
-		if cmd&1 == 0 {
-			if cmd&2 != 0 {
-				// Enter PVS
-				if existing := p.gameState.entities[currentEntity]; existing != nil {
-					// Sometimes entities don't get destroyed when they should be
-					// For instance when a player is replaced by a BOT
-					existing.Destroy()
-				}
+	entityIndex := -1
 
-				p.gameState.entities[currentEntity] = p.stParser.ReadEnterPVS(r, currentEntity)
-			} else {
-				// Delta Update
-				if entity := p.gameState.entities[currentEntity]; entity != nil {
-					entity.ApplyUpdate(r)
+	for i := 0; i < int(pe.GetUpdatedEntries()); i++ {
+		entityIndex += 1 + int(r.ReadUBitInt())
+
+		//nolint:nestif
+		if r.ReadBit() {
+			// FHDR_LEAVEPVS => LeavePVS
+			if r.ReadBit() {
+				// FHDR_LEAVEPVS | FHDR_DELETE => LeavePVS with force delete. Should never happen on full update
+				if existingEntity := p.gameState.entities[entityIndex]; existingEntity != nil {
+					existingEntity.Destroy()
+					delete(p.gameState.entities, entityIndex)
 				}
 			}
+		} else if r.ReadBit() {
+			// FHDR_ENTERPVS => EnterPVS
+			p.gameState.entities[entityIndex] = p.stParser.ReadEnterPVS(r, entityIndex, p.gameState.entities, p.recordingPlayerSlot)
 		} else {
-			if cmd&2 != 0 {
-				// Leave PVS
-				if entity := p.gameState.entities[currentEntity]; entity != nil {
-					entity.Destroy()
-					delete(p.gameState.entities, currentEntity)
-				}
+			// Delta update
+			if p.gameState.entities[entityIndex] != nil {
+				p.gameState.entities[entityIndex].ApplyUpdate(r)
+			} else {
+				panic(fmt.Sprintf("Entity with index %d doesn't exist but got an update", entityIndex))
 			}
 		}
 	}
diff --git a/pkg/demoinfocs/parser.go b/pkg/demoinfocs/parser.go
index c627943..df64b3a 100644
--- a/pkg/demoinfocs/parser.go
+++ b/pkg/demoinfocs/parser.go
@@ -64,6 +64,11 @@ type parser struct {
 	err                          error            // Contains a error that occurred during parsing if any
 	errLock                      sync.Mutex       // Used to sync up error mutations between parsing & handling go-routines
 	decryptionKey                []byte           // Stored in `match730_*.dem.info` see MatchInfoDecryptionKey().
+	/**
+	 * Set to the client slot of the recording player.
+	 * Always -1 for GOTV demos.
+	 */
+	recordingPlayerSlot int
 
 	// Additional fields, mainly caching & tracking things
 
@@ -350,6 +355,7 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
 	p.bombsiteA.index = -1
 	p.bombsiteB.index = -1
 	p.decryptionKey = config.NetMessageDecryptionKey
+	p.recordingPlayerSlot = -1
 
 	dispatcherCfg := dp.Config{
 		PanicHandler: func(v any) {
diff --git a/pkg/demoinfocs/sendtables/entity.go b/pkg/demoinfocs/sendtables/entity.go
index 76cf622..139b9a8 100644
--- a/pkg/demoinfocs/sendtables/entity.go
+++ b/pkg/demoinfocs/sendtables/entity.go
@@ -15,11 +15,14 @@ import (
 type entity struct {
 	serverClass *ServerClass
 	id          int
+	serialNum   int
 	props       []property
 
-	onCreateFinished []func()
-	onDestroy        []func()
-	position         func() r3.Vector
+	onCreateFinished   []func()
+	onDestroy          []func()
+	position           func() r3.Vector
+	positionPropNameXY string
+	positionPropNameZ  string
 }
 
 // ServerClass returns the entity's server-class.
@@ -32,6 +35,11 @@ func (e *entity) ID() int {
 	return e.id
 }
 
+// SerialNum returns the entity's serial number.
+func (e *entity) SerialNum() int {
+	return e.serialNum
+}
+
 // Properties returns all properties of the entity.
 func (e *entity) Properties() (out []Property) {
 	for i := range e.props {
@@ -189,13 +197,15 @@ func (e *entity) applyBaseline(baseline map[int]PropertyValue) {
 const (
 	maxCoordInt = 16384
 
-	propCellBits          = "m_cellbits"
-	propCellX             = "m_cellX"
-	propCellY             = "m_cellY"
-	propCellZ             = "m_cellZ"
-	propVecOrigin         = "m_vecOrigin"
-	propVecOriginPlayerXY = "cslocaldata.m_vecOrigin"
-	propVecOriginPlayerZ  = "cslocaldata.m_vecOrigin[2]"
+	propCellBits                  = "m_cellbits"
+	propCellX                     = "m_cellX"
+	propCellY                     = "m_cellY"
+	propCellZ                     = "m_cellZ"
+	propVecOrigin                 = "m_vecOrigin"
+	propVecOriginPlayerXY         = "cslocaldata.m_vecOrigin"
+	propVecOriginPlayerZ          = "cslocaldata.m_vecOrigin[2]"
+	nonLocalPropVecOriginPlayerXY = "csnonlocaldata.m_vecOrigin"
+	nonLocalPropVecOriginPlayerZ  = "csnonlocaldata.m_vecOrigin[2]"
 
 	serverClassPlayer = "CCSPlayer"
 )
@@ -203,11 +213,22 @@ const (
 // Sets up the entity.Position() function
 // Necessary because Property() is fairly slow
 // This way we only need to find the necessary properties once
-func (e *entity) initialize() {
+func (e *entity) initialize(recordingPlayerSlot int) {
 	// Player positions are calculated differently
 	if e.isPlayer() {
-		xyProp := e.Property(propVecOriginPlayerXY)
-		zProp := e.Property(propVecOriginPlayerZ)
+		isGOTV := recordingPlayerSlot == -1
+		isRecording := recordingPlayerSlot == e.id-1
+
+		if isGOTV || isRecording {
+			e.positionPropNameXY = propVecOriginPlayerXY
+			e.positionPropNameZ = propVecOriginPlayerZ
+		} else {
+			e.positionPropNameXY = nonLocalPropVecOriginPlayerXY
+			e.positionPropNameZ = nonLocalPropVecOriginPlayerZ
+		}
+
+		xyProp := e.Property(e.positionPropNameXY)
+		zProp := e.Property(e.positionPropNameZ)
 
 		e.position = func() r3.Vector {
 			xy := xyProp.Value().VectorVal
@@ -266,8 +287,8 @@ func (e *entity) OnPositionUpdate(h func(pos r3.Vector)) {
 	}
 
 	if e.isPlayer() {
-		e.Property(propVecOriginPlayerXY).OnUpdate(firePosUpdate)
-		e.Property(propVecOriginPlayerZ).OnUpdate(firePosUpdate)
+		e.Property(e.positionPropNameXY).OnUpdate(firePosUpdate)
+		e.Property(e.positionPropNameZ).OnUpdate(firePosUpdate)
 	} else {
 		e.Property(propCellX).OnUpdate(firePosUpdate)
 		e.Property(propCellY).OnUpdate(firePosUpdate)
@@ -366,6 +387,7 @@ func (pe *property) firePropertyUpdate() {
 Bind binds a property's value to a pointer.
 
 Example:
+
 	var i int
 	property.Bind(&i, ValTypeInt)
 
diff --git a/pkg/demoinfocs/sendtables/entity_interface.go b/pkg/demoinfocs/sendtables/entity_interface.go
index d5d5ed3..c5176f4 100644
--- a/pkg/demoinfocs/sendtables/entity_interface.go
+++ b/pkg/demoinfocs/sendtables/entity_interface.go
@@ -14,6 +14,8 @@ type Entity interface {
 	ServerClass() *ServerClass
 	// ID returns the entity's ID.
 	ID() int
+	// SerialNum returns the entity's serial number.
+	SerialNum() int
 	// Properties returns all properties of the entity.
 	Properties() (out []Property)
 	// Property finds a property on the entity by name.
diff --git a/pkg/demoinfocs/sendtables/fake/entity.go b/pkg/demoinfocs/sendtables/fake/entity.go
index c34a239..fc2ea12 100644
--- a/pkg/demoinfocs/sendtables/fake/entity.go
+++ b/pkg/demoinfocs/sendtables/fake/entity.go
@@ -39,6 +39,11 @@ func (e *Entity) ID() int {
 	return e.Called().Int(0)
 }
 
+// SerialNum is a mock-implementation of Entity.SerialNum().
+func (e *Entity) SerialNum() int {
+	return e.Called().Int(0)
+}
+
 // Properties is a mock-implementation of Entity.Properties().
 func (e *Entity) Properties() []st.Property {
 	return e.Called().Get(0).([]st.Property)
diff --git a/pkg/demoinfocs/sendtables/propdecoder.go b/pkg/demoinfocs/sendtables/propdecoder.go
index 7ee8b59..c17e411 100644
--- a/pkg/demoinfocs/sendtables/propdecoder.go
+++ b/pkg/demoinfocs/sendtables/propdecoder.go
@@ -91,6 +91,7 @@ var propDecoder propertyDecoder
 type PropertyValue struct {
 	VectorVal r3.Vector
 	IntVal    int
+	Int64Val  int64
 	ArrayVal  []PropertyValue
 	StringVal string
 	FloatVal  float32
@@ -123,6 +124,9 @@ func (propertyDecoder) decodeProp(prop *property, reader *bit.BitReader) {
 	case propTypeString:
 		prop.value.StringVal = propDecoder.decodeString(reader)
 
+	case propTypeInt64:
+		prop.value.Int64Val = propDecoder.decodeInt64(prop.entry.prop, reader)
+
 	default:
 		panic(fmt.Sprintf("Unknown prop type %d", prop.entry.prop.rawType))
 	}
@@ -144,6 +148,39 @@ func (propertyDecoder) decodeInt(prop *sendTableProperty, reader *bit.BitReader)
 	return reader.ReadSignedInt(prop.numberOfBits)
 }
 
+func (propertyDecoder) decodeInt64(prop *sendTableProperty, reader *bit.BitReader) int64 {
+	if prop.flags.hasFlagSet(propFlagVarInt) {
+		if prop.flags.hasFlagSet(propFlagUnsigned) {
+			return int64(reader.ReadVarInt64())
+		}
+
+		return reader.ReadSignedVarInt64()
+	}
+
+	var (
+		high       uint
+		low        uint
+		isNegative = false
+	)
+
+	if prop.flags.hasFlagSet(propFlagUnsigned) {
+		low = reader.ReadInt(32)
+		high = reader.ReadInt(prop.numberOfBits - 32)
+	} else {
+		isNegative = reader.ReadBit()
+		low = reader.ReadInt(32)
+		high = reader.ReadInt(prop.numberOfBits - 32 - 1)
+	}
+
+	var result = (int64(high) << 32) | int64(low)
+
+	if isNegative {
+		result = -result
+	}
+
+	return result
+}
+
 func (propertyDecoder) decodeFloat(prop *sendTableProperty, reader *bit.BitReader) float32 {
 	if prop.flags&specialFloatFlags != 0 {
 		return propDecoder.decodeSpecialFloat(prop, reader)
diff --git a/pkg/demoinfocs/sendtables/property_interface.go b/pkg/demoinfocs/sendtables/property_interface.go
index 196b81f..169dc40 100644
--- a/pkg/demoinfocs/sendtables/property_interface.go
+++ b/pkg/demoinfocs/sendtables/property_interface.go
@@ -22,6 +22,7 @@ type Property interface {
 	   Bind binds a property's value to a pointer.
 
 	   Example:
+
 	   	var i int
 	   	property.Bind(&i, ValTypeInt)
 
diff --git a/pkg/demoinfocs/sendtables/sendtables.go b/pkg/demoinfocs/sendtables/sendtables.go
index 4224624..a471d7e 100644
--- a/pkg/demoinfocs/sendtables/sendtables.go
+++ b/pkg/demoinfocs/sendtables/sendtables.go
@@ -120,16 +120,16 @@ func (sc *ServerClass) PropertyEntryDefinitions() []PropertyEntry {
 	return res
 }
 
-func (sc *ServerClass) newEntity(entityDataReader *bit.BitReader, entityID int) *entity {
+func (sc *ServerClass) newEntity(entityDataReader *bit.BitReader, entityID int, serialNum int, recordingPlayerSlot int) *entity {
 	props := make([]property, len(sc.flattenedProps))
 
 	for i := range sc.flattenedProps {
 		props[i] = property{entry: &sc.flattenedProps[i]}
 	}
 
-	entity := &entity{serverClass: sc, id: entityID, props: props}
+	entity := &entity{serverClass: sc, id: entityID, serialNum: serialNum, props: props}
 
-	entity.initialize()
+	entity.initialize(recordingPlayerSlot)
 
 	if sc.preprocessedBaseline != nil {
 		entity.applyBaseline(sc.preprocessedBaseline)
diff --git a/pkg/demoinfocs/sendtables/st_parser.go b/pkg/demoinfocs/sendtables/st_parser.go
index 8a12989..44235ae 100644
--- a/pkg/demoinfocs/sendtables/st_parser.go
+++ b/pkg/demoinfocs/sendtables/st_parser.go
@@ -306,12 +306,23 @@ func (p *SendTableParser) SetInstanceBaseline(scID int, data []byte) {
 // ReadEnterPVS reads an entity entering the PVS (potentially visible system).
 //
 // Intended for internal use only.
-func (p *SendTableParser) ReadEnterPVS(r *bit.BitReader, entityID int) Entity {
-	scID := int(r.ReadInt(p.classBits()))
+func (p *SendTableParser) ReadEnterPVS(r *bit.BitReader, entityID int, existingEntities map[int]Entity, recordingPlayerSlot int) Entity {
+	classID := int(r.ReadInt(p.classBits()))
+	serialNum := int(r.ReadInt(constants.EntityHandleSerialNumberBits))
+	existingEntity := existingEntities[entityID]
+
+	if existingEntity != nil && existingEntity.SerialNum() == serialNum {
+		existingEntity.ApplyUpdate(r)
+		return existingEntity
+	}
 
-	r.Skip(constants.EntityHandleSerialNumberBits) // Serial Number
+	// Serial numbers are different, delete the entity
+	if existingEntity != nil {
+		existingEntity.Destroy()
+		delete(existingEntities, entityID)
+	}
 
-	return p.serverClasses[scID].newEntity(r, entityID)
+	return p.serverClasses[classID].newEntity(r, entityID, serialNum, recordingPlayerSlot)
 }
 
 // classBits seems to calculate how many bits must be read for the server-class ID.
diff --git a/pkg/demoinfocs/stringtables.go b/pkg/demoinfocs/stringtables.go
index 92ff94b..5a9af4e 100644
--- a/pkg/demoinfocs/stringtables.go
+++ b/pkg/demoinfocs/stringtables.go
@@ -239,6 +239,11 @@ func (p *parser) processStringTable(tab *msg.CSVCMsg_CreateStringTable) {
 		case stNameUserInfo:
 			player := parsePlayerInfo(bytes.NewReader(userdata))
 
+			if p.header.ClientName == player.Name {
+				p.recordingPlayerSlot = entryIndex
+				p.eventDispatcher.Dispatch(events.POVRecordingPlayerDetected{PlayerSlot: entryIndex, PlayerInfo: player})
+			}
+
 			p.setRawPlayer(entryIndex, player)
 
 		case stNameInstanceBaseline:


Test patch:
diff --git a/pkg/demoinfocs/common/common_test.go b/pkg/demoinfocs/common/common_test.go
index 0daca05..31a69f2 100644
--- a/pkg/demoinfocs/common/common_test.go
+++ b/pkg/demoinfocs/common/common_test.go
@@ -166,6 +166,11 @@ func TestConvertSteamID64To32(t *testing.T) {
 	assert.Equal(t, uint32(52686539), id)
 }
 
+type fakeProp struct {
+	propName string
+	value    st.PropertyValue
+}
+
 type demoInfoProviderMock struct {
 	tickRate             float64
 	ingameTick           int
@@ -221,6 +226,21 @@ func entityWithProperty(propName string, value st.PropertyValue) *stfake.Entity
 	return entity
 }
 
+func entityWithProperties(properties []fakeProp) *stfake.Entity {
+	entity := entityWithID(1)
+
+	for _, prop := range properties {
+		property := new(stfake.Property)
+		property.On("Value").Return(prop.value)
+
+		entity.On("Property", prop.propName).Return(property)
+		entity.On("PropertyValue", prop.propName).Return(prop.value, true)
+		entity.On("PropertyValueMust", prop.propName).Return(prop.value)
+	}
+
+	return entity
+}
+
 func entityWithoutProperty(propName string) *stfake.Entity {
 	entity := entityWithID(1)
 
diff --git a/pkg/demoinfocs/common/player_test.go b/pkg/demoinfocs/common/player_test.go
index 077e43a..151e9c8 100644
--- a/pkg/demoinfocs/common/player_test.go
+++ b/pkg/demoinfocs/common/player_test.go
@@ -42,16 +42,28 @@ func TestPlayerWeapons(t *testing.T) {
 func TestPlayerAlive(t *testing.T) {
 	pl := newPlayer(0)
 
-	pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 100})
+	pl.Entity = entityWithProperties([]fakeProp{
+		{propName: "m_iHealth", value: st.PropertyValue{IntVal: 100}},
+		{propName: "m_lifeState", value: st.PropertyValue{IntVal: 0}},
+	})
 	assert.Equal(t, true, pl.IsAlive(), "Should be alive")
 
-	pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 1})
+	pl.Entity = entityWithProperties([]fakeProp{
+		{propName: "m_iHealth", value: st.PropertyValue{IntVal: 1}},
+		{propName: "m_lifeState", value: st.PropertyValue{IntVal: 0}},
+	})
 	assert.Equal(t, true, pl.IsAlive(), "Should be alive")
 
-	pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: 0})
+	pl.Entity = entityWithProperties([]fakeProp{
+		{propName: "m_iHealth", value: st.PropertyValue{IntVal: 0}},
+		{propName: "m_lifeState", value: st.PropertyValue{IntVal: 2}},
+	})
 	assert.Equal(t, false, pl.IsAlive(), "Should be dead")
 
-	pl.Entity = entityWithProperty("m_iHealth", st.PropertyValue{IntVal: -10})
+	pl.Entity = entityWithProperties([]fakeProp{
+		{propName: "m_iHealth", value: st.PropertyValue{IntVal: -10}},
+		{propName: "m_lifeState", value: st.PropertyValue{IntVal: 2}},
+	})
 	assert.Equal(t, false, pl.IsAlive(), "Should be dead")
 }
 
diff --git a/pkg/demoinfocs/demoinfocs_test.go b/pkg/demoinfocs/demoinfocs_test.go
index e9035a3..a9a2cd9 100644
--- a/pkg/demoinfocs/demoinfocs_test.go
+++ b/pkg/demoinfocs/demoinfocs_test.go
@@ -607,7 +607,7 @@ func assertGolden(tb testing.TB, assertions *assert.Assertions, testCase string,
 }
 
 func removePointers(s []byte) []byte {
-	r := regexp.MustCompile(`\(0x[\da-f]{10}\)`)
+	r := regexp.MustCompile(`\(0x[\da-f]+\)`)
 
 	return r.ReplaceAll(s, []byte("(non-nil)"))
 }
diff --git a/pkg/demoinfocs/sendtables/entity_test.go b/pkg/demoinfocs/sendtables/entity_test.go
index 90303bb..dc6bd1e 100644
--- a/pkg/demoinfocs/sendtables/entity_test.go
+++ b/pkg/demoinfocs/sendtables/entity_test.go
@@ -15,6 +15,7 @@ var testData = struct {
 			{value: PropertyValue{IntVal: 20}},
 			{value: PropertyValue{IntVal: 30}},
 		},
+		serialNum: 1337,
 		serverClass: &ServerClass{propNameToIndex: map[string]int{
 			"myProp":     0,
 			"test":       1,
@@ -33,6 +34,10 @@ func TestEntity_ServerClass(t *testing.T) {
 	assert.Equal(t, testData.entity.serverClass, testData.entity.ServerClass())
 }
 
+func TestEntity_SerialNum(t *testing.T) {
+	assert.Equal(t, testData.entity.serialNum, testData.entity.SerialNum())
+}
+
 func TestEntity_Property(t *testing.T) {
 	assert.Equal(t, &testData.entity.props[1], testData.entity.Property("test"))
 }
diff --git a/pkg/demoinfocs/sendtables/propdecoder_test.go b/pkg/demoinfocs/sendtables/propdecoder_test.go
index 20c9873..c0568d2 100644
--- a/pkg/demoinfocs/sendtables/propdecoder_test.go
+++ b/pkg/demoinfocs/sendtables/propdecoder_test.go
@@ -1,8 +1,11 @@
 package sendtables
 
 import (
+	"bytes"
+	"encoding/binary"
 	"testing"
 
+	bit "github.com/markus-wa/demoinfocs-golang/v3/internal/bitread"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -11,6 +14,18 @@ func TestPropertyValue_BoolVal(t *testing.T) {
 	assert.False(t, PropertyValue{IntVal: 0}.BoolVal())
 }
 
+func TestPropertyValue_Int64Val(t *testing.T) {
+	expected := int64(76561198000697560)
+	prop := &property{entry: &flattenedPropEntry{prop: &sendTableProperty{rawType: propTypeInt64, flags: propFlagUnsigned, numberOfBits: 64}}}
+	b := make([]byte, 8)
+	binary.LittleEndian.PutUint64(b, uint64(expected))
+	r := bit.NewSmallBitReader(bytes.NewReader(b))
+
+	propDecoder.decodeProp(prop, r)
+
+	assert.Equal(t, expected, prop.value.Int64Val)
+}
+
 func TestDecodeProp_UnknownType(t *testing.T) {
 	prop := &property{entry: &flattenedPropEntry{prop: &sendTableProperty{rawType: -1}}}