quake3live/netdemo.patch
serge_shubin 7b942c77cf Server-side demo recording and playback (netdemo) — unified
Complete netdemo feature as a single commit on implant2:

Recording:
- svdemo_record/svdemo_stop commands
- Delta-compressed entity states and player states
- Server command capture (chat, prints)
- Configstring change tracking
- Keyframes at configurable interval for seeking
- Auto-record on map load (svdemo_autorecord)
- Auto-stop on map change, clean shutdown handling

Playback:
- svdemo_play loads map, spectator auto-connects
- Free camera with client-owned PmoveSingle (real-time, works paused)
- Player follow mode with full HUD
- Scoreboard with recorded player scores
- svdemo_pause with frozen trajectories and smooth unpause
- svdemo_seek (fast, keyframe-accurate) and svdemo_seekexact (precise)
- Seeking works from paused state
- SNAPFLAG_RESET_ENTITIES for clean map_restart and seek transitions
- Proper session/cvar/configstring handling across transitions
- svdemo_pauseEmpty waits for spectator before starting

Engine changes:
- usercmd_t: optional origin field for client-owned PVS
- msg.c: serialize optional usercmd origin
- cl_parse.c: SERVERCOUNT time delta reset for seek/unpause
- CG_SETCLIENTORIGIN trap for cgame-to-engine origin communication

cgame changes:
- Client-owned spectator camera (svDemoCameraPs)
- CS_SVDEMO configstring detection
- SNAPFLAG_RESET_ENTITIES entity interpolation reset
- Backwards time handling for seeking
- Local entity cleanup on seek
- Pause detection from frozen snapshot time
- Suppress connection interrupted during demo playback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:54:32 +08:00

1593 lines
50 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

diff --git a/code/cgame/cg_snapshot.c b/code/cgame/cg_snapshot.c
index add49f0..6bc3a0c 100644
--- a/code/cgame/cg_snapshot.c
+++ b/code/cgame/cg_snapshot.c
@@ -203,6 +203,15 @@ static void CG_SetNextSnap( snapshot_t *snap ) {
cg.nextSnap = snap;
+ // SNAPFLAG_RESET_ENTITIES: invalidate all entities so they are
+ // treated as new (no interpolation from old positions).
+ // Must happen before the entity loop below.
+ if ( snap->snapFlags & SNAPFLAG_RESET_ENTITIES ) {
+ for ( num = 0 ; num < MAX_GENTITIES ; num++ ) {
+ cg_entities[ num ].currentValid = qfalse;
+ }
+ }
+
BG_PlayerStateToEntityState( &snap->ps, &cg_entities[ snap->ps.clientNum ].nextState, qfalse );
cg_entities[ cg.snap->ps.clientNum ].interpolate = qtrue;
@@ -241,6 +250,11 @@ static void CG_SetNextSnap( snapshot_t *snap ) {
cg.nextFrameTeleport = qtrue;
}
+ // entity reset also prevents playerstate interpolation
+ if ( cg.nextSnap->snapFlags & SNAPFLAG_RESET_ENTITIES ) {
+ cg.nextFrameTeleport = qtrue;
+ }
+
// sort out solid entities
CG_BuildSolidList();
}
diff --git a/code/game/g_client.c b/code/game/g_client.c
index 5a584d4..719e382 100644
--- a/code/game/g_client.c
+++ b/code/game/g_client.c
@@ -1012,6 +1012,13 @@ void ClientBegin( int clientNum ) {
client->pers.enterTime = level.time;
client->pers.teamState.state = TEAM_BEGIN;
+ // demo playback: force all clients to spectator
+ // demo playback: force to spectator and update configstring
+ if ( g_svDemoPlaying.integer ) {
+ client->sess.sessionTeam = TEAM_SPECTATOR;
+ ClientUserinfoChanged( ent->client - level.clients );
+ }
+
// save eflags around this, because changing teams will
// cause this to happen with a valid entity, and we
// want to make sure the teleport bit is set right
diff --git a/code/game/g_cmds.c b/code/game/g_cmds.c
index e72c80e..20e7b58 100644
--- a/code/game/g_cmds.c
+++ b/code/game/g_cmds.c
@@ -661,6 +661,12 @@ void Cmd_Team_f( gentity_t *ent ) {
int oldTeam;
char s[MAX_TOKEN_CHARS];
+ // demo playback: spectators only
+ if ( trap_Cvar_VariableIntegerValue( "sv_demoplaying" ) ) {
+ trap_SendServerCommand( ent-g_entities, "print \"Team changes disabled during demo playback.\n\"" );
+ return;
+ }
+
if ( trap_Argc() != 2 ) {
oldTeam = ent->client->sess.sessionTeam;
switch ( oldTeam ) {
diff --git a/code/game/g_local.h b/code/game/g_local.h
index 1a55955..057722c 100644
--- a/code/game/g_local.h
+++ b/code/game/g_local.h
@@ -641,6 +641,7 @@ void ClientCommand( int clientNum );
// g_active.c
//
void ClientThink( int clientNum );
+void ClientThink_real( gentity_t *ent );
void ClientEndFrame( gentity_t *ent );
void G_RunClient( gentity_t *ent );
@@ -715,6 +716,7 @@ extern gentity_t g_entities[MAX_GENTITIES];
#define FOFS(x) ((int)&(((gentity_t *)0)->x))
extern vmCvar_t g_gametype;
+extern vmCvar_t g_svDemoPlaying;
extern vmCvar_t g_dedicated;
extern vmCvar_t g_cheats;
extern vmCvar_t g_maxclients; // allow this many total, including spectators
diff --git a/code/game/g_main.c b/code/game/g_main.c
index 9f60272..a468854 100644
--- a/code/game/g_main.c
+++ b/code/game/g_main.c
@@ -39,6 +39,7 @@ gentity_t g_entities[MAX_GENTITIES];
gclient_t g_clients[MAX_CLIENTS];
vmCvar_t g_gametype;
+vmCvar_t g_svDemoPlaying;
vmCvar_t g_dmflags;
vmCvar_t g_fraglimit;
vmCvar_t g_timelimit;
@@ -108,6 +109,7 @@ static cvarTable_t gameCvarTable[] = {
// latched vars
{ &g_gametype, "g_gametype", "0", CVAR_SERVERINFO | CVAR_USERINFO | CVAR_LATCH, 0, qfalse },
+ { &g_svDemoPlaying, "sv_demoplaying", "0", CVAR_ROM, 0, qfalse },
{ &g_maxclients, "sv_maxclients", "8", CVAR_SERVERINFO | CVAR_LATCH | CVAR_ARCHIVE, 0, qfalse },
{ &g_maxGameClients, "g_maxGameClients", "0", CVAR_SERVERINFO | CVAR_LATCH | CVAR_ARCHIVE, 0, qfalse },
@@ -764,6 +766,10 @@ void CalculateRanks( void ) {
int rank;
int score;
int newScore;
+
+ // (demo playback note: this runs normally so the spectator
+ // appears in sortedClients. Recorded players won't show here
+ // since they're not connected in the game module.)
gclient_t *cl;
level.follow1 = -1;
@@ -1729,6 +1735,51 @@ int start, end;
// get any cvar changes
G_UpdateCvars();
+ // demo playback: sync recorded player states and process spectator
+ if ( g_svDemoPlaying.integer ) {
+ gentity_t *specEnt = NULL;
+
+ // mark recorded players as connected based on their playerState.
+ // the server injected playerStates via SV_GameClientNum before
+ // calling G_RunFrame, so g_clients[i].ps is already populated.
+ for ( i = 0; i < level.maxclients; i++ ) {
+ gclient_t *cl = &level.clients[i];
+ gentity_t *e = &g_entities[i];
+
+ // find and process the spectator
+ if ( e->client && cl->pers.connected == CON_CONNECTED
+ && cl->sess.sessionTeam == TEAM_SPECTATOR ) {
+ ClientThink_real( e );
+ specEnt = e;
+ continue;
+ }
+
+ // check if server injected a valid playerState for this slot
+ if ( cl->ps.commandTime > 0 ) {
+ cl->pers.connected = CON_CONNECTED;
+ cl->sess.sessionTeam = cl->ps.persistant[PERS_TEAM];
+ e->inuse = qtrue;
+ e->client = cl;
+ e->s.clientNum = i;
+ e->s.number = i;
+ } else if ( cl->pers.connected == CON_CONNECTED
+ && cl->sess.sessionTeam != TEAM_SPECTATOR ) {
+ // player left — mark disconnected
+ cl->pers.connected = CON_DISCONNECTED;
+ e->inuse = qfalse;
+ }
+ }
+
+ // update rankings so follow mode can cycle through players
+ CalculateRanks();
+
+ // run end-of-frame for spectator (handles follow mode PS copy)
+ if ( specEnt ) {
+ ClientEndFrame( specEnt );
+ }
+ return;
+ }
+
//
// go through all allocated objects
//
diff --git a/code/game/q_shared.h b/code/game/q_shared.h
index 99e5a67..4834c94 100644
--- a/code/game/q_shared.h
+++ b/code/game/q_shared.h
@@ -1085,6 +1085,7 @@ typedef enum {
#define SNAPFLAG_RATE_DELAYED 1
#define SNAPFLAG_NOT_ACTIVE 2 // snapshot used during connection and for zombies
#define SNAPFLAG_SERVERCOUNT 4 // toggled every map_restart so transitions can be detected
+#define SNAPFLAG_RESET_ENTITIES 16 // snap all entities to current position, no interpolation
//
// per-level limits
diff --git a/code/quake3.vcxproj b/code/quake3.vcxproj
index 340ba18..f850f17 100644
--- a/code/quake3.vcxproj
+++ b/code/quake3.vcxproj
@@ -995,6 +995,10 @@
<Optimization Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">MaxSpeed</Optimization>
<Optimization Condition="'$(Configuration)|$(Platform)'=='vector|Win32'">MaxSpeed</Optimization>
</ClCompile>
+ <ClCompile Include="server\sv_netdemo.c">
+ </ClCompile>
+ <ClCompile Include="server\lz4.c">
+ </ClCompile>
<ClCompile Include="server\sv_world.c">
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">Disabled</Optimization>
<BrowseInformation Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">true</BrowseInformation>
diff --git a/code/server/server.h b/code/server/server.h
index c32b2dc..02f8ae8 100644
--- a/code/server/server.h
+++ b/code/server/server.h
@@ -341,6 +341,23 @@ int SV_BotGetConsoleMessage( int client, char *buf, int size );
int BotImport_DebugPolygonCreate(int color, int numPoints, vec3_t *points);
void BotImport_DebugPolygonDelete(int id);
+//
+// sv_netdemo.c
+//
+void SVD_Record_f( void );
+void SVD_StopRecord_f( void );
+void SVD_RecordFrame( void );
+void SVD_ResetDeltaState( void );
+void SVD_AutoRecord( void );
+void SVD_CaptureServerCommand( const char *cmd );
+void SVD_Play_f( void );
+void SVD_StopPlay_f( void );
+void SVD_Stop_f( void );
+qboolean SVD_PlaybackFrame( void );
+qboolean SVD_IsRecording( void );
+qboolean SVD_IsPlaying( void );
+qboolean SVD_ShouldPause( void );
+
//============================================================
//
// high level object sorting to reduce interaction tests
diff --git a/code/server/sv_ccmds.c b/code/server/sv_ccmds.c
index 62ae179..b55d15f 100644
--- a/code/server/sv_ccmds.c
+++ b/code/server/sv_ccmds.c
@@ -280,6 +280,9 @@ static void SV_MapRestart_f( void ) {
sv.state = SS_GAME;
sv.restarting = qfalse;
+ // reset demo delta state so next frame writes full entities
+ SVD_ResetDeltaState();
+
// connect and begin all the clients
for (i=0 ; i<sv_maxclients->integer ; i++) {
client = &svs.clients[i];
@@ -736,6 +739,11 @@ void SV_AddOperatorCommands( void ) {
if( com_dedicated->integer ) {
Cmd_AddCommand ("say", SV_ConSay_f);
}
+
+ // server-side demo recording/playback
+ Cmd_AddCommand ("svdemo_record", SVD_Record_f);
+ Cmd_AddCommand ("svdemo_stop", SVD_Stop_f);
+ Cmd_AddCommand ("svdemo_play", SVD_Play_f);
}
/*
diff --git a/code/server/sv_game.c b/code/server/sv_game.c
index 202994e..b227f3c 100644
--- a/code/server/sv_game.c
+++ b/code/server/sv_game.c
@@ -83,13 +83,20 @@ Sends a command string to a client
===============
*/
void SV_GameSendServerCommand( int clientNum, const char *text ) {
+ // capture for demo recording: broadcasts and per-client chat/tchat
+ if ( clientNum == -1 ) {
+ SVD_CaptureServerCommand( text );
+ } else if ( !strncmp( text, "chat", 4 ) || !strncmp( text, "tchat", 5 ) ) {
+ SVD_CaptureServerCommand( text );
+ }
+
if ( clientNum == -1 ) {
SV_SendServerCommand( NULL, "%s", text );
} else {
if ( clientNum < 0 || clientNum >= sv_maxclients->integer ) {
return;
}
- SV_SendServerCommand( svs.clients + clientNum, "%s", text );
+ SV_SendServerCommand( svs.clients + clientNum, "%s", text );
}
}
diff --git a/code/server/sv_init.c b/code/server/sv_init.c
index b3d0c4d..e1ffb4b 100644
--- a/code/server/sv_init.c
+++ b/code/server/sv_init.c
@@ -351,6 +351,12 @@ void SV_SpawnServer( char *server, qboolean killBots ) {
char systemInfo[16384];
const char *p;
+ // stop any active demo recording (one demo = one map)
+ if ( SVD_IsRecording() ) {
+ Com_Printf( "Map change — stopping demo recording.\n" );
+ SVD_StopRecord_f();
+ }
+
// shut down the existing game if it is running
SV_ShutdownGameProgs();
@@ -546,6 +552,9 @@ void SV_SpawnServer( char *server, qboolean killBots ) {
Hunk_SetMark();
Com_Printf ("-----------------------------------\n");
+
+ // auto-record demo if enabled
+ SVD_AutoRecord();
}
/*
@@ -612,6 +621,11 @@ void SV_Init (void) {
sv_lanForceRate = Cvar_Get ("sv_lanForceRate", "1", CVAR_ARCHIVE );
sv_strictAuth = Cvar_Get ("sv_strictAuth", "1", CVAR_ARCHIVE );
+ // server-side demo settings
+ Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE);
+ Cvar_Get ("svdemo_compress", "1", CVAR_ARCHIVE);
+ Cvar_Get ("svdemo_pauseEmpty", "1", CVAR_ARCHIVE);
+
// initialize bot cvars so they are listed and can be set before loading the botlib
SV_BotInitCvars();
@@ -667,6 +681,14 @@ void SV_Shutdown( char *finalmsg ) {
Com_Printf( "----- Server Shutdown -----\n" );
+ // clean up any active demo recording/playback
+ if ( SVD_IsRecording() ) {
+ SVD_StopRecord_f();
+ }
+ if ( SVD_IsPlaying() ) {
+ SVD_StopPlay_f();
+ }
+
if ( svs.clients && !com_errorEntered ) {
SV_FinalMessage( finalmsg );
}
diff --git a/code/server/sv_main.c b/code/server/sv_main.c
index 98c74ce..da5e89a 100644
--- a/code/server/sv_main.c
+++ b/code/server/sv_main.c
@@ -833,16 +833,27 @@ void SV_Frame( int msec ) {
sv.timeResidual -= frameMsec;
svs.time += frameMsec;
+ if ( SVD_IsPlaying() ) {
+ // demo playback: read recorded entities instead of running game logic
+ SVD_PlaybackFrame();
+ // still call the game frame for spectator movement
+ }
+
// let everything in the world think and move
VM_Call( gvm, GAME_RUN_FRAME, svs.time );
+
+ // capture frame for demo recording
+ SVD_RecordFrame();
}
if ( com_speeds->integer ) {
time_game = Sys_Milliseconds () - startTime;
}
- // check timeouts
- SV_CheckTimeouts();
+ // check timeouts (skip during demo playback — zombie slots would be freed)
+ if ( !SVD_IsPlaying() ) {
+ SV_CheckTimeouts();
+ }
// send messages back to the clients
SV_SendClientMessages();
diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c
new file mode 100644
index 0000000..f87afc3
--- /dev/null
+++ b/code/server/sv_netdemo.c
@@ -0,0 +1,1056 @@
+/*
+===========================================================================
+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 <demoname>\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 <demoname>\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
+}
+
diff --git a/svdemo.txt b/svdemo.txt
new file mode 100644
index 0000000..24dc836
--- /dev/null
+++ b/svdemo.txt
@@ -0,0 +1,160 @@
+===========================================================================
+ Серверные демо-записи (SVDEMO)
+ Руководство пользователя
+===========================================================================
+
+ОПИСАНИЕ
+--------
+
+Система серверных демо-записей позволяет записывать полное состояние
+игрового сервера (все сущности, все игроки) и воспроизводить запись
+с возможностью свободного перемещения камеры или просмотра от первого
+лица любого игрока.
+
+В отличие от обычных клиентских демо (запись только того, что видит
+один игрок), серверная запись содержит полную картину матча.
+
+
+КОНСОЛЬНЫЕ КОМАНДЫ
+------------------
+
+ svdemo_record <имя>
+ Начать запись серверного демо. Файл сохраняется в
+ svdemos/<имя>.svdm внутри игровой директории.
+
+ svdemo_play <имя>
+ Воспроизвести серверное демо. Загружает карту из записи,
+ подключает зрителя автоматически.
+
+ svdemo_stop
+ Остановить текущую запись или воспроизведение.
+ При остановке воспроизведения происходит отключение от сервера.
+
+
+НАСТРОЙКИ (CVARS)
+-----------------
+
+ svdemo_autorecord <0|1> (по умолчанию: 0)
+ Автоматическая запись демо при каждой загрузке карты.
+ Файлы именуются автоматически: <карта>_ГГГГММДД_ЧЧММСС.svdm
+ Пример: q3dm6_20260323_141530.svdm
+
+ Для включения: set svdemo_autorecord 1
+ Значение сохраняется в конфигурации (CVAR_ARCHIVE).
+
+ svdemo_compress <0|1> (по умолчанию: 1)
+ Сжатие данных демо алгоритмом LZ4. Уменьшает размер файла
+ при незначительных затратах на производительность.
+ Включено по умолчанию. Установите 0 для отключения.
+ Значение сохраняется в конфигурации (CVAR_ARCHIVE).
+ Влияет только на запись — воспроизведение автоматически
+ определяет, сжат файл или нет.
+
+
+ЗАПИСЬ
+------
+
+1. Запустите сервер и начните игру как обычно:
+ devmap q3dm6
+
+2. Начните запись:
+ svdemo_record mymatch
+
+3. Играйте. Все действия всех игроков записываются.
+
+4. Остановите запись:
+ svdemo_stop
+
+ Запись также автоматически останавливается при:
+ - Смене карты (map, devmap, nextmap по таймлимиту/фраглимиту)
+ - Выключении сервера
+ Перезапуск карты (map_restart) НЕ прерывает запись.
+
+
+ВОСПРОИЗВЕДЕНИЕ
+---------------
+
+1. Запустите воспроизведение:
+ svdemo_play mymatch
+
+2. Карта загрузится автоматически. Вы подключитесь как зритель
+ со свободной камерой (полёт по карте).
+
+3. Управление зрителем:
+ - Свободная камера: перемещайтесь как обычный спектатор
+ - Следование за игроком: нажмите MOUSE2 (USE) для входа
+ в режим следования
+ - Переключение между игроками: MOUSE1 (ATTACK)
+ - Выход из следования: MOUSE2 снова
+
+4. В режиме следования вы видите игру от первого лица выбранного
+ игрока с полным HUD: здоровье, броня, боеприпасы, оружие.
+
+5. Табло (TAB) показывает счёт записанных игроков.
+
+6. Остановка:
+ svdemo_stop
+ Также воспроизведение останавливается автоматически при
+ достижении конца записи.
+
+
+ФОРМАТ ФАЙЛА
+-------------
+
+Расширение: .svdm
+Директория: svdemos/
+
+Файл содержит:
+- Заголовок: название карты, настройки сервера, конфигстроки
+ (имена игроков, модели, настройки игры)
+- Покадровые данные: дельта-сжатые состояния сущностей и игроков,
+ изменения конфигстрок
+
+Используется двухуровневое сжатие:
+1. Дельта-кодирование: записываются только изменившиеся поля
+ сущностей и игроков между кадрами.
+2. LZ4-сжатие (опционально): дополнительно сжимает блоки данных
+ каждого кадра. Включено по умолчанию (svdemo_compress 1).
+
+Типичная 10-секундная запись занимает ~50 КБ.
+
+Одна запись = одна карта. При смене карты запись останавливается.
+
+
+ОГРАНИЧЕНИЯ
+-----------
+
+- Нельзя перематывать запись вперёд или назад.
+- Если в записанной игре было 64 игрока (MAX_CLIENTS), один
+ из них не будет виден при воспроизведении (его слот занят зрителем).
+- Воспроизведение требует наличия тех же pk3-файлов (карты, модели),
+ что использовались при записи.
+- Демо несовместимы между разными версиями движка, если изменился
+ формат сетевых структур.
+
+
+ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
+---------------------
+
+Автоматическая запись всех матчей:
+
+ set svdemo_autorecord 1
+ devmap q3dm17
+
+ (все матчи на этом сервере будут записываться автоматически)
+
+
+Запись конкретного матча:
+
+ devmap q3tourney2
+ svdemo_record duel_finals
+ (играть...)
+ svdemo_stop
+
+
+Просмотр записи:
+
+ svdemo_play duel_finals
+
+
+===========================================================================