Compare commits

...

10 commits

Author SHA1 Message Date
3d8291658f Remove redundant forward declaration and server-side CS_SVDEMO set
Forward declaration of SVD_CleanupPlayback no longer needed (non-static,
declared in server.h). Server-side SV_SetConfigstring(CS_SVDEMO) is
redundant — G_InitGame sets it from the cvar which is now reliably
set before devmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 06:35:28 +08:00
72d5df4ec9 Remove redundant pm_type/speed set in ClientBegin for demo mode
Server doesn't run PmoveSingle for demo spectator. cgame uses its
own local camera with PM_SPECTATOR. Game module checks
sess.sessionTeam, not pm_type. The set was dead code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 06:33:30 +08:00
f871cc004f Fix svdemo_play from in-game and devmap from demo playback
Multiple issues fixed for seamless transitions:

Server lifecycle:
- SV_Shutdown before devmap in SVD_Play_f: drops old clients so
  spectator doesn't land in slot 0 (recorded player collision)
- SVD_IsStarting flag prevents cleanup hooks from destroying demo
  state during our own SV_Shutdown/SV_SpawnServer calls
- SV_SpawnServer stops demo playback on non-demo map changes
  via SVD_CleanupPlayback (no disconnect, just state cleanup)
- SVD_CleanupPlayback made non-static for use from sv_init.c

Cvar handling:
- Use Cvar_Set2 with force=qtrue for CVAR_ROM sv_demoplaying
- Set cvar AFTER SV_Shutdown (old game module gone) but BEFORE
  devmap (so new G_InitGame reads correct value)
- Set CS_SVDEMO configstring after devmap as backup for cgame

Game module:
- ClientBegin: set pm_type=PM_SPECTATOR after ClientSpawn (which
  memsets ps). ClientThink_real normally sets this but is disabled
  in demo mode.
- G_WriteSessionData: skip during demo playback so forced
  TEAM_SPECTATOR doesn't persist to next normal game
- ClientThink: return early in demo mode (no server-side movement)

Removed debug prints and unused SVD_GetPlayMaxClients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 06:32:08 +08:00
74e2fc39c8 Remove LZ4 compression — negligible benefit on delta data
Delta-compressed entity/playerState bitstreams have minimal
redundancy for LZ4 to exploit. The per-block 8-byte header
overhead was comparable to the compression savings.

Removed: lz4.h include, SVD_WriteBlock/ReadBlock functions,
SVDEMO_FLAG_COMPRESSED, demo.compressed field, svdemo_compress
cvar, lz4.c from build. Direct size+data writes replace block I/O.

Demo format v3 (flags field reserved, always 0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 05:18:48 +08:00
fe628a2cc4 Remove PVS data from demo format, derive during playback
Stop recording currentOrigin, absmin, absmax, linked per entity
(44 bytes per moving entity per frame). During playback, G_RunFrame
computes currentOrigin via BG_EvaluateTrajectory and calls
trap_LinkEntity to register in BSP for PVS.

svFlags still recorded (1-bit change flag + 4 bytes when changed).
Entity linking moved from SVD_ReadFrame (server, no trajectory eval)
to G_RunFrame demo mode (game module, has BG_EvaluateTrajectory).

Scan all MAX_GENTITIES during playback since recorded entities may
have indices above level.num_entities (game-module-spawned count).

Demo format bumped to v3. Significant file size reduction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 05:00:21 +08:00
5a98ef02cf Use cached g_svDemoPlaying.integer instead of trap_Cvar syscalls
sv_demoplaying is set before devmap, synced during G_RegisterCvars,
and updated every frame by G_UpdateCvars. No need for defensive
trap_Cvar_VariableIntegerValue calls which add unnecessary syscall
overhead on every invocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 04:32:57 +08:00
2ce4aa88f2 Fix follow mode switching and PVS during pause
Three issues fixed:

1. ClientThink called ClientThink_real during demo playback, which
   ran server-side PmoveSingle and overwrote the client-owned
   ps.origin. Now ClientThink returns early in demo mode after
   updating pers.cmd (for button access).

2. Buttons never reached the server while paused: SV_UserMove
   discards usercmds with duplicate serverTime (line 1421). During
   pause, all usercmds have frozen serverTime. Fix: always process
   the last usercmd in the packet during demo playback.

3. Spectator button handling in G_RunFrame demo mode processes
   MOUSE1 (attack) to cycle follow targets via Cmd_FollowCycle_f.
   Removed debug print.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 04:31:39 +08:00
c4dca5f950 Remove unnecessary SNAPFLAG_RESET_ENTITIES on unpause
Entity positions change by one server frame (50ms) on unpause —
normal interpolation handles this fine. Only SNAPFLAG_SERVERCOUNT
is needed to hard-reset the client's time delta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 04:10:50 +08:00
41f0ca2e50 Show "Playback Paused" instead of "Connection Interrupted"
Suppress CG_DrawDisconnect during server demo playback — connection
can't be interrupted on a local demo. Detect pause from snapshot
serverTime (frozen time = paused) rather than configstrings, since
configstrings are OOB and may not be in sync with snapshots.
Show "Playback Paused" centered on screen when detected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 04:09:47 +08:00
490fcd9bde Smooth unpause: reset client time delta and entity interpolation
On unpause, toggle SNAPFLAG_SERVERCOUNT and set SNAPFLAG_RESET_ENTITIES.
In CL_ParseSnapshot, detect SERVERCOUNT toggle and hard-reset
cl.serverTimeDelta instead of letting CL_AdjustTimeDelta slowly drift.
During pause, the delta drifted because snapshots had frozen serverTime
while cls.realtime advanced. Without the hard reset, it took 1-2 seconds
of choppy interpolation to re-sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 03:59:44 +08:00
12 changed files with 165 additions and 168 deletions

View file

@ -1650,6 +1650,16 @@ static void CG_DrawDisconnect( void ) {
const char *s; const char *s;
int w; // bk010215 - FIXME char message[1024]; int w; // bk010215 - FIXME char message[1024];
// server demo playback: detect pause from frozen snapshot time
if ( cg.svDemoPlayback ) {
if ( cg.nextSnap && cg.nextSnap->serverTime == cg.snap->serverTime ) {
s = "Playback Paused";
w = CG_DrawStrlen( s ) * BIGCHAR_WIDTH;
CG_DrawBigString( 320 - w/2, 100, s, 1.0F );
}
return;
}
// draw the phone jack if we are completely past our buffers // draw the phone jack if we are completely past our buffers
cmdNum = trap_GetCurrentCmdNumber() - CMD_BACKUP + 1; cmdNum = trap_GetCurrentCmdNumber() - CMD_BACKUP + 1;
trap_GetUserCmd( cmdNum, &cmd ); trap_GetUserCmd( cmdNum, &cmd );

View file

@ -288,6 +288,9 @@ void CL_ParseSnapshot( msg_t *msg ) {
cl.snapshots[oldMessageNum & PACKET_MASK].valid = qfalse; cl.snapshots[oldMessageNum & PACKET_MASK].valid = qfalse;
} }
{
int oldSnapFlags = cl.snap.snapFlags;
// copy to the current good spot // copy to the current good spot
cl.snap = newSnap; cl.snap = newSnap;
cl.snap.ping = 999; cl.snap.ping = 999;
@ -299,6 +302,15 @@ void CL_ParseSnapshot( msg_t *msg ) {
break; break;
} }
} }
// if server count changed (map_restart or demo unpause), force-reset
// the time delta so the client doesn't slowly drift back to sync
if ( ( oldSnapFlags ^ newSnap.snapFlags ) & SNAPFLAG_SERVERCOUNT ) {
cl.serverTimeDelta = cl.snap.serverTime - cls.realtime;
cl.oldServerTime = cl.snap.serverTime;
cl.serverTime = cl.snap.serverTime;
}
}
// save the frame off in the backup array for later delta comparisons // save the frame off in the backup array for later delta comparisons
cl.snapshots[cl.snap.messageNum & PACKET_MASK] = cl.snap; cl.snapshots[cl.snap.messageNum & PACKET_MASK] = cl.snap;

View file

@ -1034,6 +1034,12 @@ void ClientThink( int clientNum ) {
// phone jack if they don't get any for a while // phone jack if they don't get any for a while
ent->client->lastCmdTime = level.time; ent->client->lastCmdTime = level.time;
// demo playback: don't run ClientThink_real — cgame owns
// the camera movement, G_RunFrame handles buttons and PVS origin
if ( g_svDemoPlaying.integer ) {
return;
}
if ( !(ent->r.svFlags & SVF_BOT) && !g_synchronousClients.integer ) { if ( !(ent->r.svFlags & SVF_BOT) && !g_synchronousClients.integer ) {
ClientThink_real( ent ); ClientThink_real( ent );
} }

View file

@ -1013,7 +1013,6 @@ void ClientBegin( int clientNum ) {
client->pers.teamState.state = TEAM_BEGIN; client->pers.teamState.state = TEAM_BEGIN;
// demo playback: force all clients to spectator // demo playback: force all clients to spectator
// demo playback: force to spectator and update configstring
if ( g_svDemoPlaying.integer ) { if ( g_svDemoPlaying.integer ) {
client->sess.sessionTeam = TEAM_SPECTATOR; client->sess.sessionTeam = TEAM_SPECTATOR;
ClientUserinfoChanged( ent->client - level.clients ); ClientUserinfoChanged( ent->client - level.clients );

View file

@ -662,7 +662,7 @@ void Cmd_Team_f( gentity_t *ent ) {
char s[MAX_TOKEN_CHARS]; char s[MAX_TOKEN_CHARS];
// demo playback: only allow "team spectator" (to exit follow mode) // demo playback: only allow "team spectator" (to exit follow mode)
if ( trap_Cvar_VariableIntegerValue( "sv_demoplaying" ) ) { if ( g_svDemoPlaying.integer ) {
char s2[MAX_TOKEN_CHARS]; char s2[MAX_TOKEN_CHARS];
if ( trap_Argc() == 2 ) { if ( trap_Argc() == 2 ) {
trap_Argv( 1, s2, sizeof(s2) ); trap_Argv( 1, s2, sizeof(s2) );

View file

@ -419,7 +419,7 @@ void G_InitGame( int levelTime, int randomSeed, int restart ) {
G_RegisterCvars(); G_RegisterCvars();
// signal server-side demo mode to cgame via configstring // signal server-side demo mode to cgame via configstring
if ( trap_Cvar_VariableIntegerValue( "sv_demoplaying" ) ) { if ( g_svDemoPlaying.integer ) {
trap_SetConfigstring( CS_SVDEMO, "1" ); trap_SetConfigstring( CS_SVDEMO, "1" );
} }
@ -1761,6 +1761,17 @@ int start, end;
VectorCopy( cl->pers.cmd.origin, e->s.pos.trBase ); VectorCopy( cl->pers.cmd.origin, e->s.pos.trBase );
VectorCopy( cl->pers.cmd.origin, e->r.currentOrigin ); VectorCopy( cl->pers.cmd.origin, e->r.currentOrigin );
} }
// process spectator buttons for follow mode switching
// (pers.cmd is updated by ClientThink which still runs)
{
int oldButtons = cl->buttons;
cl->oldbuttons = cl->buttons;
cl->buttons = cl->pers.cmd.buttons;
// attack cycles follow targets
if ( ( cl->buttons & BUTTON_ATTACK ) && !( oldButtons & BUTTON_ATTACK ) ) {
Cmd_FollowCycle_f( e, 1 );
}
}
specEnt = e; specEnt = e;
continue; continue;
} }
@ -1781,6 +1792,27 @@ int start, end;
} }
} }
// evaluate trajectories and link all recorded entities for PVS.
// SVD_ReadFrame injected entityState_t but didn't link because
// the server doesn't have BG_EvaluateTrajectory.
// scan up to MAX_GENTITIES because recorded entities may exceed
// level.num_entities (which only counts game-module-spawned entities).
for ( i = 0; i < MAX_GENTITIES; i++ ) {
ent = &g_entities[i];
if ( ent->s.eType == 0 && !ent->inuse ) {
continue;
}
// skip spectator slot
if ( ent->client && ent->client->pers.connected == CON_CONNECTED
&& ent->client->sess.sessionTeam == TEAM_SPECTATOR ) {
continue;
}
// compute currentOrigin from trajectory
BG_EvaluateTrajectory( &ent->s.pos, level.time, ent->r.currentOrigin );
ent->r.linked = qtrue;
trap_LinkEntity( ent );
}
// update rankings so follow mode can cycle through players // update rankings so follow mode can cycle through players
CalculateRanks(); CalculateRanks();

View file

@ -183,6 +183,12 @@ G_WriteSessionData
void G_WriteSessionData( void ) { void G_WriteSessionData( void ) {
int i; int i;
// don't persist demo spectator sessions — the forced TEAM_SPECTATOR
// would carry over to the next normal game
if ( g_svDemoPlaying.integer ) {
return;
}
trap_Cvar_Set( "session", va("%i", g_gametype.integer) ); trap_Cvar_Set( "session", va("%i", g_gametype.integer) );
for ( i = 0 ; i < level.maxclients ; i++ ) { for ( i = 0 ; i < level.maxclients ; i++ ) {

View file

@ -997,8 +997,6 @@
</ClCompile> </ClCompile>
<ClCompile Include="server\sv_netdemo.c"> <ClCompile Include="server\sv_netdemo.c">
</ClCompile> </ClCompile>
<ClCompile Include="server\lz4.c">
</ClCompile>
<ClCompile Include="server\sv_world.c"> <ClCompile Include="server\sv_world.c">
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">Disabled</Optimization> <Optimization Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">Disabled</Optimization>
<BrowseInformation Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">true</BrowseInformation> <BrowseInformation Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">true</BrowseInformation>

View file

@ -352,12 +352,14 @@ void SVD_AutoRecord( void );
void SVD_CaptureServerCommand( const char *cmd ); void SVD_CaptureServerCommand( const char *cmd );
void SVD_Play_f( void ); void SVD_Play_f( void );
void SVD_StopPlay_f( void ); void SVD_StopPlay_f( void );
void SVD_CleanupPlayback( void );
void SVD_Stop_f( void ); void SVD_Stop_f( void );
void SVD_Pause_f( void ); void SVD_Pause_f( void );
qboolean SVD_PlaybackFrame( void ); qboolean SVD_PlaybackFrame( void );
qboolean SVD_IsRecording( void ); qboolean SVD_IsRecording( void );
qboolean SVD_IsPlaying( void ); qboolean SVD_IsPlaying( void );
qboolean SVD_IsPaused( void ); qboolean SVD_IsPaused( void );
qboolean SVD_IsStarting( void );
qboolean SVD_ShouldPause( void ); qboolean SVD_ShouldPause( void );
//============================================================ //============================================================

View file

@ -1419,8 +1419,13 @@ static void SV_UserMove( client_t *cl, msg_t *msg, qboolean delta ) {
// don't execute if this is an old cmd which is already executed // don't execute if this is an old cmd which is already executed
// these old cmds are included when cl_packetdup > 0 // these old cmds are included when cl_packetdup > 0
if ( cmds[i].serverTime <= cl->lastUsercmd.serverTime ) { if ( cmds[i].serverTime <= cl->lastUsercmd.serverTime ) {
// demo playback: still process the LAST cmd even with duplicate
// serverTime (paused = frozen time, all cmds have same time).
// Need buttons and origin from fresh usercmds.
if ( !SVD_IsPlaying() || i != cmdCount - 1 ) {
continue; continue;
} }
}
SV_ClientThink (cl, &cmds[ i ]); SV_ClientThink (cl, &cmds[ i ]);
} }
} }

View file

@ -351,11 +351,17 @@ void SV_SpawnServer( char *server, qboolean killBots ) {
char systemInfo[16384]; char systemInfo[16384];
const char *p; const char *p;
// stop any active demo recording (one demo = one map) // stop any active demo recording/playback (one demo = one map).
// SVD_IsStarting() returns true when SVD_Play_f is calling devmap
// internally — don't stop our own playback.
if ( SVD_IsRecording() ) { if ( SVD_IsRecording() ) {
Com_Printf( "Map change — stopping demo recording.\n" ); Com_Printf( "Map change — stopping demo recording.\n" );
SVD_StopRecord_f(); SVD_StopRecord_f();
} }
if ( SVD_IsPlaying() && !SVD_IsStarting() ) {
Com_Printf( "Map change — stopping demo playback.\n" );
SVD_CleanupPlayback();
}
// shut down the existing game if it is running // shut down the existing game if it is running
SV_ShutdownGameProgs(); SV_ShutdownGameProgs();
@ -623,7 +629,6 @@ void SV_Init (void) {
// server-side demo settings // server-side demo settings
Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE); Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE);
Cvar_Get ("svdemo_compress", "1", CVAR_ARCHIVE);
Cvar_Get ("svdemo_pauseEmpty", "1", CVAR_ARCHIVE); Cvar_Get ("svdemo_pauseEmpty", "1", CVAR_ARCHIVE);
// initialize bot cvars so they are listed and can be set before loading the botlib // initialize bot cvars so they are listed and can be set before loading the botlib
@ -681,12 +686,13 @@ void SV_Shutdown( char *finalmsg ) {
Com_Printf( "----- Server Shutdown -----\n" ); Com_Printf( "----- Server Shutdown -----\n" );
// clean up any active demo recording/playback // clean up any active demo recording/playback.
// skip if SVD_Play_f is calling SV_Shutdown internally.
if ( SVD_IsRecording() ) { if ( SVD_IsRecording() ) {
SVD_StopRecord_f(); SVD_StopRecord_f();
} }
if ( SVD_IsPlaying() ) { if ( SVD_IsPlaying() && !SVD_IsStarting() ) {
SVD_StopPlay_f(); SVD_CleanupPlayback();
} }
if ( svs.clients && !com_errorEntered ) { if ( svs.clients && !com_errorEntered ) {

View file

@ -10,7 +10,9 @@ snapshot pipeline delivers them to a spectator client.
*/ */
#include "server.h" #include "server.h"
#include "lz4.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 // File format
@ -51,24 +53,19 @@ snapshot pipeline delivers them to a spectator client.
// //
#define SVDEMO_MAGIC (('S') | ('V' << 8) | ('D' << 16) | ('M' << 24)) #define SVDEMO_MAGIC (('S') | ('V' << 8) | ('D' << 16) | ('M' << 24))
#define SVDEMO_VERSION 2 // v2: optional LZ4 compression #define SVDEMO_VERSION 3 // v3: removed PVS data, svFlags only
#define SVDEMO_MAX_MAPNAME 64 #define SVDEMO_MAX_MAPNAME 64
// header flags // header flags
#define SVDEMO_FLAG_COMPRESSED 1 // per-frame data is LZ4 compressed
// --------------------------------------------------------------- // ---------------------------------------------------------------
// State // State
// --------------------------------------------------------------- // ---------------------------------------------------------------
// per-entity data stored for delta compression (entityState + PVS fields) // per-entity data stored for delta compression
typedef struct { typedef struct {
entityState_t es; entityState_t es;
int svFlags; int svFlags;
qboolean linked;
vec3_t currentOrigin;
vec3_t absmin;
vec3_t absmax;
qboolean active; // was this entity present last frame? qboolean active; // was this entity present last frame?
} svdEntityState_t; } svdEntityState_t;
@ -85,7 +82,6 @@ typedef struct {
// recording // recording
fileHandle_t recordFile; fileHandle_t recordFile;
qboolean recording; qboolean recording;
qboolean compressed; // LZ4 compression enabled
char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection
svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta
svdPlayerState_t prevPlayers[MAX_CLIENTS]; // previous frame player states svdPlayerState_t prevPlayers[MAX_CLIENTS]; // previous frame player states
@ -104,13 +100,13 @@ typedef struct {
char playMapName[SVDEMO_MAX_MAPNAME]; char playMapName[SVDEMO_MAX_MAPNAME];
qboolean endOfDemo; qboolean endOfDemo;
qboolean needConfigstrings; // apply saved configstrings on first frame qboolean needConfigstrings; // apply saved configstrings on first frame
qboolean starting; // SVD_Play_f is running devmap internally
qboolean paused; qboolean paused;
svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read
svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states svdPlayerState_t playPrevPlayers[MAX_CLIENTS]; // previous frame player states
} svDemo_t; } svDemo_t;
static svDemo_t demo; static svDemo_t demo;
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Recording helpers // Recording helpers
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -135,58 +131,6 @@ static short SVD_ReadShort( fileHandle_t f ) {
return v; 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 // Write header
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -197,7 +141,7 @@ static void SVD_WriteHeader( fileHandle_t f ) {
SVD_WriteInt( f, SVDEMO_MAGIC ); SVD_WriteInt( f, SVDEMO_MAGIC );
SVD_WriteInt( f, SVDEMO_VERSION ); SVD_WriteInt( f, SVDEMO_VERSION );
SVD_WriteInt( f, demo.compressed ? SVDEMO_FLAG_COMPRESSED : 0 ); SVD_WriteInt( f, 0 ); // flags (reserved)
SVD_WriteInt( f, sv_maxclients->integer ); SVD_WriteInt( f, sv_maxclients->integer );
SVD_WriteInt( f, sv_fps->integer ); SVD_WriteInt( f, sv_fps->integer );
@ -275,38 +219,17 @@ static void SVD_WriteFrame( fileHandle_t f ) {
} }
MSG_WriteDeltaEntity( &msg, from, &ent->s, qtrue ); MSG_WriteDeltaEntity( &msg, from, &ent->s, qtrue );
// write PVS fields only if changed from previous frame // write svFlags only if changed (rarely changes)
{ if ( ent->r.svFlags != demo.prevEntities[i].svFlags ) {
qboolean pvsChanged = !demo.prevEntities[i].active MSG_WriteBits( &msg, 1, 1 );
|| 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.svFlags );
MSG_WriteLong( &msg, ent->r.linked ); } else {
MSG_WriteFloat( &msg, ent->r.currentOrigin[0] ); MSG_WriteBits( &msg, 0, 1 );
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 // update prev state
demo.prevEntities[i].es = ent->s; demo.prevEntities[i].es = ent->s;
demo.prevEntities[i].svFlags = ent->r.svFlags; 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; demo.prevEntities[i].active = qtrue;
} else if ( demo.prevEntities[i].active ) { } else if ( demo.prevEntities[i].active ) {
// entity was removed — write a remove marker // entity was removed — write a remove marker
@ -319,8 +242,9 @@ static void SVD_WriteFrame( fileHandle_t f ) {
// end of entities marker // end of entities marker
MSG_WriteBits( &msg, (MAX_GENTITIES - 1), GENTITYNUM_BITS ); MSG_WriteBits( &msg, (MAX_GENTITIES - 1), GENTITYNUM_BITS );
// write entity message to file (optionally LZ4 compressed) // write entity message to file
SVD_WriteBlock( f, msg.data, msg.cursize ); SVD_WriteInt( f, msg.cursize );
FS_Write( msg.data, msg.cursize, f );
// write player states (delta compressed) // write player states (delta compressed)
{ {
@ -371,7 +295,8 @@ static void SVD_WriteFrame( fileHandle_t f ) {
MSG_WriteBits( &psmsg, MAX_CLIENTS - 1, 6 ); MSG_WriteBits( &psmsg, MAX_CLIENTS - 1, 6 );
MSG_WriteBits( &psmsg, 0, 1 ); MSG_WriteBits( &psmsg, 0, 1 );
SVD_WriteBlock( f, psmsg.data, psmsg.cursize ); SVD_WriteInt( f, psmsg.cursize );
FS_Write( psmsg.data, psmsg.cursize, f );
} }
// configstring changes // configstring changes
@ -442,10 +367,8 @@ static qboolean SVD_StartRecording( const char *demoname ) {
return qfalse; return qfalse;
} }
Com_Printf( "Recording server demo to %s%s\n", path, Com_Printf( "Recording server demo to %s\n", path );
Cvar_VariableIntegerValue("svdemo_compress") ? " (LZ4)" : "" );
demo.recording = qtrue; demo.recording = qtrue;
demo.compressed = Cvar_VariableIntegerValue("svdemo_compress") ? qtrue : qfalse;
// clear delta state for fresh recording // clear delta state for fresh recording
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) ); Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
@ -592,10 +515,7 @@ static qboolean SVD_ReadHeader( fileHandle_t f ) {
return qfalse; return qfalse;
} }
{ SVD_ReadInt( f ); // flags (reserved)
int flags = SVD_ReadInt( f );
demo.compressed = ( flags & SVDEMO_FLAG_COMPRESSED ) ? qtrue : qfalse;
}
demo.playMaxClients = SVD_ReadInt( f ); demo.playMaxClients = SVD_ReadInt( f );
demo.playFps = SVD_ReadInt( f ); demo.playFps = SVD_ReadInt( f );
@ -686,11 +606,12 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
} }
} }
// read entity message (optionally LZ4 compressed) // read entity message
blockLen = SVD_ReadBlock( f, msgBuf, sizeof(msgBuf) ); blockLen = SVD_ReadInt( f );
if ( blockLen <= 0 ) { if ( blockLen <= 0 || blockLen > (int)sizeof(msgBuf) ) {
return qfalse; return qfalse;
} }
FS_Read( msgBuf, blockLen, f );
MSG_Init( &msg, msgBuf, sizeof(msgBuf) ); MSG_Init( &msg, msgBuf, sizeof(msgBuf) );
msg.cursize = blockLen; msg.cursize = blockLen;
@ -734,40 +655,20 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
continue; continue;
} }
// read PVS fields // read svFlags
{ if ( MSG_ReadBits( &msg, 1 ) ) {
qboolean pvsChanged = MSG_ReadBits( &msg, 1 );
if ( pvsChanged ) {
demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg ); 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].es = newEs;
demo.playPrevEntities[entNum].active = qtrue; demo.playPrevEntities[entNum].active = qtrue;
// apply to server entity // apply to server entity (entity linking done in G_RunFrame
// which has BG_EvaluateTrajectory for computing currentOrigin)
ent = SV_GentityNum( entNum ); ent = SV_GentityNum( entNum );
ent->s = newEs; ent->s = newEs;
ent->s.number = entNum; ent->s.number = entNum;
ent->r.svFlags = demo.playPrevEntities[entNum].svFlags; 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 ) { if ( entNum + 1 > sv.num_entities ) {
sv.num_entities = entNum + 1; sv.num_entities = entNum + 1;
@ -780,8 +681,9 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
static byte psBuf[MAX_CLIENTS * 600]; // worst case: full playerState from baseline static byte psBuf[MAX_CLIENTS * 600]; // worst case: full playerState from baseline
int psMsgLen; int psMsgLen;
psMsgLen = SVD_ReadBlock( f, psBuf, sizeof(psBuf) ); psMsgLen = SVD_ReadInt( f );
if ( psMsgLen > 0 ) { if ( psMsgLen > 0 && psMsgLen <= (int)sizeof(psBuf) ) {
FS_Read( psBuf, psMsgLen, f );
MSG_Init( &psmsg, psBuf, sizeof(psBuf) ); MSG_Init( &psmsg, psBuf, sizeof(psBuf) );
psmsg.cursize = psMsgLen; psmsg.cursize = psMsgLen;
@ -871,8 +773,8 @@ void SVD_Play_f( void ) {
char *s; char *s;
int len; int len;
if ( demo.playing ) { if ( demo.recording ) {
Com_Printf( "Already playing a server demo.\n" ); Com_Printf( "Stop recording first (svdemo_stop).\n" );
return; return;
} }
@ -882,6 +784,11 @@ void SVD_Play_f( void ) {
return; return;
} }
// stop current playback if switching demos
if ( demo.playing ) {
SVD_CleanupPlayback();
}
Com_sprintf( name, sizeof(name), "svdemos/%s.svdm", s ); Com_sprintf( name, sizeof(name), "svdemos/%s.svdm", s );
memset( &demo, 0, sizeof(demo) ); memset( &demo, 0, sizeof(demo) );
@ -900,42 +807,46 @@ void SVD_Play_f( void ) {
demo.playing = qtrue; demo.playing = qtrue;
demo.endOfDemo = qfalse; demo.endOfDemo = qfalse;
demo.needConfigstrings = qtrue;
Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n", Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n",
demo.playMapName, demo.playMaxClients, demo.playFps ); demo.playMapName, demo.playMaxClients, demo.playFps );
// Signal demo mode BEFORE map load so the game module knows // Shut down current server first so no clients carry over to
// during ClientConnect/ClientBegin to force spectator team. // reserved slots. SV_Shutdown triggers our cleanup hook, but
Cvar_Set( "sv_demoplaying", "1" ); // 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 );
// 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_maxclients %d\n", MAX_CLIENTS) );
Cbuf_ExecuteText( EXEC_NOW, va("set sv_fps %d\n", demo.playFps) ); Cbuf_ExecuteText( EXEC_NOW, va("set sv_fps %d\n", demo.playFps) );
Cbuf_ExecuteText( EXEC_NOW, va("devmap %s\n", demo.playMapName) ); Cbuf_ExecuteText( EXEC_NOW, va("devmap %s\n", demo.playMapName) );
demo.starting = qfalse;
// Reserve recorded player slots so the connecting spectator // CS_SVDEMO configstring is set by G_InitGame from the cvar
// 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). // Reserve recorded player slots. Server is fresh (SV_Shutdown cleared
// Set rate and nextSnapshotTime so SV_SendClientMessages doesn't // old clients), local client hasn't connected yet.
// crash on division by zero or try to send them snapshots.
{ {
int i; int i;
for ( i = 0; i < demo.playMaxClients; i++ ) { for ( i = 0; i < demo.playMaxClients; i++ ) {
if ( svs.clients[i].state == CS_FREE ) { if ( svs.clients[i].state == CS_FREE ) {
svs.clients[i].state = CS_ZOMBIE; svs.clients[i].state = CS_ZOMBIE;
svs.clients[i].rate = 10000; svs.clients[i].rate = 10000;
svs.clients[i].nextSnapshotTime = 0x7FFFFFFF; // never send svs.clients[i].nextSnapshotTime = 0x7FFFFFFF;
svs.clients[i].lastPacketTime = svs.time; 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 ) { void SVD_CleanupPlayback( void ) {
int i; int i;
if ( !demo.playing ) { if ( !demo.playing ) {
@ -959,7 +870,7 @@ static void SVD_CleanupPlayback( void ) {
} }
memset( &demo, 0, sizeof(demo) ); memset( &demo, 0, sizeof(demo) );
Cvar_Set( "sv_demoplaying", "0" ); Cvar_Set2( "sv_demoplaying", "0", qtrue );
} }
void SVD_StopPlay_f( void ) { void SVD_StopPlay_f( void ) {
@ -994,6 +905,11 @@ void SVD_Pause_f( void ) {
return; return;
} }
demo.paused = !demo.paused; 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" ); Com_Printf( "Demo playback %s.\n", demo.paused ? "paused" : "resumed" );
} }
@ -1053,6 +969,11 @@ qboolean SVD_IsPaused( void ) {
return demo.playing && demo.paused; return demo.playing && demo.paused;
} }
qboolean SVD_IsStarting( void ) {
return demo.starting;
}
/* /*
Returns qtrue if demo playback should pause (no active spectators). Returns qtrue if demo playback should pause (no active spectators).
Controlled by svdemo_pauseEmpty cvar. Controlled by svdemo_pauseEmpty cvar.