diff --git a/code/game/g_client.c b/code/game/g_client.c index 5a584d4..c48fcb7 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 + if ( g_svDemoPlaying.integer ) { + client->sess.sessionTeam = TEAM_SPECTATOR; + client->ps.pm_type = PM_SPECTATOR; + client->ps.persistant[PERS_TEAM] = TEAM_SPECTATOR; + } + // 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_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..b6a93e5 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 }, @@ -1729,6 +1731,19 @@ int start, end; // get any cvar changes G_UpdateCvars(); + // demo playback: only process spectator client, skip all entity logic + if ( g_svDemoPlaying.integer ) { + for ( i = 0; i < level.maxclients; i++ ) { + ent = &g_entities[i]; + if ( ent->inuse && ent->client && + ent->client->sess.sessionTeam == TEAM_SPECTATOR && + ent->client->pers.connected == CON_CONNECTED ) { + ClientThink_real( ent ); + } + } + return; + } + // // go through all allocated objects // diff --git a/code/quake3.vcxproj b/code/quake3.vcxproj index 340ba18..25d6f18 100644 --- a/code/quake3.vcxproj +++ b/code/quake3.vcxproj @@ -995,6 +995,8 @@ MaxSpeed MaxSpeed + + Disabled true diff --git a/code/server/server.h b/code/server/server.h index c32b2dc..c27ec1e 100644 --- a/code/server/server.h +++ b/code/server/server.h @@ -341,6 +341,19 @@ 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_Play_f( void ); +void SVD_StopPlay_f( void ); +qboolean SVD_PlaybackFrame( void ); +qboolean SVD_IsRecording( void ); +qboolean SVD_IsPlaying( void ); +int SVD_SpectatorClientNum( 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..060c030 100644 --- a/code/server/sv_ccmds.c +++ b/code/server/sv_ccmds.c @@ -736,6 +736,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_StopRecord_f); + Cmd_AddCommand ("svdemo_play", SVD_Play_f); } /* diff --git a/code/server/sv_main.c b/code/server/sv_main.c index 98c74ce..84a9312 100644 --- a/code/server/sv_main.c +++ b/code/server/sv_main.c @@ -833,8 +833,17 @@ 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 ) { diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c new file mode 100644 index 0000000..81f1b2e --- /dev/null +++ b/code/server/sv_netdemo.c @@ -0,0 +1,585 @@ +/* +=========================================================================== +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" + +// --------------------------------------------------------------- +// 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 1 +#define SVDEMO_MAX_MAPNAME 64 + +// --------------------------------------------------------------- +// State +// --------------------------------------------------------------- + +typedef struct { + // recording + fileHandle_t recordFile; + qboolean recording; + char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection + + // 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]; + int spectatorClientNum; // which client slot is the demo spectator + int nextFrameTime; // serverTime of next frame to read + qboolean endOfDemo; + qboolean needConfigstrings; // apply saved configstrings on first frame +} svDemo_t; + +static svDemo_t demo; + +// --------------------------------------------------------------- +// Recording helpers +// --------------------------------------------------------------- + +static void SVD_WriteInt( fileHandle_t f, int v ) { + FS_Write( &v, 4, f ); +} + +static void SVD_WriteShort( fileHandle_t f, short v ) { + FS_Write( &v, 2, f ); +} + +static int SVD_ReadInt( fileHandle_t f ) { + int v = 0; + FS_Read( &v, 4, f ); + return v; +} + +static short SVD_ReadShort( fileHandle_t f ) { + short v = 0; + FS_Read( &v, 2, f ); + return v; +} + +// --------------------------------------------------------------- +// Write header +// --------------------------------------------------------------- + +static void SVD_WriteHeader( fileHandle_t f ) { + int i; + char mapBuf[SVDEMO_MAX_MAPNAME]; + + SVD_WriteInt( f, SVDEMO_MAGIC ); + SVD_WriteInt( f, SVDEMO_VERSION ); + SVD_WriteInt( f, 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, count; + sharedEntity_t *ent; + short numChanges; + + SVD_WriteInt( f, svs.time ); + + // count active entities + count = 0; + for ( i = 0; i < sv.num_entities; i++ ) { + ent = SV_GentityNum( i ); + if ( ent->r.linked || ent->s.eType != 0 ) { + count++; + } + } + SVD_WriteShort( f, (short)count ); + + // write each active entity + for ( i = 0; i < sv.num_entities; i++ ) { + ent = SV_GentityNum( i ); + if ( !ent->r.linked && ent->s.eType == 0 ) { + continue; + } + + SVD_WriteShort( f, (short)i ); + FS_Write( &ent->s, sizeof(entityState_t), f ); + SVD_WriteInt( f, ent->r.svFlags ); + SVD_WriteInt( f, ent->r.linked ); + FS_Write( ent->r.currentOrigin, 12, f ); + FS_Write( ent->r.absmin, 12, f ); + FS_Write( ent->r.absmax, 12, f ); + } + + // configstring changes + numChanges = 0; + for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { + const char *cur = sv.configstrings[i] ? sv.configstrings[i] : ""; + const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : ""; + if ( strcmp( cur, old ) != 0 ) { + numChanges++; + } + } + SVD_WriteShort( f, numChanges ); + + if ( numChanges > 0 ) { + for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { + const char *cur = sv.configstrings[i] ? sv.configstrings[i] : ""; + const char *old = demo.lastConfigstrings[i] ? demo.lastConfigstrings[i] : ""; + if ( strcmp( cur, old ) != 0 ) { + int len = strlen( cur ) + 1; + SVD_WriteShort( f, (short)i ); + SVD_WriteShort( f, (short)len ); + FS_Write( cur, len, f ); + + if ( demo.lastConfigstrings[i] ) { + Z_Free( demo.lastConfigstrings[i] ); + } + demo.lastConfigstrings[i] = CopyString( cur ); + } + } + } +} + +// --------------------------------------------------------------- +// Recording commands +// --------------------------------------------------------------- + +void SVD_Record_f( void ) { + char name[MAX_OSPATH]; + char *s; + + if ( demo.recording ) { + Com_Printf( "Already recording a server demo.\n" ); + return; + } + + if ( sv.state != SS_GAME ) { + Com_Printf( "Not running a server.\n" ); + return; + } + + s = Cmd_Argv(1); + if ( !s[0] ) { + Com_Printf( "Usage: svdemo_record \n" ); + return; + } + + Com_sprintf( name, sizeof(name), "svdemos/%s.svdm", s ); + + demo.recordFile = FS_FOpenFileWrite( name ); + if ( !demo.recordFile ) { + Com_Printf( "ERROR: couldn't open %s for writing.\n", name ); + return; + } + + Com_Printf( "Recording server demo to %s\n", name ); + demo.recording = qtrue; + + SVD_WriteHeader( demo.recordFile ); +} + +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. +*/ +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; + } + + 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; + sharedEntity_t *ent; + + serverTime = SVD_ReadInt( f ); + if ( serverTime == -1 ) { + return qfalse; // end of demo + } + + svs.time = serverTime; + + // clear all entities except the spectator slot + for ( i = 0; i < sv.num_entities; i++ ) { + if ( i == demo.spectatorClientNum ) { + continue; + } + ent = SV_GentityNum( i ); + ent->r.linked = qfalse; + ent->s.eType = 0; + ent->s.number = i; + } + + numEnts = SVD_ReadShort( f ); + + for ( i = 0; i < numEnts; i++ ) { + short entNum; + entityState_t es; + int svFlags, linked; + vec3_t currentOrigin, absmin, absmax; + + entNum = SVD_ReadShort( f ); + FS_Read( &es, sizeof(entityState_t), f ); + svFlags = SVD_ReadInt( f ); + linked = SVD_ReadInt( f ); + FS_Read( currentOrigin, 12, f ); + FS_Read( absmin, 12, f ); + FS_Read( absmax, 12, f ); + + // skip the spectator's slot + if ( entNum == demo.spectatorClientNum ) { + continue; + } + + ent = SV_GentityNum( entNum ); + ent->s = es; + ent->s.number = entNum; + ent->r.svFlags = svFlags; + ent->r.linked = linked; + VectorCopy( currentOrigin, ent->r.currentOrigin ); + VectorCopy( absmin, ent->r.absmin ); + VectorCopy( absmax, ent->r.absmax ); + + // link into the world so PVS can find this entity + if ( linked ) { + SV_LinkEntity( ent ); + } + + // update num_entities high water mark + if ( entNum + 1 > sv.num_entities ) { + sv.num_entities = entNum + 1; + } + } + + // ensure spectator slot is counted + if ( demo.spectatorClientNum + 1 > sv.num_entities ) { + sv.num_entities = demo.spectatorClientNum + 1; + } + + // read configstring changes + 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'; + SV_SetConfigstring( idx, 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 ); + + len = FS_FOpenFileRead( name, &demo.playFile, qtrue ); + if ( !demo.playFile || len <= 0 ) { + Com_Printf( "ERROR: couldn't open %s.\n", name ); + return; + } + + // read the header (populates demo.playMapName etc.) + memset( &demo, 0, sizeof(demo) ); + demo.playFile = 0; // re-open after memset + len = FS_FOpenFileRead( name, &demo.playFile, qtrue ); + + if ( !SVD_ReadHeader( demo.playFile ) ) { + FS_FCloseFile( demo.playFile ); + demo.playFile = 0; + return; + } + + demo.playing = qtrue; + demo.endOfDemo = qfalse; + + // The spectator gets the highest slot: MAX_CLIENTS - 1 + demo.spectatorClientNum = MAX_CLIENTS - 1; + + Com_Printf( "Playing server demo: map=%s maxclients=%d fps=%d\n", + demo.playMapName, demo.playMaxClients, demo.playFps ); + + // 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) ); + + // Signal demo mode to the game module AFTER map load + Cvar_Set( "sv_demoplaying", "1" ); + + // 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; +} + +void SVD_StopPlay_f( void ) { + int i; + + if ( !demo.playing ) { + Com_Printf( "Not playing a server demo.\n" ); + return; + } + + FS_FCloseFile( demo.playFile ); + + // free saved configstrings + for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { + if ( demo.savedConfigstrings[i] ) { + Z_Free( demo.savedConfigstrings[i] ); + } + } + memset( &demo, 0, sizeof(demo) ); + + Cvar_Set( "sv_demoplaying", "0" ); + Com_Printf( "Server demo playback stopped.\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; + } + + // apply recorded configstrings once after map load + if ( demo.needConfigstrings ) { + SVD_ApplyConfigstrings(); + demo.needConfigstrings = qfalse; + } + + if ( !SVD_ReadFrame( demo.playFile ) ) { + demo.endOfDemo = qtrue; + Com_Printf( "Server demo playback finished.\n" ); + return qfalse; + } + + return qtrue; +} + +// --------------------------------------------------------------- +// Queries +// --------------------------------------------------------------- + +qboolean SVD_IsRecording( void ) { + return demo.recording; +} + +qboolean SVD_IsPlaying( void ) { + return demo.playing; +} + +int SVD_SpectatorClientNum( void ) { + return demo.spectatorClientNum; +}