quake3live/code/server/sv_netdemo.c
serge_shubin 0b4eb7b69f Start demo playback only when spectator enters game
Move pause check from SV_Frame (which blocked connection handshake)
into SVD_PlaybackFrame. The server runs frames normally so clients
can connect and load, but demo data isn't consumed until a client
reaches CS_ACTIVE. The spectator sees the demo from the very first
frame without missing any gameplay.

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

1064 lines
30 KiB
C

/*
===========================================================================
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];
// spectatorClientNum removed — zombie slots handle reservation,
// and G_RunFrame recreates the spectator entity each frame.
int nextFrameTime; // serverTime of next frame to read
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 ) {
int j;
// map was restarted — reset playback delta state to match
// the recording's reset (both now decode from zero baseline).
// Also toggle server bit and force non-delta snapshot so the
// client clears old entity state and doesn't interpolate.
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
// send a full gamestate to all active clients — this makes
// CL_ParseGamestate → CL_ClearState wipe all snapshot/entity
// history, exactly like a real map_restart does.
for ( j = 0; j < sv_maxclients->integer; j++ ) {
if ( svs.clients[j].state >= CS_PRIMED ) {
SV_SendClientGameState( &svs.clients[j] );
}
}
}
}
// 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;
}
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
}