Complete netdemo feature as a single commit on implant2: Recording: - svdemo_record/svdemo_stop commands - Delta-compressed entity states and player states - Server command capture (chat, prints) - Configstring change tracking - Keyframes at configurable interval for seeking - Auto-record on map load (svdemo_autorecord) - Auto-stop on map change, clean shutdown handling Playback: - svdemo_play loads map, spectator auto-connects - Free camera with client-owned PmoveSingle (real-time, works paused) - Player follow mode with full HUD - Scoreboard with recorded player scores - svdemo_pause with frozen trajectories and smooth unpause - svdemo_seek (fast, keyframe-accurate) and svdemo_seekexact (precise) - Seeking works from paused state - SNAPFLAG_RESET_ENTITIES for clean map_restart and seek transitions - Proper session/cvar/configstring handling across transitions - svdemo_pauseEmpty waits for spectator before starting Engine changes: - usercmd_t: optional origin field for client-owned PVS - msg.c: serialize optional usercmd origin - cl_parse.c: SERVERCOUNT time delta reset for seek/unpause - CG_SETCLIENTORIGIN trap for cgame-to-engine origin communication cgame changes: - Client-owned spectator camera (svDemoCameraPs) - CS_SVDEMO configstring detection - SNAPFLAG_RESET_ENTITIES entity interpolation reset - Backwards time handling for seeking - Local entity cleanup on seek - Pause detection from frozen snapshot time - Suppress connection interrupted during demo playback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1263 lines
36 KiB
C
1263 lines
36 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"
|
|
|
|
// Cvar_Set2 not in public header but needed to force-set CVAR_ROM cvars
|
|
extern cvar_t *Cvar_Set2( const char *var_name, const char *value, qboolean force );
|
|
|
|
// ---------------------------------------------------------------
|
|
// 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 3 // v3: removed PVS data, svFlags only
|
|
#define SVDEMO_MAX_MAPNAME 64
|
|
|
|
// header flags
|
|
|
|
// ---------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------
|
|
|
|
// per-entity data stored for delta compression
|
|
typedef struct {
|
|
entityState_t es;
|
|
int svFlags;
|
|
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;
|
|
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
|
|
qboolean isKeyframe; // next frame is a keyframe (delta from baseline)
|
|
int keyframeInterval; // frames between keyframes (0 = disabled)
|
|
int framesSinceKeyframe; // counter for next keyframe
|
|
|
|
// 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
|
|
qboolean starting; // SVD_Play_f is running devmap internally
|
|
qboolean paused;
|
|
qboolean seeked; // just seeked, next frame needs RESET
|
|
svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read
|
|
svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states
|
|
|
|
// keyframe index (shared by recording and playback)
|
|
int numKeyframes;
|
|
int maxKeyframes; // allocated size
|
|
int *keyframeTimes; // serverTime of each keyframe
|
|
int *keyframeOffsets; // file offset of each keyframe
|
|
} 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, 0 ); // flags (reserved)
|
|
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, bit 1 = keyframe
|
|
{
|
|
byte frameFlags = 0;
|
|
if ( demo.mapRestarted ) {
|
|
frameFlags |= 1;
|
|
demo.mapRestarted = qfalse;
|
|
}
|
|
if ( demo.isKeyframe ) {
|
|
frameFlags |= 2;
|
|
demo.isKeyframe = 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 svFlags only if changed (rarely changes)
|
|
if ( ent->r.svFlags != demo.prevEntities[i].svFlags ) {
|
|
MSG_WriteBits( &msg, 1, 1 );
|
|
MSG_WriteLong( &msg, ent->r.svFlags );
|
|
} else {
|
|
MSG_WriteBits( &msg, 0, 1 );
|
|
}
|
|
|
|
// update prev state
|
|
demo.prevEntities[i].es = ent->s;
|
|
demo.prevEntities[i].svFlags = ent->r.svFlags;
|
|
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
|
|
SVD_WriteInt( f, msg.cursize );
|
|
FS_Write( msg.data, msg.cursize, f );
|
|
|
|
// 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_WriteInt( f, psmsg.cursize );
|
|
FS_Write( psmsg.data, psmsg.cursize, 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 );
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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\n", path );
|
|
demo.recording = qtrue;
|
|
|
|
// clear delta state for fresh recording
|
|
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
|
|
Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) );
|
|
|
|
// keyframe interval from cvar (seconds to frames at sv_fps)
|
|
{
|
|
int secs = Cvar_VariableIntegerValue( "svdemo_keyframeInterval" );
|
|
if ( secs > 0 ) {
|
|
demo.keyframeInterval = secs * sv_fps->integer;
|
|
} else {
|
|
demo.keyframeInterval = 0;
|
|
}
|
|
// first frame is always a keyframe (makes beginning seekable)
|
|
demo.framesSinceKeyframe = demo.keyframeInterval;
|
|
demo.numKeyframes = 0;
|
|
}
|
|
|
|
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 );
|
|
|
|
// write keyframe index after the end marker.
|
|
// layout: [numKf][time0 off0 time1 off1 ...][numKf_copy]
|
|
// numKf_copy at the very end lets playback find the table
|
|
// by seeking to fileLen - 4.
|
|
{
|
|
int kf;
|
|
SVD_WriteInt( demo.recordFile, demo.numKeyframes );
|
|
for ( kf = 0; kf < demo.numKeyframes; kf++ ) {
|
|
SVD_WriteInt( demo.recordFile, demo.keyframeTimes[kf] );
|
|
SVD_WriteInt( demo.recordFile, demo.keyframeOffsets[kf] );
|
|
}
|
|
SVD_WriteInt( demo.recordFile, demo.numKeyframes ); // copy at end
|
|
Com_Printf( "Wrote %d keyframes.\n", demo.numKeyframes );
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// free keyframe index
|
|
if ( demo.keyframeTimes ) { Z_Free( demo.keyframeTimes ); demo.keyframeTimes = NULL; }
|
|
if ( demo.keyframeOffsets ) { Z_Free( demo.keyframeOffsets ); demo.keyframeOffsets = NULL; }
|
|
demo.numKeyframes = demo.maxKeyframes = 0;
|
|
|
|
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;
|
|
}
|
|
|
|
// periodic keyframe: reset delta state so this frame is decodable
|
|
// from baseline. record file offset for the keyframe index.
|
|
if ( demo.keyframeInterval > 0 ) {
|
|
demo.framesSinceKeyframe++;
|
|
if ( demo.framesSinceKeyframe >= demo.keyframeInterval ) {
|
|
demo.framesSinceKeyframe = 0;
|
|
demo.isKeyframe = qtrue;
|
|
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
|
|
Com_Memset( demo.prevPlayers, 0, sizeof(demo.prevPlayers) );
|
|
// store keyframe: file offset before writing, serverTime
|
|
if ( demo.numKeyframes >= demo.maxKeyframes ) {
|
|
int newMax = demo.maxKeyframes ? demo.maxKeyframes * 2 : 256;
|
|
int *newTimes = Z_Malloc( newMax * sizeof(int) );
|
|
int *newOffsets = Z_Malloc( newMax * sizeof(int) );
|
|
if ( demo.keyframeTimes ) {
|
|
Com_Memcpy( newTimes, demo.keyframeTimes, demo.numKeyframes * sizeof(int) );
|
|
Com_Memcpy( newOffsets, demo.keyframeOffsets, demo.numKeyframes * sizeof(int) );
|
|
Z_Free( demo.keyframeTimes );
|
|
Z_Free( demo.keyframeOffsets );
|
|
}
|
|
demo.keyframeTimes = newTimes;
|
|
demo.keyframeOffsets = newOffsets;
|
|
demo.maxKeyframes = newMax;
|
|
}
|
|
demo.keyframeTimes[demo.numKeyframes] = svs.time;
|
|
demo.keyframeOffsets[demo.numKeyframes] = FS_FTell( demo.recordFile );
|
|
demo.numKeyframes++;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
SVD_ReadInt( f ); // flags (reserved)
|
|
|
|
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 & 3 ) {
|
|
// bit 0 = map restart, bit 1 = keyframe.
|
|
// both mean: delta state was reset during recording,
|
|
// so reset playback delta state to decode from baseline.
|
|
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
|
|
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
|
|
}
|
|
if ( ( frameFlags & 1 ) || demo.seeked ) {
|
|
// map restart or seek: reset entity interpolation in cgame
|
|
svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES;
|
|
demo.seeked = qfalse;
|
|
}
|
|
}
|
|
|
|
// read entity message
|
|
blockLen = SVD_ReadInt( f );
|
|
if ( blockLen <= 0 || blockLen > (int)sizeof(msgBuf) ) {
|
|
return qfalse;
|
|
}
|
|
FS_Read( msgBuf, blockLen, f );
|
|
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 svFlags
|
|
if ( MSG_ReadBits( &msg, 1 ) ) {
|
|
demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg );
|
|
}
|
|
|
|
demo.playPrevEntities[entNum].es = newEs;
|
|
demo.playPrevEntities[entNum].active = qtrue;
|
|
|
|
// apply to server entity and link for PVS.
|
|
// use trBase as initial origin — G_RunFrame will refine with
|
|
// BG_EvaluateTrajectory for moving entities (rockets etc).
|
|
ent = SV_GentityNum( entNum );
|
|
ent->s = newEs;
|
|
ent->s.number = entNum;
|
|
ent->r.svFlags = demo.playPrevEntities[entNum].svFlags;
|
|
VectorCopy( newEs.pos.trBase, ent->r.currentOrigin );
|
|
ent->r.linked = qtrue;
|
|
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_ReadInt( f );
|
|
if ( psMsgLen > 0 && psMsgLen <= (int)sizeof(psBuf) ) {
|
|
FS_Read( psBuf, psMsgLen, f );
|
|
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.recording ) {
|
|
Com_Printf( "Stop recording first (svdemo_stop).\n" );
|
|
return;
|
|
}
|
|
|
|
s = Cmd_Argv(1);
|
|
if ( !s[0] ) {
|
|
Com_Printf( "Usage: svdemo_play <demoname>\n" );
|
|
return;
|
|
}
|
|
|
|
// stop current playback if switching demos
|
|
if ( demo.playing ) {
|
|
SVD_CleanupPlayback();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// read keyframe index from the end of the file.
|
|
// layout: [frames][-1][numKf][time0 off0 ...][numKf_copy]
|
|
// last 4 bytes of file = numKf_copy.
|
|
{
|
|
int frameStart = FS_FTell( demo.playFile );
|
|
int numKf, kf;
|
|
|
|
FS_Seek( demo.playFile, len - 4, FS_SEEK_SET );
|
|
numKf = SVD_ReadInt( demo.playFile );
|
|
|
|
if ( numKf > 0 && numKf < 1000000 ) {
|
|
// seek to start of keyframe table: end - 4 - numKf*8 - 4
|
|
int tableStart = len - 4 - numKf * 8 - 4;
|
|
FS_Seek( demo.playFile, tableStart + 4, FS_SEEK_SET ); // skip numKf
|
|
|
|
demo.numKeyframes = numKf;
|
|
demo.maxKeyframes = numKf;
|
|
demo.keyframeTimes = Z_Malloc( numKf * sizeof(int) );
|
|
demo.keyframeOffsets = Z_Malloc( numKf * sizeof(int) );
|
|
|
|
for ( kf = 0; kf < numKf; kf++ ) {
|
|
demo.keyframeTimes[kf] = SVD_ReadInt( demo.playFile );
|
|
demo.keyframeOffsets[kf] = SVD_ReadInt( demo.playFile );
|
|
}
|
|
Com_Printf( "Loaded %d keyframes.\n", numKf );
|
|
}
|
|
|
|
// seek back to start of frame data
|
|
FS_Seek( demo.playFile, frameStart, FS_SEEK_SET );
|
|
}
|
|
|
|
demo.playing = qtrue;
|
|
demo.endOfDemo = qfalse;
|
|
demo.needConfigstrings = qtrue;
|
|
|
|
Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n",
|
|
demo.playMapName, demo.playMaxClients, demo.playFps );
|
|
|
|
// Shut down current server first so no clients carry over to
|
|
// reserved slots. SV_Shutdown triggers our cleanup hook, but
|
|
// demo.starting prevents it from clearing our state.
|
|
demo.starting = qtrue;
|
|
if ( com_sv_running->integer ) {
|
|
SV_Shutdown( "Demo playback\n" );
|
|
}
|
|
|
|
// Set demo cvar BEFORE devmap so G_InitGame can read it.
|
|
// The previous server is gone so no old game module to conflict with.
|
|
Cvar_Set2( "sv_demoplaying", "1", qtrue );
|
|
|
|
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) );
|
|
demo.starting = qfalse;
|
|
|
|
// CS_SVDEMO configstring is set by G_InitGame from the cvar
|
|
|
|
// Reserve recorded player slots. Server is fresh (SV_Shutdown cleared
|
|
// old clients), local client hasn't connected yet.
|
|
{
|
|
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;
|
|
svs.clients[i].lastPacketTime = svs.time;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// free keyframe index
|
|
if ( demo.keyframeTimes ) { Z_Free( demo.keyframeTimes ); }
|
|
if ( demo.keyframeOffsets ) { Z_Free( demo.keyframeOffsets ); }
|
|
|
|
memset( &demo, 0, sizeof(demo) );
|
|
Cvar_Set2( "sv_demoplaying", "0", qtrue );
|
|
}
|
|
|
|
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" );
|
|
}
|
|
}
|
|
|
|
void SVD_Pause_f( void ) {
|
|
if ( !demo.playing ) {
|
|
Com_Printf( "Not playing a server demo.\n" );
|
|
return;
|
|
}
|
|
demo.paused = !demo.paused;
|
|
if ( !demo.paused ) {
|
|
// resuming — toggle SERVERCOUNT to reset client snapshot timing
|
|
// (drifted during pause from identical serverTimes).
|
|
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
|
|
}
|
|
Com_Printf( "Demo playback %s.\n", demo.paused ? "paused" : "resumed" );
|
|
}
|
|
|
|
void SVD_Seek_f( void ) {
|
|
int targetTime, i, bestKf;
|
|
float seconds;
|
|
|
|
if ( !demo.playing ) {
|
|
Com_Printf( "Not playing a server demo.\n" );
|
|
return;
|
|
}
|
|
|
|
if ( Cmd_Argc() < 2 ) {
|
|
Com_Printf( "Usage: svdemo_seek <seconds>\n" );
|
|
return;
|
|
}
|
|
|
|
if ( demo.numKeyframes <= 0 ) {
|
|
Com_Printf( "No keyframes in this demo — seeking not available.\n" );
|
|
return;
|
|
}
|
|
|
|
seconds = atof( Cmd_Argv(1) );
|
|
targetTime = svs.time + (int)(seconds * 1000);
|
|
|
|
// find nearest keyframe at or before target time
|
|
bestKf = -1;
|
|
for ( i = 0; i < demo.numKeyframes; i++ ) {
|
|
if ( demo.keyframeTimes[i] <= targetTime ) {
|
|
bestKf = i;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( bestKf < 0 ) {
|
|
// target is before the first keyframe — seek to first
|
|
bestKf = 0;
|
|
targetTime = demo.keyframeTimes[0];
|
|
}
|
|
|
|
// seek to keyframe file position
|
|
FS_Seek( demo.playFile, demo.keyframeOffsets[bestKf], FS_SEEK_SET );
|
|
|
|
// reset delta state (keyframe is encoded from baseline)
|
|
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
|
|
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
|
|
|
|
// set svs.time to the keyframe time so the SV_Frame loop
|
|
// doesn't advance from the old time before reading
|
|
svs.time = demo.keyframeTimes[bestKf];
|
|
|
|
// toggle SERVERCOUNT to reset client time delta
|
|
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
|
|
|
|
demo.seeked = qtrue;
|
|
demo.endOfDemo = qfalse;
|
|
|
|
// read the keyframe directly (works even when paused)
|
|
svs.snapFlagServerBit &= ~SNAPFLAG_RESET_ENTITIES;
|
|
if ( !SVD_ReadFrame( demo.playFile ) ) {
|
|
demo.endOfDemo = qtrue;
|
|
}
|
|
|
|
// reset client snapshot timing
|
|
{
|
|
int j;
|
|
for ( j = 0; j < sv_maxclients->integer; j++ ) {
|
|
if ( svs.clients[j].state >= CS_ACTIVE ) {
|
|
svs.clients[j].nextSnapshotTime = svs.time;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensure one frame runs on next SV_Frame (for G_RunFrame + snapshot)
|
|
sv.timeResidual = 1000 / sv_fps->integer;
|
|
|
|
Com_Printf( "Seeked to time %d.\n", svs.time );
|
|
}
|
|
|
|
void SVD_SeekExact_f( void ) {
|
|
int targetTime, i, bestKf;
|
|
float seconds;
|
|
|
|
if ( !demo.playing ) {
|
|
Com_Printf( "Not playing a server demo.\n" );
|
|
return;
|
|
}
|
|
|
|
if ( Cmd_Argc() < 2 ) {
|
|
Com_Printf( "Usage: svdemo_seekexact <seconds>\n" );
|
|
return;
|
|
}
|
|
|
|
if ( demo.numKeyframes <= 0 ) {
|
|
Com_Printf( "No keyframes in this demo.\n" );
|
|
return;
|
|
}
|
|
|
|
seconds = atof( Cmd_Argv(1) );
|
|
targetTime = svs.time + (int)(seconds * 1000);
|
|
|
|
// find nearest keyframe at or before target time
|
|
bestKf = -1;
|
|
for ( i = 0; i < demo.numKeyframes; i++ ) {
|
|
if ( demo.keyframeTimes[i] <= targetTime ) {
|
|
bestKf = i;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( bestKf < 0 ) {
|
|
bestKf = 0;
|
|
targetTime = demo.keyframeTimes[0];
|
|
}
|
|
|
|
// seek to keyframe
|
|
FS_Seek( demo.playFile, demo.keyframeOffsets[bestKf], FS_SEEK_SET );
|
|
Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
|
|
Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
|
|
svs.time = demo.keyframeTimes[bestKf];
|
|
svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT;
|
|
demo.seeked = qtrue;
|
|
demo.endOfDemo = qfalse;
|
|
|
|
// read forward from keyframe to target time
|
|
while ( svs.time < targetTime ) {
|
|
if ( !SVD_ReadFrame( demo.playFile ) ) {
|
|
demo.endOfDemo = qtrue;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// reset client snapshot timing
|
|
{
|
|
int j;
|
|
for ( j = 0; j < sv_maxclients->integer; j++ ) {
|
|
if ( svs.clients[j].state >= CS_ACTIVE ) {
|
|
svs.clients[j].nextSnapshotTime = svs.time;
|
|
}
|
|
}
|
|
}
|
|
|
|
sv.timeResidual = 1000 / sv_fps->integer;
|
|
|
|
Com_Printf( "Seeked to time %d (read forward %d ms from keyframe).\n",
|
|
svs.time, svs.time - demo.keyframeTimes[bestKf] );
|
|
}
|
|
|
|
/*
|
|
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;
|
|
}
|
|
|
|
|
|
// manual pause — don't consume demo data
|
|
if ( demo.paused ) {
|
|
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;
|
|
}
|
|
|
|
qboolean SVD_IsPaused( void ) {
|
|
return demo.playing && demo.paused;
|
|
}
|
|
|
|
qboolean SVD_IsStarting( void ) {
|
|
return demo.starting;
|
|
}
|
|
|
|
|
|
/*
|
|
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
|
|
}
|
|
|