Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
25 changes: 25 additions & 0 deletions code/server/server.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 );



//
Expand Down
9 changes: 9 additions & 0 deletions code/server/sv_ccmds.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ static void SV_MapRestart_f( void ) {
return;
}

if ( sv.demoPlayback ) {
Com_Printf( "Cannot map_restart during demo cinema. Use 'map <mapname>' to start a normal game.\n" );
return;
}

if ( sv.restartTime != 0 ) {
return;
}
Expand Down Expand Up @@ -1557,6 +1562,9 @@ void SV_AddOperatorCommands( void ) {
static constexpr std::array<operatorCommand_t, 1> mapCommands = {{
{ "map", SV_Map_f },
}};
static constexpr std::array<operatorCommand_t, 1> demoCommands = {{
{ "sv_playdemo", SV_PlayDemo_f },
}};
#ifndef PRE_RELEASE_DEMO
static constexpr std::array<operatorCommand_t, 3> extraMapCommands = {{
{ "devmap", SV_Map_f },
Expand Down Expand Up @@ -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 );
Expand Down
89 changes: 60 additions & 29 deletions code/server/sv_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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<const char *>( 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<const char *>( 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 ) {
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 );
Expand All @@ -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 );
Expand Down Expand Up @@ -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 );
}
}


Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ) );
}

Expand Down
Loading