Compare commits

...

5 commits

Author SHA1 Message Date
0ebcc76135 Seeking works from paused state
Read keyframe directly in SVD_Seek_f instead of deferring to
SVD_PlaybackFrame (which is blocked when paused). Both svdemo_seek
and svdemo_seekexact now work regardless of pause state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:35:14 +08:00
d0a4310bad Add svdemo_seekexact for precise seeking with read-forward
svdemo_seek snaps to the nearest keyframe (fast, ±interval accuracy).
svdemo_seekexact reads forward from the keyframe to the exact target
time, giving frame-accurate positioning at the cost of a brief
processing delay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:38:22 +08:00
a8bfa738b0 Fix seeking: snapshot timing, backwards time, entity cleanup
Server:
- Reset nextSnapshotTime for active clients after seek so
  SV_SendClientMessages doesn't skip sending (was comparing
  future nextSnapshotTime against past svs.time)
- First frame always a keyframe (beginning seekable)
- Keyframes during normal playback only reset delta state,
  no SNAPFLAG_RESET_ENTITIES (no visual glitch every 5 sec)

Engine client:
- Reset cl.oldFrameServerTime on SERVERCOUNT toggle to prevent
  "time went backwards" error in CL_SetCGameTime

cgame:
- Handle backwards time in CG_ProcessSnapshots for demo seek:
  accept the time jump, reset cg.time, clear all entity state
  (currentValid, interpolate, time-dependent fields), clear
  local entities (particles, gibs, smoke), wait for next snapshot
- Prevents CG_InterpolateEntityPosition NULL nextSnap error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:31:47 +08:00
628007ec57 Keyframe recording and seeking
Recording:
- svdemo_keyframeInterval cvar (default 5 seconds, 0 = disabled)
- Periodic delta state reset produces full-state frames
- Frame flag bit 1 marks keyframes for reader to reset delta state
- Keyframe index (time + file offset) written at end of file
  with numKf_copy trailer for efficient playback loading
- Dynamic allocation for unlimited keyframe count

Playback:
- Keyframe index loaded from file footer on playback start
- svdemo_seek <seconds>: relative seek from current time
  Finds nearest keyframe at or before target, seeks file position,
  resets delta state + svs.time, sets SNAPFLAG_RESET_ENTITIES
  via demo.seeked flag (one-shot on next frame read)
- Normal keyframe frames only reset delta state (no visual glitch)
- Map restart frames also set SNAPFLAG_RESET_ENTITIES

Demo format: keyframe flag (bit 1) added to frame flags byte.
Backward compatible — old demos have no keyframes (numKf=0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:55:44 +08:00
cbd2d1f6ef Remove SNAPFLAG_RESET_ENTITIES from unpause (cleared before reaching client)
The flag was cleared at the start of SVD_PlaybackFrame before the
snapshot was built — it never reached the client. Only SERVERCOUNT
toggle is needed for the time delta reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:51:59 +08:00
6 changed files with 303 additions and 11 deletions

View file

@ -402,8 +402,34 @@ void CG_ProcessSnapshots( void ) {
CG_SetNextSnap( snap );
// if time went backwards, we have a level restart
// if time went backwards, we have a level restart or demo seek
if ( cg.nextSnap->serverTime < cg.snap->serverTime ) {
if ( cg.svDemoPlayback ) {
// demo seek — discard old snap, use nextSnap as current,
// and wait for another snapshot before rendering
cg.snap = cg.nextSnap;
cg.nextSnap = NULL;
cg.time = cg.snap->serverTime;
// reset all entity state and time-dependent fields
{
int e;
for ( e = 0; e < MAX_GENTITIES; e++ ) {
cg_entities[e].currentValid = qfalse;
cg_entities[e].interpolate = qfalse;
cg_entities[e].muzzleFlashTime = 0;
cg_entities[e].trailTime = 0;
cg_entities[e].dustTrailTime = 0;
cg_entities[e].miscTime = 0;
cg_entities[e].snapShotTime = 0;
cg_entities[e].previousEvent = 0;
cg_entities[e].teleportFlag = 0;
}
}
// clear local entities (particles, gibs, etc.)
// they reference old times and would render incorrectly
CG_InitLocalEntities();
break; // exit loop, wait for next snapshot
}
CG_Error( "CG_ProcessSnapshots: Server time went backwards" );
}
}

View file

@ -308,6 +308,7 @@ void CL_ParseSnapshot( msg_t *msg ) {
cl.serverTimeDelta = cl.snap.serverTime - cls.realtime;
cl.oldServerTime = cl.snap.serverTime;
cl.serverTime = cl.snap.serverTime;
cl.oldFrameServerTime = cl.snap.serverTime; // prevent backwards time error on seek
}
}

View file

@ -355,6 +355,8 @@ void SVD_StopPlay_f( void );
void SVD_CleanupPlayback( void );
void SVD_Stop_f( void );
void SVD_Pause_f( void );
void SVD_Seek_f( void );
void SVD_SeekExact_f( void );
qboolean SVD_PlaybackFrame( void );
qboolean SVD_IsRecording( void );
qboolean SVD_IsPlaying( void );

View file

@ -745,6 +745,8 @@ void SV_AddOperatorCommands( void ) {
Cmd_AddCommand ("svdemo_stop", SVD_Stop_f);
Cmd_AddCommand ("svdemo_play", SVD_Play_f);
Cmd_AddCommand ("svdemo_pause", SVD_Pause_f);
Cmd_AddCommand ("svdemo_seek", SVD_Seek_f);
Cmd_AddCommand ("svdemo_seekexact", SVD_SeekExact_f);
}
/*

View file

@ -630,6 +630,7 @@ void SV_Init (void) {
// server-side demo settings
Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE);
Cvar_Get ("svdemo_pauseEmpty", "1", CVAR_ARCHIVE);
Cvar_Get ("svdemo_keyframeInterval", "5", CVAR_ARCHIVE); // seconds, 0 = disabled
// initialize bot cvars so they are listed and can be set before loading the botlib
SV_BotInitCvars();

View file

@ -90,6 +90,9 @@ typedef struct {
char serverCmds[SVD_MAX_SERVERCMDS][SVD_MAX_SERVERCMD_LEN];
int numServerCmds;
qboolean mapRestarted; // set by SVD_ResetDeltaState, written as frame flag
qboolean isKeyframe; // next frame is a keyframe (delta from baseline)
int keyframeInterval; // frames between keyframes (0 = disabled)
int framesSinceKeyframe; // counter for next keyframe
// playback
fileHandle_t playFile;
@ -102,8 +105,15 @@ typedef struct {
qboolean needConfigstrings; // apply saved configstrings on first frame
qboolean starting; // SVD_Play_f is running devmap internally
qboolean paused;
qboolean seeked; // just seeked, next frame needs RESET
svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read
svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states
// keyframe index (shared by recording and playback)
int numKeyframes;
int maxKeyframes; // allocated size
int *keyframeTimes; // serverTime of each keyframe
int *keyframeOffsets; // file offset of each keyframe
} svDemo_t;
static svDemo_t demo;
@ -189,13 +199,17 @@ static void SVD_WriteFrame( fileHandle_t f ) {
SVD_WriteInt( f, svs.time );
SVD_WriteShort( f, (short)sv.num_entities );
// frame flags: bit 0 = map restarted
// frame flags: bit 0 = map restarted, bit 1 = keyframe
{
byte frameFlags = 0;
if ( demo.mapRestarted ) {
frameFlags |= 1;
demo.mapRestarted = qfalse;
}
if ( demo.isKeyframe ) {
frameFlags |= 2;
demo.isKeyframe = qfalse;
}
FS_Write( &frameFlags, 1, f );
}
@ -374,6 +388,19 @@ static qboolean SVD_StartRecording( const char *demoname ) {
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) );
// keyframe interval from cvar (seconds to frames at sv_fps)
{
int secs = Cvar_VariableIntegerValue( "svdemo_keyframeInterval" );
if ( secs > 0 ) {
demo.keyframeInterval = secs * sv_fps->integer;
} else {
demo.keyframeInterval = 0;
}
// first frame is always a keyframe (makes beginning seekable)
demo.framesSinceKeyframe = demo.keyframeInterval;
demo.numKeyframes = 0;
}
SVD_WriteHeader( demo.recordFile );
return qtrue;
}
@ -432,6 +459,21 @@ void SVD_StopRecord_f( void ) {
// write end marker
SVD_WriteInt( demo.recordFile, -1 );
// write keyframe index after the end marker.
// layout: [numKf][time0 off0 time1 off1 ...][numKf_copy]
// numKf_copy at the very end lets playback find the table
// by seeking to fileLen - 4.
{
int kf;
SVD_WriteInt( demo.recordFile, demo.numKeyframes );
for ( kf = 0; kf < demo.numKeyframes; kf++ ) {
SVD_WriteInt( demo.recordFile, demo.keyframeTimes[kf] );
SVD_WriteInt( demo.recordFile, demo.keyframeOffsets[kf] );
}
SVD_WriteInt( demo.recordFile, demo.numKeyframes ); // copy at end
Com_Printf( "Wrote %d keyframes.\n", demo.numKeyframes );
}
FS_FCloseFile( demo.recordFile );
demo.recordFile = 0;
demo.recording = qfalse;
@ -444,6 +486,11 @@ void SVD_StopRecord_f( void ) {
}
}
// free keyframe index
if ( demo.keyframeTimes ) { Z_Free( demo.keyframeTimes ); demo.keyframeTimes = NULL; }
if ( demo.keyframeOffsets ) { Z_Free( demo.keyframeOffsets ); demo.keyframeOffsets = NULL; }
demo.numKeyframes = demo.maxKeyframes = 0;
Com_Printf( "Server demo recording stopped.\n" );
}
@ -493,6 +540,37 @@ void SVD_RecordFrame( void ) {
if ( !demo.recording ) {
return;
}
// periodic keyframe: reset delta state so this frame is decodable
// from baseline. record file offset for the keyframe index.
if ( demo.keyframeInterval > 0 ) {
demo.framesSinceKeyframe++;
if ( demo.framesSinceKeyframe >= demo.keyframeInterval ) {
demo.framesSinceKeyframe = 0;
demo.isKeyframe = qtrue;
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) );
// store keyframe: file offset before writing, serverTime
if ( demo.numKeyframes >= demo.maxKeyframes ) {
int newMax = demo.maxKeyframes ? demo.maxKeyframes * 2 : 256;
int *newTimes = Z_Malloc( newMax * sizeof(int) );
int *newOffsets = Z_Malloc( newMax * sizeof(int) );
if ( demo.keyframeTimes ) {
Com_Memcpy( newTimes, demo.keyframeTimes, demo.numKeyframes * sizeof(int) );
Com_Memcpy( newOffsets, demo.keyframeOffsets, demo.numKeyframes * sizeof(int) );
Z_Free( demo.keyframeTimes );
Z_Free( demo.keyframeOffsets );
}
demo.keyframeTimes = newTimes;
demo.keyframeOffsets = newOffsets;
demo.maxKeyframes = newMax;
}
demo.keyframeTimes[demo.numKeyframes] = svs.time;
demo.keyframeOffsets[demo.numKeyframes] = FS_FTell( demo.recordFile );
demo.numKeyframes++;
}
}
SVD_WriteFrame( demo.recordFile );
}
@ -594,15 +672,17 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
{
byte frameFlags;
FS_Read( &frameFlags, 1, f );
if ( frameFlags & 1 ) {
// map was restarted — reset playback delta state to match
// the recording's reset (both now decode from zero baseline).
if ( frameFlags & 3 ) {
// bit 0 = map restart, bit 1 = keyframe.
// both mean: delta state was reset during recording,
// so reset playback delta state to decode from baseline.
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
// set one-shot SNAPFLAG_RESET_ENTITIES so cgame snaps all
// entities to current position without interpolation.
// OR'd into snapFlagServerBit, cleared after one frame.
}
if ( ( frameFlags & 1 ) || demo.seeked ) {
// map restart or seek: reset entity interpolation in cgame
svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES;
demo.seeked = qfalse;
}
}
@ -809,6 +889,37 @@ void SVD_Play_f( void ) {
return;
}
// read keyframe index from the end of the file.
// layout: [frames][-1][numKf][time0 off0 ...][numKf_copy]
// last 4 bytes of file = numKf_copy.
{
int frameStart = FS_FTell( demo.playFile );
int numKf, kf;
FS_Seek( demo.playFile, len - 4, FS_SEEK_SET );
numKf = SVD_ReadInt( demo.playFile );
if ( numKf > 0 && numKf < 1000000 ) {
// seek to start of keyframe table: end - 4 - numKf*8 - 4
int tableStart = len - 4 - numKf * 8 - 4;
FS_Seek( demo.playFile, tableStart + 4, FS_SEEK_SET ); // skip numKf
demo.numKeyframes = numKf;
demo.maxKeyframes = numKf;
demo.keyframeTimes = Z_Malloc( numKf * sizeof(int) );
demo.keyframeOffsets = Z_Malloc( numKf * sizeof(int) );
for ( kf = 0; kf < numKf; kf++ ) {
demo.keyframeTimes[kf] = SVD_ReadInt( demo.playFile );
demo.keyframeOffsets[kf] = SVD_ReadInt( demo.playFile );
}
Com_Printf( "Loaded %d keyframes.\n", numKf );
}
// seek back to start of frame data
FS_Seek( demo.playFile, frameStart, FS_SEEK_SET );
}
demo.playing = qtrue;
demo.endOfDemo = qfalse;
demo.needConfigstrings = qtrue;
@ -873,6 +984,10 @@ void SVD_CleanupPlayback( void ) {
}
}
// free keyframe index
if ( demo.keyframeTimes ) { Z_Free( demo.keyframeTimes ); }
if ( demo.keyframeOffsets ) { Z_Free( demo.keyframeOffsets ); }
memset( &demo, 0, sizeof(demo) );
Cvar_Set2( "sv_demoplaying", "0", qtrue );
}
@ -912,14 +1027,158 @@ void SVD_Pause_f( void ) {
if ( !demo.paused ) {
// resuming — toggle SERVERCOUNT to reset client snapshot timing
// (drifted during pause from identical serverTimes).
// also reset entities so fast-moving objects (rockets) snap to
// position instead of interpolating through the pause gap.
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES;
}
Com_Printf( "Demo playback %s.\n", demo.paused ? "paused" : "resumed" );
}
void SVD_Seek_f( void ) {
int targetTime, i, bestKf;
float seconds;
if ( !demo.playing ) {
Com_Printf( "Not playing a server demo.\n" );
return;
}
if ( Cmd_Argc() < 2 ) {
Com_Printf( "Usage: svdemo_seek <seconds>\n" );
return;
}
if ( demo.numKeyframes <= 0 ) {
Com_Printf( "No keyframes in this demo — seeking not available.\n" );
return;
}
seconds = atof( Cmd_Argv(1) );
targetTime = svs.time + (int)(seconds * 1000);
// find nearest keyframe at or before target time
bestKf = -1;
for ( i = 0; i < demo.numKeyframes; i++ ) {
if ( demo.keyframeTimes[i] <= targetTime ) {
bestKf = i;
} else {
break;
}
}
if ( bestKf < 0 ) {
// target is before the first keyframe — seek to first
bestKf = 0;
targetTime = demo.keyframeTimes[0];
}
// seek to keyframe file position
FS_Seek( demo.playFile, demo.keyframeOffsets[bestKf], FS_SEEK_SET );
// reset delta state (keyframe is encoded from baseline)
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
// set svs.time to the keyframe time so the SV_Frame loop
// doesn't advance from the old time before reading
svs.time = demo.keyframeTimes[bestKf];
// toggle SERVERCOUNT to reset client time delta
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
demo.seeked = qtrue;
demo.endOfDemo = qfalse;
// read the keyframe directly (works even when paused)
svs.snapFlagServerBit &= ~SNAPFLAG_RESET_ENTITIES;
if ( !SVD_ReadFrame( demo.playFile ) ) {
demo.endOfDemo = qtrue;
}
// reset client snapshot timing
{
int j;
for ( j = 0; j < sv_maxclients->integer; j++ ) {
if ( svs.clients[j].state >= CS_ACTIVE ) {
svs.clients[j].nextSnapshotTime = svs.time;
}
}
}
// ensure one frame runs on next SV_Frame (for G_RunFrame + snapshot)
sv.timeResidual = 1000 / sv_fps->integer;
Com_Printf( "Seeked to time %d.\n", svs.time );
}
void SVD_SeekExact_f( void ) {
int targetTime, i, bestKf;
float seconds;
if ( !demo.playing ) {
Com_Printf( "Not playing a server demo.\n" );
return;
}
if ( Cmd_Argc() < 2 ) {
Com_Printf( "Usage: svdemo_seekexact <seconds>\n" );
return;
}
if ( demo.numKeyframes <= 0 ) {
Com_Printf( "No keyframes in this demo.\n" );
return;
}
seconds = atof( Cmd_Argv(1) );
targetTime = svs.time + (int)(seconds * 1000);
// find nearest keyframe at or before target time
bestKf = -1;
for ( i = 0; i < demo.numKeyframes; i++ ) {
if ( demo.keyframeTimes[i] <= targetTime ) {
bestKf = i;
} else {
break;
}
}
if ( bestKf < 0 ) {
bestKf = 0;
targetTime = demo.keyframeTimes[0];
}
// seek to keyframe
FS_Seek( demo.playFile, demo.keyframeOffsets[bestKf], FS_SEEK_SET );
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
svs.time = demo.keyframeTimes[bestKf];
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
demo.seeked = qtrue;
demo.endOfDemo = qfalse;
// read forward from keyframe to target time
while ( svs.time < targetTime ) {
if ( !SVD_ReadFrame( demo.playFile ) ) {
demo.endOfDemo = qtrue;
break;
}
}
// reset client snapshot timing
{
int j;
for ( j = 0; j < sv_maxclients->integer; j++ ) {
if ( svs.clients[j].state >= CS_ACTIVE ) {
svs.clients[j].nextSnapshotTime = svs.time;
}
}
}
sv.timeResidual = 1000 / sv_fps->integer;
Com_Printf( "Seeked to time %d (read forward %d ms from keyframe).\n",
svs.time, svs.time - demo.keyframeTimes[bestKf] );
}
/*
Called from SV_Frame() to advance playback by one frame.
Returns qtrue if a frame was read, qfalse if demo ended.
@ -929,6 +1188,7 @@ qboolean SVD_PlaybackFrame( void ) {
return qfalse;
}
// manual pause — don't consume demo data
if ( demo.paused ) {
return qfalse;