Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

parser: add Parser.FrameRate() and Parser.FrameTime() for demos with corrupt headers (#235) #237

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pkg/demoinfocs/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ type DemoHeader struct {
// Not necessarily the tick-rate the server ran on during the game.
//
// Returns 0 if PlaybackTime or PlaybackFrames are 0 (corrupt demo headers).
func (h *DemoHeader) FrameRate() float64 {
// Deprecated: see Parser.FrameRate() for a more resilient implementation that should work with corrupt demo headers.
func (h DemoHeader) FrameRate() float64 {
if h.PlaybackTime == 0 {
return 0
}
Expand All @@ -53,7 +54,8 @@ func (h *DemoHeader) FrameRate() float64 {
// FrameTime returns the time a frame / demo-tick takes in seconds.
//
// Returns 0 if PlaybackTime or PlaybackFrames are 0 (corrupt demo headers).
func (h *DemoHeader) FrameTime() time.Duration {
// Deprecated: see Parser.FrameTime() for a more resilient implementation that should work with corrupt demo headers.
func (h DemoHeader) FrameTime() time.Duration {
if h.PlaybackFrames == 0 {
return 0
}
Expand Down
34 changes: 32 additions & 2 deletions pkg/demoinfocs/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,13 +447,43 @@ type SayText2 struct {
IsChatAll bool // Seems to always be false, team chat might not be recorded
}

// TickRateInfoAvailable signals that the tick-rate information has been received via CSVCMsg_ServerInfo.
// TickRateSource is the type for TickRateInfo.Source.
// Useful for when you have a preference of which tick-rate you'd like to use over others.
type TickRateSource byte

const (
TickRateSourceHeader TickRateSource = iota
TickRateSourceServerInfo
)

// TickRateInfo signals that the tick-rate information has been received via CSVCMsg_ServerInfo.
// This can be useful for corrupt demo headers where the tick-rate is missing in the beginning of the demo.
type TickRateInfoAvailable struct {
type TickRateInfo struct {
Source TickRateSource
TickRate float64 // See Parser.TickRate()
TickTime time.Duration // See Parser.TickTime()
}

// FrameRateSource is the type for FrameRateInfo.Source.
// Useful for when you have a preference of which frame-rate you'd like to use over others.
type FrameRateSource byte

const (
FrameRateSourceHeader FrameRateSource = iota
FrameRateSourceConVars
FrameRateSourceCalibration
)

// FrameRateInfo signals that the demo's frame rate is available.
// This can happen either by reading the demo header,
// or if that is corrupt then once enough frames have passed to calibrate th frame-rate manually.
// See also ParserConfig.FrameRateCalibrationFrames.
type FrameRateInfo struct {
Source FrameRateSource // specifies from where the frame-rate (or the estimation of it) comes. see FrameRateSource.
FrameRate float64
FrameTime time.Duration
}

// ChatMessage signals a player generated chat message.
// Since team chat is generally not recorded IsChatAll will probably always be false.
// See SayText for admin / console messages and SayText2 for raw network package data.
Expand Down
10 changes: 10 additions & 0 deletions pkg/demoinfocs/fake/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ func (p *Parser) TickTime() time.Duration {
return p.Called().Get(0).(time.Duration)
}

// FrameRateCalculated is a mock-implementation of Parser.FrameRateCalculated().
func (p *Parser) FrameRateCalculated() float64 {
return p.Called().Get(0).(float64)
}

// FrameTimeCalculated is a mock-implementation of Parser.FrameTimeCalculated().
func (p *Parser) FrameTimeCalculated() time.Duration {
return p.Called().Get(0).(time.Duration)
}

// Progress is a mock-implementation of Parser.Progress().
func (p *Parser) Progress() float32 {
return p.Called().Get(0).(float32)
Expand Down
7 changes: 0 additions & 7 deletions pkg/demoinfocs/game_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,6 @@ type lastFlash struct {
projectileByPlayer map[*common.Player]*common.GrenadeProjectile
}

type ingameTickNumber int

func (gs *gameState) handleIngameTickNumber(n ingameTickNumber) {
gs.ingameTick = int(n)
debugIngameTick(gs.ingameTick)
}

// IngameTick returns the latest actual tick number of the server during the game.
//
// Watch out, I've seen this return wonky negative numbers at the start of demos.
Expand Down
24 changes: 23 additions & 1 deletion pkg/demoinfocs/net_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package demoinfocs

import (
"bytes"
"strconv"
"time"

bit "github.com/markus-wa/demoinfocs-golang/v2/internal/bitread"
events "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/events"
Expand Down Expand Up @@ -57,16 +59,36 @@ func (p *parser) handleSetConVar(setConVar *msg.CNETMsg_SetConVar) {
p.gameState.rules.conVars[cvar.Name] = cvar.Value
}

// note we should only update the frame rate if it's not determinable from the header
// this is because changing the frame rate mid game has no effect on the active recording
if rate, ok := updated["tv_snapshotrate"]; ok && p.frameRate == 0 {
tvSnapshotRate, err := strconv.Atoi(rate)
if err != nil {
p.setError(err)
} else {
p.setFrameRate(float64(tvSnapshotRate), events.FrameRateSourceConVars)
}
}

p.eventDispatcher.Dispatch(events.ConVarsUpdated{
UpdatedConVars: updated,
})
}

func frameRateInfoAvailableEvent(rate float64, source events.FrameRateSource) events.FrameRateInfo {
return events.FrameRateInfo{
Source: source,
FrameRate: rate,
FrameTime: time.Duration(float64(time.Second) / rate),
}
}

func (p *parser) handleServerInfo(srvInfo *msg.CSVCMsg_ServerInfo) {
// srvInfo.MapCrc might be interesting as well
p.tickInterval = srvInfo.TickInterval

p.eventDispatcher.Dispatch(events.TickRateInfoAvailable{
p.eventDispatcher.Dispatch(events.TickRateInfo{
Source: events.TickRateSourceServerInfo,
TickRate: p.TickRate(),
TickTime: p.TickTime(),
})
Expand Down
89 changes: 76 additions & 13 deletions pkg/demoinfocs/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type parser struct {
userMessageHandler userMessageHandler
eventDispatcher *dp.Dispatcher
currentFrame int // Demo-frame, not ingame-tick
frameRate float64 // Calibrated frame-rate for corrupt demo headers, only available after calibration
tickInterval float32 // Duration between ticks in seconds
header *common.DemoHeader // Pointer so we can check for nil
gameState *gameState
Expand All @@ -64,16 +65,18 @@ type parser struct {

// Additional fields, mainly caching & tracking things

bombsiteA bombsite
bombsiteB bombsite
equipmentMapping map[*st.ServerClass]common.EquipmentType // Maps server classes to equipment-types
rawPlayers map[int]*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)
gameEventDescs map[int32]*msg.CSVCMsg_GameEventListDescriptorT // Maps game-event IDs to descriptors
grenadeModelIndices map[int]common.EquipmentType // Used to map model indices to grenades (used for grenade projectiles)
stringTables []*msg.CSVCMsg_CreateStringTable // Contains all created sendtables, needed when updating them
delayedEventHandlers []func() // Contains event handlers that need to be executed at the end of a tick (e.g. flash events because FlashDuration isn't updated before that)
bombsiteA bombsite
bombsiteB bombsite
equipmentMapping map[*st.ServerClass]common.EquipmentType // Maps server classes to equipment-types
rawPlayers map[int]*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)
gameEventDescs map[int32]*msg.CSVCMsg_GameEventListDescriptorT // Maps game-event IDs to descriptors
grenadeModelIndices map[int]common.EquipmentType // Used to map model indices to grenades (used for grenade projectiles)
stringTables []*msg.CSVCMsg_CreateStringTable // Contains all created sendtables, needed when updating them
delayedEventHandlers []func() // Contains event handlers that need to be executed at the end of a tick (e.g. flash events because FlashDuration isn't updated before that)
tickDiffs map[int]int // Used for frame rate calibration if the demo header is corrupt
frameRateCalibrationFrames int // See ParserConfig.FrameRateCalibrationFrames
}

// NetMessageCreator creates additional net-messages to be dispatched to net-message handlers.
Expand Down Expand Up @@ -174,6 +177,43 @@ func legayTickTime(h common.DemoHeader) time.Duration {
return time.Duration(h.PlaybackTime.Nanoseconds() / int64(h.PlaybackTicks))
}

// FrameRateCalculated returns the frame rate of the demo (frames aka. demo-ticks per second).
// Not necessarily the tick-rate the server ran on during the game.
//
// Returns frame rate from DemoHeader if it's not corrupt.
// Otherwise returns frame rate that has automatically bee calibrated or read from tv_snapshotrate.
// May also return -1 before calibration has finished.
// See also events.FrameRateInfo.
func (p *parser) FrameRateCalculated() float64 {
if p.header != nil && p.header.PlaybackTime != 0 && p.header.PlaybackFrames != 0 {
return legacyFrameRate(*p.header)
}

if p.frameRate > 0 {
return p.frameRate
}

return -1
}

func legacyFrameRate(h common.DemoHeader) float64 {
return float64(h.PlaybackFrames) / h.PlaybackTime.Seconds()
}

// FrameTimeCalculated returns the time a frame / demo-tick takes in seconds.
//
// Returns frame time from DemoHeader if it's not corrupt.
// Otherwise returns frame time that has automatically bee calibrated or calculated from tv_snapshotrate.
// May also return -1 before calibration has finished.
// See also events.FrameRateInfo.
func (p *parser) FrameTimeCalculated() time.Duration {
if frameRate := p.FrameRateCalculated(); frameRate > 0 {
return time.Duration(float64(time.Second) / frameRate)
}

return -1
}

// Progress returns the parsing progress from 0 to 1.
// Where 0 means nothing has been parsed yet and 1 means the demo has been parsed to the end.
//
Expand Down Expand Up @@ -289,13 +329,28 @@ type ParserConfig struct {
// The creators should return a new instance of the correct protobuf-message type (from the msg package).
// Interesting net-message-IDs can easily be discovered with the build-tag 'debugdemoinfocs'; when looking for 'UnhandledMessage'.
// Check out parsing.go to see which net-messages are already being parsed by default.
// This is a beta feature and may be changed or replaced without notice.
AdditionalNetMessageCreators map[int]NetMessageCreator

// FrameRateCalibrationFrames defines the number of frames the parser should wait until determining the frame rate of the demo.
// This value is only used if the frame rate cannot be determined from the demo header.
// This determines at what point the FrameRateCalibrated event will be raised.
// Negative values will raise the event at the end of the demo.
// Values below demoinfocs.MinFrameRateCalibrationFrames will set the value to MinFrameRateCalibrationFrames (defaults to 1000).
// See also https://github.com/markus-wa/demoinfocs-golang/issues/235
FrameRateCalibrationFrames int
}

const (
minFrameRateCalibrationFramesDefault = 1000
frameRateCalibrationFramesDefault = 1000
)

var MinFrameRateCalibrationFrames = minFrameRateCalibrationFramesDefault

// DefaultParserConfig is the default Parser configuration used by NewParser().
var DefaultParserConfig = ParserConfig{
MsgQueueBufferSize: -1,
MsgQueueBufferSize: -1,
FrameRateCalibrationFrames: frameRateCalibrationFramesDefault,
}

// NewParserWithConfig returns a new Parser with a custom configuration.
Expand All @@ -315,6 +370,14 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
p.grenadeModelIndices = make(map[int]common.EquipmentType)
p.gameEventHandler = newGameEventHandler(&p)
p.userMessageHandler = newUserMessageHandler(&p)
p.currentFrame = -1
p.tickDiffs = make(map[int]int)

if config.FrameRateCalibrationFrames >= 0 && config.FrameRateCalibrationFrames < MinFrameRateCalibrationFrames {
p.frameRateCalibrationFrames = MinFrameRateCalibrationFrames
} else {
p.frameRateCalibrationFrames = config.FrameRateCalibrationFrames
}

dispatcherCfg := dp.Config{
PanicHandler: func(v interface{}) {
Expand All @@ -334,7 +397,7 @@ func NewParserWithConfig(demostream io.Reader, config ParserConfig) Parser {
p.msgDispatcher.RegisterHandler(p.handleSetConVar)
p.msgDispatcher.RegisterHandler(p.handleFrameParsed)
p.msgDispatcher.RegisterHandler(p.handleServerInfo)
p.msgDispatcher.RegisterHandler(p.gameState.handleIngameTickNumber)
p.msgDispatcher.RegisterHandler(p.handleIngameTickNumber)

if config.MsgQueueBufferSize >= 0 {
p.initMsgQueue(config.MsgQueueBufferSize)
Expand Down
15 changes: 15 additions & 0 deletions pkg/demoinfocs/parser_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ type Parser interface {
// Returns tick time based on CSVCMsg_ServerInfo if possible.
// Otherwise returns tick time based on demo header or -1 if the header info isn't available.
TickTime() time.Duration
// FrameRateCalculated returns the frame rate of the demo (frames aka. demo-ticks per second).
// Not necessarily the tick-rate the server ran on during the game.
//
// Returns frame rate from DemoHeader if it's not corrupt.
// Otherwise returns frame rate that has automatically bee calibrated or read from tv_snapshotrate.
// May also return -1 before calibration has finished.
// See also events.FrameRateInfo.
FrameRateCalculated() float64
// FrameTimeCalculated returns the time a frame / demo-tick takes in seconds.
//
// Returns frame time from DemoHeader if it's not corrupt.
// Otherwise returns frame time that has automatically bee calibrated or calculated from tv_snapshotrate.
// May also return -1 before calibration has finished.
// See also events.FrameRateInfo.
FrameTimeCalculated() time.Duration
// Progress returns the parsing progress from 0 to 1.
// Where 0 means nothing has been parsed yet and 1 means the demo has been parsed to the end.
//
Expand Down
66 changes: 66 additions & 0 deletions pkg/demoinfocs/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,72 @@ func TestParser_TickTime_FallbackToHeader(t *testing.T) {
assert.Equal(t, time.Duration(200)*time.Millisecond, p.TickTime())
}

func TestParser_FrameRate(t *testing.T) {
p := &parser{
header: &common.DemoHeader{
PlaybackTime: time.Second,
PlaybackTicks: 128,
},
currentFrame: 1,
gameState: &gameState{
ingameTick: 1,
},
}

assert.Equal(t, float64(128), p.FrameRateCalculated())
}

func TestParser_FrameRate_FallbackToHeader(t *testing.T) {
p := &parser{
header: &common.DemoHeader{
PlaybackTime: time.Second,
PlaybackFrames: 128,
},
gameState: new(gameState),
}

assert.Equal(t, float64(128), p.FrameRateCalculated())
}

func TestParser_FrameRate_Minus1(t *testing.T) {
p := &parser{gameState: new(gameState)}

assert.Equal(t, float64(-1), p.FrameRateCalculated())
}

func TestParser_FrameTime(t *testing.T) {
p := &parser{
header: &common.DemoHeader{
PlaybackTime: time.Second,
PlaybackTicks: 5,
},
currentFrame: 1,
gameState: &gameState{
ingameTick: 1,
},
}

assert.Equal(t, 200*time.Millisecond, p.FrameTimeCalculated())
}

func TestParser_FrameTime_FallbackToHeader(t *testing.T) {
p := &parser{
header: &common.DemoHeader{
PlaybackTime: time.Second,
PlaybackFrames: 5,
},
gameState: new(gameState),
}

assert.Equal(t, 200*time.Millisecond, p.FrameTimeCalculated())
}

func TestParser_FrameTime_Minus1(t *testing.T) {
p := &parser{gameState: new(gameState)}

assert.Equal(t, time.Duration(-1), p.FrameTimeCalculated())
}

func TestParser_Progress_NoHeader(t *testing.T) {
assert.Zero(t, new(parser).Progress())
assert.Zero(t, (&parser{header: &common.DemoHeader{}}).Progress())
Expand Down
Loading