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>
1593 lines
50 KiB
Diff
1593 lines
50 KiB
Diff
diff --git a/code/cgame/cg_snapshot.c b/code/cgame/cg_snapshot.c
|
||
index add49f0..6bc3a0c 100644
|
||
--- a/code/cgame/cg_snapshot.c
|
||
+++ b/code/cgame/cg_snapshot.c
|
||
@@ -203,6 +203,15 @@ static void CG_SetNextSnap( snapshot_t *snap ) {
|
||
|
||
cg.nextSnap = snap;
|
||
|
||
+ // SNAPFLAG_RESET_ENTITIES: invalidate all entities so they are
|
||
+ // treated as new (no interpolation from old positions).
|
||
+ // Must happen before the entity loop below.
|
||
+ if ( snap->snapFlags & SNAPFLAG_RESET_ENTITIES ) {
|
||
+ for ( num = 0 ; num < MAX_GENTITIES ; num++ ) {
|
||
+ cg_entities[ num ].currentValid = qfalse;
|
||
+ }
|
||
+ }
|
||
+
|
||
BG_PlayerStateToEntityState( &snap->ps, &cg_entities[ snap->ps.clientNum ].nextState, qfalse );
|
||
cg_entities[ cg.snap->ps.clientNum ].interpolate = qtrue;
|
||
|
||
@@ -241,6 +250,11 @@ static void CG_SetNextSnap( snapshot_t *snap ) {
|
||
cg.nextFrameTeleport = qtrue;
|
||
}
|
||
|
||
+ // entity reset also prevents playerstate interpolation
|
||
+ if ( cg.nextSnap->snapFlags & SNAPFLAG_RESET_ENTITIES ) {
|
||
+ cg.nextFrameTeleport = qtrue;
|
||
+ }
|
||
+
|
||
// sort out solid entities
|
||
CG_BuildSolidList();
|
||
}
|
||
diff --git a/code/game/g_client.c b/code/game/g_client.c
|
||
index 5a584d4..719e382 100644
|
||
--- a/code/game/g_client.c
|
||
+++ b/code/game/g_client.c
|
||
@@ -1012,6 +1012,13 @@ void ClientBegin( int clientNum ) {
|
||
client->pers.enterTime = level.time;
|
||
client->pers.teamState.state = TEAM_BEGIN;
|
||
|
||
+ // demo playback: force all clients to spectator
|
||
+ // demo playback: force to spectator and update configstring
|
||
+ if ( g_svDemoPlaying.integer ) {
|
||
+ client->sess.sessionTeam = TEAM_SPECTATOR;
|
||
+ ClientUserinfoChanged( ent->client - level.clients );
|
||
+ }
|
||
+
|
||
// save eflags around this, because changing teams will
|
||
// cause this to happen with a valid entity, and we
|
||
// want to make sure the teleport bit is set right
|
||
diff --git a/code/game/g_cmds.c b/code/game/g_cmds.c
|
||
index e72c80e..20e7b58 100644
|
||
--- a/code/game/g_cmds.c
|
||
+++ b/code/game/g_cmds.c
|
||
@@ -661,6 +661,12 @@ void Cmd_Team_f( gentity_t *ent ) {
|
||
int oldTeam;
|
||
char s[MAX_TOKEN_CHARS];
|
||
|
||
+ // demo playback: spectators only
|
||
+ if ( trap_Cvar_VariableIntegerValue( "sv_demoplaying" ) ) {
|
||
+ trap_SendServerCommand( ent-g_entities, "print \"Team changes disabled during demo playback.\n\"" );
|
||
+ return;
|
||
+ }
|
||
+
|
||
if ( trap_Argc() != 2 ) {
|
||
oldTeam = ent->client->sess.sessionTeam;
|
||
switch ( oldTeam ) {
|
||
diff --git a/code/game/g_local.h b/code/game/g_local.h
|
||
index 1a55955..057722c 100644
|
||
--- a/code/game/g_local.h
|
||
+++ b/code/game/g_local.h
|
||
@@ -641,6 +641,7 @@ void ClientCommand( int clientNum );
|
||
// g_active.c
|
||
//
|
||
void ClientThink( int clientNum );
|
||
+void ClientThink_real( gentity_t *ent );
|
||
void ClientEndFrame( gentity_t *ent );
|
||
void G_RunClient( gentity_t *ent );
|
||
|
||
@@ -715,6 +716,7 @@ extern gentity_t g_entities[MAX_GENTITIES];
|
||
#define FOFS(x) ((int)&(((gentity_t *)0)->x))
|
||
|
||
extern vmCvar_t g_gametype;
|
||
+extern vmCvar_t g_svDemoPlaying;
|
||
extern vmCvar_t g_dedicated;
|
||
extern vmCvar_t g_cheats;
|
||
extern vmCvar_t g_maxclients; // allow this many total, including spectators
|
||
diff --git a/code/game/g_main.c b/code/game/g_main.c
|
||
index 9f60272..a468854 100644
|
||
--- a/code/game/g_main.c
|
||
+++ b/code/game/g_main.c
|
||
@@ -39,6 +39,7 @@ gentity_t g_entities[MAX_GENTITIES];
|
||
gclient_t g_clients[MAX_CLIENTS];
|
||
|
||
vmCvar_t g_gametype;
|
||
+vmCvar_t g_svDemoPlaying;
|
||
vmCvar_t g_dmflags;
|
||
vmCvar_t g_fraglimit;
|
||
vmCvar_t g_timelimit;
|
||
@@ -108,6 +109,7 @@ static cvarTable_t gameCvarTable[] = {
|
||
|
||
// latched vars
|
||
{ &g_gametype, "g_gametype", "0", CVAR_SERVERINFO | CVAR_USERINFO | CVAR_LATCH, 0, qfalse },
|
||
+ { &g_svDemoPlaying, "sv_demoplaying", "0", CVAR_ROM, 0, qfalse },
|
||
|
||
{ &g_maxclients, "sv_maxclients", "8", CVAR_SERVERINFO | CVAR_LATCH | CVAR_ARCHIVE, 0, qfalse },
|
||
{ &g_maxGameClients, "g_maxGameClients", "0", CVAR_SERVERINFO | CVAR_LATCH | CVAR_ARCHIVE, 0, qfalse },
|
||
@@ -764,6 +766,10 @@ void CalculateRanks( void ) {
|
||
int rank;
|
||
int score;
|
||
int newScore;
|
||
+
|
||
+ // (demo playback note: this runs normally so the spectator
|
||
+ // appears in sortedClients. Recorded players won't show here
|
||
+ // since they're not connected in the game module.)
|
||
gclient_t *cl;
|
||
|
||
level.follow1 = -1;
|
||
@@ -1729,6 +1735,51 @@ int start, end;
|
||
// get any cvar changes
|
||
G_UpdateCvars();
|
||
|
||
+ // demo playback: sync recorded player states and process spectator
|
||
+ if ( g_svDemoPlaying.integer ) {
|
||
+ gentity_t *specEnt = NULL;
|
||
+
|
||
+ // mark recorded players as connected based on their playerState.
|
||
+ // the server injected playerStates via SV_GameClientNum before
|
||
+ // calling G_RunFrame, so g_clients[i].ps is already populated.
|
||
+ for ( i = 0; i < level.maxclients; i++ ) {
|
||
+ gclient_t *cl = &level.clients[i];
|
||
+ gentity_t *e = &g_entities[i];
|
||
+
|
||
+ // find and process the spectator
|
||
+ if ( e->client && cl->pers.connected == CON_CONNECTED
|
||
+ && cl->sess.sessionTeam == TEAM_SPECTATOR ) {
|
||
+ ClientThink_real( e );
|
||
+ specEnt = e;
|
||
+ continue;
|
||
+ }
|
||
+
|
||
+ // check if server injected a valid playerState for this slot
|
||
+ if ( cl->ps.commandTime > 0 ) {
|
||
+ cl->pers.connected = CON_CONNECTED;
|
||
+ cl->sess.sessionTeam = cl->ps.persistant[PERS_TEAM];
|
||
+ e->inuse = qtrue;
|
||
+ e->client = cl;
|
||
+ e->s.clientNum = i;
|
||
+ e->s.number = i;
|
||
+ } else if ( cl->pers.connected == CON_CONNECTED
|
||
+ && cl->sess.sessionTeam != TEAM_SPECTATOR ) {
|
||
+ // player left — mark disconnected
|
||
+ cl->pers.connected = CON_DISCONNECTED;
|
||
+ e->inuse = qfalse;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // update rankings so follow mode can cycle through players
|
||
+ CalculateRanks();
|
||
+
|
||
+ // run end-of-frame for spectator (handles follow mode PS copy)
|
||
+ if ( specEnt ) {
|
||
+ ClientEndFrame( specEnt );
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+
|
||
//
|
||
// go through all allocated objects
|
||
//
|
||
diff --git a/code/game/q_shared.h b/code/game/q_shared.h
|
||
index 99e5a67..4834c94 100644
|
||
--- a/code/game/q_shared.h
|
||
+++ b/code/game/q_shared.h
|
||
@@ -1085,6 +1085,7 @@ typedef enum {
|
||
#define SNAPFLAG_RATE_DELAYED 1
|
||
#define SNAPFLAG_NOT_ACTIVE 2 // snapshot used during connection and for zombies
|
||
#define SNAPFLAG_SERVERCOUNT 4 // toggled every map_restart so transitions can be detected
|
||
+#define SNAPFLAG_RESET_ENTITIES 16 // snap all entities to current position, no interpolation
|
||
|
||
//
|
||
// per-level limits
|
||
diff --git a/code/quake3.vcxproj b/code/quake3.vcxproj
|
||
index 340ba18..f850f17 100644
|
||
--- a/code/quake3.vcxproj
|
||
+++ b/code/quake3.vcxproj
|
||
@@ -995,6 +995,10 @@
|
||
<Optimization Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">MaxSpeed</Optimization>
|
||
<Optimization Condition="'$(Configuration)|$(Platform)'=='vector|Win32'">MaxSpeed</Optimization>
|
||
</ClCompile>
|
||
+ <ClCompile Include="server\sv_netdemo.c">
|
||
+ </ClCompile>
|
||
+ <ClCompile Include="server\lz4.c">
|
||
+ </ClCompile>
|
||
<ClCompile Include="server\sv_world.c">
|
||
<Optimization Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">Disabled</Optimization>
|
||
<BrowseInformation Condition="'$(Configuration)|$(Platform)'=='Debug TA DEMO|Win32'">true</BrowseInformation>
|
||
diff --git a/code/server/server.h b/code/server/server.h
|
||
index c32b2dc..02f8ae8 100644
|
||
--- a/code/server/server.h
|
||
+++ b/code/server/server.h
|
||
@@ -341,6 +341,23 @@ int SV_BotGetConsoleMessage( int client, char *buf, int size );
|
||
int BotImport_DebugPolygonCreate(int color, int numPoints, vec3_t *points);
|
||
void BotImport_DebugPolygonDelete(int id);
|
||
|
||
+//
|
||
+// sv_netdemo.c
|
||
+//
|
||
+void SVD_Record_f( void );
|
||
+void SVD_StopRecord_f( void );
|
||
+void SVD_RecordFrame( void );
|
||
+void SVD_ResetDeltaState( void );
|
||
+void SVD_AutoRecord( void );
|
||
+void SVD_CaptureServerCommand( const char *cmd );
|
||
+void SVD_Play_f( void );
|
||
+void SVD_StopPlay_f( void );
|
||
+void SVD_Stop_f( void );
|
||
+qboolean SVD_PlaybackFrame( void );
|
||
+qboolean SVD_IsRecording( void );
|
||
+qboolean SVD_IsPlaying( void );
|
||
+qboolean SVD_ShouldPause( void );
|
||
+
|
||
//============================================================
|
||
//
|
||
// high level object sorting to reduce interaction tests
|
||
diff --git a/code/server/sv_ccmds.c b/code/server/sv_ccmds.c
|
||
index 62ae179..b55d15f 100644
|
||
--- a/code/server/sv_ccmds.c
|
||
+++ b/code/server/sv_ccmds.c
|
||
@@ -280,6 +280,9 @@ static void SV_MapRestart_f( void ) {
|
||
sv.state = SS_GAME;
|
||
sv.restarting = qfalse;
|
||
|
||
+ // reset demo delta state so next frame writes full entities
|
||
+ SVD_ResetDeltaState();
|
||
+
|
||
// connect and begin all the clients
|
||
for (i=0 ; i<sv_maxclients->integer ; i++) {
|
||
client = &svs.clients[i];
|
||
@@ -736,6 +739,11 @@ void SV_AddOperatorCommands( void ) {
|
||
if( com_dedicated->integer ) {
|
||
Cmd_AddCommand ("say", SV_ConSay_f);
|
||
}
|
||
+
|
||
+ // server-side demo recording/playback
|
||
+ Cmd_AddCommand ("svdemo_record", SVD_Record_f);
|
||
+ Cmd_AddCommand ("svdemo_stop", SVD_Stop_f);
|
||
+ Cmd_AddCommand ("svdemo_play", SVD_Play_f);
|
||
}
|
||
|
||
/*
|
||
diff --git a/code/server/sv_game.c b/code/server/sv_game.c
|
||
index 202994e..b227f3c 100644
|
||
--- a/code/server/sv_game.c
|
||
+++ b/code/server/sv_game.c
|
||
@@ -83,13 +83,20 @@ Sends a command string to a client
|
||
===============
|
||
*/
|
||
void SV_GameSendServerCommand( int clientNum, const char *text ) {
|
||
+ // capture for demo recording: broadcasts and per-client chat/tchat
|
||
+ if ( clientNum == -1 ) {
|
||
+ SVD_CaptureServerCommand( text );
|
||
+ } else if ( !strncmp( text, "chat", 4 ) || !strncmp( text, "tchat", 5 ) ) {
|
||
+ SVD_CaptureServerCommand( text );
|
||
+ }
|
||
+
|
||
if ( clientNum == -1 ) {
|
||
SV_SendServerCommand( NULL, "%s", text );
|
||
} else {
|
||
if ( clientNum < 0 || clientNum >= sv_maxclients->integer ) {
|
||
return;
|
||
}
|
||
- SV_SendServerCommand( svs.clients + clientNum, "%s", text );
|
||
+ SV_SendServerCommand( svs.clients + clientNum, "%s", text );
|
||
}
|
||
}
|
||
|
||
diff --git a/code/server/sv_init.c b/code/server/sv_init.c
|
||
index b3d0c4d..e1ffb4b 100644
|
||
--- a/code/server/sv_init.c
|
||
+++ b/code/server/sv_init.c
|
||
@@ -351,6 +351,12 @@ void SV_SpawnServer( char *server, qboolean killBots ) {
|
||
char systemInfo[16384];
|
||
const char *p;
|
||
|
||
+ // stop any active demo recording (one demo = one map)
|
||
+ if ( SVD_IsRecording() ) {
|
||
+ Com_Printf( "Map change — stopping demo recording.\n" );
|
||
+ SVD_StopRecord_f();
|
||
+ }
|
||
+
|
||
// shut down the existing game if it is running
|
||
SV_ShutdownGameProgs();
|
||
|
||
@@ -546,6 +552,9 @@ void SV_SpawnServer( char *server, qboolean killBots ) {
|
||
Hunk_SetMark();
|
||
|
||
Com_Printf ("-----------------------------------\n");
|
||
+
|
||
+ // auto-record demo if enabled
|
||
+ SVD_AutoRecord();
|
||
}
|
||
|
||
/*
|
||
@@ -612,6 +621,11 @@ void SV_Init (void) {
|
||
sv_lanForceRate = Cvar_Get ("sv_lanForceRate", "1", CVAR_ARCHIVE );
|
||
sv_strictAuth = Cvar_Get ("sv_strictAuth", "1", CVAR_ARCHIVE );
|
||
|
||
+ // server-side demo settings
|
||
+ Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE);
|
||
+ Cvar_Get ("svdemo_compress", "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
|
||
SV_BotInitCvars();
|
||
|
||
@@ -667,6 +681,14 @@ void SV_Shutdown( char *finalmsg ) {
|
||
|
||
Com_Printf( "----- Server Shutdown -----\n" );
|
||
|
||
+ // clean up any active demo recording/playback
|
||
+ if ( SVD_IsRecording() ) {
|
||
+ SVD_StopRecord_f();
|
||
+ }
|
||
+ if ( SVD_IsPlaying() ) {
|
||
+ SVD_StopPlay_f();
|
||
+ }
|
||
+
|
||
if ( svs.clients && !com_errorEntered ) {
|
||
SV_FinalMessage( finalmsg );
|
||
}
|
||
diff --git a/code/server/sv_main.c b/code/server/sv_main.c
|
||
index 98c74ce..da5e89a 100644
|
||
--- a/code/server/sv_main.c
|
||
+++ b/code/server/sv_main.c
|
||
@@ -833,16 +833,27 @@ void SV_Frame( int msec ) {
|
||
sv.timeResidual -= frameMsec;
|
||
svs.time += frameMsec;
|
||
|
||
+ if ( SVD_IsPlaying() ) {
|
||
+ // demo playback: read recorded entities instead of running game logic
|
||
+ SVD_PlaybackFrame();
|
||
+ // still call the game frame for spectator movement
|
||
+ }
|
||
+
|
||
// let everything in the world think and move
|
||
VM_Call( gvm, GAME_RUN_FRAME, svs.time );
|
||
+
|
||
+ // capture frame for demo recording
|
||
+ SVD_RecordFrame();
|
||
}
|
||
|
||
if ( com_speeds->integer ) {
|
||
time_game = Sys_Milliseconds () - startTime;
|
||
}
|
||
|
||
- // check timeouts
|
||
- SV_CheckTimeouts();
|
||
+ // check timeouts (skip during demo playback — zombie slots would be freed)
|
||
+ if ( !SVD_IsPlaying() ) {
|
||
+ SV_CheckTimeouts();
|
||
+ }
|
||
|
||
// send messages back to the clients
|
||
SV_SendClientMessages();
|
||
diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c
|
||
new file mode 100644
|
||
index 0000000..f87afc3
|
||
--- /dev/null
|
||
+++ b/code/server/sv_netdemo.c
|
||
@@ -0,0 +1,1056 @@
|
||
+/*
|
||
+===========================================================================
|
||
+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];
|
||
+ 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 ) {
|
||
+ // map was restarted — reset playback delta state to match
|
||
+ // the recording's reset (both now decode from zero baseline).
|
||
+ Com_Memset( demo.playPrevEntities, 0, sizeof(demo.playPrevEntities) );
|
||
+ Com_Memset( demo.playPrevPlayers, 0, sizeof(demo.playPrevPlayers) );
|
||
+ // set one-shot SNAPFLAG_RESET_ENTITIES so cgame snaps all
|
||
+ // entities to current position without interpolation.
|
||
+ // OR'd into snapFlagServerBit, cleared after one frame.
|
||
+ svs.snapFlagServerBit |= SNAPFLAG_RESET_ENTITIES;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 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;
|
||
+ }
|
||
+
|
||
+ // 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;
|
||
+}
|
||
+
|
||
+/*
|
||
+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
|
||
+}
|
||
+
|
||
diff --git a/svdemo.txt b/svdemo.txt
|
||
new file mode 100644
|
||
index 0000000..24dc836
|
||
--- /dev/null
|
||
+++ b/svdemo.txt
|
||
@@ -0,0 +1,160 @@
|
||
+===========================================================================
|
||
+ Серверные демо-записи (SVDEMO)
|
||
+ Руководство пользователя
|
||
+===========================================================================
|
||
+
|
||
+ОПИСАНИЕ
|
||
+--------
|
||
+
|
||
+Система серверных демо-записей позволяет записывать полное состояние
|
||
+игрового сервера (все сущности, все игроки) и воспроизводить запись
|
||
+с возможностью свободного перемещения камеры или просмотра от первого
|
||
+лица любого игрока.
|
||
+
|
||
+В отличие от обычных клиентских демо (запись только того, что видит
|
||
+один игрок), серверная запись содержит полную картину матча.
|
||
+
|
||
+
|
||
+КОНСОЛЬНЫЕ КОМАНДЫ
|
||
+------------------
|
||
+
|
||
+ svdemo_record <имя>
|
||
+ Начать запись серверного демо. Файл сохраняется в
|
||
+ svdemos/<имя>.svdm внутри игровой директории.
|
||
+
|
||
+ svdemo_play <имя>
|
||
+ Воспроизвести серверное демо. Загружает карту из записи,
|
||
+ подключает зрителя автоматически.
|
||
+
|
||
+ svdemo_stop
|
||
+ Остановить текущую запись или воспроизведение.
|
||
+ При остановке воспроизведения происходит отключение от сервера.
|
||
+
|
||
+
|
||
+НАСТРОЙКИ (CVARS)
|
||
+-----------------
|
||
+
|
||
+ svdemo_autorecord <0|1> (по умолчанию: 0)
|
||
+ Автоматическая запись демо при каждой загрузке карты.
|
||
+ Файлы именуются автоматически: <карта>_ГГГГММДД_ЧЧММСС.svdm
|
||
+ Пример: q3dm6_20260323_141530.svdm
|
||
+
|
||
+ Для включения: set svdemo_autorecord 1
|
||
+ Значение сохраняется в конфигурации (CVAR_ARCHIVE).
|
||
+
|
||
+ svdemo_compress <0|1> (по умолчанию: 1)
|
||
+ Сжатие данных демо алгоритмом LZ4. Уменьшает размер файла
|
||
+ при незначительных затратах на производительность.
|
||
+ Включено по умолчанию. Установите 0 для отключения.
|
||
+ Значение сохраняется в конфигурации (CVAR_ARCHIVE).
|
||
+ Влияет только на запись — воспроизведение автоматически
|
||
+ определяет, сжат файл или нет.
|
||
+
|
||
+
|
||
+ЗАПИСЬ
|
||
+------
|
||
+
|
||
+1. Запустите сервер и начните игру как обычно:
|
||
+ devmap q3dm6
|
||
+
|
||
+2. Начните запись:
|
||
+ svdemo_record mymatch
|
||
+
|
||
+3. Играйте. Все действия всех игроков записываются.
|
||
+
|
||
+4. Остановите запись:
|
||
+ svdemo_stop
|
||
+
|
||
+ Запись также автоматически останавливается при:
|
||
+ - Смене карты (map, devmap, nextmap по таймлимиту/фраглимиту)
|
||
+ - Выключении сервера
|
||
+ Перезапуск карты (map_restart) НЕ прерывает запись.
|
||
+
|
||
+
|
||
+ВОСПРОИЗВЕДЕНИЕ
|
||
+---------------
|
||
+
|
||
+1. Запустите воспроизведение:
|
||
+ svdemo_play mymatch
|
||
+
|
||
+2. Карта загрузится автоматически. Вы подключитесь как зритель
|
||
+ со свободной камерой (полёт по карте).
|
||
+
|
||
+3. Управление зрителем:
|
||
+ - Свободная камера: перемещайтесь как обычный спектатор
|
||
+ - Следование за игроком: нажмите MOUSE2 (USE) для входа
|
||
+ в режим следования
|
||
+ - Переключение между игроками: MOUSE1 (ATTACK)
|
||
+ - Выход из следования: MOUSE2 снова
|
||
+
|
||
+4. В режиме следования вы видите игру от первого лица выбранного
|
||
+ игрока с полным HUD: здоровье, броня, боеприпасы, оружие.
|
||
+
|
||
+5. Табло (TAB) показывает счёт записанных игроков.
|
||
+
|
||
+6. Остановка:
|
||
+ svdemo_stop
|
||
+ Также воспроизведение останавливается автоматически при
|
||
+ достижении конца записи.
|
||
+
|
||
+
|
||
+ФОРМАТ ФАЙЛА
|
||
+-------------
|
||
+
|
||
+Расширение: .svdm
|
||
+Директория: svdemos/
|
||
+
|
||
+Файл содержит:
|
||
+- Заголовок: название карты, настройки сервера, конфигстроки
|
||
+ (имена игроков, модели, настройки игры)
|
||
+- Покадровые данные: дельта-сжатые состояния сущностей и игроков,
|
||
+ изменения конфигстрок
|
||
+
|
||
+Используется двухуровневое сжатие:
|
||
+1. Дельта-кодирование: записываются только изменившиеся поля
|
||
+ сущностей и игроков между кадрами.
|
||
+2. LZ4-сжатие (опционально): дополнительно сжимает блоки данных
|
||
+ каждого кадра. Включено по умолчанию (svdemo_compress 1).
|
||
+
|
||
+Типичная 10-секундная запись занимает ~50 КБ.
|
||
+
|
||
+Одна запись = одна карта. При смене карты запись останавливается.
|
||
+
|
||
+
|
||
+ОГРАНИЧЕНИЯ
|
||
+-----------
|
||
+
|
||
+- Нельзя перематывать запись вперёд или назад.
|
||
+- Если в записанной игре было 64 игрока (MAX_CLIENTS), один
|
||
+ из них не будет виден при воспроизведении (его слот занят зрителем).
|
||
+- Воспроизведение требует наличия тех же pk3-файлов (карты, модели),
|
||
+ что использовались при записи.
|
||
+- Демо несовместимы между разными версиями движка, если изменился
|
||
+ формат сетевых структур.
|
||
+
|
||
+
|
||
+ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
|
||
+---------------------
|
||
+
|
||
+Автоматическая запись всех матчей:
|
||
+
|
||
+ set svdemo_autorecord 1
|
||
+ devmap q3dm17
|
||
+
|
||
+ (все матчи на этом сервере будут записываться автоматически)
|
||
+
|
||
+
|
||
+Запись конкретного матча:
|
||
+
|
||
+ devmap q3tourney2
|
||
+ svdemo_record duel_finals
|
||
+ (играть...)
|
||
+ svdemo_stop
|
||
+
|
||
+
|
||
+Просмотр записи:
|
||
+
|
||
+ svdemo_play duel_finals
|
||
+
|
||
+
|
||
+===========================================================================
|