diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82e5357 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +FnQ3 (FnQuake3) is a modernized Quake III Arena source port. Its lineage is: id Software Q3A → ioquake3 → Quake3e → FnQ3. The top constraint is **retail Quake III Arena compatibility**: demo playback, network protocol, filesystem/pak search order, and VM ABI are all compatibility surfaces and must not regress silently. + +## Build + +Meson is the preferred build system. The legacy Makefile remains functional but new local work should use Meson. + +```sh +meson setup meson/build +meson compile -C meson/build +meson test -C meson/build +``` + +Useful Meson options: +- `-Drenderers=opengl,glx,vulkan` — which renderer modules to build +- `-Drenderer-default=opengl` — default `cl_renderer` value (keep `opengl` until GLx promotion gate is green) +- `-Drenderer-dlopen=false -Drenderer-default=vulkan` — link one renderer statically for testing +- `-Daudio-tests=true` / `-Dglx-tests=true` — include C++ test binaries +- `--wrap-mode=nofallback` — force system libraries only; `--wrap-mode=forcefallback` — force subproject fallbacks + +Makefile equivalents for quick Linux builds: +```sh +make # full default build +make BUILD_SERVER=0 USE_GLX=1 # client + GLx module, no dedicated server +make BUILD_SERVER=0 USE_RENDERER_DLOPEN=0 RENDERER_DEFAULT=vulkan # static Vulkan client +``` + +Output binaries: `fnquake3[.x86_64]` (client), `fnquake3.ded[.x86_64]` (server), `fnquake3_{opengl,glx,vulkan}_x86_64` (renderer modules). + +## Tests + +Most tests are Python source-contract scripts that grep/parse source files — no game assets or GL context needed. Run a single test: + +```sh +python3 tests/shadow_manager_source_tests.py +python3 tests/version_metadata_tests.py +``` + +Run all Python tests at once: + +```sh +python3 -m unittest discover -s tests -p "*_tests.py" +``` + +The GLx logic tests are C++ and require a Meson build: + +```sh +meson compile -C meson/build fnq3_glx_logic_tests +meson test -C meson/build fnq3_glx_logic fnq3_glx_header_boundary --print-errorlogs +``` + +Audio C++ tests are built with `-Daudio-tests=true` and run via `meson test`. + +## Architecture + +### Renderer system + +Three backends coexist as runtime-selectable dynamic modules, chosen via `cl_renderer`: + +- `opengl` — legacy renderer, the current release default. Lives in `code/renderer/`. +- `glx` — canonical OpenGL-lineage replacement under active development. GLx-specific code lives in `code/rendererglx/`; it also compiles in `code/renderer/*.c` as a compatibility baseline (tracked in `docs/fnquake3/GLX_LEGACY_COUPLING.md`). Not the default yet — gated by `scripts/glx_promotion.py`. +- `vulkan` — modern backend. Lives in `code/renderervk/`. + +Shared renderer types and image loaders are in `code/renderercommon/`. All renderers implement the same `refexport_t` / `refimport_t` ABI (`REF_API_VERSION 8`, single `GetRefAPI` export). + +**GLx product tiers** (selected once at GL init, used throughout hot paths): +- `GL12` — fixed-function floor, no GLSL, conservative feature surface +- `GL2X` — first programmable tier, GLSL material execution +- `GL3X` — first performance tier: FBO, UBO, timer queries, sync-aware streaming +- `GL41` — macOS ceiling: modern path without GL4.3+ requirements +- `GL46` — high-end Windows/Linux: persistent mapping, DSA, multi-draw-indirect + +Key GLx modules in `code/rendererglx/`: `glx_caps.cpp` (tier selection), `glx_material.cpp` (GLSL key/compile), `glx_draw.cpp` (submission), `glx_static_world.cpp` (VBO cache), `glx_stream.cpp` (dynamic ring buffer), `glx_postprocess.cpp` (post-chain), `glx_module.cpp` (entry point, profile table). + +### Audio system + +Modern audio lives in `code/client/audio/`. OpenAL Soft is the default backend; the original software mixer (`legacy/`) is the deterministic fallback via `s_backend legacy`. Advanced map tuning uses `.azb` audio-zone sidecars — missing sidecars must never break a map. + +### VM and game code + +`code/game/` (server-side game), `code/cgame/` (client game), `code/ui/` (menus) are QVM bytecode modules. They communicate with the engine through a stable ABI. Do not change VM ABI or protocol behavior without explicit intent. + +## Key Conventions + +**Version:** `version/fnq3_version.h` is the single source of truth. It feeds runtime strings, Windows resources, Meson, Make, and doc generation. Always update it first for release work. + +**Docs generation:** `README.md` and `.install/README.html` are generated — edit the templates in `docs/templates/`, then run `python scripts/generate_docs.py`. + +**Changelog:** Pending release notes live in `docs/fnquake3/CHANGELOG.md` under `Unreleased`. Use `scripts/changelog.py` to manage sections. + +**Third-party dependencies:** Managed through Meson `subprojects/*.wrap` files (SDL3, OpenAL Soft, libcurl, libjpeg-turbo, Ogg/Vorbis). Do not add new in-tree vendor source trees under `code/lib*/`. + +**Scratch space:** Use `.tmp/` for temporary investigation files and intermediate staging. `.install/` is the tracked distribution area for release artifacts. + +**GLx promotion gate:** `opengl` must not be aliased to GLx and `RENDERER_DEFAULT` must not be changed to `glx` in release builds until `python scripts/glx_promotion.py --require-ready` passes. Local `RENDERER_DEFAULT=glx` builds are fine for explicit developer testing. + +## Maintainer Docs + +Technical docs that require reading before making significant changes to a subsystem: + +- `docs/fnquake3/TECHNICAL.md` — repo structure, versioning, release flow +- `docs/fnquake3/GLX_RENDERER.md` — GLx architecture and tier definitions +- `docs/fnquake3/GLX_FEATURE_MATRIX.md` — coverage ledger for each legacy feature +- `docs/fnquake3/GLX_LEGACY_COUPLING.md` — which `code/renderer/*.c` files GLx still compiles and why +- `docs/fnquake3/GLX_PROMOTION.md` — promotion gate requirements +- `docs/fnquake3/AUDIO_ENGINE.md` — modern audio architecture and compatibility boundaries +- `docs/fnquake3/DLIGHT_SHADOWMAP_ROADMAP.md` — shadow system roadmap +- `AGENTS.md` — project constraints and guardrails for automated agents diff --git a/Makefile b/Makefile index dd42a10..33686a3 100644 --- a/Makefile +++ b/Makefile @@ -1337,6 +1337,7 @@ Q3OBJ = \ $(B)/client/sv_bot.o \ $(B)/client/sv_ccmds.o \ $(B)/client/sv_client.o \ + $(B)/client/sv_demo_play.o \ $(B)/client/sv_filter.o \ $(B)/client/sv_game.o \ $(B)/client/sv_init.o \ @@ -1551,6 +1552,7 @@ Q3DOBJ = \ $(B)/ded/sv_bot.o \ $(B)/ded/sv_client.o \ $(B)/ded/sv_ccmds.o \ + $(B)/ded/sv_demo_play.o \ $(B)/ded/sv_filter.o \ $(B)/ded/sv_game.o \ $(B)/ded/sv_init.o \ diff --git a/code/server/server.h b/code/server/server.h index cf78821..ea6da36 100644 --- a/code/server/server.h +++ b/code/server/server.h @@ -94,6 +94,20 @@ typedef struct { int time; byte baselineUsed[ MAX_GENTITIES ]; + + // demo cinema playback state (sv_demo_play.cpp) + qboolean demoPlayback; // server is replaying a demo file + fileHandle_t demoFile; // open demo file handle + int demoClientNum; // recorded player's clientNum from demo header + int demoChecksumFeed; // checksumFeed from demo header + qboolean demoEnded; // EOF reached, holding on last frame + int demoMessageSequence; // sequence counter from the demo stream + playerState_t demoPS; // current decoded playerState + entityState_t demoPrevPS_base; // previous playerState for delta decoding + entityState_t demoEnts[MAX_GENTITIES]; // current decoded entity states + int demoNumEnts; // number of valid entries in demoEnts[] + entityState_t demoPrevEnts[MAX_GENTITIES];// previous entity states for delta decoding + playerState_t demoPrevPS; // previous playerState for delta decoding } server_t; typedef struct { @@ -362,8 +376,19 @@ void SV_UpdateConfigstrings( client_t *client ); void SV_SetUserinfo( int index, const char *val ); void SV_GetUserinfo( int index, char *buffer, int bufferSize ); +void SV_Startup( void ); +void SV_ChangeMaxClients( void ); +void SV_ClearServer( void ); void SV_SpawnServer( const char *mapname, qboolean killBots ); +// +// sv_demo_play.cpp +// +void SV_SpawnDemoServer( const char *demoName ); +void SV_DemoAdvance( void ); +void SV_BuildDemoSnapshot( clientSnapshot_t *frame ); +void SV_PlayDemo_f( void ); + // diff --git a/code/server/sv_ccmds.cpp b/code/server/sv_ccmds.cpp index 9d48b26..65d3037 100644 --- a/code/server/sv_ccmds.cpp +++ b/code/server/sv_ccmds.cpp @@ -252,6 +252,11 @@ static void SV_MapRestart_f( void ) { return; } + if ( sv.demoPlayback ) { + Com_Printf( "Cannot map_restart during demo cinema. Use 'map ' to start a normal game.\n" ); + return; + } + if ( sv.restartTime != 0 ) { return; } @@ -1557,6 +1562,9 @@ void SV_AddOperatorCommands( void ) { static constexpr std::array mapCommands = {{ { "map", SV_Map_f }, }}; + static constexpr std::array demoCommands = {{ + { "sv_playdemo", SV_PlayDemo_f }, + }}; #ifndef PRE_RELEASE_DEMO static constexpr std::array extraMapCommands = {{ { "devmap", SV_Map_f }, @@ -1593,6 +1601,7 @@ void SV_AddOperatorCommands( void ) { #endif SV_AddCommandSet( mapCommands ); SV_SetMapCompletionForCommands( mapCommands ); + SV_AddCommandSet( demoCommands ); #ifndef PRE_RELEASE_DEMO SV_AddCommandSet( extraMapCommands ); SV_SetMapCompletionForCommands( extraMapCommands ); diff --git a/code/server/sv_client.cpp b/code/server/sv_client.cpp index 07812e5..fe8d4ac 100644 --- a/code/server/sv_client.cpp +++ b/code/server/sv_client.cpp @@ -1029,7 +1029,9 @@ void SV_DirectConnect( const netadr_t *from ) { if ( newcl->state >= CS_CONNECTED ) { // call QVM disconnect function before calling connect again // fixes issues such as disappearing CTF flags in unpatched mods - VM_Call( gvm, 1, GAME_CLIENT_DISCONNECT, SV_ClientIndex( newcl ) ); + if ( !sv.demoPlayback ) { + VM_Call( gvm, 1, GAME_CLIENT_DISCONNECT, SV_ClientIndex( newcl ) ); + } // don't leak memory or file handles due to e.g. downloads in progress SV_FreeClient( newcl ); @@ -1142,14 +1144,17 @@ void SV_DirectConnect( const netadr_t *from ) { } // get the game a chance to reject this connection or modify the userinfo - denied = VM_Call( gvm, 3, GAME_CLIENT_CONNECT, clientNum, qtrue, qfalse ); // firstTime = qtrue - if ( denied ) { - // we can't just use VM_ArgPtr, because that is only valid inside a VM_Call - const char *str = static_cast( GVM_ArgPtr( denied ) ); - - NET_OutOfBandPrint( NS_SERVER, from, "print\n%s\n", str ); - Com_DPrintf( "Game rejected a connection: %s.\n", str ); - return; + // (skipped in demo cinema mode: no game VM is running) + if ( !sv.demoPlayback ) { + denied = VM_Call( gvm, 3, GAME_CLIENT_CONNECT, clientNum, qtrue, qfalse ); // firstTime = qtrue + if ( denied ) { + // we can't just use VM_ArgPtr, because that is only valid inside a VM_Call + const char *str = static_cast( GVM_ArgPtr( denied ) ); + + NET_OutOfBandPrint( NS_SERVER, from, "print\n%s\n", str ); + Com_DPrintf( "Game rejected a connection: %s.\n", str ); + return; + } } if ( sv_clientTLD->integer ) { @@ -1239,7 +1244,9 @@ void SV_DropClient( client_t *drop, const char *reason ) { // call the prog function for removing a client // this will remove the body, among other things - VM_Call( gvm, 1, GAME_CLIENT_DISCONNECT, SV_ClientIndex( drop ) ); + if ( !sv.demoPlayback ) { + VM_Call( gvm, 1, GAME_CLIENT_DISCONNECT, SV_ClientIndex( drop ) ); + } // add the disconnect command if ( reason ) { @@ -1383,7 +1390,10 @@ static void SV_SendClientGameState( client_t *client ) { client->gotCP = qfalse; // to start generating delta for packet entities - client->gentity = SV_GentityNum( SV_ClientIndex( client ) ); + // (demo cinema: no gentities exist, SV_BuildDemoSnapshot bypasses this check) + if ( !sv.demoPlayback ) { + client->gentity = SV_GentityNum( SV_ClientIndex( client ) ); + } // when we receive the first packet from the client, we will // notice that it is from a different serverid and that the @@ -1444,7 +1454,13 @@ static void SV_SendClientGameState( client_t *client ) { MSG_WriteByte( &msg, svc_EOF ); - MSG_WriteLong( &msg, SV_ClientIndex( client ) ); + // In demo cinema mode every spectator "becomes" the recorded player so + // cgame initializes identically to local demo playback. + if ( sv.demoPlayback ) { + MSG_WriteLong( &msg, sv.demoClientNum ); + } else { + MSG_WriteLong( &msg, SV_ClientIndex( client ) ); + } // write the checksum feed MSG_WriteLong( &msg, sv.checksumFeed ); @@ -1463,12 +1479,15 @@ static void SV_SendClientGameState( client_t *client ) { } // deliver this to the client - if ( client->demoRecordServerId != sv.serverId ) { - SV_StopDemoRecord( client, qfalse ); - } - if ( sv_autoRecordDemos && sv_autoRecordDemos->integer ) { - if ( ( sv_autoRecordDemos->integer != 2 && sv_autoRecordDemos->integer != 4 ) || !sv_cheats->integer ) { - SV_StartDemoRecord( client ); + if ( !sv.demoPlayback ) { + // Do not auto-record during demo cinema (would re-record the replay). + if ( client->demoRecordServerId != sv.serverId ) { + SV_StopDemoRecord( client, qfalse ); + } + if ( sv_autoRecordDemos && sv_autoRecordDemos->integer ) { + if ( ( sv_autoRecordDemos->integer != 2 && sv_autoRecordDemos->integer != 4 ) || !sv_cheats->integer ) { + SV_StartDemoRecord( client ); + } } } SV_SendMessageToClient( &msg, client ); @@ -1504,17 +1523,23 @@ void SV_ClientEnterWorld( client_t *client ) { SV_UpdateConfigstrings( client ); } - // set up the entity for the client - clientNum = SV_ClientIndex( client ); - ent = SV_GentityNum( clientNum ); - ent->s.number = clientNum; - client->gentity = ent; - client->deltaMessage = client->netchan.outgoingSequence - (PACKET_BACKUP + 1); // force delta reset client->lastSnapshotTime = svs.time - 9999; // generate a snapshot immediately - // call the game begin function - VM_Call( gvm, 1, GAME_CLIENT_BEGIN, clientNum ); + if ( sv.demoPlayback ) { + // Demo cinema: no game VM, no gentity needed. + // gentity stays nullptr; SV_BuildDemoSnapshot bypasses the gentity check. + client->gentity = nullptr; + } else { + // set up the entity for the client + clientNum = SV_ClientIndex( client ); + ent = SV_GentityNum( clientNum ); + ent->s.number = clientNum; + client->gentity = ent; + + // call the game begin function + VM_Call( gvm, 1, GAME_CLIENT_BEGIN, clientNum ); + } } @@ -2178,7 +2203,7 @@ void SV_UserinfoChanged( client_t *cl, qboolean updateUserinfo, qboolean runFilt // name for C code val = Info_ValueForKey( cl->userinfo, "name" ); // truncate if it is too long as it may cause memory corruption in OSP mod - if ( gvm->forceDataMask && strlen( val ) >= buf.size() ) { + if ( gvm != nullptr && gvm->forceDataMask && strlen( val ) >= buf.size() ) { Q_strncpyz( buf.data(), val, SV_ArraySize(buf) ); Info_SetValueForKey( cl->userinfo, "name", buf.data() ); val = buf.data(); @@ -2236,7 +2261,9 @@ static void SV_UpdateUserinfo_f( client_t *cl ) { SV_UserinfoChanged( cl, qtrue, qtrue ); // update userinfo, run filter // call prog code to allow overrides - VM_Call( gvm, 1, GAME_CLIENT_USERINFO_CHANGED, SV_ClientIndex( cl ) ); + if ( !sv.demoPlayback ) { + VM_Call( gvm, 1, GAME_CLIENT_USERINFO_CHANGED, SV_ClientIndex( cl ) ); + } } extern int SV_Strlen( const char *str ); @@ -2419,7 +2446,7 @@ qboolean SV_ExecuteClientCommand( client_t *cl, const char *s ) { Com_DPrintf( "client text ignored for %s: %s\n", cl->name, Cmd_Argv(0) ); } else { // pass unknown strings to the game - if ( ucmd == nullptr && sv.state == SS_GAME && cl->state >= CS_PRIMED ) { + if ( !sv.demoPlayback && ucmd == nullptr && sv.state == SS_GAME && cl->state >= CS_PRIMED ) { if ( gvm->forceDataMask ) Cmd_Args_Sanitize( "\n\r;" ); // handle ';' for OSP else @@ -2486,6 +2513,10 @@ void SV_ClientThink (client_t *cl, usercmd_t *cmd) { return; // may have been kicked during the last usercmd } + if ( sv.demoPlayback ) { + return; // no game VM in demo cinema mode; spectator usercmds are dropped + } + VM_Call( gvm, 1, GAME_CLIENT_THINK, SV_ClientIndex( cl ) ); } diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp new file mode 100644 index 0000000..d34b352 --- /dev/null +++ b/code/server/sv_demo_play.cpp @@ -0,0 +1,687 @@ +/* +=========================================================================== +Copyright (C) 1999-2005 Id Software, Inc. + +This file is part of Quake III Arena source code. + +Quake III Arena source code is free software; you can redistribute it +and/or modify it under the terms of the GNU General Public License as +published by the Free Software Foundation; either version 2 of the License, +or (at your option) any later version. + +Quake III Arena source code is distributed in the hope that it will be +useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Quake III Arena source code; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +=========================================================================== +*/ +// sv_demo_play.cpp -- server-side demo cinema: replay a demo file to all +// connected clients simultaneously, locked to the recorded player's view. + +#include "server.h" + +#include +#include + +// MAX_PARSE_ENTITIES is not available server-side; we only need a two-frame +// rolling buffer of entity states for delta decoding, which server_t already +// provides (demoEnts / demoPrevEnts). + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/* +SV_DemoResolveFile + +Try to open as a demo file. Search order: + 1. demos/.dm_ (client-recorded demos) + 2. demos/server/.dm_ (server auto-recorded demos) + 3. verbatim (absolute or already-extended path) + +On failure prints each path that was tried and returns FS_INVALID_HANDLE. +On success fills outPath with the resolved path and returns the handle. +*/ +static fileHandle_t SV_DemoResolveFile( const char *name, char *outPath, int outSize ) +{ + fileHandle_t f; + const int protocols[] = { NEW_PROTOCOL_VERSION, OLD_PROTOCOL_VERSION, 0 }; + std::array tried{}; + bool anyTried = false; + + auto tryPath = [&]( const char *path ) -> fileHandle_t { + FS_FOpenFileRead( path, &f, qtrue ); + if ( !anyTried ) { + Com_Printf( " trying: %s ... %s\n", path, f != FS_INVALID_HANDLE ? "found" : "not found" ); + anyTried = true; + } else { + Com_Printf( " trying: %s ... %s\n", path, f != FS_INVALID_HANDLE ? "found" : "not found" ); + } + if ( f != FS_INVALID_HANDLE ) { + Q_strncpyz( outPath, path, outSize ); + } + return f; + }; + + for ( int i = 0; protocols[i]; i++ ) { + Com_sprintf( tried.data(), static_cast( tried.size() ), + "demos/%s." DEMOEXT "%d", name, protocols[i] ); + if ( tryPath( tried.data() ) != FS_INVALID_HANDLE ) return f; + } + + for ( int i = 0; protocols[i]; i++ ) { + Com_sprintf( tried.data(), static_cast( tried.size() ), + "demos/server/%s." DEMOEXT "%d", name, protocols[i] ); + if ( tryPath( tried.data() ) != FS_INVALID_HANDLE ) return f; + } + + // Verbatim last — handles a fully-qualified path or already-extended name. + Q_strncpyz( tried.data(), name, static_cast( tried.size() ) ); + return tryPath( tried.data() ); +} + + +/* +SV_DemoReadRawMessage + +Read the next length-prefixed message from the demo file into msg. +Returns qtrue on success, qfalse on EOF or error. +The caller must provide a buffer of at least MAX_MSGLEN_BUF bytes. +*/ +static qboolean SV_DemoReadRawMessage( msg_t *msg, byte *buf, int bufSize ) +{ + int sequence, length; + + // sequence number (4 bytes) + if ( FS_Read( &sequence, 4, sv.demoFile ) != 4 ) { + return qfalse; + } + sequence = LittleLong( sequence ); + + // length (4 bytes); -1 signals end of demo + if ( FS_Read( &length, 4, sv.demoFile ) != 4 ) { + return qfalse; + } + length = LittleLong( length ); + + if ( length == -1 ) { + return qfalse; // normal EOF terminator + } + + if ( length < 0 || length > bufSize ) { + Com_Printf( "SV_DemoReadRawMessage: bad length %d\n", length ); + return qfalse; + } + + if ( FS_Read( buf, length, sv.demoFile ) != length ) { + return qfalse; + } + + sv.demoMessageSequence = sequence; + MSG_Init( msg, buf, length ); + msg->cursize = length; + return qtrue; +} + + +/* +SV_DemoParseGamestate + +Consume the gamestate message that opens every demo file. Populates +sv.configstrings[], sv.svEntities[].baseline / sv.baselineUsed[], +sv.demoClientNum, and sv.demoChecksumFeed. +*/ +static qboolean SV_DemoParseGamestate( msg_t *msg ) +{ + entityState_t nullstate{}; + int cmd; + int newnum; + const char *s; + + // The gamestate message starts with the server's reliableSequence. + // We don't need it server-side but must read it to advance the cursor. + MSG_ReadLong( msg ); // serverCommandSequence / reliableSequence + + while ( 1 ) { + cmd = MSG_ReadByte( msg ); + + if ( cmd == svc_EOF ) { + break; + } + + if ( cmd == svc_configstring ) { + int index = MSG_ReadShort( msg ); + if ( index < 0 || index >= MAX_CONFIGSTRINGS ) { + Com_Error( ERR_DROP, "SV_DemoParseGamestate: configstring index %d out of range", index ); + } + s = MSG_ReadBigString( msg ); + SV_ZFree( sv.configstrings[index] ); + sv.configstrings[index] = CopyString( s ); + } else if ( cmd == svc_baseline ) { + newnum = MSG_ReadEntitynum( msg ); + if ( newnum < 0 || newnum >= MAX_GENTITIES ) { + Com_Error( ERR_DROP, "SV_DemoParseGamestate: baseline entity %d out of range", newnum ); + } + MSG_ReadDeltaEntity( msg, &nullstate, &sv.svEntities[newnum].baseline, newnum ); + sv.baselineUsed[newnum] = 1; + } else { + Com_Error( ERR_DROP, "SV_DemoParseGamestate: bad command byte %d", cmd ); + } + } + + // clientNum and checksumFeed follow the closing svc_EOF. + sv.demoClientNum = MSG_ReadLong( msg ); + sv.demoChecksumFeed = MSG_ReadLong( msg ); + sv.checksumFeed = sv.demoChecksumFeed; + + return qtrue; +} + + +/* +SV_DemoHandleServerCommand + +Called for each svc_serverCommand token found in a snapshot message. +Configstring commands (cs/bcs0/bcs1/bcs2) are applied to sv.configstrings +so late-joining clients see correct state. All other commands are broadcast +to spectators so chat, centerprints, scores etc. reach the audience. +*/ +static void SV_DemoHandleServerCommand( const char *cmd ) +{ + int index; + char key[MAX_STRING_CHARS]; + char value[MAX_STRING_CHARS]; + + if ( !strncmp( cmd, "cs ", 3 ) ) { + // "cs " + index = atoi( cmd + 3 ); + if ( index >= 0 && index < MAX_CONFIGSTRINGS ) { + const char *sp = strchr( cmd + 3, ' ' ); + if ( sp ) { + Q_strncpyz( value, sp + 1, sizeof( value ) ); + // strip surrounding quotes that SV_SendServerCommand adds + if ( value[0] == '"' ) { + int len = strlen( value ); + if ( len > 1 && value[len-1] == '"' ) { + value[len-1] = '\0'; + SV_SetConfigstring( index, value + 1 ); + } else { + SV_SetConfigstring( index, value ); + } + } else { + SV_SetConfigstring( index, value ); + } + } + } + } else if ( !strncmp( cmd, "bcs0 ", 5 ) || !strncmp( cmd, "bcs1 ", 5 ) || !strncmp( cmd, "bcs2 ", 5 ) ) { + // chunked configstring update — parse index and accumulate in scratch + // bcs0 starts a new string, bcs1 appends, bcs2 appends and commits + static int bcsIndex = -1; + static std::array bcsAccum{}; + + index = atoi( cmd + 5 ); + if ( index < 0 || index >= MAX_CONFIGSTRINGS ) return; + + const char *chunk = strchr( cmd + 5, ' ' ); + if ( !chunk ) return; + chunk++; // skip space + // strip outer quotes + if ( chunk[0] == '"' ) { + Q_strncpyz( key, chunk + 1, sizeof(key) ); + int kl = strlen( key ); + if ( kl > 0 && key[kl-1] == '"' ) key[kl-1] = '\0'; + } else { + Q_strncpyz( key, chunk, sizeof(key) ); + } + + if ( cmd[3] == '0' ) { + bcsIndex = index; + Q_strncpyz( bcsAccum.data(), key, static_cast(bcsAccum.size()) ); + } else { + if ( bcsIndex == index ) { + Q_strcat( bcsAccum.data(), static_cast(bcsAccum.size()), key ); + } + } + if ( cmd[3] == '2' && bcsIndex == index ) { + SV_SetConfigstring( index, bcsAccum.data() ); + bcsIndex = -1; + } + } else { + // Non-configstring server command: broadcast to all spectators. + SV_SendServerCommand( nullptr, "%s", cmd ); + } +} + + +/* +SV_DemoParseSnapshot + +Decode one svc_snapshot message from the demo into sv.demoPS and sv.demoEnts[]. +Also drains any leading svc_serverCommand tokens that precede the snapshot. +Returns qtrue on success. +*/ +static qboolean SV_DemoParseSnapshot( msg_t *msg ) +{ + int cmd; + int serverTime; + int deltaNum; + int snapFlags; + int areabytes; + byte areamask[MAX_MAP_AREA_BYTES]; + int newnum; + entityState_t *oldEnt; + int i; + + // Each demo message starts with the 4-byte lastClientCommand ack, + // then optional svc_serverCommand entries, then svc_snapshot. + // Skip the ack first or we read its bytes as command bytes. + MSG_ReadLong( msg ); // lastClientCommand ack + + // Drain any leading server commands. + while ( 1 ) { + cmd = MSG_ReadByte( msg ); + + if ( cmd == svc_serverCommand ) { + int seq = MSG_ReadLong( msg ); + (void)seq; + const char *s = MSG_ReadString( msg ); + SV_DemoHandleServerCommand( s ); + continue; + } + + if ( cmd == svc_snapshot ) { + break; + } + + if ( cmd == svc_EOF ) { + // Empty message (no snapshot this frame) — not a fatal error. + return qfalse; + } + + Com_Printf( "SV_DemoParseSnapshot: unexpected command byte %d before snapshot\n", cmd ); + return qfalse; + } + + // svc_snapshot header + serverTime = MSG_ReadLong( msg ); + deltaNum = MSG_ReadByte( msg ); + snapFlags = MSG_ReadByte( msg ); (void)snapFlags; + + areabytes = MSG_ReadByte( msg ); + if ( areabytes > static_cast( sizeof(areamask) ) ) { + Com_Printf( "SV_DemoParseSnapshot: invalid areabytes %d\n", areabytes ); + return qfalse; + } + MSG_ReadData( msg, areamask, areabytes ); + + // Playerstate — delta from previous or null. + if ( deltaNum == 0 ) { + // Non-delta (first snapshot after gamestate, or forced non-delta) + playerState_t nullPS{}; + MSG_ReadDeltaPlayerstate( msg, &nullPS, &sv.demoPS ); + } else { + MSG_ReadDeltaPlayerstate( msg, &sv.demoPrevPS, &sv.demoPS ); + } + sv.demoPrevPS = sv.demoPS; + + // Entities — same walk as CL_ParsePacketEntities, but into our flat arrays. + // We decode against demoPrevEnts and write into demoEnts. + entityState_t newEnts[MAX_GENTITIES]; + int newCount = 0; + + // Build an index-sorted snapshot from demoPrevEnts for delta sources + // (identical to how the client uses cl.parseEntities). + int oldIndex = 0; + + while ( 1 ) { + newnum = MSG_ReadEntitynum( msg ); + if ( newnum < 0 ) { + Com_Printf( "SV_DemoParseSnapshot: end of message in entity list\n" ); + break; + } + if ( newnum == MAX_GENTITIES - 1 ) { + break; // terminator + } + + // Carry over unchanged old entities whose number < newnum + while ( deltaNum != 0 && oldIndex < sv.demoNumEnts && sv.demoPrevEnts[oldIndex].number < newnum ) { + newEnts[newCount++] = sv.demoPrevEnts[oldIndex++]; + } + + // Find the delta source for this entity + oldEnt = nullptr; + if ( deltaNum != 0 ) { + // Check if old list has a matching entry + for ( i = 0; i < sv.demoNumEnts; i++ ) { + if ( sv.demoPrevEnts[i].number == newnum ) { + oldEnt = &sv.demoPrevEnts[i]; + oldIndex = i + 1; + break; + } + } + } + + entityState_t decoded{}; + if ( oldEnt ) { + MSG_ReadDeltaEntity( msg, oldEnt, &decoded, newnum ); + } else { + // Delta from baseline + MSG_ReadDeltaEntity( msg, &sv.svEntities[newnum].baseline, &decoded, newnum ); + } + + if ( decoded.number == MAX_GENTITIES - 1 ) { + // Entity was removed (delta-to-null sentinel) + continue; + } + + if ( newCount < MAX_GENTITIES ) { + newEnts[newCount++] = decoded; + } + } + + // Carry over any remaining old entities + while ( deltaNum != 0 && oldIndex < sv.demoNumEnts ) { + newEnts[newCount++] = sv.demoPrevEnts[oldIndex++]; + } + + // Commit new frame + sv.demoNumEnts = newCount; + for ( i = 0; i < newCount; i++ ) { + sv.demoEnts[i] = newEnts[i]; + sv.demoPrevEnts[i] = newEnts[i]; + } + + sv.time = serverTime; + return qtrue; +} + + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/* +SV_BuildDemoSnapshot + +Fill a per-client snapshot frame from the current decoded demo frame. +Called from SV_BuildClientSnapshot when sv.demoPlayback is set. +*/ +void SV_BuildDemoSnapshot( clientSnapshot_t *frame ) +{ + int i; + + frame->ps = sv.demoPS; + frame->areabytes = 0; // no area culling: send all entities + + // Stage demoEnts into svs.currFrame storage so SV_WriteSnapshotToClient + // can reference them via the normal pointer array. + if ( svs.currFrame == nullptr ) { + // Allocate a fresh common frame (mirrors SV_BuildCommonSnapshot + // but without reading from the game's linked entity list). + snapshotFrame_t *sf = &svs.snapFrames[ svs.snapshotFrame % NUM_SNAPSHOT_FRAMES ]; + + int count = sv.demoNumEnts; + + if ( svs.snapshotFrame - svs.lastValidFrame > ( NUM_SNAPSHOT_FRAMES - 1 ) ) { + svs.lastValidFrame = svs.snapshotFrame - ( NUM_SNAPSHOT_FRAMES - 1 ); + svs.freeStorageEntities += sf->count; + sf->count = 0; + } + + while ( svs.freeStorageEntities < count && svs.lastValidFrame != svs.snapshotFrame ) { + snapshotFrame_t *tmp = &svs.snapFrames[ svs.lastValidFrame % NUM_SNAPSHOT_FRAMES ]; + svs.lastValidFrame++; + svs.freeStorageEntities += tmp->count; + tmp->count = 0; + } + + sf->count = count; + svs.freeStorageEntities -= count; + sf->start = svs.currentStoragePosition; + sf->frameNum = svs.snapshotFrame; + svs.snapshotFrame++; + + int index = sf->start; + for ( i = 0; i < count; i++ ) { + svs.snapshotEntities[index] = sv.demoEnts[i]; + sf->ents[i] = &svs.snapshotEntities[index]; + index = ( index + 1 ) % svs.numSnapshotEntities; + } + svs.currentStoragePosition = index; + + svs.currFrame = sf; + } + + int entsToSend = sv.demoNumEnts; + if ( entsToSend > MAX_SNAPSHOT_ENTITIES ) { + entsToSend = MAX_SNAPSHOT_ENTITIES; + } + frame->num_entities = entsToSend; + for ( i = 0; i < entsToSend; i++ ) { + frame->ents[i] = svs.currFrame->ents[i]; + } +} + + +/* +SV_DemoAdvance + +Called once per server frame from SV_Frame when sv.demoPlayback is set. +Reads the next snapshot message from the demo file. On EOF, sets +sv.demoEnded and sends a one-time "demo ended" centerprint. +*/ +void SV_DemoAdvance( void ) +{ + std::array buf{}; + msg_t msg; + + if ( sv.demoEnded || sv.demoFile == FS_INVALID_HANDLE ) { + return; + } + + if ( !SV_DemoReadRawMessage( &msg, buf.data(), static_cast( buf.size() ) ) ) { + // EOF or read error — enter hold state. + sv.demoEnded = qtrue; + SV_SendServerCommand( nullptr, "cp \"Demo playback ended.\n\"" ); + Com_Printf( "SV_DemoAdvance: demo ended, holding on last frame.\n" ); + return; + } + + SV_DemoParseSnapshot( &msg ); +} + + +/* +SV_SpawnDemoServer + +Switch the server into demo-cinema mode for the given demo file. +Replaces the usual SV_SpawnServer: skips the game VM, BSP load, +and entity simulation. Connected clients are kept and will receive +a fresh gamestate derived from the demo. +*/ +void SV_SpawnDemoServer( const char *demoName ) +{ + std::array resolvedPath{}; + std::array buf{}; + msg_t msg; + fileHandle_t demoFile; + + Com_Printf( "------ Demo Cinema Initialization ------\n" ); + Com_Printf( "Demo: %s\n", demoName ); + + // Resolve and open the demo file BEFORE touching the running game. + // If the file is not found we abort without disturbing the current server + // state, which prevents gvm from being left null while sv_running is 1. + demoFile = SV_DemoResolveFile( demoName, resolvedPath.data(), static_cast( resolvedPath.size() ) ); + if ( demoFile == FS_INVALID_HANDLE ) { + Com_Printf( S_COLOR_RED "sv_playdemo: could not find demo '%s'\n" + S_COLOR_WHITE " Place demos in /demos/ or /demos/server/\n" + " and omit the .dm_N extension.\n", demoName ); + return; + } + + Com_Printf( "Opened demo: %s\n", resolvedPath.data() ); + + // File is confirmed open — safe to tear down the existing game now. + // Close any previously open cinema demo file first. + SV_CloseFileHandle( sv.demoFile ); + sv.demoFile = demoFile; // take ownership before any further early returns + + // Shut down the existing game if it is running (frees gvm, etc.). + SV_ShutdownGameProgs(); + +#ifndef DEDICATED + CL_MapLoading(); + CL_ShutdownAll(); +#endif + + // Do NOT clear the hunk — we have no BSP, so no new hunk allocation + // is needed. Just clear the per-level server_t and reset snapshot + // storage. + + // Init/resize client slots if needed. + if ( !Cvar_VariableIntegerValue( "sv_running" ) ) { + SV_Startup(); + } else { + if ( sv_maxclients->modified ) { + SV_ChangeMaxClients(); + } + } + + // Allocate snapshot entity storage. + svs.snapshotEntities = SV_HunkAllocArray( svs.numSnapshotEntities, h_high ); + SV_InitSnapshotStorage(); + + svs.snapFlagServerBit ^= SNAPFLAG_SERVERCOUNT; + + // Save per-client timing before the clear. + for ( client_t &client : SV_Clients() ) { + if ( client.state >= CS_CONNECTED ) { + client.oldServerTime = sv.time; + } else { + client.oldServerTime = 0; + } + } + + const int preservedMaxClients = sv.maxclients; + + // SV_ClearServer zeros sv including sv.demoFile, so grab the handle + // from our local copy before the clear and restore it afterwards. + SV_ClearServer(); + sv.maxclients = preservedMaxClients; + sv.demoFile = demoFile; + + // Reset all configstrings. + for ( int index : SV_Indices( MAX_CONFIGSTRINGS ) ) { + sv.configstrings[index] = CopyString( "" ); + } + + // Assign a fresh serverId. + sv.serverId = com_frameTime; + sv.restartedServerId = sv.serverId; + Cvar_SetIntegerValue( "sv_serverid", sv.serverId ); + + // Demo cinema never runs sv_pure checks. + sv_pure = Cvar_Get( "sv_pure", "0", CVAR_SYSTEMINFO | CVAR_LATCH ); + Cvar_Set( "sv_pure", "0" ); + sv.pure = 0; + + // Read and parse the gamestate from the demo header. + if ( !SV_DemoReadRawMessage( &msg, buf.data(), static_cast( buf.size() ) ) ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: failed to read demo gamestate — " + "file may be empty or truncated\n" ); + SV_Shutdown( "demo cinema init failed" ); + return; + } + + // Skip the ack long, then drain any svc_serverCommand entries that can + // precede svc_gamestate (mirrors SV_SendClientGameState's layout). + // Leave the cursor sitting just after the svc_gamestate byte so that + // SV_DemoParseGamestate can read reliableSequence as its first long. + // + // IMPORTANT: do NOT MSG_Init the message here — MSG_Init zeroes cursize + // which causes every subsequent read to return -1 immediately. + MSG_ReadLong( &msg ); // lastClientCommand ack + { + for ( ;; ) { + int cmd = MSG_ReadByte( &msg ); + if ( msg.readcount > msg.cursize ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: svc_gamestate not found — " + "corrupt or incompatible demo\n" ); + SV_Shutdown( "demo cinema init failed" ); + return; + } + if ( cmd == svc_gamestate ) { + break; + } + if ( cmd == svc_serverCommand ) { + MSG_ReadLong( &msg ); // server command sequence + MSG_ReadBigString( &msg ); // command string — drain and discard + continue; + } + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: unexpected byte %d before svc_gamestate\n", cmd ); + SV_Shutdown( "demo cinema init failed" ); + return; + } + } + + if ( !SV_DemoParseGamestate( &msg ) ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: failed to parse demo gamestate\n" ); + SV_Shutdown( "demo cinema init failed" ); + return; + } + + // Publish the mapname so clients see it in the serverinfo. + const char *mapname = Info_ValueForKey( sv.configstrings[CS_SERVERINFO], "mapname" ); + if ( mapname && *mapname ) { + Cvar_Set( "mapname", mapname ); + } + sv_mapname = Cvar_Get( "mapname", "nomap", CVAR_SERVERINFO | CVAR_ROM ); + + // Mark demo playback active. + sv.demoPlayback = qtrue; + sv.demoEnded = qfalse; + sv.demoNumEnts = 0; + sv.demoPS = {}; + sv.demoPrevPS = {}; + + sv.state = SS_GAME; + + Cvar_Set( "sv_running", "1" ); + + // Read the very first snapshot so sv.time is valid before any client connects. + SV_DemoAdvance(); + + // Force all already-connected clients back to CS_CONNECTED so the + // normal per-client handshake loop re-sends a fresh gamestate. + for ( client_t &client : SV_Clients() ) { + if ( client.state >= CS_CONNECTED ) { + client.gamestateAck = GSA_INIT; + client.state = CS_CONNECTED; + client.gentity = nullptr; + } + } + + Com_Printf( "Demo cinema ready. Connect to watch.\n" ); +} + + +/* +SV_PlayDemo_f + +Console command handler: sv_playdemo +*/ +void SV_PlayDemo_f( void ) +{ + if ( Cmd_Argc() < 2 ) { + Com_Printf( "Usage: sv_playdemo \n" ); + Com_Printf( "Example: sv_playdemo baseq3/demos/myfrag\n" ); + return; + } + + SV_SpawnDemoServer( Cmd_Argv( 1 ) ); +} diff --git a/code/server/sv_init.cpp b/code/server/sv_init.cpp index d4c59e8..3aacaf5 100644 --- a/code/server/sv_init.cpp +++ b/code/server/sv_init.cpp @@ -87,7 +87,9 @@ void SV_UpdateConfigstrings(client_t *client) continue; // do not always send server info to all clients - if ( index == CS_SERVERINFO && ( SV_GentityNum( SV_ClientIndex( client ) )->r.svFlags & SVF_NOSERVERINFO ) ) { + // sv.gentities is null in demo cinema mode (no game VM), skip this check. + if ( index == CS_SERVERINFO && sv.gentities != nullptr && + ( SV_GentityNum( SV_ClientIndex( client ) )->r.svFlags & SVF_NOSERVERINFO ) ) { continue; } @@ -134,7 +136,9 @@ void SV_SetConfigstring (int index, const char *val) { continue; } // do not always send server info to all clients - if ( index == CS_SERVERINFO && ( SV_GentityNum( SV_ClientIndex( &client ) )->r.svFlags & SVF_NOSERVERINFO ) ) { + // sv.gentities is null in demo cinema mode (no game VM), so skip this check. + if ( index == CS_SERVERINFO && sv.gentities != nullptr && + ( SV_GentityNum( SV_ClientIndex( &client ) )->r.svFlags & SVF_NOSERVERINFO ) ) { continue; } @@ -291,7 +295,7 @@ NOT cause this to be called, unless the game is exited to the menu system first. =============== */ -static void SV_Startup( void ) { +void SV_Startup( void ) { if ( svs.initialized ) { Com_Error( ERR_FATAL, "SV_Startup: svs.initialized" ); } @@ -321,7 +325,7 @@ static void SV_Startup( void ) { SV_ChangeMaxClients ================== */ -static void SV_ChangeMaxClients( void ) { +void SV_ChangeMaxClients( void ) { client_t *oldClients; int maxclients; int count; @@ -383,7 +387,7 @@ static void SV_ChangeMaxClients( void ) { SV_ClearServer ================ */ -static void SV_ClearServer( void ) { +void SV_ClearServer( void ) { for ( int index : SV_Indices( MAX_CONFIGSTRINGS ) ) { SV_ZFree( sv.configstrings[index] ); } diff --git a/code/server/sv_main.cpp b/code/server/sv_main.cpp index cdf6dbe..8f73641 100644 --- a/code/server/sv_main.cpp +++ b/code/server/sv_main.cpp @@ -724,10 +724,13 @@ static void SVC_Status( const netadr_t *from ) { for ( SV_ClientSlot slot : SV_ClientSlots() ) { if ( slot.client.state >= CS_CONNECTED ) { - - ps = SV_GameClientNum( slot.index ); - playerLength = Com_sprintf( player.data(), SV_ArraySize(player), "%i %i \"%s\"\n", - ps->persistant[ PERS_SCORE ], slot.client.ping, slot.client.name ); + int score = 0; + if ( sv.gameClients != nullptr ) { + ps = SV_GameClientNum( slot.index ); + score = ps->persistant[ PERS_SCORE ]; + } + playerLength = Com_sprintf( player.data(), SV_ArraySize(player), "%i %i \"%s\"\n", + score, slot.client.ping, slot.client.name ); if ( statusLength + playerLength >= MAX_PACKETLEN-4 ) break; // can't hold any more @@ -1407,13 +1410,29 @@ void SV_Frame( int msec, int realMsec ) { if (com_dedicated->integer) SV_BotFrame (sv.time); - // run the game simulation in chunks + // run the game simulation in chunks (or advance demo playback) while ( frameMsec > 0 && sv.timeResidual >= frameMsec ) { sv.timeResidual -= frameMsec; - sv.time += frameMsec; - // let everything in the world think and move - VM_Call( gvm, 1, GAME_RUN_FRAME, sv.time ); + if ( sv.demoPlayback ) { + // Advance demo by one snapshot per frame tick. + // SV_DemoAdvance updates sv.time from the demo's serverTime. + // If the demo has ended we still advance sv.time to keep + // client connections alive (hold on last frame). + if ( sv.demoEnded ) { + sv.time += frameMsec; + } else { + SV_DemoAdvance(); + // Reset svs.currFrame each frame so SV_BuildDemoSnapshot + // stages a fresh copy of demoEnts into snapshot storage. + svs.currFrame = nullptr; + } + } else { + sv.time += frameMsec; + + // let everything in the world think and move + VM_Call( gvm, 1, GAME_RUN_FRAME, sv.time ); + } } if ( com_speeds->integer ) { diff --git a/code/server/sv_snapshot.cpp b/code/server/sv_snapshot.cpp index e8ecb4f..174ec2f 100644 --- a/code/server/sv_snapshot.cpp +++ b/code/server/sv_snapshot.cpp @@ -760,6 +760,18 @@ static void SV_BuildClientSnapshot( client_t *client ) { if ( client->state == CS_ZOMBIE ) return; + if ( sv.demoPlayback ) { + // Demo cinema path: populate frame from the decoded demo state and + // return early — no PVS culling, no game VM client lookup needed. + SV_BuildDemoSnapshot( frame ); + // Set PMF_FOLLOW so cgame calls CG_InterpolatePlayerState(grabAngles=false) + // instead of running prediction. That path lerps position and viewangles + // directly between consecutive snapshots without applying the spectator's + // mouse input, giving a smooth locked view with no prediction jitter. + frame->ps.pm_flags |= PMF_FOLLOW; + return; + } + // grab the current playerState_t ps = SV_GameClientNum( cl ); frame->ps = *ps; diff --git a/meson.build b/meson.build index 081aaa2..23d2a84 100644 --- a/meson.build +++ b/meson.build @@ -212,6 +212,7 @@ server_src = [ 'code/server/sv_bot.cpp', 'code/server/sv_ccmds.cpp', 'code/server/sv_client.cpp', + 'code/server/sv_demo_play.cpp', 'code/server/sv_filter.cpp', 'code/server/sv_game.cpp', 'code/server/sv_init.cpp',