diff --git a/code/server/server.h b/code/server/server.h index 02f8ae8..8fc7b40 100644 --- a/code/server/server.h +++ b/code/server/server.h @@ -353,9 +353,11 @@ void SVD_CaptureServerCommand( const char *cmd ); void SVD_Play_f( void ); void SVD_StopPlay_f( void ); void SVD_Stop_f( void ); +void SVD_Pause_f( void ); qboolean SVD_PlaybackFrame( void ); qboolean SVD_IsRecording( void ); qboolean SVD_IsPlaying( void ); +qboolean SVD_IsPaused( void ); qboolean SVD_ShouldPause( void ); //============================================================ diff --git a/code/server/sv_ccmds.c b/code/server/sv_ccmds.c index b55d15f..7962933 100644 --- a/code/server/sv_ccmds.c +++ b/code/server/sv_ccmds.c @@ -744,6 +744,7 @@ void SV_AddOperatorCommands( void ) { Cmd_AddCommand ("svdemo_record", SVD_Record_f); Cmd_AddCommand ("svdemo_stop", SVD_Stop_f); Cmd_AddCommand ("svdemo_play", SVD_Play_f); + Cmd_AddCommand ("svdemo_pause", SVD_Pause_f); } /* diff --git a/code/server/sv_main.c b/code/server/sv_main.c index da5e89a..27834a3 100644 --- a/code/server/sv_main.c +++ b/code/server/sv_main.c @@ -831,6 +831,15 @@ void SV_Frame( int msec ) { // run the game simulation in chunks while ( sv.timeResidual >= frameMsec ) { sv.timeResidual -= frameMsec; + + if ( SVD_IsPaused() ) { + // demo paused: freeze svs.time so trajectories freeze + // and client doesn't see time jumps on unpause. + // still run game frame for spectator movement (at frozen time). + VM_Call( gvm, GAME_RUN_FRAME, svs.time ); + continue; + } + svs.time += frameMsec; if ( SVD_IsPlaying() ) { diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c index f87afc3..1ef536f 100644 --- a/code/server/sv_netdemo.c +++ b/code/server/sv_netdemo.c @@ -1,1056 +1,1075 @@ -/* -=========================================================================== -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" -#include "lz4.h" - -// --------------------------------------------------------------- -// 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 2 // v2: optional LZ4 compression -#define SVDEMO_MAX_MAPNAME 64 - -// header flags -#define SVDEMO_FLAG_COMPRESSED 1 // per-frame data is LZ4 compressed - -// --------------------------------------------------------------- -// State -// --------------------------------------------------------------- - -// per-entity data stored for delta compression (entityState + PVS fields) -typedef struct { - entityState_t es; - int svFlags; - qboolean linked; - vec3_t currentOrigin; - vec3_t absmin; - vec3_t absmax; - 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; - qboolean compressed; // LZ4 compression enabled - 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 - - // 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 - svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read - svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states -} 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; -} - -// --------------------------------------------------------------- -// LZ4 block I/O: writes [uncompressed_size][compressed_size][data] -// --------------------------------------------------------------- - -static void SVD_WriteBlock( fileHandle_t f, const byte *data, int len ) { - if ( demo.compressed && len > 0 ) { - int bound = LZ4_compressBound( len ); - static byte compBuf[MAX_GENTITIES * 300]; - int compLen; - - compLen = LZ4_compress_default( (const char *)data, (char *)compBuf, len, bound ); - if ( compLen > 0 ) { - SVD_WriteInt( f, len ); // original size - SVD_WriteInt( f, compLen ); // compressed size - FS_Write( compBuf, compLen, f ); - return; - } - // fall through to uncompressed on failure - } - SVD_WriteInt( f, len ); // original size - SVD_WriteInt( f, 0 ); // 0 = not compressed - FS_Write( data, len, f ); -} - -static int SVD_ReadBlock( fileHandle_t f, byte *buf, int bufSize ) { - int origLen, compLen; - - origLen = SVD_ReadInt( f ); - compLen = SVD_ReadInt( f ); - - if ( origLen <= 0 || origLen > bufSize ) { - return -1; - } - - if ( compLen > 0 ) { - // compressed - static byte compBuf[MAX_GENTITIES * 300]; - if ( compLen > (int)sizeof(compBuf) ) { - return -1; - } - FS_Read( compBuf, compLen, f ); - if ( LZ4_decompress_safe( (const char *)compBuf, (char *)buf, compLen, bufSize ) != origLen ) { - return -1; - } - } else { - // uncompressed - FS_Read( buf, origLen, f ); - } - - return origLen; -} - -// --------------------------------------------------------------- -// 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, demo.compressed ? SVDEMO_FLAG_COMPRESSED : 0 ); - 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 - { - byte frameFlags = 0; - if ( demo.mapRestarted ) { - frameFlags |= 1; - demo.mapRestarted = 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 PVS fields only if changed from previous frame - { - qboolean pvsChanged = !demo.prevEntities[i].active - || ent->r.svFlags != demo.prevEntities[i].svFlags - || ent->r.linked != demo.prevEntities[i].linked - || !VectorCompare( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin ) - || !VectorCompare( ent->r.absmin, demo.prevEntities[i].absmin ) - || !VectorCompare( ent->r.absmax, demo.prevEntities[i].absmax ); - - MSG_WriteBits( &msg, pvsChanged, 1 ); - if ( pvsChanged ) { - MSG_WriteLong( &msg, ent->r.svFlags ); - MSG_WriteLong( &msg, ent->r.linked ); - MSG_WriteFloat( &msg, ent->r.currentOrigin[0] ); - MSG_WriteFloat( &msg, ent->r.currentOrigin[1] ); - MSG_WriteFloat( &msg, ent->r.currentOrigin[2] ); - MSG_WriteFloat( &msg, ent->r.absmin[0] ); - MSG_WriteFloat( &msg, ent->r.absmin[1] ); - MSG_WriteFloat( &msg, ent->r.absmin[2] ); - MSG_WriteFloat( &msg, ent->r.absmax[0] ); - MSG_WriteFloat( &msg, ent->r.absmax[1] ); - MSG_WriteFloat( &msg, ent->r.absmax[2] ); - } - } - - // update prev state - demo.prevEntities[i].es = ent->s; - demo.prevEntities[i].svFlags = ent->r.svFlags; - demo.prevEntities[i].linked = ent->r.linked; - VectorCopy( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin ); - VectorCopy( ent->r.absmin, demo.prevEntities[i].absmin ); - VectorCopy( ent->r.absmax, demo.prevEntities[i].absmax ); - 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 (optionally LZ4 compressed) - SVD_WriteBlock( f, msg.data, msg.cursize ); - - // 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_WriteBlock( f, psmsg.data, psmsg.cursize ); - } - - // 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%s\n", path, - Cvar_VariableIntegerValue("svdemo_compress") ? " (LZ4)" : "" ); - demo.recording = qtrue; - demo.compressed = Cvar_VariableIntegerValue("svdemo_compress") ? qtrue : qfalse; - - // clear delta state for fresh recording - Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) ); - Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) ); - - 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 ); - - 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; - } - } - - 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; - } - 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; - } - - { - int flags = SVD_ReadInt( f ); - demo.compressed = ( flags & SVDEMO_FLAG_COMPRESSED ) ? qtrue : qfalse; - } - - 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 & 1 ) { - // map was restarted — reset playback delta state to match - // the recording's reset (both now decode from zero 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. - svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES; - } - } - - // read entity message (optionally LZ4 compressed) - blockLen = SVD_ReadBlock( f, msgBuf, sizeof(msgBuf) ); - if ( blockLen <= 0 ) { - return qfalse; - } - 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 PVS fields - { - qboolean pvsChanged = MSG_ReadBits( &msg, 1 ); - if ( pvsChanged ) { - demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg ); - demo.playPrevEntities[entNum].linked = MSG_ReadLong( &msg ); - demo.playPrevEntities[entNum].currentOrigin[0] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].currentOrigin[1] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].currentOrigin[2] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmin[0] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmin[1] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmin[2] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmax[0] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmax[1] = MSG_ReadFloat( &msg ); - demo.playPrevEntities[entNum].absmax[2] = MSG_ReadFloat( &msg ); - } - } - - demo.playPrevEntities[entNum].es = newEs; - demo.playPrevEntities[entNum].active = qtrue; - - // apply to server entity - ent = SV_GentityNum( entNum ); - ent->s = newEs; - ent->s.number = entNum; - ent->r.svFlags = demo.playPrevEntities[entNum].svFlags; - ent->r.linked = demo.playPrevEntities[entNum].linked; - VectorCopy( demo.playPrevEntities[entNum].currentOrigin, ent->r.currentOrigin ); - VectorCopy( demo.playPrevEntities[entNum].absmin, ent->r.absmin ); - VectorCopy( demo.playPrevEntities[entNum].absmax, ent->r.absmax ); - - if ( ent->r.linked ) { - 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_ReadBlock( f, psBuf, sizeof(psBuf) ); - if ( psMsgLen > 0 ) { - 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.playing ) { - Com_Printf( "Already playing a server demo.\n" ); - return; - } - - s = Cmd_Argv(1); - if ( !s[0] ) { - Com_Printf( "Usage: svdemo_play \n" ); - return; - } - - 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; - } - - demo.playing = qtrue; - demo.endOfDemo = qfalse; - - Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n", - demo.playMapName, demo.playMaxClients, demo.playFps ); - - // Signal demo mode BEFORE map load so the game module knows - // during ClientConnect/ClientBegin to force spectator team. - Cvar_Set( "sv_demoplaying", "1" ); - - // Load the map with maxclients = MAX_CLIENTS to avoid entity slot collisions. - 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) ); - - // Reserve recorded player slots so the connecting spectator - // doesn't land in slot 0 (which collides with recorded player 0). - // Mark as CS_ZOMBIE (non-free, won't be reused by SV_DirectConnect). - // Set rate and nextSnapshotTime so SV_SendClientMessages doesn't - // crash on division by zero or try to send them snapshots. - { - 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; // never send - svs.clients[i].lastPacketTime = svs.time; - } - } - } - - // Configstrings will be applied on the first playback frame, - // after the map has fully loaded. - demo.needConfigstrings = qtrue; -} - -static 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; - } - } - - memset( &demo, 0, sizeof(demo) ); - Cvar_Set( "sv_demoplaying", "0" ); -} - -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" ); - } -} - -/* -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; - } - - // 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; -} - -/* -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 -} - +/* +=========================================================================== +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" +#include "lz4.h" + +// --------------------------------------------------------------- +// 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 2 // v2: optional LZ4 compression +#define SVDEMO_MAX_MAPNAME 64 + +// header flags +#define SVDEMO_FLAG_COMPRESSED 1 // per-frame data is LZ4 compressed + +// --------------------------------------------------------------- +// State +// --------------------------------------------------------------- + +// per-entity data stored for delta compression (entityState + PVS fields) +typedef struct { + entityState_t es; + int svFlags; + qboolean linked; + vec3_t currentOrigin; + vec3_t absmin; + vec3_t absmax; + 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; + qboolean compressed; // LZ4 compression enabled + 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 + + // 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 paused; + svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read + svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states +} 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; +} + +// --------------------------------------------------------------- +// LZ4 block I/O: writes [uncompressed_size][compressed_size][data] +// --------------------------------------------------------------- + +static void SVD_WriteBlock( fileHandle_t f, const byte *data, int len ) { + if ( demo.compressed && len > 0 ) { + int bound = LZ4_compressBound( len ); + static byte compBuf[MAX_GENTITIES * 300]; + int compLen; + + compLen = LZ4_compress_default( (const char *)data, (char *)compBuf, len, bound ); + if ( compLen > 0 ) { + SVD_WriteInt( f, len ); // original size + SVD_WriteInt( f, compLen ); // compressed size + FS_Write( compBuf, compLen, f ); + return; + } + // fall through to uncompressed on failure + } + SVD_WriteInt( f, len ); // original size + SVD_WriteInt( f, 0 ); // 0 = not compressed + FS_Write( data, len, f ); +} + +static int SVD_ReadBlock( fileHandle_t f, byte *buf, int bufSize ) { + int origLen, compLen; + + origLen = SVD_ReadInt( f ); + compLen = SVD_ReadInt( f ); + + if ( origLen <= 0 || origLen > bufSize ) { + return -1; + } + + if ( compLen > 0 ) { + // compressed + static byte compBuf[MAX_GENTITIES * 300]; + if ( compLen > (int)sizeof(compBuf) ) { + return -1; + } + FS_Read( compBuf, compLen, f ); + if ( LZ4_decompress_safe( (const char *)compBuf, (char *)buf, compLen, bufSize ) != origLen ) { + return -1; + } + } else { + // uncompressed + FS_Read( buf, origLen, f ); + } + + return origLen; +} + +// --------------------------------------------------------------- +// 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, demo.compressed ? SVDEMO_FLAG_COMPRESSED : 0 ); + 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 + { + byte frameFlags = 0; + if ( demo.mapRestarted ) { + frameFlags |= 1; + demo.mapRestarted = 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 PVS fields only if changed from previous frame + { + qboolean pvsChanged = !demo.prevEntities[i].active + || ent->r.svFlags != demo.prevEntities[i].svFlags + || ent->r.linked != demo.prevEntities[i].linked + || !VectorCompare( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin ) + || !VectorCompare( ent->r.absmin, demo.prevEntities[i].absmin ) + || !VectorCompare( ent->r.absmax, demo.prevEntities[i].absmax ); + + MSG_WriteBits( &msg, pvsChanged, 1 ); + if ( pvsChanged ) { + MSG_WriteLong( &msg, ent->r.svFlags ); + MSG_WriteLong( &msg, ent->r.linked ); + MSG_WriteFloat( &msg, ent->r.currentOrigin[0] ); + MSG_WriteFloat( &msg, ent->r.currentOrigin[1] ); + MSG_WriteFloat( &msg, ent->r.currentOrigin[2] ); + MSG_WriteFloat( &msg, ent->r.absmin[0] ); + MSG_WriteFloat( &msg, ent->r.absmin[1] ); + MSG_WriteFloat( &msg, ent->r.absmin[2] ); + MSG_WriteFloat( &msg, ent->r.absmax[0] ); + MSG_WriteFloat( &msg, ent->r.absmax[1] ); + MSG_WriteFloat( &msg, ent->r.absmax[2] ); + } + } + + // update prev state + demo.prevEntities[i].es = ent->s; + demo.prevEntities[i].svFlags = ent->r.svFlags; + demo.prevEntities[i].linked = ent->r.linked; + VectorCopy( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin ); + VectorCopy( ent->r.absmin, demo.prevEntities[i].absmin ); + VectorCopy( ent->r.absmax, demo.prevEntities[i].absmax ); + 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 (optionally LZ4 compressed) + SVD_WriteBlock( f, msg.data, msg.cursize ); + + // 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_WriteBlock( f, psmsg.data, psmsg.cursize ); + } + + // 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%s\n", path, + Cvar_VariableIntegerValue("svdemo_compress") ? " (LZ4)" : "" ); + demo.recording = qtrue; + demo.compressed = Cvar_VariableIntegerValue("svdemo_compress") ? qtrue : qfalse; + + // clear delta state for fresh recording + Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) ); + Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) ); + + 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 ); + + 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; + } + } + + 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; + } + 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; + } + + { + int flags = SVD_ReadInt( f ); + demo.compressed = ( flags & SVDEMO_FLAG_COMPRESSED ) ? qtrue : qfalse; + } + + 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 & 1 ) { + // map was restarted — reset playback delta state to match + // the recording's reset (both now decode from zero 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. + svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES; + } + } + + // read entity message (optionally LZ4 compressed) + blockLen = SVD_ReadBlock( f, msgBuf, sizeof(msgBuf) ); + if ( blockLen <= 0 ) { + return qfalse; + } + 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 PVS fields + { + qboolean pvsChanged = MSG_ReadBits( &msg, 1 ); + if ( pvsChanged ) { + demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg ); + demo.playPrevEntities[entNum].linked = MSG_ReadLong( &msg ); + demo.playPrevEntities[entNum].currentOrigin[0] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].currentOrigin[1] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].currentOrigin[2] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmin[0] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmin[1] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmin[2] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmax[0] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmax[1] = MSG_ReadFloat( &msg ); + demo.playPrevEntities[entNum].absmax[2] = MSG_ReadFloat( &msg ); + } + } + + demo.playPrevEntities[entNum].es = newEs; + demo.playPrevEntities[entNum].active = qtrue; + + // apply to server entity + ent = SV_GentityNum( entNum ); + ent->s = newEs; + ent->s.number = entNum; + ent->r.svFlags = demo.playPrevEntities[entNum].svFlags; + ent->r.linked = demo.playPrevEntities[entNum].linked; + VectorCopy( demo.playPrevEntities[entNum].currentOrigin, ent->r.currentOrigin ); + VectorCopy( demo.playPrevEntities[entNum].absmin, ent->r.absmin ); + VectorCopy( demo.playPrevEntities[entNum].absmax, ent->r.absmax ); + + if ( ent->r.linked ) { + 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_ReadBlock( f, psBuf, sizeof(psBuf) ); + if ( psMsgLen > 0 ) { + 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.playing ) { + Com_Printf( "Already playing a server demo.\n" ); + return; + } + + s = Cmd_Argv(1); + if ( !s[0] ) { + Com_Printf( "Usage: svdemo_play \n" ); + return; + } + + 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; + } + + demo.playing = qtrue; + demo.endOfDemo = qfalse; + + Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n", + demo.playMapName, demo.playMaxClients, demo.playFps ); + + // Signal demo mode BEFORE map load so the game module knows + // during ClientConnect/ClientBegin to force spectator team. + Cvar_Set( "sv_demoplaying", "1" ); + + // Load the map with maxclients = MAX_CLIENTS to avoid entity slot collisions. + 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) ); + + // Reserve recorded player slots so the connecting spectator + // doesn't land in slot 0 (which collides with recorded player 0). + // Mark as CS_ZOMBIE (non-free, won't be reused by SV_DirectConnect). + // Set rate and nextSnapshotTime so SV_SendClientMessages doesn't + // crash on division by zero or try to send them snapshots. + { + 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; // never send + svs.clients[i].lastPacketTime = svs.time; + } + } + } + + // Configstrings will be applied on the first playback frame, + // after the map has fully loaded. + demo.needConfigstrings = qtrue; +} + +static 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; + } + } + + memset( &demo, 0, sizeof(demo) ); + Cvar_Set( "sv_demoplaying", "0" ); +} + +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; + Com_Printf( "Demo playback %s.\n", demo.paused ? "paused" : "resumed" ); +} + +/* +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; +} + +/* +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 +} +