diff --git a/code/game/g_client.c b/code/game/g_client.c
index 5a584d4..c48fcb7 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
+ if ( g_svDemoPlaying.integer ) {
+ client->sess.sessionTeam = TEAM_SPECTATOR;
+ client->ps.pm_type = PM_SPECTATOR;
+ client->ps.persistant[PERS_TEAM] = TEAM_SPECTATOR;
+ }
+
// 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_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..b6a93e5 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 },
@@ -1729,6 +1731,19 @@ int start, end;
// get any cvar changes
G_UpdateCvars();
+ // demo playback: only process spectator client, skip all entity logic
+ if ( g_svDemoPlaying.integer ) {
+ for ( i = 0; i < level.maxclients; i++ ) {
+ ent = &g_entities[i];
+ if ( ent->inuse && ent->client &&
+ ent->client->sess.sessionTeam == TEAM_SPECTATOR &&
+ ent->client->pers.connected == CON_CONNECTED ) {
+ ClientThink_real( ent );
+ }
+ }
+ return;
+ }
+
//
// go through all allocated objects
//
diff --git a/code/quake3.vcxproj b/code/quake3.vcxproj
index 340ba18..25d6f18 100644
--- a/code/quake3.vcxproj
+++ b/code/quake3.vcxproj
@@ -995,6 +995,8 @@
MaxSpeed
MaxSpeed
+
+
Disabled
true
diff --git a/code/server/server.h b/code/server/server.h
index c32b2dc..c27ec1e 100644
--- a/code/server/server.h
+++ b/code/server/server.h
@@ -341,6 +341,19 @@ 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_Play_f( void );
+void SVD_StopPlay_f( void );
+qboolean SVD_PlaybackFrame( void );
+qboolean SVD_IsRecording( void );
+qboolean SVD_IsPlaying( void );
+int SVD_SpectatorClientNum( 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..060c030 100644
--- a/code/server/sv_ccmds.c
+++ b/code/server/sv_ccmds.c
@@ -736,6 +736,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_StopRecord_f);
+ Cmd_AddCommand ("svdemo_play", SVD_Play_f);
}
/*
diff --git a/code/server/sv_main.c b/code/server/sv_main.c
index 98c74ce..84a9312 100644
--- a/code/server/sv_main.c
+++ b/code/server/sv_main.c
@@ -833,8 +833,17 @@ 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 ) {
diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c
new file mode 100644
index 0000000..81f1b2e
--- /dev/null
+++ b/code/server/sv_netdemo.c
@@ -0,0 +1,585 @@
+/*
+===========================================================================
+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"
+
+// ---------------------------------------------------------------
+// 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 1
+#define SVDEMO_MAX_MAPNAME 64
+
+// ---------------------------------------------------------------
+// State
+// ---------------------------------------------------------------
+
+typedef struct {
+ // recording
+ fileHandle_t recordFile;
+ qboolean recording;
+ char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection
+
+ // 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];
+ int spectatorClientNum; // which client slot is the demo spectator
+ int nextFrameTime; // serverTime of next frame to read
+ qboolean endOfDemo;
+ qboolean needConfigstrings; // apply saved configstrings on first frame
+} svDemo_t;
+
+static svDemo_t demo;
+
+// ---------------------------------------------------------------
+// Recording helpers
+// ---------------------------------------------------------------
+
+static void SVD_WriteInt( fileHandle_t f, int v ) {
+ FS_Write( &v, 4, f );
+}
+
+static void SVD_WriteShort( fileHandle_t f, short v ) {
+ FS_Write( &v, 2, f );
+}
+
+static int SVD_ReadInt( fileHandle_t f ) {
+ int v = 0;
+ FS_Read( &v, 4, f );
+ return v;
+}
+
+static short SVD_ReadShort( fileHandle_t f ) {
+ short v = 0;
+ FS_Read( &v, 2, f );
+ return v;
+}
+
+// ---------------------------------------------------------------
+// Write header
+// ---------------------------------------------------------------
+
+static void SVD_WriteHeader( fileHandle_t f ) {
+ int i;
+ char mapBuf[SVDEMO_MAX_MAPNAME];
+
+ SVD_WriteInt( f, SVDEMO_MAGIC );
+ SVD_WriteInt( f, SVDEMO_VERSION );
+ SVD_WriteInt( f, 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, count;
+ sharedEntity_t *ent;
+ short numChanges;
+
+ SVD_WriteInt( f, svs.time );
+
+ // count active entities
+ count = 0;
+ for ( i = 0; i < sv.num_entities; i++ ) {
+ ent = SV_GentityNum( i );
+ if ( ent->r.linked || ent->s.eType != 0 ) {
+ count++;
+ }
+ }
+ SVD_WriteShort( f, (short)count );
+
+ // write each active entity
+ for ( i = 0; i < sv.num_entities; i++ ) {
+ ent = SV_GentityNum( i );
+ if ( !ent->r.linked && ent->s.eType == 0 ) {
+ continue;
+ }
+
+ SVD_WriteShort( f, (short)i );
+ FS_Write( &ent->s, sizeof(entityState_t), f );
+ SVD_WriteInt( f, ent->r.svFlags );
+ SVD_WriteInt( f, ent->r.linked );
+ FS_Write( ent->r.currentOrigin, 12, f );
+ FS_Write( ent->r.absmin, 12, f );
+ FS_Write( ent->r.absmax, 12, f );
+ }
+
+ // configstring changes
+ numChanges = 0;
+ for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) {
+ const char *cur = sv.configstrings[i] ? sv.configstrings[i] : "";
+ const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : "";
+ if ( strcmp( cur, old ) != 0 ) {
+ numChanges++;
+ }
+ }
+ SVD_WriteShort( f, numChanges );
+
+ if ( numChanges > 0 ) {
+ for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) {
+ const char *cur = sv.configstrings[i] ? sv.configstrings[i] : "";
+ const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : "";
+ if ( strcmp( cur, old ) != 0 ) {
+ int len = strlen( cur ) + 1;
+ SVD_WriteShort( f, (short)i );
+ SVD_WriteShort( f, (short)len );
+ FS_Write( cur, len, f );
+
+ if ( demo.lastConfigstrings[i] ) {
+ Z_Free( demo.lastConfigstrings[i] );
+ }
+ demo.lastConfigstrings[i] = CopyString( cur );
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------
+// Recording commands
+// ---------------------------------------------------------------
+
+void SVD_Record_f( void ) {
+ char name[MAX_OSPATH];
+ char *s;
+
+ if ( demo.recording ) {
+ Com_Printf( "Already recording a server demo.\n" );
+ return;
+ }
+
+ if ( sv.state != SS_GAME ) {
+ Com_Printf( "Not running a server.\n" );
+ return;
+ }
+
+ s = Cmd_Argv(1);
+ if ( !s[0] ) {
+ Com_Printf( "Usage: svdemo_record \n" );
+ return;
+ }
+
+ Com_sprintf( name, sizeof(name), "svdemos/%s.svdm", s );
+
+ demo.recordFile = FS_FOpenFileWrite( name );
+ if ( !demo.recordFile ) {
+ Com_Printf( "ERROR: couldn't open %s for writing.\n", name );
+ return;
+ }
+
+ Com_Printf( "Recording server demo to %s\n", name );
+ demo.recording = qtrue;
+
+ SVD_WriteHeader( demo.recordFile );
+}
+
+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.
+*/
+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;
+ }
+
+ 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;
+ sharedEntity_t *ent;
+
+ serverTime = SVD_ReadInt( f );
+ if ( serverTime == -1 ) {
+ return qfalse; // end of demo
+ }
+
+ svs.time = serverTime;
+
+ // clear all entities except the spectator slot
+ for ( i = 0; i < sv.num_entities; i++ ) {
+ if ( i == demo.spectatorClientNum ) {
+ continue;
+ }
+ ent = SV_GentityNum( i );
+ ent->r.linked = qfalse;
+ ent->s.eType = 0;
+ ent->s.number = i;
+ }
+
+ numEnts = SVD_ReadShort( f );
+
+ for ( i = 0; i < numEnts; i++ ) {
+ short entNum;
+ entityState_t es;
+ int svFlags, linked;
+ vec3_t currentOrigin, absmin, absmax;
+
+ entNum = SVD_ReadShort( f );
+ FS_Read( &es, sizeof(entityState_t), f );
+ svFlags = SVD_ReadInt( f );
+ linked = SVD_ReadInt( f );
+ FS_Read( currentOrigin, 12, f );
+ FS_Read( absmin, 12, f );
+ FS_Read( absmax, 12, f );
+
+ // skip the spectator's slot
+ if ( entNum == demo.spectatorClientNum ) {
+ continue;
+ }
+
+ ent = SV_GentityNum( entNum );
+ ent->s = es;
+ ent->s.number = entNum;
+ ent->r.svFlags = svFlags;
+ ent->r.linked = linked;
+ VectorCopy( currentOrigin, ent->r.currentOrigin );
+ VectorCopy( absmin, ent->r.absmin );
+ VectorCopy( absmax, ent->r.absmax );
+
+ // link into the world so PVS can find this entity
+ if ( linked ) {
+ SV_LinkEntity( ent );
+ }
+
+ // update num_entities high water mark
+ if ( entNum + 1 > sv.num_entities ) {
+ sv.num_entities = entNum + 1;
+ }
+ }
+
+ // ensure spectator slot is counted
+ if ( demo.spectatorClientNum + 1 > sv.num_entities ) {
+ sv.num_entities = demo.spectatorClientNum + 1;
+ }
+
+ // read configstring changes
+ 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';
+ SV_SetConfigstring( idx, 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 );
+
+ len = FS_FOpenFileRead( name, &demo.playFile, qtrue );
+ if ( !demo.playFile || len <= 0 ) {
+ Com_Printf( "ERROR: couldn't open %s.\n", name );
+ return;
+ }
+
+ // read the header (populates demo.playMapName etc.)
+ memset( &demo, 0, sizeof(demo) );
+ demo.playFile = 0; // re-open after memset
+ len = FS_FOpenFileRead( name, &demo.playFile, qtrue );
+
+ if ( !SVD_ReadHeader( demo.playFile ) ) {
+ FS_FCloseFile( demo.playFile );
+ demo.playFile = 0;
+ return;
+ }
+
+ demo.playing = qtrue;
+ demo.endOfDemo = qfalse;
+
+ // The spectator gets the highest slot: MAX_CLIENTS - 1
+ demo.spectatorClientNum = MAX_CLIENTS - 1;
+
+ Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n",
+ demo.playMapName, demo.playMaxClients, demo.playFps );
+
+ // 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) );
+
+ // Signal demo mode to the game module AFTER map load
+ Cvar_Set( "sv_demoplaying", "1" );
+
+ // 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;
+}
+
+void SVD_StopPlay_f( void ) {
+ int i;
+
+ if ( !demo.playing ) {
+ Com_Printf( "Not playing a server demo.\n" );
+ return;
+ }
+
+ FS_FCloseFile( demo.playFile );
+
+ // free saved configstrings
+ for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) {
+ if ( demo.savedConfigstrings[i] ) {
+ Z_Free( demo.savedConfigstrings[i] );
+ }
+ }
+ memset( &demo, 0, sizeof(demo) );
+
+ Cvar_Set( "sv_demoplaying", "0" );
+ Com_Printf( "Server demo playback stopped.\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;
+ }
+
+ // apply recorded configstrings once after map load
+ if ( demo.needConfigstrings ) {
+ SVD_ApplyConfigstrings();
+ demo.needConfigstrings = qfalse;
+ }
+
+ if ( !SVD_ReadFrame( demo.playFile ) ) {
+ demo.endOfDemo = qtrue;
+ Com_Printf( "Server demo playback finished.\n" );
+ return qfalse;
+ }
+
+ return qtrue;
+}
+
+// ---------------------------------------------------------------
+// Queries
+// ---------------------------------------------------------------
+
+qboolean SVD_IsRecording( void ) {
+ return demo.recording;
+}
+
+qboolean SVD_IsPlaying( void ) {
+ return demo.playing;
+}
+
+int SVD_SpectatorClientNum( void ) {
+ return demo.spectatorClientNum;
+}