From 628007ec57a11af4c666d7791aa0fce7c1614fc0 Mon Sep 17 00:00:00 2001 From: serge_shubin Date: Tue, 24 Mar 2026 18:55:44 +0800 Subject: [PATCH] Keyframe recording and seeking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 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) --- code/server/server.h | 1 + code/server/sv_ccmds.c | 1 + code/server/sv_init.c | 1 + code/server/sv_netdemo.c | 189 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/code/server/server.h b/code/server/server.h index 9a68b00..ec44870 100644 --- a/code/server/server.h +++ b/code/server/server.h @@ -355,6 +355,7 @@ void SVD_StopPlay_f( void ); void SVD_CleanupPlayback( void ); void SVD_Stop_f( void ); void SVD_Pause_f( void ); +void SVD_Seek_f( void ); qboolean SVD_PlaybackFrame( void ); qboolean SVD_IsRecording( void ); qboolean SVD_IsPlaying( void ); diff --git a/code/server/sv_ccmds.c b/code/server/sv_ccmds.c index 7962933..3e4df9b 100644 --- a/code/server/sv_ccmds.c +++ b/code/server/sv_ccmds.c @@ -745,6 +745,7 @@ 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); } /* diff --git a/code/server/sv_init.c b/code/server/sv_init.c index 701e8cd..8448195 100644 --- a/code/server/sv_init.c +++ b/code/server/sv_init.c @@ -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(); diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c index 558ee48..0c7f8fb 100644 --- a/code/server/sv_netdemo.c +++ b/code/server/sv_netdemo.c @@ -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,18 @@ 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; + } + demo.framesSinceKeyframe = 0; + demo.numKeyframes = 0; + } + SVD_WriteHeader( demo.recordFile ); return qtrue; } @@ -432,6 +458,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 +485,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 +539,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 +671,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 +888,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 +983,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 ); } @@ -917,6 +1031,67 @@ void SVD_Pause_f( void ) { 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 \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; + + // mark that we seeked — next SVD_ReadFrame sets SNAPFLAG_RESET_ENTITIES + demo.seeked = qtrue; + // on the next SV_Frame. SVD_ReadFrame will detect the keyframe flag + // and set SNAPFLAG_RESET_ENTITIES + reset delta state. + demo.endOfDemo = qfalse; + + Com_Printf( "Seeked to keyframe at time %d.\n", demo.keyframeTimes[bestKf] ); +} + /* Called from SV_Frame() to advance playback by one frame. Returns qtrue if a frame was read, qfalse if demo ended.