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 @@
MaxSpeed
MaxSpeed
+
+
+
+
Disabled
true
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 ; iinteger ; 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 \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
+}
+
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
+
+
+===========================================================================