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 @@ MaxSpeed MaxSpeed + + + + Disabled true 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 ; iinteger ; 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 \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 \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 + + +===========================================================================