Repository: markus-wa/demoinfocs-golang
Pull request #385 (2023-05-18 23:34:12)

WIP: s2 entities part 2


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index dcad283..5929dbc 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -13,7 +13,6 @@ linters:
   disable-all: true
   enable:
     - bodyclose
-    - depguard
     - dogsled
     - dupl
     - exportloopref
diff --git a/pkg/demoinfocs/common/common.go b/pkg/demoinfocs/common/common.go
index 184a89c..711a7df 100644
--- a/pkg/demoinfocs/common/common.go
+++ b/pkg/demoinfocs/common/common.go
@@ -217,9 +217,7 @@ func NewTeamState(team Team, membersCallback func(Team) []*Player) TeamState {
 // ConvertSteamIDTxtTo32 converts a Steam-ID in text format to a 32-bit variant.
 // See https://developer.valvesoftware.com/wiki/SteamID
 func ConvertSteamIDTxtTo32(steamID string) (uint32, error) {
-	if strings.HasSuffix(steamID, "]") {
-		steamID = strings.TrimSuffix(steamID, "]") // Source 2 has [U:1:397560266] instead of STEAM_0:1:198780133
-	}
+	steamID = strings.TrimSuffix(steamID, "]") // Source 2 has [U:1:397560266] instead of STEAM_0:1:198780133
 
 	arr := strings.Split(steamID, ":")
 
diff --git a/pkg/demoinfocs/common/entity_util.go b/pkg/demoinfocs/common/entity_util.go
index c609ebb..7518ef3 100644
--- a/pkg/demoinfocs/common/entity_util.go
+++ b/pkg/demoinfocs/common/entity_util.go
@@ -7,7 +7,7 @@ func getInt(entity st.Entity, propName string) int {
 		return 0
 	}
 
-	return entity.PropertyValueMust(propName).IntVal
+	return entity.PropertyValueMust(propName).Int()
 }
 
 func getFloat(entity st.Entity, propName string) float32 {
@@ -15,7 +15,7 @@ func getFloat(entity st.Entity, propName string) float32 {
 		return 0
 	}
 
-	return entity.PropertyValueMust(propName).FloatVal
+	return entity.PropertyValueMust(propName).Float()
 }
 
 func getString(entity st.Entity, propName string) string {
@@ -23,7 +23,7 @@ func getString(entity st.Entity, propName string) string {
 		return ""
 	}
 
-	return entity.PropertyValueMust(propName).StringVal
+	return entity.PropertyValueMust(propName).String()
 }
 
 func getBool(entity st.Entity, propName string) bool {
diff --git a/pkg/demoinfocs/common/player.go b/pkg/demoinfocs/common/player.go
index 8241348..3e13639 100644
--- a/pkg/demoinfocs/common/player.go
+++ b/pkg/demoinfocs/common/player.go
@@ -35,6 +35,18 @@ type Player struct {
 	IsUnknown         bool // Used to identify unknown/broken players. see https://github.com/markus-wa/demoinfocs-golang/issues/162
 }
 
+func (p *Player) PlayerPawnEntity() st.Entity {
+	return p.demoInfoProvider.FindEntityByHandle(p.Entity.PropertyValueMust("m_hPlayerPawn").Handle())
+}
+
+func (p *Player) GetTeam() Team {
+	return Team(p.PlayerPawnEntity().PropertyValueMust("m_iTeamNum").S2UInt64())
+}
+
+func (p *Player) GetFlashDuration() float32 {
+	return p.PlayerPawnEntity().PropertyValueMust("m_flFlashDuration").Float()
+}
+
 // String returns the player's name.
 // Implements fmt.Stringer.
 func (p *Player) String() string {
@@ -53,6 +65,12 @@ func (p *Player) SteamID32() uint32 {
 
 // IsAlive returns true if the player is alive.
 func (p *Player) IsAlive() bool {
+	s2IsAlive := p.Entity.Property("m_bPawnIsAlive")
+
+	if s2IsAlive != nil {
+		return s2IsAlive.Value().BoolVal()
+	}
+
 	return p.Health() > 0 || getInt(p.Entity, "m_lifeState") == 0
 }
 
@@ -245,6 +263,12 @@ func (p *Player) ControlledBot() *Player {
 
 // Health returns the player's health points, normally 0-100.
 func (p *Player) Health() int {
+	s2Prop := p.Entity.Property("m_iPawnHealth")
+
+	if s2Prop != nil {
+		return int(s2Prop.Value().S2UInt64())
+	}
+
 	return getInt(p.Entity, "m_iHealth")
 }
 
@@ -501,6 +525,7 @@ type demoInfoProvider interface {
 	FindPlayerByHandle(handle int) *Player
 	PlayerResourceEntity() st.Entity
 	FindWeaponByEntityID(id int) *Equipment
+	FindEntityByHandle(handle uint64) st.Entity
 }
 
 // NewPlayer creates a *Player with an initialized equipment map.
diff --git a/pkg/demoinfocs/datatables.go b/pkg/demoinfocs/datatables.go
index a6cd083..f3d461d 100644
--- a/pkg/demoinfocs/datatables.go
+++ b/pkg/demoinfocs/datatables.go
@@ -14,7 +14,7 @@ import (
 )
 
 func (p *parser) mapEquipment() {
-	for _, sc := range p.stParser.ServerClasses() {
+	for _, sc := range p.stParser.ServerClasses().All() {
 		switch sc.Name() {
 		case "CC4":
 			p.equipmentMapping[sc] = common.EqBomb
@@ -82,9 +82,19 @@ func (p *parser) bindBomb() {
 			bomb.LastOnGroundPosition = pos
 		})
 
-		bombEntity.Property("m_hOwner").OnUpdate(func(val st.PropertyValue) {
-			bomb.Carrier = p.gameState.Participants().FindByHandle(val.IntVal)
-		})
+		if p.isSource2() {
+			bombEntity.Property("m_hOwnerEntity").OnUpdate(func(val st.PropertyValue) {
+				bomb.Carrier = p.gameState.Participants().FindByHandle64(val.Handle())
+			})
+		} else {
+			bombEntity.Property("m_hOwner").OnUpdate(func(val st.PropertyValue) {
+				if val.IntVal < 0 {
+					panic("bomb owner < 0")
+				}
+
+				bomb.Carrier = p.gameState.Participants().FindByHandle(val.IntVal)
+			})
+		}
 
 		bombEntity.Property("m_bStartedArming").OnUpdate(func(val st.PropertyValue) {
 			if val.IntVal != 0 {
@@ -108,7 +118,8 @@ func (p *parser) bindBomb() {
 
 func (p *parser) bindTeamStates() {
 	p.stParser.ServerClasses().FindByName("CCSTeam").OnEntityCreated(func(entity st.Entity) {
-		team := entity.PropertyValueMust("m_szTeamname").StringVal
+		teamVal := entity.PropertyValueMust("m_szTeamname")
+		team := teamVal.String()
 
 		var s *common.TeamState
 
@@ -130,14 +141,24 @@ func (p *parser) bindTeamStates() {
 			s.Entity = entity
 
 			// Register updates
-			var score int
-			entity.Property("m_scoreTotal").OnUpdate(func(val st.PropertyValue) {
+			var (
+				scoreProp st.Property
+				score     int
+			)
+
+			if p.isSource2() {
+				scoreProp = entity.Property("m_iScore")
+			} else {
+				scoreProp = entity.Property("m_scoreTotal")
+			}
+
+			scoreProp.OnUpdate(func(val st.PropertyValue) {
 				oldScore := score
-				score = val.IntVal
+				score = val.Int()
 
 				p.eventDispatcher.Dispatch(events.ScoreUpdated{
 					OldScore:  oldScore,
-					NewScore:  val.IntVal,
+					NewScore:  val.Int(),
 					TeamState: s,
 				})
 			})
@@ -155,19 +176,33 @@ func (p *parser) bindBombSites() {
 		t := new(boundingBoxInformation)
 		p.triggers[baseTrigger.ID()] = t
 
-		baseTrigger.BindProperty("m_Collision.m_vecMins", &t.min, st.ValTypeVector)
-		baseTrigger.BindProperty("m_Collision.m_vecMaxs", &t.max, st.ValTypeVector)
+		if p.isSource2() {
+			baseTrigger.BindProperty("m_vecMins", &t.min, st.ValTypeVector)
+			baseTrigger.BindProperty("m_vecMaxs", &t.max, st.ValTypeVector)
+		} else {
+			baseTrigger.BindProperty("m_Collision.m_vecMins", &t.min, st.ValTypeVector)
+			baseTrigger.BindProperty("m_Collision.m_vecMaxs", &t.max, st.ValTypeVector)
+		}
 	})
 }
 
 func (p *parser) bindPlayers() {
-	p.stParser.ServerClasses().FindByName("CCSPlayer").OnEntityCreated(func(player st.Entity) {
-		p.bindNewPlayer(player)
-	})
+	if p.isSource2() {
+		p.stParser.ServerClasses().FindByName("CCSPlayerController").OnEntityCreated(func(player st.Entity) {
+			p.bindNewPlayerControllerS2(player)
+		})
+		p.stParser.ServerClasses().FindByName("CCSPlayerPawn").OnEntityCreated(func(player st.Entity) {
+			p.bindNewPlayerPawnS2(player)
+		})
+	} else {
+		p.stParser.ServerClasses().FindByName("CCSPlayerResource").OnEntityCreated(func(entity st.Entity) {
+			p.gameState.playerResourceEntity = entity
+		})
 
-	p.stParser.ServerClasses().FindByName("CCSPlayerResource").OnEntityCreated(func(entity st.Entity) {
-		p.gameState.playerResourceEntity = entity
-	})
+		p.stParser.ServerClasses().FindByName("CCSPlayer").OnEntityCreated(func(player st.Entity) {
+			p.bindNewPlayerS1(player)
+		})
+	}
 }
 
 func (p *parser) getOrCreatePlayer(entityID int, rp *common.PlayerInfo) (isNew bool, player *common.Player) {
@@ -209,7 +244,7 @@ func (p *parser) getOrCreatePlayer(entityID int, rp *common.PlayerInfo) (isNew b
 }
 
 //nolint:funlen
-func (p *parser) bindNewPlayer(playerEntity st.Entity) {
+func (p *parser) bindNewPlayerS1(playerEntity st.Entity) {
 	entityID := playerEntity.ID()
 	rp := p.rawPlayers[entityID-1]
 
@@ -247,18 +282,19 @@ func (p *parser) bindNewPlayer(playerEntity st.Entity) {
 		pl.FlashDuration = val.FloatVal
 	})
 
-	p.bindPlayerWeapons(playerEntity, pl)
-
-	// Active weapon
-	playerEntity.Property("m_hActiveWeapon").OnUpdate(func(val st.PropertyValue) {
-		pl.IsReloading = false
-	})
+	p.bindPlayerWeapons(playerEntity, pl) // FIXME: make weapons work for S2
 
 	for i := 0; i < 32; i++ {
 		i2 := i // Copy so it stays the same
 		playerEntity.BindProperty("m_iAmmo."+fmt.Sprintf("%03d", i2), &pl.AmmoLeft[i2], st.ValTypeInt)
 	}
 
+	activeWep := playerEntity.Property("m_hActiveWeapon")
+
+	activeWep.OnUpdate(func(val st.PropertyValue) {
+		pl.IsReloading = false
+	})
+
 	playerEntity.Property("m_bIsDefusing").OnUpdate(func(val st.PropertyValue) {
 		if p.gameState.currentDefuser == pl && pl.IsDefusing && val.IntVal == 0 {
 			p.eventDispatcher.Dispatch(events.BombDefuseAborted{Player: pl})
@@ -287,6 +323,109 @@ func (p *parser) bindNewPlayer(playerEntity st.Entity) {
 	}
 }
 
+func (p *parser) bindNewPlayerControllerS2(controllerEntity st.Entity) {
+	controllerEntityID := controllerEntity.ID()
+
+	rp := p.rawPlayers[controllerEntityID-1]
+
+	isNew, pl := p.getOrCreatePlayer(controllerEntityID, rp)
+
+	pl.EntityID = controllerEntityID
+	pl.Entity = controllerEntity
+	pl.IsConnected = true
+
+	controllerEntity.OnDestroy(func() {
+		delete(p.gameState.playersByEntityID, controllerEntityID)
+		pl.Entity = nil
+	})
+
+	if isNew {
+		if pl.SteamID64 != 0 {
+			p.eventDispatcher.Dispatch(events.PlayerConnect{Player: pl})
+		} else {
+			p.eventDispatcher.Dispatch(events.BotConnect{Player: pl})
+		}
+	}
+}
+
+func (p *parser) bindNewPlayerPawnS2(pawnEntity st.Entity) {
+	player := func() *common.Player {
+		return p.gameState.Participants().FindByHandle64(pawnEntity.PropertyValueMust("m_hController").Handle())
+	}
+
+	// Position
+	pawnEntity.OnPositionUpdate(func(pos r3.Vector) {
+		pl := player()
+		if pl == nil {
+			return
+		}
+
+		if pl.IsAlive() {
+			pl.LastAlivePosition = pos
+		}
+	})
+
+	pawnEntity.Property("m_flFlashDuration").OnUpdate(func(val st.PropertyValue) {
+		pl := player()
+		if pl == nil {
+			return
+		}
+
+		if val.Float() == 0 {
+			pl.FlashTick = 0
+		} else {
+			pl.FlashTick = p.gameState.ingameTick
+		}
+
+		pl.FlashDuration = val.Float()
+	})
+
+	//p.bindPlayerWeapons(controllerEntity, pl) // FIXME: make weapons work for S2
+	//
+	//for i := 0; i < 32; i++ {
+	//	i2 := i // Copy so it stays the same
+	//	controllerEntity.BindProperty("m_iAmmo."+fmt.Sprintf("%03d", i2), &pl.AmmoLeft[i2], st.ValTypeInt)
+	//}
+
+	pawnEntity.Property("m_pWeaponServices.m_hActiveWeapon").OnUpdate(func(val st.PropertyValue) {
+		pl := player()
+		if pl == nil {
+			return
+		}
+
+		pl.IsReloading = false
+	})
+
+	pawnEntity.Property("m_bIsDefusing").OnUpdate(func(val st.PropertyValue) {
+		pl := player()
+		if pl == nil {
+			return
+		}
+
+		if p.gameState.currentDefuser == pl && pl.IsDefusing && !val.BoolVal() {
+			p.eventDispatcher.Dispatch(events.BombDefuseAborted{Player: pl})
+			p.gameState.currentDefuser = nil
+		}
+
+		pl.IsDefusing = val.BoolVal()
+	})
+
+	spottedByMaskProp := pawnEntity.Property("m_bSpottedByMask.0000")
+	if spottedByMaskProp != nil {
+		pl := player()
+		if pl == nil {
+			return
+		}
+
+		spottersChanged := func(val st.PropertyValue) {
+			p.eventDispatcher.Dispatch(events.PlayerSpottersChanged{Spotted: pl})
+		}
+
+		spottedByMaskProp.OnUpdate(spottersChanged)
+		pawnEntity.Property("m_bSpottedByMask.0001").OnUpdate(spottersChanged)
+	}
+}
+
 const maxWeapons = 64
 
 func (p *parser) bindPlayerWeapons(playerEntity st.Entity, pl *common.Player) {
@@ -340,7 +479,7 @@ func (p *parser) bindPlayerWeapons(playerEntity st.Entity, pl *common.Player) {
 }
 
 func (p *parser) bindWeapons() {
-	for _, sc := range p.stParser.ServerClasses() {
+	for _, sc := range p.stParser.ServerClasses().All() {
 		for _, bc := range sc.BaseClasses() {
 			switch bc.Name() {
 			case "CWeaponCSBase":
@@ -399,7 +538,7 @@ func (p *parser) bindGrenadeProjectiles(entity st.Entity) {
 	})
 
 	entity.Property("m_hOwnerEntity").OnUpdate(func(val st.PropertyValue) {
-		proj.Owner = p.gameState.Participants().FindByHandle(val.IntVal)
+		proj.Owner = p.gameState.Participants().FindByHandle64(val.Handle())
 	})
 
 	entity.OnPositionUpdate(func(newPos r3.Vector) {
@@ -534,12 +673,16 @@ func (p *parser) infernoExpired(inf *common.Inferno) {
 
 //nolint:funlen
 func (p *parser) bindGameRules() {
-	grPrefix := func(s string) string {
-		return fmt.Sprintf("%s.%s", gameRulesPrefix, s)
-	}
-
 	gameRules := p.ServerClasses().FindByName("CCSGameRulesProxy")
 	gameRules.OnEntityCreated(func(entity st.Entity) {
+		grPrefix := func(s string) string {
+			if p.isSource2() {
+				return fmt.Sprintf("%s.%s", gameRulesPrefixS2, s)
+			}
+
+			return fmt.Sprintf("%s.%s", gameRulesPrefix, s)
+		}
+
 		p.gameState.rules.entity = entity
 
 		entity.Property(grPrefix("m_gamePhase")).OnUpdate(func(val st.PropertyValue) {
diff --git a/pkg/demoinfocs/fake/game_state.go b/pkg/demoinfocs/fake/game_state.go
index 03b5160..cd393e3 100644
--- a/pkg/demoinfocs/fake/game_state.go
+++ b/pkg/demoinfocs/fake/game_state.go
@@ -90,7 +90,7 @@ func (gs *GameState) Rules() demoinfocs.GameRules {
 	return gs.Called().Get(0).(demoinfocs.GameRules)
 }
 
-// PlayerResourceEntity is a mock-implementation of GameState.PlayerResorceEntity().
+// PlayerResourceEntity is a mock-implementation of GameState.PlayerResourceEntity().
 func (gs *GameState) PlayerResourceEntity() st.Entity {
 	return gs.Called().Get(0).(st.Entity)
 }
@@ -99,3 +99,8 @@ func (gs *GameState) PlayerResourceEntity() st.Entity {
 func (gs *GameState) Hostages() []*common.Hostage {
 	return gs.Called().Get(0).([]*common.Hostage)
 }
+
+// EntityByHandle is a mock-implementation of GameState.EntityByHandle().
+func (gs *GameState) EntityByHandle(handle uint64) st.Entity {
+	return gs.Called(handle).Get(0).(st.Entity)
+}
diff --git a/pkg/demoinfocs/fake/participants.go b/pkg/demoinfocs/fake/participants.go
index c9e24ae..0d58255 100644
--- a/pkg/demoinfocs/fake/participants.go
+++ b/pkg/demoinfocs/fake/participants.go
@@ -54,6 +54,11 @@ func (ptcp *Participants) FindByHandle(handle int) *common.Player {
 	return ptcp.Called().Get(0).(*common.Player)
 }
 
+// FindByHandle64 is a mock-implementation of Participants.FindByHandle64().
+func (ptcp *Participants) FindByHandle64(handle uint64) *common.Player {
+	return ptcp.Called().Get(0).(*common.Player)
+}
+
 // SpottersOf is a mock-implementation of Participants.SpottersOf().
 func (ptcp *Participants) SpottersOf(spotted *common.Player) []*common.Player {
 	return ptcp.Called().Get(0).([]*common.Player)
diff --git a/pkg/demoinfocs/game_state.go b/pkg/demoinfocs/game_state.go
index a650e8f..00c3629 100644
--- a/pkg/demoinfocs/game_state.go
+++ b/pkg/demoinfocs/game_state.go
@@ -170,10 +170,24 @@ func (gs gameState) IsMatchStarted() bool {
 
 // PlayerResourceEntity returns the game's CCSPlayerResource entity.
 // Contains scoreboard information and more.
-func (gs *gameState) PlayerResourceEntity() st.Entity {
+func (gs gameState) PlayerResourceEntity() st.Entity {
 	return gs.playerResourceEntity
 }
 
+func entityIDFromHandle(handle uint64) int {
+	if handle == constants.InvalidEntityHandle {
+		return -1
+	}
+
+	return int(handle & constants.EntityHandleIndexMask)
+}
+
+// EntityByHandle returns the entity corresponding to the given handle.
+// Returns nil if the handle is invalid.
+func (gs gameState) EntityByHandle(handle uint64) st.Entity {
+	return gs.entities[entityIDFromHandle(handle)]
+}
+
 func newGameState(demoInfo demoInfoProvider) *gameState {
 	gs := &gameState{
 		playersByEntityID:  make(map[int]*common.Player),
@@ -357,18 +371,22 @@ func (ptcp participants) TeamMembers(team common.Team) []*common.Player {
 	return res
 }
 
+// FindByHandle64 attempts to find a player by his entity-handle.
+// The entity-handle is often used in entity-properties when referencing other entities such as a weapon's owner.
+//
+// Returns nil if not found or if handle == invalidEntityHandle (used when referencing no entity).
+func (ptcp participants) FindByHandle64(handle uint64) *common.Player {
+	return ptcp.playersByEntityID[entityIDFromHandle(handle)]
+}
+
 // FindByHandle attempts to find a player by his entity-handle.
 // The entity-handle is often used in entity-properties when referencing other entities such as a weapon's owner.
 //
 // Returns nil if not found or if handle == invalidEntityHandle (used when referencing no entity).
+//
+// Deprecated: Use FindByHandle64 instead.
 func (ptcp participants) FindByHandle(handle int) *common.Player {
-	if handle == constants.InvalidEntityHandle {
-		return nil
-	}
-
-	entityID := handle & constants.EntityHandleIndexMask
-
-	return ptcp.playersByEntityID[entityID]
+	return ptcp.FindByHandle64(uint64(handle))
 }
 
 func (ptcp participants) initializeSliceFromByUserID() ([]*common.Player, map[int]*common.Player) {
diff --git a/pkg/demoinfocs/game_state_interface.go b/pkg/demoinfocs/game_state_interface.go
index ba54840..11a6206 100644
--- a/pkg/demoinfocs/game_state_interface.go
+++ b/pkg/demoinfocs/game_state_interface.go
@@ -60,4 +60,7 @@ type GameState interface {
 	// PlayerResourceEntity returns the game's CCSPlayerResource entity.
 	// Contains scoreboard information and more.
 	PlayerResourceEntity() st.Entity
+	// EntityByHandle returns the entity corresponding to the given handle.
+	// Returns nil if the handle is invalid.
+	EntityByHandle(handle uint64) st.Entity
 }
diff --git a/pkg/demoinfocs/net_messages.go b/pkg/demoinfocs/net_messages.go
index 27e63e8..f2ab40b 100644
--- a/pkg/demoinfocs/net_messages.go
+++ b/pkg/demoinfocs/net_messages.go
@@ -12,6 +12,7 @@ import (
 	events "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/events"
 	msg "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/msg"
 	"github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/msgs2"
+	"github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables"
 )
 
 func (p *parser) handlePacketEntitiesS1(pe *msg.CSVCMsg_PacketEntities) {
@@ -52,6 +53,16 @@ func (p *parser) handlePacketEntitiesS1(pe *msg.CSVCMsg_PacketEntities) {
 	p.poolBitReader(r)
 }
 
+func (p *parser) onEntity(e sendtables.Entity, op sendtables.EntityOp) error {
+	if op&sendtables.EntityOpCreated > 0 {
+		p.gameState.entities[e.ID()] = e
+	} else if op&sendtables.EntityOpDeleted > 0 {
+		delete(p.gameState.entities, e.ID())
+	}
+
+	return nil
+}
+
 func (p *parser) handleSetConVar(setConVar *msg.CNETMsg_SetConVar) {
 	updated := make(map[string]string)
 	for _, cvar := range setConVar.Convars.Cvars {
diff --git a/pkg/demoinfocs/parser.go b/pkg/demoinfocs/parser.go
index f59a300..7f8dcdb 100644
--- a/pkg/demoinfocs/parser.go
+++ b/pkg/demoinfocs/parser.go
@@ -30,6 +30,7 @@ type sendTableParser interface {
 	OnDemoClassInfo(m *msgs2.CDemoClassInfo) error
 	OnServerInfo(m *msgs2.CSVCMsg_ServerInfo) error
 	OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error
+	OnEntity(h st.EntityHandler)
 }
 
 type createStringTable struct {
@@ -91,7 +92,7 @@ type parser struct {
 
 	bombsiteA            bombsite
 	bombsiteB            bombsite
-	equipmentMapping     map[*st.ServerClass]common.EquipmentType        // Maps server classes to equipment-types
+	equipmentMapping     map[st.ServerClass]common.EquipmentType         // Maps server classes to equipment-types
 	rawPlayers           map[int]*common.PlayerInfo                      // Maps entity IDs to 'raw' player info
 	modelPreCache        []string                                        // Used to find out whether a weapon is a p250 or cz for example (same id)
 	triggers             map[int]*boundingBoxInformation                 // Maps entity IDs to triggers (used for bombsites)
@@ -360,7 +361,7 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
 
 	// Init parser
 	p.bitReader = bit.NewLargeBitReader(demostream)
-	p.equipmentMapping = make(map[*st.ServerClass]common.EquipmentType)
+	p.equipmentMapping = make(map[st.ServerClass]common.EquipmentType)
 	p.rawPlayers = make(map[int]*common.PlayerInfo)
 	p.triggers = make(map[int]*boundingBoxInformation)
 	p.demoInfoProvider = demoInfoProvider{parser: &p}
@@ -415,6 +416,10 @@ func (p *parser) initMsgQueue(buf int) {
 	p.msgDispatcher.AddQueues(p.msgQueue)
 }
 
+func (p *parser) isSource2() bool {
+	return p.header.Filestamp == "PBDEMS2"
+}
+
 type demoInfoProvider struct {
 	parser *parser
 }
@@ -431,6 +436,10 @@ func (p demoInfoProvider) FindPlayerByHandle(handle int) *common.Player {
 	return p.parser.gameState.Participants().FindByHandle(handle)
 }
 
+func (p demoInfoProvider) FindEntityByHandle(handle uint64) st.Entity {
+	return p.parser.gameState.EntityByHandle(handle)
+}
+
 func (p demoInfoProvider) PlayerResourceEntity() st.Entity {
 	return p.parser.gameState.playerResourceEntity
 }
diff --git a/pkg/demoinfocs/parsing.go b/pkg/demoinfocs/parsing.go
index 9c5171c..58510d7 100644
--- a/pkg/demoinfocs/parsing.go
+++ b/pkg/demoinfocs/parsing.go
@@ -28,6 +28,7 @@ const (
 	playerWeaponPrefix    = "m_hMyWeapons."
 	playerWeaponPrePrefix = "bcc_nonlocaldata."
 	gameRulesPrefix       = "cs_gamerules_data"
+	gameRulesPrefixS2     = "m_pGameRules"
 )
 
 // Parsing errors
@@ -71,8 +72,9 @@ func (p *parser) ParseHeader() (common.DemoHeader, error) {
 
 		p.stParser = sendtables2.NewParser()
 
+		p.stParser.OnEntity(p.onEntity)
+
 		p.RegisterNetMessageHandler(p.stParser.OnServerInfo)
-		p.RegisterNetMessageHandler(p.stParser.OnDemoClassInfo)
 		p.RegisterNetMessageHandler(p.stParser.OnPacketEntities)
 
 	default:
@@ -325,6 +327,8 @@ func (p *parser) parseFrameS2() bool {
 		tick = 0
 	}
 
+	p.msgQueue <- ingameTickNumber(int32(tick))
+
 	size := p.bitReader.ReadVarInt32()
 
 	buf := p.bitReader.ReadBytes(int(size))
@@ -368,7 +372,8 @@ func (p *parser) parseFrameS2() bool {
 		p.handleStringTables(m)
 	}
 
-	p.msgQueue <- ingameTickNumber(int32(tick))
+	// Queue up some post processing
+	p.msgQueue <- frameParsedToken
 
 	return msgType != msgs2.EDemoCommands_DEM_Stop
 }
diff --git a/pkg/demoinfocs/participants_interface.go b/pkg/demoinfocs/participants_interface.go
index 3b61543..f137a26 100644
--- a/pkg/demoinfocs/participants_interface.go
+++ b/pkg/demoinfocs/participants_interface.go
@@ -37,10 +37,17 @@ type Participants interface {
 	// TeamMembers returns all players belonging to the requested team at this time.
 	// The returned slice is a snapshot and is not updated on changes.
 	TeamMembers(team common.Team) []*common.Player
+	// FindByHandle64 attempts to find a player by his entity-handle.
+	// The entity-handle is often used in entity-properties when referencing other entities such as a weapon's owner.
+	//
+	// Returns nil if not found or if handle == invalidEntityHandle (used when referencing no entity).
+	FindByHandle64(handle uint64) *common.Player
 	// FindByHandle attempts to find a player by his entity-handle.
 	// The entity-handle is often used in entity-properties when referencing other entities such as a weapon's owner.
 	//
 	// Returns nil if not found or if handle == invalidEntityHandle (used when referencing no entity).
+	//
+	// Deprecated: Use FindByHandle64 instead.
 	FindByHandle(handle int) *common.Player
 	// SpottersOf returns a list of all players who have spotted the passed player.
 	// This is NOT "Line of Sight" / FOV - look up "CSGO TraceRay" for that.
diff --git a/pkg/demoinfocs/s2_commands.go b/pkg/demoinfocs/s2_commands.go
index 84e581e..ea815ac 100644
--- a/pkg/demoinfocs/s2_commands.go
+++ b/pkg/demoinfocs/s2_commands.go
@@ -25,6 +25,15 @@ func (p *parser) handleSendTables(msg *msgs2.CDemoSendTables) {
 }
 
 func (p *parser) handleClassInfo(msg *msgs2.CDemoClassInfo) {
+	p.msgDispatcher.SyncAllQueues()
+
+	err := p.stParser.OnDemoClassInfo(msg)
+	if err != nil {
+		panic(err)
+	}
+
+	p.mapEquipment()
+	p.bindEntities()
 }
 
 var netMsgCreators = map[msgs2.NET_Messages]NetMessageCreator{
diff --git a/pkg/demoinfocs/sendtables/entity.go b/pkg/demoinfocs/sendtables/entity.go
index 139b9a8..082578d 100644
--- a/pkg/demoinfocs/sendtables/entity.go
+++ b/pkg/demoinfocs/sendtables/entity.go
@@ -13,7 +13,7 @@ import (
 
 // entity stores a entity in the game (e.g. players etc.) with its properties.
 type entity struct {
-	serverClass *ServerClass
+	serverClass *serverClass
 	id          int
 	serialNum   int
 	props       []property
@@ -26,7 +26,7 @@ type entity struct {
 }
 
 // ServerClass returns the entity's server-class.
-func (e *entity) ServerClass() *ServerClass {
+func (e *entity) ServerClass() ServerClass {
 	return e.serverClass
 }
 
diff --git a/pkg/demoinfocs/sendtables/entity_interface.go b/pkg/demoinfocs/sendtables/entity_interface.go
index c5176f4..6c76594 100644
--- a/pkg/demoinfocs/sendtables/entity_interface.go
+++ b/pkg/demoinfocs/sendtables/entity_interface.go
@@ -11,7 +11,7 @@ import (
 // entity stores a entity in the game (e.g. players etc.) with its properties.
 type Entity interface {
 	// ServerClass returns the entity's server-class.
-	ServerClass() *ServerClass
+	ServerClass() ServerClass
 	// ID returns the entity's ID.
 	ID() int
 	// SerialNum returns the entity's serial number.
diff --git a/pkg/demoinfocs/sendtables/fake/entity.go b/pkg/demoinfocs/sendtables/fake/entity.go
index fc2ea12..b8c199e 100644
--- a/pkg/demoinfocs/sendtables/fake/entity.go
+++ b/pkg/demoinfocs/sendtables/fake/entity.go
@@ -30,8 +30,8 @@ type Entity struct {
 }
 
 // ServerClass is a mock-implementation of Entity.ServerClass().
-func (e *Entity) ServerClass() *st.ServerClass {
-	return e.Called().Get(0).(*st.ServerClass)
+func (e *Entity) ServerClass() st.ServerClass {
+	return e.Called().Get(0).(st.ServerClass)
 }
 
 // ID is a mock-implementation of Entity.ID().
diff --git a/pkg/demoinfocs/sendtables/propdecoder.go b/pkg/demoinfocs/sendtables/propdecoder.go
index c17e411..5f81515 100644
--- a/pkg/demoinfocs/sendtables/propdecoder.go
+++ b/pkg/demoinfocs/sendtables/propdecoder.go
@@ -35,6 +35,7 @@ const (
 	PropTypeArray
 	PropTypeDataTable
 	PropTypeInt64
+	PropTypeAny
 )
 
 const (
@@ -89,16 +90,84 @@ var propDecoder propertyDecoder
 // PropertyValue stores parsed & decoded send-table values.
 // For instance player health, location etc.
 type PropertyValue struct {
-	VectorVal r3.Vector
-	IntVal    int
-	Int64Val  int64
-	ArrayVal  []PropertyValue
-	StringVal string
-	FloatVal  float32
+	VectorVal r3.Vector       // Deprecated, use R3Vec() instead
+	IntVal    int             // Deprecated, use Int() instead
+	Int64Val  int64           // Deprecated, use Int64() instead
+	ArrayVal  []PropertyValue // Deprecated.
+	StringVal string          // Deprecated, use String() instead
+	FloatVal  float32         // Deprecated, use Float() instead
+	Any       any
+	S2        bool
+}
+
+func (v PropertyValue) R3Vec() r3.Vector {
+	if v.S2 {
+		fs := v.Any.([]float32)
+
+		return r3.Vector{
+			X: float64(fs[0]),
+			Y: float64(fs[1]),
+			Z: float64(fs[2]),
+		}
+	}
+
+	return v.VectorVal
+}
+
+func (v PropertyValue) Int() int {
+	if v.S2 {
+		return int(v.Any.(int32))
+	}
+
+	return v.IntVal
+}
+
+func (v PropertyValue) Int64() int64 {
+	if v.S2 {
+		return v.Any.(int64)
+	}
+
+	return v.Int64Val
+}
+
+func (v PropertyValue) S2UInt64() uint64 {
+	return v.Any.(uint64)
+}
+
+func (v PropertyValue) Handle() uint64 {
+	if v.S2 {
+		return v.S2UInt64()
+	}
+
+	if v.IntVal < 0 {
+		panic("Handle is negative")
+	}
+
+	return uint64(v.IntVal)
+}
+
+func (v PropertyValue) Float() float32 {
+	if v.S2 {
+		return v.Any.(float32)
+	}
+
+	return v.FloatVal
+}
+
+func (v PropertyValue) String() string {
+	if v.S2 {
+		return v.Any.(string)
+	}
+
+	return v.StringVal
 }
 
 // BoolVal returns true if IntVal > 0.
 func (v PropertyValue) BoolVal() bool {
+	if v.S2 {
+		return v.Any.(bool)
+	}
+
 	return v.IntVal > 0
 }
 
diff --git a/pkg/demoinfocs/sendtables/sendtables.go b/pkg/demoinfocs/sendtables/sendtables.go
index a471d7e..9dcea6c 100644
--- a/pkg/demoinfocs/sendtables/sendtables.go
+++ b/pkg/demoinfocs/sendtables/sendtables.go
@@ -43,13 +43,15 @@ type flattenedPropEntry struct {
 	name             string
 }
 
-// ServerClass stores meta information about Entity types (e.g. palyers, teams etc.).
-type ServerClass struct {
+//go:generate ifacemaker -f sendtables.go -s serverClass -i ServerClass -p sendtables -D -y "ServerClass is an auto-generated interface for property, intended to be used when mockability is needed." -c "DO NOT EDIT: Auto generated" -o serverclass_interface.go
+
+// serverClass stores meta information about Entity types (e.g. palyers, teams etc.).
+type serverClass struct {
 	id              int
 	name            string
 	dataTableID     int
 	dataTableName   string
-	baseClasses     []*ServerClass
+	baseClasses     []*serverClass
 	flattenedProps  []flattenedPropEntry
 	propNameToIndex map[string]int
 
@@ -59,32 +61,36 @@ type ServerClass struct {
 }
 
 // ID returns the server-class's ID.
-func (sc *ServerClass) ID() int {
+func (sc *serverClass) ID() int {
 	return sc.id
 }
 
 // Name returns the server-class's name.
-func (sc *ServerClass) Name() string {
+func (sc *serverClass) Name() string {
 	return sc.name
 }
 
 // DataTableID returns the data-table ID.
-func (sc *ServerClass) DataTableID() int {
+func (sc *serverClass) DataTableID() int {
 	return sc.dataTableID
 }
 
 // DataTableName returns the data-table name.
-func (sc *ServerClass) DataTableName() string {
+func (sc *serverClass) DataTableName() string {
 	return sc.dataTableName
 }
 
 // BaseClasses returns the base-classes of this server-class.
-func (sc *ServerClass) BaseClasses() []*ServerClass {
-	return sc.baseClasses
+func (sc *serverClass) BaseClasses() (res []ServerClass) {
+	for _, v := range sc.baseClasses {
+		res = append(res, v)
+	}
+
+	return
 }
 
 // PropertyEntries returns the names of all property-entries on this server-class.
-func (sc *ServerClass) PropertyEntries() []string {
+func (sc *serverClass) PropertyEntries() []string {
 	propEntryCount := len(sc.flattenedProps)
 	names := make([]string, propEntryCount)
 
@@ -102,7 +108,7 @@ type PropertyEntry struct {
 }
 
 // PropertyEntryDefinitions returns all property-entries on this server-class.
-func (sc *ServerClass) PropertyEntryDefinitions() []PropertyEntry {
+func (sc *serverClass) PropertyEntryDefinitions() []PropertyEntry {
 	propEntryCount := len(sc.flattenedProps)
 	res := make([]PropertyEntry, propEntryCount)
 
@@ -120,7 +126,7 @@ func (sc *ServerClass) PropertyEntryDefinitions() []PropertyEntry {
 	return res
 }
 
-func (sc *ServerClass) newEntity(entityDataReader *bit.BitReader, entityID int, serialNum int, recordingPlayerSlot 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 {
@@ -156,15 +162,15 @@ func (sc *ServerClass) newEntity(entityDataReader *bit.BitReader, entityID int,
 	return entity
 }
 
-// OnEntityCreated registers a function to be called when a new entity is created from this ServerClass.
-func (sc *ServerClass) OnEntityCreated(handler EntityCreatedHandler) {
+// OnEntityCreated registers a function to be called when a new entity is created from this serverClass.
+func (sc *serverClass) OnEntityCreated(handler EntityCreatedHandler) {
 	sc.createdHandlers = append(sc.createdHandlers, handler)
 }
 
 // EntityCreatedHandler is the interface for handlers that are interested in EntityCreatedEvents.
 type EntityCreatedHandler func(Entity)
 
-var serverClassStringFormat = `ServerClass: id=%d name=%s
+var serverClassStringFormat = `serverClass: id=%d name=%s
 	dataTableId=%d
 	dataTableName=%s
 	baseClasses:
@@ -172,7 +178,7 @@ var serverClassStringFormat = `ServerClass: id=%d name=%s
 	properties:
 		%s`
 
-func (sc *ServerClass) String() string {
+func (sc *serverClass) String() string {
 	baseClasses := make([]string, len(sc.baseClasses))
 	for i, bc := range sc.baseClasses {
 		baseClasses[i] = bc.name
diff --git a/pkg/demoinfocs/sendtables/serverclass_interface.go b/pkg/demoinfocs/sendtables/serverclass_interface.go
new file mode 100644
index 0000000..336834d
--- /dev/null
+++ b/pkg/demoinfocs/sendtables/serverclass_interface.go
@@ -0,0 +1,25 @@
+// DO NOT EDIT: Auto generated
+
+package sendtables
+
+// ServerClass is an auto-generated interface for property, intended to be used when mockability is needed.
+// serverClass stores meta information about Entity types (e.g. palyers, teams etc.).
+type ServerClass interface {
+	// ID returns the server-class's ID.
+	ID() int
+	// Name returns the server-class's name.
+	Name() string
+	// DataTableID returns the data-table ID.
+	DataTableID() int
+	// DataTableName returns the data-table name.
+	DataTableName() string
+	// BaseClasses returns the base-classes of this server-class.
+	BaseClasses() (res []ServerClass)
+	// PropertyEntries returns the names of all property-entries on this server-class.
+	PropertyEntries() []string
+	// PropertyEntryDefinitions returns all property-entries on this server-class.
+	PropertyEntryDefinitions() []PropertyEntry
+	// OnEntityCreated registers a function to be called when a new entity is created from this serverClass.
+	OnEntityCreated(handler EntityCreatedHandler)
+	String() string
+}
diff --git a/pkg/demoinfocs/sendtables/st_parser.go b/pkg/demoinfocs/sendtables/st_parser.go
index 044ebd3..0cdadbf 100644
--- a/pkg/demoinfocs/sendtables/st_parser.go
+++ b/pkg/demoinfocs/sendtables/st_parser.go
@@ -19,9 +19,9 @@ import (
 // Intended for internal use only.
 type SendTableParser struct {
 	sendTables         []sendTable
-	serverClasses      ServerClasses
+	serverClasses      serverClasses
 	currentExcludes    []*excludeEntry
-	currentBaseclasses []*ServerClass
+	currentBaseclasses []*serverClass
 
 	instanceBaselines map[int][]byte // Maps server-class IDs to raw instance baselines, needed for when we don't have the server-class when setting the baseline
 }
@@ -40,12 +40,62 @@ func (p *SendTableParser) OnPacketEntities(*msgs2.CSVCMsg_PacketEntities) error
 	panic("not implemented")
 }
 
+// EntityOp is a bitmask representing the type of operation performed on an Entity
+type EntityOp int
+
+const (
+	EntityOpNone           EntityOp = 0x00
+	EntityOpCreated        EntityOp = 0x01
+	EntityOpUpdated        EntityOp = 0x02
+	EntityOpDeleted        EntityOp = 0x04
+	EntityOpEntered        EntityOp = 0x08
+	EntityOpLeft           EntityOp = 0x10
+	EntityOpCreatedEntered EntityOp = EntityOpCreated | EntityOpEntered
+	EntityOpUpdatedEntered EntityOp = EntityOpUpdated | EntityOpEntered
+	EntityOpDeletedLeft    EntityOp = EntityOpDeleted | EntityOpLeft
+)
+
+var entityOpNames = map[EntityOp]string{
+	EntityOpNone:           "None",
+	EntityOpCreated:        "Created",
+	EntityOpUpdated:        "Updated",
+	EntityOpDeleted:        "Deleted",
+	EntityOpEntered:        "Entered",
+	EntityOpLeft:           "Left",
+	EntityOpCreatedEntered: "Created+Entered",
+	EntityOpUpdatedEntered: "Updated+Entered",
+	EntityOpDeletedLeft:    "Deleted+Left",
+}
+
+// Flag determines whether an EntityOp includes another. This is primarily
+// offered to prevent bit flag errors in downstream clients.
+func (o EntityOp) Flag(p EntityOp) bool {
+	return o&p != 0
+}
+
+// String returns a human identifiable string for the EntityOp
+func (o EntityOp) String() string {
+	return entityOpNames[o]
+}
+
+// EntityHandler is a function that receives Entity updates
+type EntityHandler func(Entity, EntityOp) error
+
+func (p *SendTableParser) OnEntity(h EntityHandler) {
+	panic("not implemented")
+}
+
 // ServerClasses is a searchable list of ServerClasses.
-type ServerClasses []*ServerClass
+type ServerClasses interface {
+	All() []ServerClass
+	FindByName(name string) ServerClass
+}
 
-func (sc ServerClasses) findByDataTableName(name string) *ServerClass {
+type serverClasses []*serverClass
+
+func (sc serverClasses) findByDataTableName(name string) *serverClass {
 	for _, v := range sc {
-		if v.dataTableName == name {
+		if v.DataTableName() == name {
 			return v
 		}
 	}
@@ -56,9 +106,9 @@ func (sc ServerClasses) findByDataTableName(name string) *ServerClass {
 // FindByName finds and returns a server-class by it's name.
 //
 // Returns nil if the server-class wasn't found.
-func (sc ServerClasses) FindByName(name string) *ServerClass {
+func (sc serverClasses) FindByName(name string) ServerClass {
 	for _, v := range sc {
-		if v.name == name {
+		if v.Name() == name {
 			return v
 		}
 	}
@@ -66,6 +116,15 @@ func (sc ServerClasses) FindByName(name string) *ServerClass {
 	return nil
 }
 
+// All returns all server-classes.
+func (sc serverClasses) All() (res []ServerClass) {
+	for _, v := range sc {
+		res = append(res, v)
+	}
+
+	return
+}
+
 type excludeEntry struct {
 	varName       string
 	dataTableName string
@@ -102,7 +161,7 @@ func (p *SendTableParser) ParsePacket(b []byte) error {
 	serverClassCount := int(r.ReadInt(16))
 
 	for i := 0; i < serverClassCount; i++ {
-		class := new(ServerClass)
+		class := new(serverClass)
 		class.id = int(r.ReadInt(16))
 
 		if class.id > serverClassCount {
diff --git a/pkg/demoinfocs/sendtables2/class.go b/pkg/demoinfocs/sendtables2/class.go
index d69a49b..c900102 100644
--- a/pkg/demoinfocs/sendtables2/class.go
+++ b/pkg/demoinfocs/sendtables2/class.go
@@ -1,13 +1,59 @@
 package sendtables2
 
 import (
+	"fmt"
 	"strings"
+
+	st "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables"
 )
 
 type class struct {
-	classId    int32
-	name       string
-	serializer *serializer
+	classId         int32
+	name            string
+	serializer      *serializer
+	createdHandlers []st.EntityCreatedHandler
+}
+
+func (c *class) ID() int {
+	return int(c.classId)
+}
+
+func (c *class) Name() string {
+	return c.name
+}
+
+func (c *class) DataTableID() int {
+	panic("not implemented")
+}
+
+func (c *class) DataTableName() string {
+	panic("not implemented")
+}
+
+func (c *class) BaseClasses() (res []st.ServerClass) {
+	return nil
+}
+
+func (c *class) PropertyEntries() []string {
+	panic("not implemented")
+}
+
+func (c *class) PropertyEntryDefinitions() []st.PropertyEntry {
+	panic("not implemented")
+}
+
+func (c *class) OnEntityCreated(handler st.EntityCreatedHandler) {
+	c.createdHandlers = append(c.createdHandlers, handler)
+}
+
+func (c *class) String() string {
+	props := make([]string, 0, len(c.serializer.fields))
+
+	for _, f := range c.serializer.fields {
+		props = append(props, fmt.Sprintf("%s: %s", f.varName, f.varType))
+	}
+
+	return fmt.Sprintf("%d %s\n %s", c.classId, c.name, strings.Join(props, "\n "))
 }
 
 func (c *class) getNameForFieldPath(fp *fieldPath) string {
diff --git a/pkg/demoinfocs/sendtables2/entity.go b/pkg/demoinfocs/sendtables2/entity.go
index 2a3598e..6936851 100644
--- a/pkg/demoinfocs/sendtables2/entity.go
+++ b/pkg/demoinfocs/sendtables2/entity.go
@@ -2,78 +2,273 @@ package sendtables2
 
 import (
 	"fmt"
+	"strings"
 
+	"github.com/golang/geo/r3"
+
+	bit "github.com/markus-wa/demoinfocs-golang/v3/internal/bitread"
 	"github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/msgs2"
+	st "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables"
 )
 
-// EntityOp is a bitmask representing the type of operation performed on an Entity
-type EntityOp int
+// Entity represents a single game entity in the replay
+type Entity struct {
+	index   int32
+	serial  int32
+	class   *class
+	active  bool
+	state   *fieldState
+	fpCache map[string]*fieldPath
+	fpNoop  map[string]bool
+
+	onCreateFinished []func()
+	onDestroy        []func()
+	updateHandlers   map[string][]st.PropertyUpdateHandler
+}
+
+func (e *Entity) ServerClass() st.ServerClass {
+	return e.class
+}
+
+func (e *Entity) ID() int {
+	return int(e.index)
+}
+
+func (e *Entity) SerialNum() int {
+	return int(e.serial)
+}
+
+func (e *Entity) Properties() (out []st.Property) {
+	for _, fp := range e.class.getFieldPaths(newFieldPath(), e.state) {
+		out = append(out, e.Property(e.class.getNameForFieldPath(fp)))
+	}
+
+	return
+}
+
+type property struct {
+	entity *Entity
+	name   string
+}
+
+func (p property) Name() string {
+	return p.name
+}
+
+func (p property) Value() st.PropertyValue {
+	return st.PropertyValue{
+		VectorVal: r3.Vector{},
+		IntVal:    0,
+		Int64Val:  0,
+		ArrayVal:  nil,
+		StringVal: "",
+		FloatVal:  0,
+		Any:       p.entity.Get(p.name),
+		S2:        true,
+	}
+}
+
+func (p property) Type() st.PropertyType {
+	panic("not implemented")
+}
+
+func (p property) ArrayElementType() st.PropertyType {
+	panic("not implemented")
+}
+
+func (p property) OnUpdate(handler st.PropertyUpdateHandler) {
+	p.entity.updateHandlers[p.name] = append(p.entity.updateHandlers[p.name], handler)
+}
+
+type bindFactory func(variable any) st.PropertyUpdateHandler
+
+var bindFactoryByType = map[st.PropertyValueType]bindFactory{
+	st.ValTypeVector: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*r3.Vector) = v.VectorVal // FIXME this won't work
+		}
+	},
+	st.ValTypeInt: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*int) = v.IntVal
+		}
+	},
+	st.ValTypeArray: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*[]st.PropertyValue) = v.ArrayVal
+		}
+	},
+	st.ValTypeString: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*string) = v.StringVal
+		}
+	},
+	st.ValTypeBoolInt: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*bool) = v.IntVal != 0
+		}
+	},
+	st.ValTypeFloat32: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*float32) = v.FloatVal
+		}
+	},
+	st.ValTypeFloat64: func(variable any) st.PropertyUpdateHandler {
+		return func(v st.PropertyValue) {
+			*variable.(*float64) = float64(v.FloatVal)
+		}
+	},
+}
+
+func (p property) Bind(variable any, t st.PropertyValueType) {
+	p.entity.updateHandlers[p.name] = append(p.entity.updateHandlers[p.name], bindFactoryByType[t](variable))
+}
+
+func (e *Entity) Property(name string) st.Property {
+	ok := e.class.serializer.getFieldPathForName(newFieldPath(), name)
+	if !ok {
+		return nil
+	}
+
+	return property{
+		entity: e,
+		name:   name,
+	}
+}
+
+func (e *Entity) BindProperty(prop string, variable any, t st.PropertyValueType) {
+	e.Property(prop).Bind(variable, t)
+}
+
+func (e *Entity) PropertyValue(name string) (st.PropertyValue, bool) {
+	prop := e.Property(name)
+	if prop == nil {
+		return st.PropertyValue{S2: true}, false
+	}
+
+	v := prop.Value()
+
+	return v, true
+}
+
+func (e *Entity) PropertyValueMust(name string) st.PropertyValue {
+	val, ok := e.PropertyValue(name)
+	if !ok {
+		panic(fmt.Sprintf("property '%s' not found", name))
+	}
+
+	return val
+}
+
+func (e *Entity) ApplyUpdate(reader *bit.BitReader) {
+	panic("not implemented")
+}
 
 const (
-	EntityOpNone           EntityOp = 0x00
-	EntityOpCreated        EntityOp = 0x01
-	EntityOpUpdated        EntityOp = 0x02
-	EntityOpDeleted        EntityOp = 0x04
-	EntityOpEntered        EntityOp = 0x08
-	EntityOpLeft           EntityOp = 0x10
-	EntityOpCreatedEntered EntityOp = EntityOpCreated | EntityOpEntered
-	EntityOpUpdatedEntered EntityOp = EntityOpUpdated | EntityOpEntered
-	EntityOpDeletedLeft    EntityOp = EntityOpDeleted | EntityOpLeft
+	serverClassPlayer = "CCSPlayerPawn"
+
+	propCellX = "CBodyComponent.m_cellX"
+	propCellY = "CBodyComponent.m_cellY"
+	propCellZ = "CBodyComponent.m_cellZ"
+	propVecX  = "CBodyComponent.m_vecX"
+	propVecY  = "CBodyComponent.m_vecY"
+	propVecZ  = "CBodyComponent.m_vecZ"
 )
 
-var entityOpNames = map[EntityOp]string{
-	EntityOpNone:           "None",
-	EntityOpCreated:        "Created",
-	EntityOpUpdated:        "Updated",
-	EntityOpDeleted:        "Deleted",
-	EntityOpEntered:        "Entered",
-	EntityOpLeft:           "Left",
-	EntityOpCreatedEntered: "Created+Entered",
-	EntityOpUpdatedEntered: "Updated+Entered",
-	EntityOpDeletedLeft:    "Deleted+Left",
+func (e *Entity) isPlayer() bool {
+	return e.class.name == serverClassPlayer
 }
 
-// Flag determines whether an EntityOp includes another. This is primarily
-// offered to prevent bit flag errors in downstream clients.
-func (o EntityOp) Flag(p EntityOp) bool {
-	return o&p != 0
+// Returns a coordinate from a cell + offset
+func coordFromCell(cell uint64, offset float32) float64 {
+	const (
+		cellBits    = 9
+		maxCoordInt = 16384
+	)
+
+	return float64(cell*(1<<cellBits)-maxCoordInt) + float64(offset)
 }
 
-// String returns a human identifiable string for the EntityOp
-func (o EntityOp) String() string {
-	return entityOpNames[o]
+func (e *Entity) Position() r3.Vector {
+	cellXProp := e.Property(propCellX)
+	cellYProp := e.Property(propCellY)
+	cellZProp := e.Property(propCellZ)
+	offsetXProp := e.Property(propVecX)
+	offsetYProp := e.Property(propVecY)
+	offsetZProp := e.Property(propVecZ)
+
+	cellX := cellXProp.Value().S2UInt64()
+	cellY := cellYProp.Value().S2UInt64()
+	cellZ := cellZProp.Value().S2UInt64()
+	offsetX := offsetXProp.Value().Float()
+	offsetY := offsetYProp.Value().Float()
+	offsetZ := offsetZProp.Value().Float()
+
+	return r3.Vector{
+		X: coordFromCell(cellX, offsetX),
+		Y: coordFromCell(cellY, offsetY),
+		Z: coordFromCell(cellZ, offsetZ),
+	}
 }
 
-// EntityHandler is a function that receives Entity updates
-type EntityHandler func(*Entity, EntityOp) error
+func (e *Entity) OnPositionUpdate(h func(pos r3.Vector)) {
+	pos := new(r3.Vector)
+	firePosUpdate := func(st.PropertyValue) {
+		newPos := e.Position()
+		if *pos != newPos {
+			h(newPos)
+			*pos = newPos
+		}
+	}
 
-// Entity represents a single game entity in the replay
-type Entity struct {
-	index   int32
-	serial  int32
-	class   *class
-	active  bool
-	state   *fieldState
-	fpCache map[string]*fieldPath
-	fpNoop  map[string]bool
+	e.Property(propCellX).OnUpdate(firePosUpdate)
+	e.Property(propCellY).OnUpdate(firePosUpdate)
+	e.Property(propCellZ).OnUpdate(firePosUpdate)
+	e.Property(propVecX).OnUpdate(firePosUpdate)
+	e.Property(propVecY).OnUpdate(firePosUpdate)
+	e.Property(propVecZ).OnUpdate(firePosUpdate)
+}
+
+func (e *Entity) OnDestroy(delegate func()) {
+	e.onDestroy = append(e.onDestroy, delegate)
+}
+
+func (e *Entity) Destroy() {
+	for _, delegate := range e.onDestroy {
+		delegate()
+	}
+}
+
+func (e *Entity) OnCreateFinished(delegate func()) {
+	e.onCreateFinished = append(e.onCreateFinished, delegate)
 }
 
 // newEntity returns a new entity for the given index, serial and class
 func newEntity(index, serial int32, class *class) *Entity {
 	return &Entity{
-		index:   index,
-		serial:  serial,
-		class:   class,
-		active:  true,
-		state:   newFieldState(),
-		fpCache: make(map[string]*fieldPath),
-		fpNoop:  make(map[string]bool),
+		index:            index,
+		serial:           serial,
+		class:            class,
+		active:           true,
+		state:            newFieldState(),
+		fpCache:          make(map[string]*fieldPath),
+		fpNoop:           make(map[string]bool),
+		onCreateFinished: nil,
+		onDestroy:        nil,
+		updateHandlers:   make(map[string][]st.PropertyUpdateHandler),
 	}
 }
 
 // String returns a human identifiable string for the Entity
 func (e *Entity) String() string {
-	return fmt.Sprintf("%d <%s>", e.index, e.class.name)
+	paths := e.class.getFieldPaths(newFieldPath(), e.state)
+	props := make([]string, len(paths))
+
+	for _, fp := range paths {
+		props = append(props, fmt.Sprintf("%s: %v", e.class.getNameForFieldPath(fp), e.state.get(fp)))
+	}
+
+	return fmt.Sprintf("%d <%s>\n %s", e.index, e.class.name, strings.Join(props, "\n "))
 }
 
 // Map returns a map of current entity state as key-value pairs
@@ -204,15 +399,43 @@ func (p *Parser) FindEntityByHandle(handle uint64) *Entity {
 
 // FilterEntity finds entities by callback
 func (p *Parser) FilterEntity(fb func(*Entity) bool) []*Entity {
-	entities := make([]*Entity, 0, 0)
+	entities := make([]*Entity, 0)
+
 	for _, et := range p.entities {
 		if fb(et) {
 			entities = append(entities, et)
 		}
 	}
+
 	return entities
 }
 
+func (e *Entity) readFields(r *reader) {
+	fps := readFieldPaths(r)
+
+	for _, fp := range fps {
+		decoder := e.class.serializer.getDecoderForFieldPath(fp, 0)
+
+		val := decoder(r)
+		e.state.set(fp, val)
+
+		for _, h := range e.updateHandlers[e.class.getNameForFieldPath(fp)] {
+			h(st.PropertyValue{
+				VectorVal: r3.Vector{},
+				IntVal:    0,
+				Int64Val:  0,
+				ArrayVal:  nil,
+				StringVal: "",
+				FloatVal:  0,
+				Any:       val,
+				S2:        true,
+			})
+		}
+
+		fp.release()
+	}
+}
+
 // Internal Callback for OnCSVCMsg_PacketEntities.
 func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 	r := newReader(m.GetEntityData())
@@ -223,7 +446,6 @@ func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 		cmd     uint32
 		classId int32
 		serial  int32
-		e       *Entity
 	)
 
 	if !m.GetIsDelta() {
@@ -234,17 +456,21 @@ func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 	}
 
 	type tuple struct {
-		e  *Entity
-		op EntityOp
+		ent *Entity
+		op  st.EntityOp
 	}
-	tuples := make([]tuple, 0, updates)
+
+	var tuples []tuple
 
 	for ; updates > 0; updates-- {
+		var (
+			e  *Entity
+			op st.EntityOp
+		)
+
 		next := index + int32(r.readUBitVar()) + 1
 		index = next
 
-		var op EntityOp
-
 		cmd = r.readBits(2)
 		if cmd&0x01 == 0 {
 			if cmd&0x02 != 0 {
@@ -264,22 +490,33 @@ func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 
 				e = newEntity(index, serial, class)
 				p.entities[index] = e
-				readFields(newReader(baseline), class.serializer, e.state)
-				readFields(r, class.serializer, e.state)
 
-				op = EntityOpCreated | EntityOpEntered
+				e.readFields(newReader(baseline))
+				e.readFields(r)
+
+				// Fire created-handlers so update-handlers can be registered
+				for _, h := range class.createdHandlers {
+					h(e)
+				}
+
+				// Fire all post-creation actions
+				for _, f := range e.onCreateFinished {
+					f()
+				}
+
+				op = st.EntityOpCreated | st.EntityOpEntered
 			} else {
 				if e = p.entities[index]; e == nil {
 					_panicf("unable to find existing entity %d", index)
 				}
 
-				op = EntityOpUpdated
+				op = st.EntityOpUpdated
 				if !e.active {
 					e.active = true
-					op |= EntityOpEntered
+					op |= st.EntityOpEntered
 				}
 
-				readFields(r, e.class.serializer, e.state)
+				e.readFields(r)
 			}
 		} else {
 			if e = p.entities[index]; e == nil {
@@ -290,26 +527,38 @@ func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 				_panicf("entity %d (%s) ordered to leave, already inactive", e.class.classId, e.class.name)
 			}
 
-			op = EntityOpLeft
+			op = st.EntityOpLeft
 			if cmd&0x02 != 0 {
-				op |= EntityOpDeleted
-				p.entities[index] = nil
+				op |= st.EntityOpDeleted
+				delete(p.entities, index)
 			}
 		}
 
 		tuples = append(tuples, tuple{e, op})
 	}
 
-	if r.remBytes() > 1 || r.bitCount > 7 {
-		// FIXME: maybe we should panic("didn't consume all data")
-	}
+	for _, t := range tuples {
+		e := t.ent
 
-	for _, h := range p.entityHandlers {
-		for _, t := range tuples {
-			if err := h(t.e, t.op); err != nil {
+		for _, h := range p.entityHandlers {
+			if err := h(e, t.op); err != nil {
 				return err
 			}
 		}
+
+		if t.op&st.EntityOpCreated != 0 {
+			for prop, hs := range e.updateHandlers {
+				v := e.PropertyValueMust(prop)
+
+				for _, h := range hs {
+					h(v)
+				}
+			}
+		}
+	}
+
+	if r.remBytes() > 1 || r.bitCount > 7 {
+		// FIXME: maybe we should panic("didn't consume all data")
 	}
 
 	return nil
@@ -317,6 +566,6 @@ func (p *Parser) OnPacketEntities(m *msgs2.CSVCMsg_PacketEntities) error {
 
 // OnEntity registers an EntityHandler that will be called when an entity
 // is created, updated, deleted, etc.
-func (p *Parser) OnEntity(h EntityHandler) {
+func (p *Parser) OnEntity(h st.EntityHandler) {
 	p.entityHandlers = append(p.entityHandlers, h)
 }
diff --git a/pkg/demoinfocs/sendtables2/field_path.go b/pkg/demoinfocs/sendtables2/field_path.go
index 28e61cd..0128701 100644
--- a/pkg/demoinfocs/sendtables2/field_path.go
+++ b/pkg/demoinfocs/sendtables2/field_path.go
@@ -22,7 +22,7 @@ type fieldPathOp struct {
 
 var fieldPathTable = []fieldPathOp{
 	{"PlusOne", 36271, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 	}},
 	{"PlusTwo", 10334, func(r *reader, fp *fieldPath) {
 		fp.path[fp.last] += 2
@@ -45,12 +45,12 @@ var fieldPathTable = []fieldPathOp{
 		fp.path[fp.last] = r.readUBitVarFieldPath()
 	}},
 	{"PushOneLeftDeltaOneRightZero", 521, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] = 0
 	}},
 	{"PushOneLeftDeltaOneRightNonZero", 2942, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] = r.readUBitVarFieldPath()
 	}},
@@ -103,21 +103,21 @@ var fieldPathTable = []fieldPathOp{
 		fp.path[fp.last] = int(r.readBits(5))
 	}},
 	{"PushTwoLeftDeltaOne", 0, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] += r.readUBitVarFieldPath()
 		fp.last++
 		fp.path[fp.last] += r.readUBitVarFieldPath()
 	}},
 	{"PushTwoPack5LeftDeltaOne", 0, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] += int(r.readBits(5))
 		fp.last++
 		fp.path[fp.last] += int(r.readBits(5))
 	}},
 	{"PushThreeLeftDeltaOne", 0, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] += r.readUBitVarFieldPath()
 		fp.last++
@@ -126,7 +126,7 @@ var fieldPathTable = []fieldPathOp{
 		fp.path[fp.last] += r.readUBitVarFieldPath()
 	}},
 	{"PushThreePack5LeftDeltaOne", 0, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 		fp.last++
 		fp.path[fp.last] += int(r.readBits(5))
 		fp.last++
@@ -188,7 +188,7 @@ var fieldPathTable = []fieldPathOp{
 	}},
 	{"PopOnePlusOne", 2, func(r *reader, fp *fieldPath) {
 		fp.pop(1)
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 	}},
 	{"PopOnePlusN", 0, func(r *reader, fp *fieldPath) {
 		fp.pop(1)
@@ -196,7 +196,7 @@ var fieldPathTable = []fieldPathOp{
 	}},
 	{"PopAllButOnePlusOne", 1837, func(r *reader, fp *fieldPath) {
 		fp.pop(fp.last)
-		fp.path[0] += 1
+		fp.path[0]++
 	}},
 	{"PopAllButOnePlusN", 149, func(r *reader, fp *fieldPath) {
 		fp.pop(fp.last)
@@ -212,7 +212,7 @@ var fieldPathTable = []fieldPathOp{
 	}},
 	{"PopNPlusOne", 0, func(r *reader, fp *fieldPath) {
 		fp.pop(r.readUBitVarFieldPath())
-		fp.path[fp.last] += 1
+		fp.path[fp.last]++
 	}},
 	{"PopNPlusN", 0, func(r *reader, fp *fieldPath) {
 		fp.pop(r.readUBitVarFieldPath())
@@ -234,7 +234,7 @@ var fieldPathTable = []fieldPathOp{
 		}
 	}},
 	{"NonTopoPenultimatePlusOne", 271, func(r *reader, fp *fieldPath) {
-		fp.path[fp.last-1] += 1
+		fp.path[fp.last-1]++
 	}},
 	{"NonTopoComplexPack4Bits", 99, func(r *reader, fp *fieldPath) {
 		for i := 0; i <= fp.last; i++ {
@@ -309,11 +309,13 @@ func (fp *fieldPath) release() {
 func readFieldPaths(r *reader) []*fieldPath {
 	fp := newFieldPath()
 
-	node, next := huffTree, huffTree
+	node := huffTree
 
 	paths := []*fieldPath{}
 
 	for !fp.done {
+		var next huffmanTree
+
 		if r.readBits(1) == 1 {
 			next = node.Right()
 		} else {
diff --git a/pkg/demoinfocs/sendtables2/field_reader.go b/pkg/demoinfocs/sendtables2/field_reader.go
deleted file mode 100644
index 21cdada..0000000
--- a/pkg/demoinfocs/sendtables2/field_reader.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package sendtables2
-
-func readFields(r *reader, s *serializer, state *fieldState) {
-	fps := readFieldPaths(r)
-
-	for _, fp := range fps {
-		decoder := s.getDecoderForFieldPath(fp, 0)
-
-		val := decoder(r)
-		state.set(fp, val)
-
-		fp.release()
-	}
-}
diff --git a/pkg/demoinfocs/sendtables2/parser.go b/pkg/demoinfocs/sendtables2/parser.go
index 44365e4..5ec13f2 100644
--- a/pkg/demoinfocs/sendtables2/parser.go
+++ b/pkg/demoinfocs/sendtables2/parser.go
@@ -2,6 +2,7 @@ package sendtables2
 
 import (
 	"math"
+	"strings"
 
 	"google.golang.org/protobuf/proto"
 
@@ -57,17 +58,44 @@ type Parser struct {
 	classesByName     map[string]*class
 	entityFullPackets int
 	entities          map[int32]*Entity
-	entityHandlers    []EntityHandler
+	entityHandlers    []st.EntityHandler
 }
 
 func (p *Parser) ReadEnterPVS(r *bit.BitReader, index int, entities map[int]st.Entity, slot int) st.Entity {
-	// TODO implement me
 	panic("implement me")
 }
 
+type serverClasses Parser
+
+func (sc *serverClasses) All() (res []st.ServerClass) {
+	for _, c := range sc.classesById {
+		res = append(res, c)
+	}
+
+	return
+}
+
+func (sc *serverClasses) FindByName(name string) st.ServerClass {
+	class := sc.classesByName[name]
+	if class == nil {
+		return nil
+	}
+
+	return sc.classesByName[name]
+}
+
+func (sc *serverClasses) String() string {
+	names := make([]string, 0, len(sc.classesById))
+
+	for _, c := range sc.classesById {
+		names = append(names, c.name)
+	}
+
+	return strings.Join(names, "\n")
+}
+
 func (p *Parser) ServerClasses() st.ServerClasses {
-	// TODO implement me
-	panic("implement me")
+	return (*serverClasses)(p)
 }
 
 func NewParser() *Parser {
diff --git a/pkg/demoinfocs/sendtables2/reader.go b/pkg/demoinfocs/sendtables2/reader.go
index 00fa621..10062ed 100644
--- a/pkg/demoinfocs/sendtables2/reader.go
+++ b/pkg/demoinfocs/sendtables2/reader.go
@@ -39,10 +39,12 @@ func (r *reader) remBytes() uint32 {
 
 // nextByte reads the next byte from the buffer
 func (r *reader) nextByte() byte {
-	r.pos += 1
+	r.pos++
+
 	if r.pos > r.size {
 		_panicf("nextByte: insufficient buffer (%d of %d)", r.pos, r.size)
 	}
+
 	return r.buf[r.pos-1]
 }
 
@@ -166,13 +168,13 @@ func (r *reader) readUBitVar() uint32 {
 	switch ret & 0x30 {
 	case 16:
 		ret = (ret & 15) | (r.readBits(4) << 4)
-		break
+
 	case 32:
 		ret = (ret & 15) | (r.readBits(8) << 4)
-		break
+
 	case 48:
 		ret = (ret & 15) | (r.readBits(28) << 4)
-		break
+
 	}
 
 	return ret


Test patch:
diff --git a/pkg/demoinfocs/common/common_test.go b/pkg/demoinfocs/common/common_test.go
index 31a69f2..fe582b4 100644
--- a/pkg/demoinfocs/common/common_test.go
+++ b/pkg/demoinfocs/common/common_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/golang/geo/r3"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 
 	st "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables"
 	stfake "github.com/markus-wa/demoinfocs-golang/v3/pkg/demoinfocs/sendtables/fake"
@@ -179,6 +180,10 @@ type demoInfoProviderMock struct {
 	equipment            *Equipment
 }
 
+func (p demoInfoProviderMock) FindEntityByHandle(handle uint64) st.Entity {
+	panic("implement me")
+}
+
 func (p demoInfoProviderMock) TickRate() float64 {
 	return p.tickRate
 }
@@ -229,6 +234,8 @@ func entityWithProperty(propName string, value st.PropertyValue) *stfake.Entity
 func entityWithProperties(properties []fakeProp) *stfake.Entity {
 	entity := entityWithID(1)
 
+	entity.On("Property", mock.Anything).Return(nil)
+
 	for _, prop := range properties {
 		property := new(stfake.Property)
 		property.On("Value").Return(prop.value)
diff --git a/pkg/demoinfocs/datatables_test.go b/pkg/demoinfocs/datatables_test.go
index 0dba91a..6497bca 100644
--- a/pkg/demoinfocs/datatables_test.go
+++ b/pkg/demoinfocs/datatables_test.go
@@ -36,11 +36,11 @@ func TestParser_BindNewPlayer_Issue98(t *testing.T) {
 	}
 
 	bot := fakePlayerEntity(1)
-	p.bindNewPlayer(bot)
+	p.bindNewPlayerS1(bot)
 	bot.Destroy()
 
 	player := fakePlayerEntity(2)
-	p.bindNewPlayer(player)
+	p.bindNewPlayerS1(player)
 
 	assert.Len(t, p.GameState().Participants().Connected(), 1)
 }
@@ -58,13 +58,13 @@ func TestParser_BindNewPlayer_Issue98_Reconnect(t *testing.T) {
 	}
 
 	player := fakePlayerEntity(1)
-	p.bindNewPlayer(player)
+	p.bindNewPlayerS1(player)
 	player.Destroy()
 
 	p.RegisterEventHandler(func(events.PlayerConnect) {
 		t.Error("expected no more PlayerConnect events but got one")
 	})
-	p.bindNewPlayer(player)
+	p.bindNewPlayerS1(player)
 
 	assert.Len(t, p.GameState().Participants().All(), 1)
 }
@@ -100,7 +100,7 @@ func testPlayerSpotted(t *testing.T, propName string) {
 
 	spotted.On("Property", propName).Return(spottedByProp0)
 	configurePlayerEntityMock(1, spotted)
-	p.bindNewPlayer(spotted)
+	p.bindNewPlayerS1(spotted)
 
 	var actual events.PlayerSpottersChanged
 	p.RegisterEventHandler(func(e events.PlayerSpottersChanged) {
diff --git a/pkg/demoinfocs/demoinfocs_test.go b/pkg/demoinfocs/demoinfocs_test.go
index f48cc08..065afcb 100644
--- a/pkg/demoinfocs/demoinfocs_test.go
+++ b/pkg/demoinfocs/demoinfocs_test.go
@@ -217,8 +217,8 @@ func TestS2(t *testing.T) {
 	_, err = p.ParseHeader()
 	assertions.NoError(err, "error returned by Parser.ParseHeader()")
 
-	p.RegisterEventHandler(func(event events.GenericGameEvent) {
-		fmt.Println(event.Name, event)
+	p.RegisterEventHandler(func(e any) {
+		fmt.Printf("%#v\n", e)
 	})
 
 	t.Log("Parsing to end")
diff --git a/pkg/demoinfocs/events/events_test.go b/pkg/demoinfocs/events/events_test.go
index bed8451..a8554cd 100644
--- a/pkg/demoinfocs/events/events_test.go
+++ b/pkg/demoinfocs/events/events_test.go
@@ -58,6 +58,10 @@ func TestKill_IsWallBang(t *testing.T) {
 type demoInfoProviderMock struct {
 }
 
+func (p demoInfoProviderMock) FindEntityByHandle(handle uint64) st.Entity {
+	panic("implement me")
+}
+
 func (p demoInfoProviderMock) IngameTick() int {
 	return 0
 }
diff --git a/pkg/demoinfocs/sendtables/entity_test.go b/pkg/demoinfocs/sendtables/entity_test.go
index dc6bd1e..4f52dda 100644
--- a/pkg/demoinfocs/sendtables/entity_test.go
+++ b/pkg/demoinfocs/sendtables/entity_test.go
@@ -16,7 +16,7 @@ var testData = struct {
 			{value: PropertyValue{IntVal: 30}},
 		},
 		serialNum: 1337,
-		serverClass: &ServerClass{propNameToIndex: map[string]int{
+		serverClass: &serverClass{propNameToIndex: map[string]int{
 			"myProp":     0,
 			"test":       1,
 			"anotherOne": 2,
diff --git a/pkg/demoinfocs/sendtables/sendtables_test.go b/pkg/demoinfocs/sendtables/sendtables_test.go
index 7730d22..ebb7b86 100644
--- a/pkg/demoinfocs/sendtables/sendtables_test.go
+++ b/pkg/demoinfocs/sendtables/sendtables_test.go
@@ -7,7 +7,7 @@ import (
 )
 
 func TestServerClassGetters(t *testing.T) {
-	sc := ServerClass{
+	sc := serverClass{
 		id:            1,
 		name:          "TestClass",
 		dataTableID:   2,
@@ -21,7 +21,7 @@ func TestServerClassGetters(t *testing.T) {
 }
 
 func TestServerClassPropertyEntries(t *testing.T) {
-	var sc ServerClass
+	var sc serverClass
 
 	assert.Empty(t, sc.PropertyEntries())
 
@@ -31,14 +31,14 @@ func TestServerClassPropertyEntries(t *testing.T) {
 }
 
 func TestServerClassString(t *testing.T) {
-	sc := ServerClass{
+	sc := serverClass{
 		id:            1,
 		name:          "TestClass",
 		dataTableID:   2,
 		dataTableName: "ADataTable",
 	}
 
-	expectedString := `ServerClass: id=1 name=TestClass
+	expectedString := `serverClass: id=1 name=TestClass
 	dataTableId=2
 	dataTableName=ADataTable
 	baseClasses:
@@ -48,10 +48,10 @@ func TestServerClassString(t *testing.T) {
 
 	assert.Equal(t, expectedString, sc.String())
 
-	sc.baseClasses = []*ServerClass{{name: "AnotherClass"}, {name: "YetAnotherClass"}}
+	sc.baseClasses = []*serverClass{{name: "AnotherClass"}, {name: "YetAnotherClass"}}
 	sc.flattenedProps = []flattenedPropEntry{{name: "prop1"}, {name: "prop2"}}
 
-	expectedString = `ServerClass: id=1 name=TestClass
+	expectedString = `serverClass: id=1 name=TestClass
 	dataTableId=2
 	dataTableName=ADataTable
 	baseClasses: