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>
This commit is contained in:
serge_shubin 2026-03-24 06:32:08 +08:00
parent 74e2fc39c8
commit f871cc004f
5 changed files with 67 additions and 23 deletions

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 );
@ -1031,6 +1030,13 @@ void ClientBegin( int clientNum ) {
// locate ent at a spawn point // locate ent at a spawn point
ClientSpawn( ent ); ClientSpawn( ent );
// demo playback: ClientThink_real (which normally sets pm_type
// for spectators) is disabled. Set it explicitly after spawn.
if ( g_svDemoPlaying.integer && client->sess.sessionTeam == TEAM_SPECTATOR ) {
client->ps.pm_type = PM_SPECTATOR;
client->ps.speed = 480;
}
if ( client->sess.sessionTeam != TEAM_SPECTATOR ) { if ( client->sess.sessionTeam != TEAM_SPECTATOR ) {
// send event // send event
tent = G_TempEntity( ent->client->ps.origin, EV_PLAYER_TELEPORT_IN ); tent = G_TempEntity( ent->client->ps.origin, EV_PLAYER_TELEPORT_IN );

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

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

@ -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();
@ -680,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

@ -11,6 +11,9 @@ snapshot pipeline delivers them to a spectator client.
#include "server.h" #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 // File format
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -97,12 +100,14 @@ 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;
void SVD_CleanupPlayback( void );
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Recording helpers // Recording helpers
@ -770,8 +775,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;
} }
@ -781,6 +786,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) );
@ -799,42 +809,50 @@ 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 // Also set configstring directly for cgame (must be after devmap
// doesn't land in slot 0 (which collides with recorded player 0). // which creates the server and allocates configstrings)
// Mark as CS_ZOMBIE (non-free, won't be reused by SV_DirectConnect). if ( sv.state == SS_GAME ) {
// Set rate and nextSnapshotTime so SV_SendClientMessages doesn't SV_SetConfigstring( CS_SVDEMO, "1" );
// crash on division by zero or try to send them snapshots. }
// Reserve recorded player slots. Server is fresh (SV_Shutdown cleared
// old clients), local client hasn't connected yet.
{ {
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 ) {
@ -858,7 +876,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 ) {
@ -957,6 +975,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.