Server-side demo recording and playback (netdemo)

Records full entity state array each server frame, enabling
free-camera demo playback from any viewpoint.

Recording:
- svdemo_record <name> / svdemo_stop
- Captures entityState_t + PVS fields (svFlags, linked,
  currentOrigin, absmin, absmax) for all active entities
- Records configstring changes per frame
- File format: svdemos/<name>.svdm

Playback:
- svdemo_play <name>
- Loads map with maxclients=64, reserves recorded player slots
  (CS_ZOMBIE with safe rate/timing) so spectator gets a high slot
- Injects recorded entities into sv.gentities each frame with
  SV_LinkEntity for PVS visibility
- Re-applies demo configstrings (CS_PLAYERS etc.) after map load,
  skipping CS_SERVERINFO/CS_SYSTEMINFO to avoid latch restarts
- Game module runs in demo mode (sv_demoplaying cvar): G_RunFrame
  only processes spectator movement, skips all entity logic
- Spectator forced to TEAM_SPECTATOR on connect (ClientBegin)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
serge_shubin 2026-03-23 04:28:55 +08:00
parent ba1c1b5a60
commit a8044aad8b
8 changed files with 638 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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
//

View file

@ -995,6 +995,8 @@
<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\sv_world.c">
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">Disabled</Optimization>
<BrowseInformation Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">true</BrowseInformation>

View file

@ -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

View file

@ -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);
}
/*

View file

@ -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 ) {

585
code/server/sv_netdemo.c Normal file
View file

@ -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 <demoname>\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 <demoname>\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;
}