/* =========================================================================== Server-side demo recording and playback (netdemo). Records the full entity state array each server frame so that demos can be played back from any viewpoint. During playback the recorded entities are injected into sv.gentities and the normal snapshot pipeline delivers them to a spectator client. =========================================================================== */ #include "server.h" // Cvar_Set2 not in public header but needed to force-set CVAR_ROM cvars extern cvar_t *Cvar_Set2( const char *var_name, const char *value, qboolean force ); // --------------------------------------------------------------- // File format // --------------------------------------------------------------- // // Header: // 4 bytes magic "SVDM" // 4 bytes version (1) // 4 bytes original sv_maxclients // 4 bytes original sv_fps // 64 bytes map name (null-padded) // Then: configstrings block // for each non-empty configstring: // 2 bytes index // 2 bytes string length (incl NUL) // N bytes string data // 2 bytes index = 0xFFFF (terminator) // // Per frame: // 4 bytes serverTime // 2 bytes numEntities (how many entity records follow) // for each entity: // 2 bytes entity number // entityState_t (fixed size, raw) // 4 bytes svFlags // 4 bytes linked // 12 bytes currentOrigin[3] // 12 bytes absmin[3] // 12 bytes absmax[3] // 2 bytes numConfigChanges // for each change: // 2 bytes index // 2 bytes string length (incl NUL) // N bytes string data // // Footer: // 4 bytes serverTime = -1 (end marker) // #define SVDEMO_MAGIC (('S') | ('V' << 8) | ('D' << 16) | ('M' << 24)) #define SVDEMO_VERSION 3 // v3: removed PVS data, svFlags only #define SVDEMO_MAX_MAPNAME 64 // header flags // --------------------------------------------------------------- // State // --------------------------------------------------------------- // per-entity data stored for delta compression typedef struct { entityState_t es; int svFlags; qboolean active; // was this entity present last frame? } svdEntityState_t; // per-player state for delta compression typedef struct { playerState_t ps; qboolean active; } svdPlayerState_t; #define SVD_MAX_SERVERCMDS 64 #define SVD_MAX_SERVERCMD_LEN 1024 typedef struct { // recording fileHandle_t recordFile; qboolean recording; char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta svdPlayerState_t prevPlayers[MAX_CLIENTS]; // previous frame player states // buffered server commands for current frame 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; qboolean playing; int playMaxClients; // original maxclients from the recording int playFps; // original sv_fps char *savedConfigstrings[MAX_CONFIGSTRINGS]; // from demo header, re-applied after map load char playMapName[SVDEMO_MAX_MAPNAME]; qboolean endOfDemo; 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; // --------------------------------------------------------------- // Recording helpers // --------------------------------------------------------------- static void SVD_WriteInt( fileHandle_t f, int v ) { FS_Write( &v, 4, f ); } static void SVD_WriteShort( fileHandle_t f, short v ) { FS_Write( &v, 2, f ); } static int SVD_ReadInt( fileHandle_t f ) { int v = 0; FS_Read( &v, 4, f ); return v; } static short SVD_ReadShort( fileHandle_t f ) { short v = 0; FS_Read( &v, 2, f ); return v; } // --------------------------------------------------------------- // Write header // --------------------------------------------------------------- static void SVD_WriteHeader( fileHandle_t f ) { int i; char mapBuf[SVDEMO_MAX_MAPNAME]; SVD_WriteInt( f, SVDEMO_MAGIC ); SVD_WriteInt( f, SVDEMO_VERSION ); SVD_WriteInt( f, 0 ); // flags (reserved) SVD_WriteInt( f, sv_maxclients->integer ); SVD_WriteInt( f, sv_fps->integer ); // map name memset( mapBuf, 0, sizeof(mapBuf) ); Q_strncpyz( mapBuf, sv.configstrings[CS_SERVERINFO], sizeof(mapBuf) ); // actually store the mapname from CS_SERVERINFO... or just the map name { const char *mapname = Cvar_VariableString("mapname"); memset( mapBuf, 0, sizeof(mapBuf) ); Q_strncpyz( mapBuf, mapname, sizeof(mapBuf) ); } FS_Write( mapBuf, SVDEMO_MAX_MAPNAME, f ); // configstrings for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { if ( sv.configstrings[i] && sv.configstrings[i][0] ) { int len = strlen( sv.configstrings[i] ) + 1; SVD_WriteShort( f, (short)i ); SVD_WriteShort( f, (short)len ); FS_Write( sv.configstrings[i], len, f ); // store initial copy for delta detection if ( demo.lastConfigstrings[i] ) { Z_Free( demo.lastConfigstrings[i] ); } demo.lastConfigstrings[i] = CopyString( sv.configstrings[i] ); } } // terminator SVD_WriteShort( f, (short)0xFFFF ); } // --------------------------------------------------------------- // Write one frame // --------------------------------------------------------------- static void SVD_WriteFrame( fileHandle_t f ) { int i; sharedEntity_t *ent; short numChanges; msg_t msg; static byte msgBuf[MAX_GENTITIES * 300]; // worst case: all entities full write from baseline SVD_WriteInt( f, svs.time ); SVD_WriteShort( f, (short)sv.num_entities ); // 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 ); } // delta-compress all entities into a message buffer MSG_Init( &msg, msgBuf, sizeof(msgBuf) ); for ( i = 0; i < sv.num_entities; i++ ) { qboolean active; ent = SV_GentityNum( i ); active = ( ent->r.linked || ent->s.eType != 0 ); if ( active ) { entityState_t baseline; entityState_t *from; if ( demo.prevEntities[i].active ) { from = &demo.prevEntities[i].es; } else { Com_Memset( &baseline, 0, sizeof(baseline) ); baseline.number = i; from = &baseline; } MSG_WriteDeltaEntity( &msg, from, &ent->s, qtrue ); // write svFlags only if changed (rarely changes) if ( ent->r.svFlags != demo.prevEntities[i].svFlags ) { MSG_WriteBits( &msg, 1, 1 ); MSG_WriteLong( &msg, ent->r.svFlags ); } else { MSG_WriteBits( &msg, 0, 1 ); } // update prev state demo.prevEntities[i].es = ent->s; demo.prevEntities[i].svFlags = ent->r.svFlags; demo.prevEntities[i].active = qtrue; } else if ( demo.prevEntities[i].active ) { // entity was removed — write a remove marker MSG_WriteDeltaEntity( &msg, &demo.prevEntities[i].es, NULL, qtrue ); demo.prevEntities[i].active = qfalse; } // else: entity was inactive and still inactive — write nothing } // end of entities marker MSG_WriteBits( &msg, (MAX_GENTITIES - 1), GENTITYNUM_BITS ); // write entity message to file SVD_WriteInt( f, msg.cursize ); FS_Write( msg.data, msg.cursize, f ); // write player states (delta compressed) { msg_t psmsg; static byte psBuf[MAX_CLIENTS * 600]; // worst case: full playerState from baseline int psCount = 0; MSG_Init( &psmsg, psBuf, sizeof(psBuf) ); for ( i = 0; i < sv_maxclients->integer; i++ ) { playerState_t *ps = SV_GameClientNum( i ); client_t *cl = &svs.clients[i]; qboolean active = ( cl->state >= CS_ACTIVE ); qboolean isSpectator; playerState_t specPs; // detect spectators: free cam or follow mode isSpectator = active && ( ps->pm_type == PM_SPECTATOR || (ps->pm_flags & PMF_FOLLOW) ); // for spectators, record a sanitized ps so they appear on // the scoreboard as spectators (follow mode corrupts their ps // with the followed player's data) if ( isSpectator ) { Com_Memset( &specPs, 0, sizeof(specPs) ); specPs.commandTime = ps->commandTime; specPs.pm_type = PM_SPECTATOR; specPs.persistant[PERS_TEAM] = TEAM_SPECTATOR; specPs.clientNum = i; ps = &specPs; } if ( active ) { playerState_t *from = demo.prevPlayers[i].active ? &demo.prevPlayers[i].ps : NULL; MSG_WriteBits( &psmsg, i, 6 ); // client number (0-63) MSG_WriteBits( &psmsg, 1, 1 ); // active flag MSG_WriteDeltaPlayerstate( &psmsg, from, ps ); demo.prevPlayers[i].ps = *ps; demo.prevPlayers[i].active = qtrue; psCount++; } else if ( demo.prevPlayers[i].active ) { MSG_WriteBits( &psmsg, i, 6 ); MSG_WriteBits( &psmsg, 0, 1 ); // inactive flag (player left) demo.prevPlayers[i].active = qfalse; psCount++; } } // terminator MSG_WriteBits( &psmsg, MAX_CLIENTS - 1, 6 ); MSG_WriteBits( &psmsg, 0, 1 ); SVD_WriteInt( f, psmsg.cursize ); FS_Write( psmsg.data, psmsg.cursize, f ); } // configstring changes numChanges = 0; for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { const char *cur = sv.configstrings[i] ? sv.configstrings[i] : ""; const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : ""; if ( strcmp( cur, old ) != 0 ) { numChanges++; } } SVD_WriteShort( f, numChanges ); if ( numChanges > 0 ) { for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { const char *cur = sv.configstrings[i] ? sv.configstrings[i] : ""; const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : ""; if ( strcmp( cur, old ) != 0 ) { int len = strlen( cur ) + 1; SVD_WriteShort( f, (short)i ); SVD_WriteShort( f, (short)len ); FS_Write( cur, len, f ); if ( demo.lastConfigstrings[i] ) { Z_Free( demo.lastConfigstrings[i] ); } demo.lastConfigstrings[i] = CopyString( cur ); } } } // write buffered server commands (chat, prints, etc.) SVD_WriteShort( f, (short)demo.numServerCmds ); for ( i = 0; i < demo.numServerCmds; i++ ) { short len = (short)( strlen( demo.serverCmds[i] ) + 1 ); SVD_WriteShort( f, len ); FS_Write( demo.serverCmds[i], len, f ); } demo.numServerCmds = 0; } // --------------------------------------------------------------- // Recording commands // --------------------------------------------------------------- /* Start recording a demo with the given name. Returns qtrue on success. */ static qboolean SVD_StartRecording( const char *demoname ) { char path[MAX_OSPATH]; if ( demo.recording ) { Com_Printf( "Already recording a server demo.\n" ); return qfalse; } if ( sv.state != SS_GAME ) { Com_Printf( "Not running a server.\n" ); return qfalse; } Com_sprintf( path, sizeof(path), "svdemos/%s.svdm", demoname ); demo.recordFile = FS_FOpenFileWrite( path ); if ( !demo.recordFile ) { Com_Printf( "ERROR: couldn't open %s for writing.\n", path ); return qfalse; } Com_Printf( "Recording server demo to %s\n", path ); demo.recording = qtrue; // clear delta state for fresh recording 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; } void SVD_Record_f( void ) { char *s; s = Cmd_Argv(1); if ( !s[0] ) { Com_Printf( "Usage: svdemo_record \n" ); return; } SVD_StartRecording( s ); } /* Auto-record: called from SV_SpawnServer after the map is fully loaded. Generates a name from map name + timestamp. */ void SVD_AutoRecord( void ) { char demoname[MAX_OSPATH]; const char *mapname; qtime_t now; if ( demo.recording || demo.playing ) { return; } if ( !Cvar_VariableIntegerValue( "svdemo_autorecord" ) ) { return; } if ( sv.state != SS_GAME ) { return; } mapname = Cvar_VariableString( "mapname" ); Com_RealTime( &now ); Com_sprintf( demoname, sizeof(demoname), "%s_%04d%02d%02d_%02d%02d%02d", mapname, 1900 + now.tm_year, 1 + now.tm_mon, now.tm_mday, now.tm_hour, now.tm_min, now.tm_sec ); SVD_StartRecording( demoname ); } void SVD_StopRecord_f( void ) { int i; if ( !demo.recording ) { Com_Printf( "Not recording a server demo.\n" ); return; } // 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; // free configstring tracking for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { if ( demo.lastConfigstrings[i] ) { Z_Free( demo.lastConfigstrings[i] ); demo.lastConfigstrings[i] = NULL; } } // 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" ); } /* Called from SV_Frame() after the game has run its frame. */ /* Reset delta compression state. Call on map_restart so the next recorded frame writes full entity/player states from baseline. */ /* Capture a broadcast server command for the current frame. Called from SV_SendServerCommand when cl == NULL (broadcast). */ void SVD_CaptureServerCommand( const char *cmd ) { int i; if ( !demo.recording ) { return; } if ( demo.numServerCmds >= SVD_MAX_SERVERCMDS ) { return; // overflow, drop command } // deduplicate: per-client chat is sent N times (once per client), // only store the first occurrence for ( i = 0; i < demo.numServerCmds; i++ ) { if ( !strcmp( demo.serverCmds[i], cmd ) ) { return; } } Q_strncpyz( demo.serverCmds[demo.numServerCmds], cmd, SVD_MAX_SERVERCMD_LEN ); demo.numServerCmds++; } void SVD_ResetDeltaState( void ) { if ( !demo.recording ) { return; } Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) ); Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) ); demo.mapRestarted = qtrue; // signal next frame to write restart marker } 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 ); } // --------------------------------------------------------------- // Playback: read header // --------------------------------------------------------------- static qboolean SVD_ReadHeader( fileHandle_t f ) { int magic, version; magic = SVD_ReadInt( f ); if ( magic != SVDEMO_MAGIC ) { Com_Printf( "Not a valid server demo file.\n" ); return qfalse; } version = SVD_ReadInt( f ); if ( version != SVDEMO_VERSION ) { Com_Printf( "Unsupported server demo version %d.\n", version ); return qfalse; } SVD_ReadInt( f ); // flags (reserved) demo.playMaxClients = SVD_ReadInt( f ); demo.playFps = SVD_ReadInt( f ); FS_Read( demo.playMapName, SVDEMO_MAX_MAPNAME, f ); demo.playMapName[SVDEMO_MAX_MAPNAME - 1] = '\0'; // read configstrings — store for re-application after map load { short idx; while (1) { idx = SVD_ReadShort( f ); if ( idx == (short)0xFFFF ) { break; } { short len = SVD_ReadShort( f ); char buf[BIG_INFO_STRING]; if ( len > 0 && len < (short)sizeof(buf) ) { FS_Read( buf, len, f ); buf[len - 1] = '\0'; if ( demo.savedConfigstrings[idx] ) { Z_Free( demo.savedConfigstrings[idx] ); } demo.savedConfigstrings[idx] = CopyString( buf ); } } } } return qtrue; } /* Re-apply recorded configstrings after the map has loaded. Called after devmap finishes in SVD_Play_f. */ static void SVD_ApplyConfigstrings( void ) { int i; for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { // skip CS_SERVERINFO and CS_SYSTEMINFO — they contain latched cvars // (sv_maxclients, sv_pure, etc.) that would trigger a map restart if ( i == CS_SERVERINFO || i == CS_SYSTEMINFO ) { continue; } if ( demo.savedConfigstrings[i] && demo.savedConfigstrings[i][0] ) { SV_SetConfigstring( i, demo.savedConfigstrings[i] ); } } } // --------------------------------------------------------------- // Playback: read one frame, populate sv.gentities // --------------------------------------------------------------- static qboolean SVD_ReadFrame( fileHandle_t f ) { int serverTime; short numEnts, numChanges; int i, entNum, blockLen; sharedEntity_t *ent; msg_t msg; static byte msgBuf[MAX_GENTITIES * 300]; entityState_t newEs; serverTime = SVD_ReadInt( f ); if ( serverTime == -1 ) { return qfalse; // end of demo } // set svs.time to recorded time so entity trajectory interpolation // works correctly (rockets, grenades, etc. use pos.trTime relative // to server time). zombie timeout is already skipped during playback. svs.time = serverTime; numEnts = SVD_ReadShort( f ); // read frame flags { byte frameFlags; FS_Read( &frameFlags, 1, f ); 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) ); } if ( ( frameFlags & 1 ) || demo.seeked ) { // map restart or seek: reset entity interpolation in cgame svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES; demo.seeked = qfalse; } } // read entity message blockLen = SVD_ReadInt( f ); if ( blockLen <= 0 || blockLen > (int)sizeof(msgBuf) ) { return qfalse; } FS_Read( msgBuf, blockLen, f ); MSG_Init( &msg, msgBuf, sizeof(msgBuf) ); msg.cursize = blockLen; // clear all entities (spectator's entity is recreated by ClientThink_real) for ( i = 0; i < sv.num_entities; i++ ) { ent = SV_GentityNum( i ); if ( ent->r.linked ) { SV_UnlinkEntity( ent ); } ent->s.eType = 0; ent->s.number = i; } // parse delta-compressed entities while ( 1 ) { entNum = MSG_ReadBits( &msg, GENTITYNUM_BITS ); if ( entNum == MAX_GENTITIES - 1 ) { break; // end of entities marker } if ( msg.readcount > msg.cursize ) { break; } { entityState_t baseline; entityState_t *from; Com_Memset( &newEs, 0, sizeof(newEs) ); if ( demo.playPrevEntities[entNum].active ) { from = &demo.playPrevEntities[entNum].es; } else { Com_Memset( &baseline, 0, sizeof(baseline) ); baseline.number = entNum; from = &baseline; } MSG_ReadDeltaEntity( &msg, from, &newEs, entNum ); } if ( newEs.number == MAX_GENTITIES - 1 ) { // entity was removed demo.playPrevEntities[entNum].active = qfalse; continue; } // read svFlags if ( MSG_ReadBits( &msg, 1 ) ) { demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg ); } demo.playPrevEntities[entNum].es = newEs; demo.playPrevEntities[entNum].active = qtrue; // apply to server entity and link for PVS. // use trBase as initial origin — G_RunFrame will refine with // BG_EvaluateTrajectory for moving entities (rockets etc). ent = SV_GentityNum( entNum ); ent->s = newEs; ent->s.number = entNum; ent->r.svFlags = demo.playPrevEntities[entNum].svFlags; VectorCopy( newEs.pos.trBase, ent->r.currentOrigin ); ent->r.linked = qtrue; SV_LinkEntity( ent ); if ( entNum + 1 > sv.num_entities ) { sv.num_entities = entNum + 1; } } // read player states (delta compressed) { msg_t psmsg; static byte psBuf[MAX_CLIENTS * 600]; // worst case: full playerState from baseline int psMsgLen; psMsgLen = SVD_ReadInt( f ); if ( psMsgLen > 0 && psMsgLen <= (int)sizeof(psBuf) ) { FS_Read( psBuf, psMsgLen, f ); MSG_Init( &psmsg, psBuf, sizeof(psBuf) ); psmsg.cursize = psMsgLen; while ( 1 ) { int clientNum = MSG_ReadBits( &psmsg, 6 ); int active = MSG_ReadBits( &psmsg, 1 ); if ( clientNum == MAX_CLIENTS - 1 && !active ) { break; // terminator } if ( psmsg.readcount > psmsg.cursize ) { break; } if ( active ) { playerState_t newPs; playerState_t *from; playerState_t baseline; if ( demo.playPrevPlayers[clientNum].active ) { from = &demo.playPrevPlayers[clientNum].ps; } else { Com_Memset( &baseline, 0, sizeof(baseline) ); from = &baseline; } MSG_ReadDeltaPlayerstate( &psmsg, from, &newPs ); demo.playPrevPlayers[clientNum].ps = newPs; demo.playPrevPlayers[clientNum].active = qtrue; // inject into game module's client state { playerState_t *gamePs = SV_GameClientNum( clientNum ); *gamePs = newPs; } } else { demo.playPrevPlayers[clientNum].active = qfalse; // clear game playerState so G_RunFrame sees commandTime=0 { playerState_t *gamePs = SV_GameClientNum( clientNum ); Com_Memset( gamePs, 0, sizeof(*gamePs) ); } } } } } // read configstring changes (skip SERVERINFO/SYSTEMINFO to avoid latch restarts) numChanges = SVD_ReadShort( f ); for ( i = 0; i < numChanges; i++ ) { short idx = SVD_ReadShort( f ); short len = SVD_ReadShort( f ); char buf[BIG_INFO_STRING]; if ( len > 0 && len < (short)sizeof(buf) ) { FS_Read( buf, len, f ); buf[len - 1] = '\0'; if ( idx != CS_SERVERINFO && idx != CS_SYSTEMINFO ) { SV_SetConfigstring( idx, buf ); } } } // read server commands (chat, prints, etc.) and replay to spectator { short numCmds = SVD_ReadShort( f ); for ( i = 0; i < numCmds; i++ ) { short len = SVD_ReadShort( f ); char buf[SVD_MAX_SERVERCMD_LEN]; if ( len > 0 && len < (short)sizeof(buf) ) { FS_Read( buf, len, f ); buf[len - 1] = '\0'; // broadcast — only the spectator is CS_ACTIVE, zombies are skipped SV_SendServerCommand( NULL, "%s", buf ); } } } return qtrue; } // --------------------------------------------------------------- // Playback commands // --------------------------------------------------------------- void SVD_Play_f( void ) { char name[MAX_OSPATH]; char *s; int len; if ( demo.recording ) { Com_Printf( "Stop recording first (svdemo_stop).\n" ); return; } s = Cmd_Argv(1); if ( !s[0] ) { Com_Printf( "Usage: svdemo_play \n" ); return; } // stop current playback if switching demos if ( demo.playing ) { SVD_CleanupPlayback(); } Com_sprintf( name, sizeof(name), "svdemos/%s.svdm", s ); memset( &demo, 0, sizeof(demo) ); len = FS_FOpenFileRead( name, &demo.playFile, qtrue ); if ( !demo.playFile || len <= 0 ) { Com_Printf( "ERROR: couldn't open %s.\n", name ); return; } if ( !SVD_ReadHeader( demo.playFile ) ) { FS_FCloseFile( demo.playFile ); demo.playFile = 0; 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; Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n", demo.playMapName, demo.playMaxClients, demo.playFps ); // Shut down current server first so no clients carry over to // reserved slots. SV_Shutdown triggers our cleanup hook, but // demo.starting prevents it from clearing our state. demo.starting = qtrue; if ( com_sv_running->integer ) { SV_Shutdown( "Demo playback\n" ); } // Set demo cvar BEFORE devmap so G_InitGame can read it. // The previous server is gone so no old game module to conflict with. Cvar_Set2( "sv_demoplaying", "1", qtrue ); Cbuf_ExecuteText( EXEC_NOW, va("set sv_maxclients %d\n", MAX_CLIENTS) ); Cbuf_ExecuteText( EXEC_NOW, va("set sv_fps %d\n", demo.playFps) ); Cbuf_ExecuteText( EXEC_NOW, va("devmap %s\n", demo.playMapName) ); demo.starting = qfalse; // CS_SVDEMO configstring is set by G_InitGame from the cvar // Reserve recorded player slots. Server is fresh (SV_Shutdown cleared // old clients), local client hasn't connected yet. { int i; for ( i = 0; i < demo.playMaxClients; i++ ) { if ( svs.clients[i].state == CS_FREE ) { svs.clients[i].state = CS_ZOMBIE; svs.clients[i].rate = 10000; svs.clients[i].nextSnapshotTime = 0x7FFFFFFF; svs.clients[i].lastPacketTime = svs.time; } } } } void SVD_CleanupPlayback( void ) { int i; if ( !demo.playing ) { return; } FS_FCloseFile( demo.playFile ); // free saved configstrings for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { if ( demo.savedConfigstrings[i] ) { Z_Free( demo.savedConfigstrings[i] ); } } // free zombie client slots for ( i = 0; i < demo.playMaxClients; i++ ) { if ( svs.clients[i].state == CS_ZOMBIE ) { svs.clients[i].state = CS_FREE; } } // 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 ); } void SVD_StopPlay_f( void ) { if ( !demo.playing ) { Com_Printf( "Not playing a server demo.\n" ); return; } SVD_CleanupPlayback(); Com_Printf( "Server demo playback stopped.\n" ); // disconnect to return to main menu Cbuf_ExecuteText( EXEC_APPEND, "disconnect\n" ); } /* Unified stop command: stops recording or playback, whichever is active. */ void SVD_Stop_f( void ) { if ( demo.recording ) { SVD_StopRecord_f(); } else if ( demo.playing ) { SVD_StopPlay_f(); } else { Com_Printf( "Not recording or playing a server demo.\n" ); } } void SVD_Pause_f( void ) { if ( !demo.playing ) { Com_Printf( "Not playing a server demo.\n" ); return; } demo.paused = !demo.paused; if ( !demo.paused ) { // resuming — toggle SERVERCOUNT to reset client snapshot timing // (drifted during pause from identical serverTimes). svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT; } 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; demo.seeked = qtrue; demo.endOfDemo = qfalse; // reset client snapshot timing so SV_SendClientMessages doesn't // skip sending (nextSnapshotTime was in the future, now svs.time is past) { 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 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 \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. */ qboolean SVD_PlaybackFrame( void ) { if ( !demo.playing || demo.endOfDemo ) { return qfalse; } // manual pause — don't consume demo data if ( demo.paused ) { return qfalse; } // wait for a spectator to be fully in-game before starting playback. // the server keeps running frames (so the connection handshake completes) // but no demo data is consumed until someone is CS_ACTIVE. if ( SVD_ShouldPause() ) { return qfalse; } // apply recorded configstrings once after map load if ( demo.needConfigstrings ) { SVD_ApplyConfigstrings(); demo.needConfigstrings = qfalse; } // clear one-shot reset flag from previous frame before reading new one svs.snapFlagServerBit &= ~SNAPFLAG_RESET_ENTITIES; if ( !SVD_ReadFrame( demo.playFile ) ) { Com_Printf( "Server demo playback finished.\n" ); SVD_CleanupPlayback(); Cbuf_ExecuteText( EXEC_APPEND, "disconnect\n" ); return qfalse; } return qtrue; } // --------------------------------------------------------------- // Queries // --------------------------------------------------------------- qboolean SVD_IsRecording( void ) { return demo.recording; } qboolean SVD_IsPlaying( void ) { return demo.playing; } qboolean SVD_IsPaused( void ) { return demo.playing && demo.paused; } qboolean SVD_IsStarting( void ) { return demo.starting; } /* Returns qtrue if demo playback should pause (no active spectators). Controlled by svdemo_pauseEmpty cvar. */ qboolean SVD_ShouldPause( void ) { int i; if ( !Cvar_VariableIntegerValue( "svdemo_pauseEmpty" ) ) { return qfalse; } for ( i = 0; i < sv_maxclients->integer; i++ ) { if ( svs.clients[i].state == CS_ACTIVE ) { return qfalse; // someone is in-game and watching } } return qtrue; // nobody connected, pause }