From 8960a1b2d0725c54c3bd8746e95b1c474bd2916c Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 12:48:50 -0600 Subject: [PATCH 1/9] Add server-side demo cinema (sv_playdemo) Allows a server to replay a demo file and stream it to all connected clients simultaneously, locked to the recorded player's camera. New command: sv_playdemo - Server reads the demo's gamestate (configstrings, baselines) and serves them to spectators via the normal handshake path. - Each frame the demo reader decodes one snapshot into sv.demoPS and sv.demoEnts[], which SV_BuildClientSnapshot re-encodes per client using the existing delta-compression pipeline. - Every spectator receives sv.demoClientNum as their clientNum so cgame initializes identically to local demo playback. - At EOF the server holds the final frame indefinitely (active keep-alive) with a "Demo playback ended" centerprint. - A normal "map" command returns to live game mode. - No game VM is spawned; GAME_CLIENT_CONNECT and GAME_CLIENT_BEGIN are bypassed for spectators in demo mode. - sv_autoRecordDemos is suppressed during playback. New file: code/server/sv_demo_play.cpp Modified: server.h (replay state in server_t, new declarations) sv_init.cpp (SV_Startup/ChangeMaxClients/ClearServer un-staticked) sv_main.cpp (SV_Frame: demo advance branch) sv_snapshot.cpp (SV_BuildClientSnapshot: demo path) sv_client.cpp (clientNum override, VM guards, auto-record guard) sv_ccmds.cpp (sv_playdemo command registration) meson.build / Makefile (new source file) Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 113 +++++++ Makefile | 2 + code/server/server.h | 25 ++ code/server/sv_ccmds.cpp | 4 + code/server/sv_client.cpp | 69 ++-- code/server/sv_demo_play.cpp | 630 +++++++++++++++++++++++++++++++++++ code/server/sv_init.cpp | 6 +- code/server/sv_main.cpp | 24 +- code/server/sv_snapshot.cpp | 7 + meson.build | 1 + 10 files changed, 850 insertions(+), 31 deletions(-) create mode 100644 CLAUDE.md create mode 100644 code/server/sv_demo_play.cpp 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..3114ddd 100644 --- a/code/server/sv_ccmds.cpp +++ b/code/server/sv_ccmds.cpp @@ -1557,6 +1557,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 +1596,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..10228f3 100644 --- a/code/server/sv_client.cpp +++ b/code/server/sv_client.cpp @@ -1142,14 +1142,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 ) { @@ -1383,7 +1386,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 +1450,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 +1475,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 +1519,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 ); + } } diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp new file mode 100644 index 0000000..7ee1c39 --- /dev/null +++ b/code/server/sv_demo_play.cpp @@ -0,0 +1,630 @@ +/* +=========================================================================== +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, appending .dm_ extensions in order +of preference (same strategy as the client's CL_WalkDemoExt). Returns +FS_INVALID_HANDLE on failure and fills outPath with the resolved name. +*/ +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 }; + + // Try with each demo extension. + for ( int i = 0; protocols[i]; i++ ) { + Com_sprintf( outPath, outSize, "demos/%s." DEMOEXT "%d", name, protocols[i] ); + FS_FOpenFileRead( outPath, &f, qtrue ); + if ( f != FS_INVALID_HANDLE ) { + return f; + } + } + + // Try verbatim (caller may have supplied full filename). + Q_strncpyz( outPath, name, outSize ); + FS_FOpenFileRead( outPath, &f, qtrue ); + return f; +} + + +/* +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; + + // Drain leading server commands. + while ( 1 ) { + cmd = MSG_ReadByte( msg ); + + if ( cmd == svc_serverCommand ) { + int seq = MSG_ReadLong( msg ); + (void)seq; // server-side we don't need the sequence for reliable tracking + 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.currFrame = sf; + } + + frame->num_entities = sv.demoNumEnts; + for ( i = 0; i < sv.demoNumEnts; 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; + + Com_Printf( "------ Demo Cinema Initialization ------\n" ); + Com_Printf( "Demo: %s\n", demoName ); + + // Close any currently open demo file. + SV_CloseFileHandle( sv.demoFile ); + + // 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(); + sv.maxclients = preservedMaxClients; + + // 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; + + // Open the demo file. + sv.demoFile = SV_DemoResolveFile( demoName, resolvedPath.data(), static_cast( resolvedPath.size() ) ); + if ( sv.demoFile == FS_INVALID_HANDLE ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: could not open demo '%s'\n", demoName ); + return; + } + + Com_Printf( "Opened demo: %s\n", resolvedPath.data() ); + + // 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\n" ); + SV_CloseFileHandle( sv.demoFile ); + return; + } + + // First message byte should be svc_gamestate. + { + int header = MSG_ReadLong( &msg ); (void)header; // lastClientCommand ack + int cmd = MSG_ReadByte( &msg ); + if ( cmd != svc_gamestate ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: expected svc_gamestate (%d), got %d\n", + svc_gamestate, cmd ); + SV_CloseFileHandle( sv.demoFile ); + return; + } + } + // Re-init the message at the current position to parse the gamestate body. + // Actually we need to re-read: the message cursor is already past the header byte. + // Back up: re-open the message from scratch and re-advance past ack+cmd. + MSG_Init( &msg, buf.data(), msg.cursize ); + MSG_ReadLong( &msg ); // lastClientCommand ack + MSG_ReadByte( &msg ); // svc_gamestate + + if ( !SV_DemoParseGamestate( &msg ) ) { + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: failed to parse demo gamestate\n" ); + SV_CloseFileHandle( sv.demoFile ); + 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..630ed50 100644 --- a/code/server/sv_init.cpp +++ b/code/server/sv_init.cpp @@ -291,7 +291,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 +321,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 +383,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..1ab1093 100644 --- a/code/server/sv_main.cpp +++ b/code/server/sv_main.cpp @@ -1407,13 +1407,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..3689a68 100644 --- a/code/server/sv_snapshot.cpp +++ b/code/server/sv_snapshot.cpp @@ -760,6 +760,13 @@ 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 ); + 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', From 564b5cba01da3e6cdd913899a5d292e484b90c05 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:26:13 -0600 Subject: [PATCH 2/9] Fix sv_playdemo crash and improve demo path resolution Crash: SV_SpawnDemoServer previously called SV_ShutdownGameProgs() before confirming the demo file could be opened. If the file was missing, the function returned early leaving gvm null while sv_running was 1, causing a null-dereference on the next VM_Call in SV_Frame. Fix: open and validate the demo file first. Only tear down the running game after the handle is confirmed. Late-failure paths (corrupt/truncated demo) now call SV_Shutdown to drop clients cleanly instead of returning into a gvm-null server state. The file handle is re-stashed in sv.demoFile after SV_ClearServer (which zeros sv) to avoid a double-close. Path search: also search demos/server/ (where sv_autoRecordDemos writes) in addition to demos/, and print each path attempted on failure so the user knows exactly where to place the file. Co-Authored-By: Claude Opus 4.8 --- code/server/sv_demo_play.cpp | 97 ++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp index 7ee1c39..0c86658 100644 --- a/code/server/sv_demo_play.cpp +++ b/code/server/sv_demo_play.cpp @@ -38,28 +38,50 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA /* SV_DemoResolveFile -Try to open as a demo file, appending .dm_ extensions in order -of preference (same strategy as the client's CL_WalkDemoExt). Returns -FS_INVALID_HANDLE on failure and fills outPath with the resolved name. +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 }; - - // Try with each demo extension. - for ( int i = 0; protocols[i]; i++ ) { - Com_sprintf( outPath, outSize, "demos/%s." DEMOEXT "%d", name, protocols[i] ); - FS_FOpenFileRead( outPath, &f, qtrue ); + 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 ) { - return f; + 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; } - // Try verbatim (caller may have supplied full filename). - Q_strncpyz( outPath, name, outSize ); - FS_FOpenFileRead( outPath, &f, qtrue ); - 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() ); } @@ -476,12 +498,28 @@ 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 ); - // Close any currently open demo file. + // 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(); @@ -520,8 +558,12 @@ void SV_SpawnDemoServer( const char *demoName ) } 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 ) ) { @@ -538,19 +580,11 @@ void SV_SpawnDemoServer( const char *demoName ) Cvar_Set( "sv_pure", "0" ); sv.pure = 0; - // Open the demo file. - sv.demoFile = SV_DemoResolveFile( demoName, resolvedPath.data(), static_cast( resolvedPath.size() ) ); - if ( sv.demoFile == FS_INVALID_HANDLE ) { - Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: could not open demo '%s'\n", demoName ); - return; - } - - Com_Printf( "Opened demo: %s\n", resolvedPath.data() ); - // 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\n" ); - SV_CloseFileHandle( sv.demoFile ); + 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; } @@ -559,22 +593,21 @@ void SV_SpawnDemoServer( const char *demoName ) int header = MSG_ReadLong( &msg ); (void)header; // lastClientCommand ack int cmd = MSG_ReadByte( &msg ); if ( cmd != svc_gamestate ) { - Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: expected svc_gamestate (%d), got %d\n", - svc_gamestate, cmd ); - SV_CloseFileHandle( sv.demoFile ); + Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: expected svc_gamestate (%d), got %d — " + "corrupt or incompatible demo\n", svc_gamestate, cmd ); + SV_Shutdown( "demo cinema init failed" ); return; } } - // Re-init the message at the current position to parse the gamestate body. - // Actually we need to re-read: the message cursor is already past the header byte. - // Back up: re-open the message from scratch and re-advance past ack+cmd. + // Re-init the message at the current cursor position and re-advance past + // the ack long and svc_gamestate byte we already consumed above. MSG_Init( &msg, buf.data(), msg.cursize ); MSG_ReadLong( &msg ); // lastClientCommand ack MSG_ReadByte( &msg ); // svc_gamestate if ( !SV_DemoParseGamestate( &msg ) ) { Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: failed to parse demo gamestate\n" ); - SV_CloseFileHandle( sv.demoFile ); + SV_Shutdown( "demo cinema init failed" ); return; } From df7b0028aad01430a045eeed5d7dc0b59e240204 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:31:51 -0600 Subject: [PATCH 3/9] Fix gamestate parse: remove MSG_Init that zeroed cursize MSG_Init zeroes the entire msg_t including cursize, so the call immediately after SV_DemoReadRawMessage filled the buffer was causing every subsequent read to return -1 and the configstring loop to hit "bad command byte -1". Fix: remove the re-init entirely. After SV_DemoReadRawMessage the cursor is at byte 0; skip the ack long then loop draining any svc_serverCommand entries (as SV_SendClientGameState writes before svc_gamestate), then break on svc_gamestate. The cursor is then positioned at reliableSequence, which is exactly where SV_DemoParseGamestate expects to start reading. Co-Authored-By: Claude Opus 4.8 --- code/server/sv_demo_play.cpp | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp index 0c86658..bf9a675 100644 --- a/code/server/sv_demo_play.cpp +++ b/code/server/sv_demo_play.cpp @@ -588,22 +588,36 @@ void SV_SpawnDemoServer( const char *demoName ) return; } - // First message byte should be svc_gamestate. + // 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 { - int header = MSG_ReadLong( &msg ); (void)header; // lastClientCommand ack - int cmd = MSG_ReadByte( &msg ); - if ( cmd != svc_gamestate ) { - Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: expected svc_gamestate (%d), got %d — " - "corrupt or incompatible demo\n", svc_gamestate, cmd ); + 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; } } - // Re-init the message at the current cursor position and re-advance past - // the ack long and svc_gamestate byte we already consumed above. - MSG_Init( &msg, buf.data(), msg.cursize ); - MSG_ReadLong( &msg ); // lastClientCommand ack - MSG_ReadByte( &msg ); // svc_gamestate if ( !SV_DemoParseGamestate( &msg ) ) { Com_Printf( S_COLOR_RED "SV_SpawnDemoServer: failed to parse demo gamestate\n" ); From 3ec4d327da97f66208fadfdc6fa5315620cf60bd Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:43:53 -0600 Subject: [PATCH 4/9] Fix snapshot parsing: skip ack long and update storage position SV_DemoParseSnapshot was reading command bytes before consuming the 4-byte lastClientCommand ack that opens every demo message, causing the ack's bytes to be misread as command bytes (appearing as 0, 0, 0, 0... 1, 2... and ultimately SIGSEGV when entities failed to decode). Fix: read and discard the ack long before the server-command drain loop, mirroring what SV_SpawnDemoServer already does for the gamestate message. SV_BuildDemoSnapshot was not updating svs.currentStoragePosition after staging entities into snapshotEntities[], so every frame would stage into the same circular buffer slot, corrupting delta references for subsequent frames. Fix: advance currentStoragePosition to the index after the last entity staged. Co-Authored-By: Claude Opus 4.8 --- code/server/sv_demo_play.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp index bf9a675..e29d581 100644 --- a/code/server/sv_demo_play.cpp +++ b/code/server/sv_demo_play.cpp @@ -276,13 +276,18 @@ static qboolean SV_DemoParseSnapshot( msg_t *msg ) entityState_t *oldEnt; int i; - // Drain leading server commands. + // 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; // server-side we don't need the sequence for reliable tracking + (void)seq; const char *s = MSG_ReadString( msg ); SV_DemoHandleServerCommand( s ); continue; @@ -446,6 +451,7 @@ void SV_BuildDemoSnapshot( clientSnapshot_t *frame ) sf->ents[i] = &svs.snapshotEntities[index]; index = ( index + 1 ) % svs.numSnapshotEntities; } + svs.currentStoragePosition = index; svs.currFrame = sf; } From 3061d54725950b63db0da8eb3817f5fdd122cfe9 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:50:14 -0600 Subject: [PATCH 5/9] Guard all gvm callsites in sv_client.cpp against demo cinema mode In demo cinema mode gvm is null (SV_ShutdownGameProgs was called and SV_InitGameProgs is intentionally skipped). Any spectator activity that triggers a game-VM call crashes with a null dereference. Add !sv.demoPlayback guards to every site that was not already protected: - SV_DirectConnect reconnect path: GAME_CLIENT_DISCONNECT on the old slot - SV_DropClient: GAME_CLIENT_DISCONNECT - SV_ClientUserinfoChanged: GAME_CLIENT_USERINFO_CHANGED - SV_ExecuteClientCommand: gvm->forceDataMask read + GAME_CLIENT_COMMAND - SV_ClientThink: GAME_CLIENT_THINK (spectator usercmds are silently dropped) Co-Authored-By: Claude Opus 4.8 --- code/server/sv_client.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/code/server/sv_client.cpp b/code/server/sv_client.cpp index 10228f3..5367547 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 ); @@ -1242,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 ) { @@ -2257,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 ); @@ -2440,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 @@ -2507,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 ) ); } From 35f814830eaaf6f869960d098c59dcd0a6a87649 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:55:43 -0600 Subject: [PATCH 6/9] Fix SV_SetConfigstring crash when sv.gentities is null in demo cinema mode When the demo stream replays a CS_SERVERINFO configstring update and a spectator is CS_ACTIVE, SV_SetConfigstring iterates connected clients and checks client entity flags via SV_GentityNum(). In demo cinema mode sv.gentities is null (SV_InitGameProgs is intentionally skipped), so SV_GentityNum() computes (null + offset) and dereferences it, causing SIGSEGV. Fix: skip the SVF_NOSERVERINFO check when sv.gentities is null. In demo cinema mode there is no game logic setting that flag, so all CS_ACTIVE spectators should receive every configstring update. Co-Authored-By: Claude Opus 4.8 --- code/server/sv_init.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/server/sv_init.cpp b/code/server/sv_init.cpp index 630ed50..06f330f 100644 --- a/code/server/sv_init.cpp +++ b/code/server/sv_init.cpp @@ -134,7 +134,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; } From fb3d3e931cb05b867cff1454196c84f3fef2f466 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 13:59:50 -0600 Subject: [PATCH 7/9] Fix two more sv.gentities/sv.gameClients null dereferences in demo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SV_UpdateConfigstrings had the same SV_GentityNum(clientIndex)->r.svFlags pattern as SV_SetConfigstring. It is called from SV_ClientEnterWorld when a spectator transitions CS_PRIMED→CS_ACTIVE, making it the actual crash site for the signal-11 when a client connected to a demo cinema session. SV_StatusResponse also accessed SV_GameClientNum for every connected client to get PERS_SCORE, with no guard for demo mode where sv.gameClients is null. Guard with sv.gameClients != nullptr and report score 0 for spectators. Co-Authored-By: Claude Opus 4.8 --- code/server/sv_init.cpp | 4 +++- code/server/sv_main.cpp | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/code/server/sv_init.cpp b/code/server/sv_init.cpp index 06f330f..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; } diff --git a/code/server/sv_main.cpp b/code/server/sv_main.cpp index 1ab1093..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 From 53f53794cdeab69310f322a3fe1e5a93a06c9392 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 21:56:11 -0600 Subject: [PATCH 8/9] Fix client crash and view lock for demo cinema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for sv_playdemo spectator connections: - sv_client.cpp: Guard gvm->forceDataMask in SV_UserinfoChanged with a null check. gvm is null in demo cinema mode (no game VM), so any client connecting caused a null deref before the existing !sv.demoPlayback guard at the VM_Call site was reached. - sv_demo_play.cpp: Cap entsToSend at MAX_SNAPSHOT_ENTITIES (256) in SV_BuildDemoSnapshot before writing into clientSnapshot_t::ents[], which only has 256 slots despite demoNumEnts ranging up to MAX_GENTITIES (1024). - sv_snapshot.cpp: Replace delta_angles view-lock hack with PMF_FOLLOW. The previous approach set delta_angles per snapshot to steer the spectator's predicted viewangles toward the demo's; between snapshots prediction drifted and each arrival snapped back, causing jitter. PMF_FOLLOW makes cgame call CG_InterpolatePlayerState(grabAngles=false) instead, which lerps position and viewangles directly between demo snapshots with no mouse input applied — smooth and jitter-free. Co-Authored-By: Claude Sonnet 4.6 --- code/server/sv_client.cpp | 2 +- code/server/sv_demo_play.cpp | 8 ++++++-- code/server/sv_snapshot.cpp | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/code/server/sv_client.cpp b/code/server/sv_client.cpp index 5367547..fe8d4ac 100644 --- a/code/server/sv_client.cpp +++ b/code/server/sv_client.cpp @@ -2203,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(); diff --git a/code/server/sv_demo_play.cpp b/code/server/sv_demo_play.cpp index e29d581..d34b352 100644 --- a/code/server/sv_demo_play.cpp +++ b/code/server/sv_demo_play.cpp @@ -456,8 +456,12 @@ void SV_BuildDemoSnapshot( clientSnapshot_t *frame ) svs.currFrame = sf; } - frame->num_entities = sv.demoNumEnts; - for ( i = 0; i < sv.demoNumEnts; i++ ) { + 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]; } } diff --git a/code/server/sv_snapshot.cpp b/code/server/sv_snapshot.cpp index 3689a68..174ec2f 100644 --- a/code/server/sv_snapshot.cpp +++ b/code/server/sv_snapshot.cpp @@ -764,6 +764,11 @@ static void SV_BuildClientSnapshot( client_t *client ) { // 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; } From 1b2868bd0e10231dabf829f11e2ef9f7cbc66843 Mon Sep 17 00:00:00 2001 From: sedawkgrep Date: Mon, 29 Jun 2026 22:15:21 -0600 Subject: [PATCH 9/9] Guard map_restart against demo cinema mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SV_MapRestart_f calls SV_RestartGameProgs and VM_Call(gvm, ...) directly with no demo guard; since gvm is null during demo playback these crash. map_restart is semantically wrong in demo mode anyway — use 'map ' to exit cinema and start a normal game. Print a helpful error instead. Co-Authored-By: Claude Sonnet 4.6 --- code/server/sv_ccmds.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/code/server/sv_ccmds.cpp b/code/server/sv_ccmds.cpp index 3114ddd..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; }