Skip to content

Mod support: a framework for composing gameplay rules with hooks, and CTF and Lithium mods to prove it out - #915

Merged
jdolan merged 126 commits into
mainfrom
feature/mod-support
Aug 6, 2026
Merged

Mod support: a framework for composing gameplay rules with hooks, and CTF and Lithium mods to prove it out#915
jdolan merged 126 commits into
mainfrom
feature/mod-support

Conversation

@jdolan

@jdolan jdolan commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements #906.

The game module loading race is fixed, ctf becomes a game and cgame module of its own, and the code both modules inherit moves into src/game/common and src/cgame/common.

Shape

A mod is its own gameplay, not a fork of the engine's furniture. The common directories hold what every module inherits; each module owns the rest.

Common sources are compiled per module, never linked from one prebuilt library. They resolve g_types.h / cg_local.h from the compiling module's directory, so a single prebuilt copy would bake one module's struct layout into all of them. The concrete hazard: cg_state_t embeds cg_team_info_t teams[MAX_TEAMS] plus g_gameplay_t and g_items_t, all from the per-module g_types.h, so any mod changing its team roster or item set would read cg_state at the wrong offsets.

Mechanism is shared; content stays per module. The tell is a gameplay-mode branch — g_level.gameplay, g_level.teams, GAME_INSTAGIB. Zero branches means mechanism. By that rule g_ballistics.c is shared despite being weapon code, while bg_item.c, g_item.c, g_main.c, g_combat.c, g_weapon.c, g_client.c, g_entity.c, g_types.h and the client's cg_hud.c, cg_score.c, cg_team_mode.c stay per module.

A named per-module function beats an #if guard. A guard teaches common code about every module that will ever exist. The seams are G_ResetDroppedItem (src/game/common/g_module.h) and Cg_TeamModes (src/cgame/common/cg_team_mode.h). Guards are reserved for optional features a module opts into wholesale: G_HOOK for the grapple, G_CTF for capture play.

Where only presentation varies, share primitives rather than adding hooks. common/cg_hud_draw.c holds the crosshair, blends, center print, weapon bar and icon/vital/powerup helpers; each module's cg_hud.c is composition only. Hooks were rejected here — roughly six would have been needed, and they freeze the layout so a module could append elements but never restructure.

A module can always override a common file by substituting its own in its source list. That escape hatch is what makes sharing safe.

Build systems

All three are updated. Autotools and Xcode are build verified.

Quetoo.vs15 needed repair as well as extension: the file moves had left 116 of cgame.vcxproj's 134 sources and 35 of game.vcxproj's 54 pointing at files that no longer existed. The common lists now live in game_common.props / cgame_common.props, imported by all four module projects, mirroring how the autotools builds share sources.mk. A module's own directory goes in its project's AdditionalIncludeDirectories rather than the global include path, where two modules' g_types.h and cg_local.h would be ambiguous.

Verification

  • Autotools: clean make from scratch, exit 0, no new warnings; all four modules link.
  • Xcode: builds through Quetoo.xcworkspace (not -project, so the Objectively frameworks resolve from the sibling checkouts).
  • MSVS is not build verified — it needs a Windows/MSBuild toolchain. What was checked mechanically: XML well-formedness, every source and project-reference path resolving on disk, GUID agreement across projects and both solutions, config coverage for every solution configuration, and each module's compiled source set matching autotools exactly.
  • Runtime smoke (quetoo-dedicated +set game <module> +map edge +quit) is not re-run since the rebase; it needs an install, as modules load from PKGLIBDIR.

Notes for review

  • Rebased on origin/main, which brought in func_bob. The rename carried it into src/game/common/g_entity_func.c, but ctf's own g_types.h, g_entity.c and g_client.c predated it, so the last commit gives ctf its MOD_BOB, class registration and obituary. Common code referencing MOD_BOB made ctf fail to compile until then — the contract working as intended.
  • Three things named "ctf" are not capture-play content: DEFAULT_TEAM_SKIN and the bot's gork/ctf skin are the team-coloured player skin all team play uses, and cg_beam_hook is a generic sprites/rope beam also used by the AI nav editor. BUTTON_HOOK likewise survives in default as that editor's delete-node input.

🤖 Generated with Claude Code

jdolan and others added 29 commits July 31, 2026 14:33
Sv_InitEntities resolved and dlopen'd game.so synchronously while
mapping, but the new mod's search path was only mounted later, from
Frame(), after the map command had already finished executing. A
`game <mod>; map <name>` issued in the same command batch would load
the previous (often default) game.so instead of the requested mod's.

Call Fs_SetGame synchronously in Sv_InitEntities, right after the
latched game cvar is applied and before Sv_InitGame resolves the
library, so the correct search path is always mounted first.

Also keep the client's game cvar in sync with the server's game
directory in Cl_ParseServerData, which previously remounted the
filesystem and reloaded cgame without updating the cvar itself,
leaving Cvar_GetString("game") stale for consumers like demo headers.

Refs #906
Wires up a second, independently-built game module (ctf.so) as a
verbatim copy of src/game/default/, to prove the module-loading fix
works for a real second mod: `+set game ctf +map <name>` now mounts
the ctf search path and loads ctf/game.so, confirmed by running a
dedicated server end-to-end.

This is scaffolding only, not the full CTF extraction: the copy still
contains all of default's gameplay code unchanged. Stripping non-CTF
gameplay out of this module, moving CTF-only fields out of shared
structs, and removing CTF code from default are follow-up work.

Refs #906
Mirrors the ctf game module with a matching client game module, so a
client connecting to a ctf server loads ctf/cgame.so rather than
falling back on default's. Include paths, install dirs and the
libitem/libpmove references are repointed from default to ctf.

Like the game module, this is a verbatim copy for now; stripping it
down to CTF-only content is follow-up work.

Refs #906
The ctf cgame module carried a verbatim copy of the entire ObjectivelyMVC
UI - menus, settings, controls, credits, editor - none of which is
mod-specific. Every additional module would have duplicated another
~15k lines, and upstream UI fixes would never reach a mod that had
already forked them.

Move the UI to src/cgame/shared/ui and have each module compile it via
the sources.mk fragment. Sharing sources rather than a prebuilt library
is deliberate: cg_state_t embeds cg_team_info_t teams[MAX_TEAMS] plus
g_gameplay_t and g_items_t, all of which come from the module's own
game/<module>/g_types.h, so a library built against one module's
definitions would read cg_state at the wrong offsets in any module that
changed the team roster or item set. Compiling per module costs a little
build time and removes that failure mode entirely.

Each module's convenience library is named for the module, because with
subdir-objects automake derives object file names from it and two
modules both using the cgame_la- prefix would write colliding objects
into the shared source directory.

UI assets now install once to @PKGLIBDIR@/ui rather than per module.
The engine already mounts lib_dir itself as its first search path, so
any module resolves them; verified by hiding the directory, which makes
the client fail on ui/fonts/small.

Refs #906
The ctf game module carried a byte-identical copy of the func_* brush
entities, target_* entities, entity physics and sound emission - roughly
3.7k lines of map and BSP plumbing that a mod has no reason to reshape.
Move them to src/game/shared and compile them per module, matching what
the cgame UI now does.

Per-module compilation is again deliberate rather than incidental: these
files include the module's own g_local.h, so g_entity_t, g_client_t and
g_level all take that module's definitions. Each module's convenience
library is named for the module so automake's subdir-objects naming
cannot collide in the shared directory.

G_Ripple moves out of g_ballistics.c into a new shared g_effect.c. It is
a pure temp-entity emitter with no weapon content, but g_physics.c calls
it, so leaving it among the weapon rules would have made shared code
depend on a file every mod rewrites.

Gameplay stays per module: match flow, scoring, damage, items, weapons,
client handling and the structs themselves. The few names shared code
calls back into - G_Damage, G_Explode, G_UseTargets and the MOD_CRUSH
family - are documented in sources.mk as contract a module must keep.

Deliberately not moved yet: g_entity_trigger.c, g_entity_misc.c and
g_util.c. Each is coupled to code slated for removal - the dropped flag
and tech reset, the grapple, and g_util's team and flag helpers - so
sharing them before CTF is stripped out of default would mean doing the
work twice.

Verified default and ctf both spawn 82 entities on edge, unchanged.

Refs #906
The project already uses "common" for shared code, and src/game/shared
sat badly next to the engine-wide src/shared, reading as though the two
were related. Rename to src/game/common and src/cgame/common, and rename
the makefile variables to match: GSHARED_* becomes GCOMMON_*, and the
per-module convenience libraries become libgcommon<module>.

Directory and variable names only; no build or behavior change.
Techs are a CTF construct - they only spawned when capture play was on,
and g_techs defaulted to following g_level.ctf. Deathmatch has no use for
them, so they belong to the ctf module alone.

Removes the five tech items and their tags, the g_techs cvar and
G_CheckTechs, tech spawning and respawn point selection, pickup, drop and
dropped-item reset, the haste fire-rate scaling, the resist, strength and
vampire multipliers inside G_Damage, the regen tick, STAT_TECH and its HUD
icon, the tech sound indices, and the bot's tech goal filter. regen_time
went with them, having had no other driver.

Techs touched no engine code, so this is confined to the module: the tag
enum, the item table, g_types.h and the matching cgame, all rebuilt
together. FLAG_FIRST now follows POWERUP_LAST directly.

Verified the item table still aligns with the tag enum, which G_InitItems
indexes directly: 53 slots for 52 entries plus the ITEM_NONE placeholder.
default's module carries no tech symbols or asset strings and 45 item
classnames; ctf keeps 50. Both still load and spawn 82 entities on edge.

Refs #906
G_CreateTeamSpawnPoints existed to make CTF playable on maps that were
never built for it: with no flags present it ran an O(n squared) search
for the two furthest-apart deathmatch spawns, repurposed them as flag
positions, then partitioned the remaining spawns by distance to each
fabricated flag. Its own comment conceded the flags "will be in crap
positions".

Purpose-built CTF levels supply their own team spawns and flag placement,
so the hack has no remaining use. Team play in the default module now
simply uses info_player_deathmatch for everyone, which needs no new code:
G_SelectRandomSpawnPoint already recurses into g_level.spawn_points when a
team's pool is empty.

On edge, a deathmatch map, both modules now spawn 80 entities rather than
82 - the two flags this code used to invent.
Player movement is the same physics for every module, and the two copies
were still byte-identical. Move bg_pmove.c and its header to
src/game/common so a mod inherits movement rather than forking 1400 lines
of it.

bg_item deliberately stays per module: it is the mod's item roster, and
the two copies have already diverged over techs. Movement is inherited,
content is owned.

The pmove convenience library is named per module, as the other common
sources are, because automake's subdir-objects derives object file names
from it and a shared name would collide in src/game/common. Compiling per
module also keeps the hook branches in pmove honest - they read this
module's headers, and stay unreachable unless the module sets
pm.s.type to one of the PM_HOOK_* values.
The create-server menu hardcoded free for all, team deathmatch and
capture the flag, so every module advertised capture play whether or not
it implemented any. It also mapped the modes to cvars by hand, and had a
bug doing it: free for all is added with value 0, which fell through to
the default case and set g_ctf to 1, so choosing free for all enabled
capture the flag. Cases 2 and default were identical.

Each cgame module now supplies a small table of the arrangements it
actually offers, and the common controller builds its list from that and
applies whatever cvars each entry names. The bug goes with the hand
mapping: a mode's cvars are stated once, next to the mode.

Overriding the whole controller per module was the alternative, but it
would fork 191 lines to vary three, leave each fork to miss later fixes
to the create-server flow, and repeat for every mod. What varies is the
list, not the view, so only the list moves. Cg_TeamModes joins G_Damage
and G_Explode as a name a module must provide.

default offers two arrangements, ctf three.
Quake, Quake II and Quake III all leave the grapple out of the base game,
and default is plain deathmatch, so the hook goes with capture play. It
was already gated behind g_hook defaulting to follow g_level.ctf.

Removes the hook projectile and its trail, detach, think and fire logic,
the eight g_hook cvars, G_CheckHook, the hook style handling and its
user_info round trip, MOD_HOOK, TE_HOOK_IMPACT, TRAIL_HOOK,
CS_HOOK_PULL_SPEED, the hook model and six sounds, the level and client
fields, the teleporter interactions, and the matching cgame rendering,
impact effect and prediction input.

Nothing in bg_pmove or the network protocol changes. Pm_CheckHook returns
immediately unless pm.s.type is one of the PM_HOOK_* values, and only
g_client.c ever set those, so the movement code is now unreachable in this
module rather than deleted. pm_state's hook_position and hook_length stay
networked for ctf's sake and simply remain zero here, which delta
compression never sends.

Two things deliberately survive, both developer tooling rather than
gameplay: BUTTON_HOOK and the +hook command, which the AI nav editor uses
to delete nodes, and cg_beam_hook, which despite its name is a generic
rope sprite that also draws mover connections in that editor.

default's game module now carries no hook symbols and no grapplehook
assets; ctf keeps 36 and 7. Both still load and spawn 80 entities on edge,
and the client still initializes.

Refs #906
Fifteen conditionals asked whether team play was active by testing
g_level.teams || g_level.ctf, because capture play deliberately turned
teams off: "ctf overrides teams" forced g_level.teams to 0 whenever both
were set. Every caller then had to remember that capture play was team
play wearing a different flag.

Invert it. Capture play now clamps g_level.teams on rather than off, so
g_level.teams alone answers "are we playing teams" and g_level.ctf is left
meaning only "are flags in play". The fifteen compound tests reduce to
g_level.teams, and the clamp is applied wherever either value is assigned:
the map, worldspawn and cvar precedence chain, and both cvar handlers.

The create-server menu's capture entry set g_teams to 0 to match the old
inversion; it now sets 1.

Verified capture play on edge with g_teams explicitly 0: the clamp engages
and the server starts clean.
g_entity_misc.c held the misc_teleporter, misc_fireball and friends, and
the two copies differed only in the teleporter's grapple handling: detach
a hook projectile at the mouth rather than let it anchor past it, and
release a hooked player as they warp so they aren't tethered across the
portal.

Move it to src/game/common and wrap those two blocks in G_HOOK. A module
opts in by adding GCOMMON_HOOK_CFLAGS to its compile flags, which is how
the common sources learn the module has a hook at all; without it the
references are never compiled and the module needs none of the hook's
fields or symbols. Verified from the objects: the same source yields no
G_HookDetach reference for default and one for ctf.

This keeps the behaviour from #867 in ctf rather than trading it away for
a shared file, and establishes the pattern for optional features in
common code.
The hook was spread across five files of the ctf module, and the only way
for a second mod to get it was to copy those files. Move it to
src/game/common/g_hook.c, compiled only by modules that ask for it.

Earlier I argued against this on the grounds that the hook needed 21
declarations from its host module. That counted the coupling as it stood
rather than what it had to be. The hook now owns almost all of it: its
eight cvars, its model and six sounds, its enabled state and the map's
allowance are all private to g_hook.c, and g_hook_style_t moves to
g_hook_types.h. G_CheckHook took g_level.ctf, a field common code cannot
see, so G_Hook_CheckState takes the fallback as an argument instead.

What a module must still supply is six lines: the types header, four wire
values the client game reads off the network - CS_HOOK_PULL_SPEED,
TE_HOOK_IMPACT, TRAIL_HOOK and MOD_HOOK - and two pieces of state,
g_client_hook_t for the per-life hook and g_hook_style_t in the persistent
client, which outlives respawns. default's g_types.h mentions the hook
nowhere at all.

G_ImmediateWall, a trace helper G_HookProjectile needed, was a static copy
in each module's g_ballistics.c. It moves to g_effect.c, so there is now
one definition rather than two.

Verified: g_hook.c is compiled only for ctf, default's module has no hook
symbols and no grapple assets against ctf's 64 and 7, and capture play on
edge still spawns 85 entities with a clean log.
With techs, the grapple and the synthetic spawn hack already gone, this
takes out the flags themselves: the four flag items and ITEM_TYPE_FLAG,
the g_ctf and g_capture_limit cvars and their map and worldspawn
precedence chains, CS_CTF, STAT_CAPTURES, SCORE_CTF_FLAG, the EF_CTF_*
carrier effects, the three ctf sounds, pickup, drop, toss and dropped-flag
return, the team flag classnames and flag entities, capture counters on
teams, clients and the score wire struct, the bots' flag goals and
carrier-priority weighting, and on the client the captures HUD counter,
held-flag icon, scoreboard capture columns and carrier icon, and the
carrier trail and shell effects.

g_capture_t and PostStats stay as they are. They belong to src/game/game.h,
the game import ABI shared with the engine and every module, so this module
keeps the signature and passes NULL and 0.

DEFAULT_TEAM_SKIN and the bot's "gork/ctf" skin stay too: despite the name,
players/<model>/ctf.skin is the team-coloured skin used by all team play,
and renaming it would mean renaming assets in quetoo-data.

Three orphans went with it - Cg_OrbitalTrail and Cg_OrbitTrail_Think, which
existed only to draw the carrier trail, and a stray return an earlier tech
removal had left in the drop command.

Verified the item table still aligns with the tag enum G_InitItems indexes
directly, 49 slots for 48 entries plus the placeholder, and that the only
items ctf now has which default lacks are the four flags and five techs.
default's modules carry no flag or capture symbols. Deathmatch, team
deathmatch and capture play all start clean on edge.

Closes the Phase 2 strip for #906.
Moving the cgame UI and the shared game entities into common directories
left the Xcode project referencing files that were no longer there, and
adding a second game module broke it a second way: the project searched
for headers recursively through $(SRCROOT)/src/**, so bg_item.h and
g_types.h each resolved to two different files and a translation unit
pulled in both, colliding on every item enumerator.

File references only store a basename, resolving their path from the group
they sit in, so relocating them is just re-parenting: the whole ui tree is
one group and moves in a single step, alongside the twelve game files that
went to common.

The recursive search path is replaced by an explicit ordered list per
target, which is what the autotools build always did with -I. This is a
prerequisite for more than one module: two modules that both define
g_types.h cannot share one recursive path.

Verified with xcodebuild: the game and cgame schemes compile with zero
source errors, having failed 13 and 19 respectively before. Linking still
fails on Objectively.framework, which this project expects as a built
sibling and which autotools instead resolves through pkg-config; that is
unrelated to these changes and unbuilt in my environment.

The ctf targets are not added yet. game-item and cgame-ui also need
splitting per module before they can be: both are single prebuilt static
libraries, and their sources include the module's own headers.
g_effect.c, which now holds G_Ripple and G_ImmediateWall, and
cg_team_mode.c were created during the module split and never registered
with the Xcode project, so the game target failed to link both symbols.

Adds them along with the hook's files and cg_team_mode.h. g_hook.c is
registered as a reference only, since it belongs to whichever targets opt
into the hook rather than to the default module.

Verified by building through Quetoo.xcworkspace, which resolves the
Objectively frameworks from the sibling projects: the game, cgame and
quetoo-dedicated schemes now build and link. The quetoo client scheme
still fails in the renderer's Compile Shaders script phase for want of
glslc, which autotools avoids by shipping the compiled shader blobs; it
reports no source errors.
Four new targets, mirroring what autotools builds: game-ctf and cgame-ctf
producing the module libraries, game-item-ctf for the module's own item
table, and cgame-ui-ctf for the common UI.

The two static libraries exist because their sources are not module
agnostic. bg_item.c includes g_types.h and the UI sources include
cg_local.h, so each module needs its own objects compiled against its own
headers - the same reason autotools builds libcguidefault alongside
libcguictf. game-pmove is shared, since bg_pmove.c includes only its own
header. The rest of the common sources are compiled straight into each
module target rather than through another library.

Both modules produce files named game.so and cgame.so, so the ctf targets
set CONFIGURATION_BUILD_DIR to a ctf subdirectory of the products
directory, which matches the layout the engine expects under lib/quetoo.
That redirect also moves where the linker looks, so their
LIBRARY_SEARCH_PATHS names the parent products directory to find
game-pmove and discord-rpc. Static library targets keep an empty
Frameworks phase, as libtool -static rejects -l flags.

game-ctf defines G_HOOK, which is what pulls g_hook.c and the teleporter's
hook handling into the build.

Verified against Quetoo.xcworkspace: the game, cgame, game-ctf, cgame-ctf
and quetoo-dedicated schemes all build and link. The products land flat for
default and under ctf/ for ctf, and carry the right contents - default's
game.so has no tech, flag or grapple symbols and no such asset strings,
retaining only Pm_CheckHook and Pm_CheckHookJump, which are unreachable
without a module setting pm.s.type. Autotools still builds clean.
The AI was the largest duplicated block left: 5044 lines across six
sources and seven headers, of which four sources and every header were
byte-identical between the two modules. The only real divergence was 44
lines - the bots' interest in techs and flags, which the default module
lost when capture play was removed from it.

Move all of it to src/game/common and guard those four blocks with G_CTF:
the tech and flag goal filters in g_ai_item.c, and the flag-carrier target
priority and chase weighting in g_ai_main.c. A module opts in with
GCOMMON_CTF_CFLAGS, alongside GCOMMON_HOOK_CFLAGS for the grapple. A dead
commented-out hook line went too.

Nothing in the AI needed abstracting. Its remaining CTF-adjacent details
are asset names rather than gameplay: g_ai_info.c names a "gork/ctf" bot
skin, which is the team-coloured skin all team play uses, and g_ai_node.c
reads BUTTON_HOOK as the nav editor's delete-node input, which stays
defined for every module in the common bg_pmove.h.

In Xcode the AI references move to the common group and the ctf targets'
duplicates are dropped, their build files repointed at the shared
references, with G_CTF added to game-ctf.

Verified both ways: autotools produces distinct objects per module, ctf's
g_ai_main.o being 1624 bytes larger for the flag logic, and both modules
load the same 346 nav nodes on edge with clean logs. The game, game-ctf,
cgame and cgame-ctf schemes all build, and each module's product carries
214 AI symbols while only ctf's names flag and tech assets.
The cgame was the last large duplicate: 14387 lines, of which roughly
6700 were already byte-identical between the two modules and the rest
differed by only about 350 lines. Move all of it to src/cgame/common,
leaving each module just its team mode table.

The differences fell into three kinds. Four files differed only by a
module-qualified include, game/default/bg_item.h against game/ctf's; those
become bare includes resolved through a per-module -I, so the divergence
disappears rather than needing a guard. The rest is grapple code behind
G_HOOK - the trail, impact effect, prediction input, cvar and sample - and
capture-play code behind G_CTF: the captures counter and held-flag icon,
the scoreboard columns and carrier icon, the carrier trail and shell, and
the Discord mode label.

The guards were derived from the difference between the two copies, then
checked both ways: dropping the guarded lines must reproduce default's
file and dropping only the directives must reproduce ctf's. That caught
four files where a hunk boundary fell inside a doc comment, so the opening
/** swallowed the #endif, and one where an #endif landed between } and
else. Those are guarded by hand around whole functions and statements
instead, and a check now asserts no directive sits inside a comment.

cg_types.h stays common but keeps resolving g_types.h per module, so
cg_state_t still takes each module's definitions - which is why these
sources are compiled per module rather than linked from one library.

Verified both build systems. Every guarded object is larger for ctf than
for default, by 104 to 10176 bytes, and the capture strings appear only in
ctf's module. All seven Xcode schemes build, autotools builds clean, and
the client still initializes.
Sharing cg_hud.c and cg_score.c behind G_CTF was the wrong call. It put
mod knowledge into common code, and it would only accumulate: a fifth
module wanting lap times adds G_RACE guards to the same shared file, and
common ends up knowing about every mod that will ever exist. The HUD and
scoreboard are the most visible thing a mod changes - they are content,
not mechanism, the same category as bg_item.c.

So both files go back per module, with no guards. What they share instead
is cg_hud_draw, the drawing primitives that know nothing about which stats
a module shows or how it arranges them: the crosshair, screen blends,
center print, target name, the weapon bar, and the icon, vital and powerup
helpers, along with the HUD state and media they own. A module's HUD is
now composition only, 356 lines for default and 434 for ctf against 840
shared, rather than 1300 duplicated.

The alternative was about six hook functions - draw an extra badge, an
extra column, an extra row - which is more API surface than the
duplication saves and freezes the layout besides: a module could append
elements but never restructure. Primitives leave each module free to
arrange its own HUD while generic fixes still flow from common.

cg_hud_state and the blend and weapon-bar media move to common with the
primitives that own them, since that is where nearly all their use is: 59
references against 6.

Verified both build systems, and that Xcode compiled the per-module files
once rather than twice after the references moved. All seven schemes build,
autotools is clean, the client initializes, and only ctf's module carries
the capture strings.
Five more pairs move to src/game/common: the weapon projectiles, the
trigger_* entities, the client view, the chasecam and the info_* entities.
All five were byte-identical between the modules, and none contains a
single gameplay-mode branch.

g_ballistics was the judgement call, since weapons are what mods change
and by that reading it is content. But it has no mode branches at all and
reads its four g_balance_* cvars from each module's own g_main.c, so the
tuning is already the module's and the projectile code is mechanism. A
module that wants genuinely different projectiles overrides the file.

g_entity_trigger diverged by eight lines - what becomes of a dropped item
that falls into the void. Guarding that with G_CTF would have repeated the
mistake of teaching common code about particular modules, so it becomes a
seam instead: G_ResetDroppedItem, declared in the new g_module.h and
implemented per module. Default frees the item; ctf returns flags to base
and respawns techs. G_Explode in g_util.c had the same dispatch and now
shares the seam, which also cleared two orphaned break statements an
earlier strip had left in default's copy.

A game module is now 11612 lines for default and 12721 for ctf, from
14273 and 15381.

Verified both build systems: the seam resolves as one definition per
module with the common object referencing it, all seven Xcode schemes
build, autotools is clean, and capture play on edge still spawns 85
entities against deathmatch's 80.
g_util.c held 26 functions of which only four were capture-specific:
G_TeamForFlag, G_FlagForTeam, G_EffectForTeam and G_GetFlag. The other 22
- entity lookup and allocation, targets, kill boxes, explosions, gibs, the
terrain predicates, animation, and the gameplay and team name helpers -
were identical between the modules.

Move those to src/game/common and give ctf the four flag helpers as its
own g_flag.c. Nothing needed guarding: the divergence was whole functions
belonging to one module, so they simply move there.

A game module is now 10854 lines for default and 12016 for ctf, and a
cgame module 768 and 871.

Verified all seven Xcode schemes, autotools, and both modules at runtime:
deathmatch spawns 80 entities on edge and capture play 85, both clean.
Building the client regenerated all 27 .dxil blobs on any machine whose
shaderc differs from the one that produced the committed copies, dirtying
the tree every time. Only DXIL was affected; .spv and .metal reproduce
byte for byte.

D3D12 is unusable pending an SDL_gpu fix, so only Vulkan and Metal are
exercised and the DXIL output has no consumer today. Drop the four
transpilation steps that produce it.

The committed blobs stay, and stay installed: ObjectivelyGPU requests all
four shader formats, so SDL is free to hand back a D3D12 device, and its
loader resolves .dxil for one. Deleting them would leave that path unable
to load shaders at all rather than merely hitting the SDL_gpu bug. Removing
them wants DXIL dropped from the requested formats too, which is a change
in ObjectivelyGPU rather than here.

Also ignore /build/, which xcodebuild -project leaves at the repository
root; building through Quetoo.xcworkspace uses DerivedData instead.

Verified the tree stays clean through a full client build.
Xcode rewrote the hand-authored entries into its own formatting when it
first built these targets, and generated shared schemes for the four new
ones. Both are worth keeping: the schemes make game-ctf, cgame-ctf,
game-item-ctf and cgame-ui-ctf selectable in the UI and buildable with
xcodebuild -scheme, and the normalized file is what Xcode will write
anyway.

Verified the four module schemes still build after the rewrite.
The controls menu offered a Grapple hook section to every module, but
default has no grapple: cg_main.c already guards hook_style behind
G_HOOK, so the CvarSelect bound to a cvar that does not exist and logged
a "not found" warning. Guard the section the same way, identifying the
box so a module without the feature drops it.

+hook itself stays registered for all modules — it is the navigation
editor's delete-node input, and the engine binds it to mouse 2 by
default, so nothing is stranded by losing the menu row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splitting the game and cgame sources into src/game/common and
src/cgame/common left Quetoo.vs15 behind: 116 of cgame.vcxproj's 134
sources, 35 of game.vcxproj's 54, libpmove's bg_pmove and nine
QuetooFullIncludePath entries all pointed at files that had moved.

The common file lists now live in game_common.props and
cgame_common.props, imported by each module's project, so the four
projects share one list the way the autotools builds share sources.mk.
A module's own directory goes in its project's AdditionalIncludeDirectories
rather than in the global include path, where two modules' g_types.h and
cg_local.h would be ambiguous.

game-ctf and cgame-ctf mirror their Xcode targets: they keep the names
the engine loads them by, build into a ctf subdirectory so they do not
clobber default's output, and define G_HOOK and G_CTF. ROBO recurses, so
the module copies now use a flat variant that cannot drag one module's
output into the other's install directory.

Unlike autotools and Xcode, none of this is build verified; it needs a
Windows toolchain to confirm.

Refs #906

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The filters files had drifted from the projects they group: libnet still
listed net.c and net_tcp.c, replaced by net_sock.c, and cmodel, server,
shared and quemap were each missing a pair of sources added since anyone
last opened the solution. Being IDE grouping only, none of this broke a
build, which is why it went unnoticed.
The rebase carried func_bob into src/game/common/g_entity_func.c along
with the rename, but ctf's own g_types.h, g_entity.c and g_client.c were
branched from default before func_bob existed, so the module was missing
the three per-module halves: MOD_BOB, the entity class registration and
the obituary. Common code referencing MOD_BOB meant ctf failed to
compile, which is the contract working as intended.

MOD_BOB joins the names sources.mk records as contract between the common
sources and every module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 18:34
Comment thread src/client/cl_main.c Outdated
Comment thread src/client/cl_media.c Outdated
Comment thread src/client/cl_predict.c
Comment thread src/game/default/Makefile.am
Comment thread src/game/common/g_hook.c
/**
* @brief Detach the player's hook if it's still attached.
*/
void G_HookDetach(g_client_t *cl) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling the hook mid-level strands a client who is attached at that moment.

G_CheckCvars_Hook returns super.CheckCvars() without requesting a restart, unlike G_CheckCvars_Tech which sets restart = true. Both G_HookDetach here and G_HookThink return immediately when !g_hook_enabled.

So a client pulling on the hook when an admin sets g_hook 0: g_hook_enabled goes false, no restart happens, G_HookThink never runs again, and G_HookDetach refuses to act — cl->hook.pull stays true, so G_PrepareMove_Hook keeps forcing PM_HOOK_PULL and the projectile and trail entities keep thinking, until the client dies or the map changes.

The early-return is what makes it unrecoverable rather than merely odd. Either tear down an already-attached hook before returning, or set restart = true as the techs do and let the level restart clear it.

Comment thread src/client/cl_parse.c Outdated
Comment thread src/client/cl_cgame.c
import.Draw3DBox = R_Draw3DBox;

cgame_handle = Sys_OpenLibrary("cgame", true);
cgame_handle = Sys_OpenLibrary("cgame");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed Cl_InitCgame leaks cgame_handle and leaves a stale addClassImage registration.

If Sys_OpenLibrary succeeds and addClassImage(cgame_handle) runs, but Sys_LoadLibrary then errors — an unresolved Cg_LoadCgame, or the api_version mismatch below — cls.cgame stays NULL. On the next Cl_InitCgame, the if (cls.cgame) guard is false, so Cl_ShutdownCgame is skipped: the previous dlopen handle is overwritten and never closed, and its class image is never removeClassImaged, so classForName keeps a registered image nobody owns.

MAX_CLASS_IMAGES is 8 with an assert, so repeated failed loads eventually abort. This matters more now than it would have before, because dropping RTLD_GLOBAL makes the addClassImage/removeClassImage pairing load-bearing rather than incidental.

Suggest tracking and cleaning cgame_handle independently of cls.cgame — unregister and close at the top of Cl_InitCgame, or on each error path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open, but narrowed. The game command pre-checks Sys_HasLibrary before it tears anything down, so the most reachable cause — asking for a game that ships no cgame — no longer reaches this path. What remains is a failure after dlopen succeeds: an unresolved Cg_LoadCgame, or the api_version mismatch below. Those still leave cgame_handle open and its class image registered, with cls.cgame NULL so the next Cl_InitCgame skips the shutdown.

Worth tracking separately rather than holding the branch.

@jdolan

jdolan commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Review: 362 files, five reviewers, five areas

Reviewed the engine, the game common seams, the cgame/HUD split, the three build systems, and cross-module divergence. Verification I ran myself, not just read:

  • Clean rebuild of all six module targets (game/cgame x default/ctf/lithium): 290 objects, zero warnings.
  • src/tools/verify_projects.py: 6/6 modules agree across autotools, MSVS and Xcode, with the expected per-module define sets.
  • Every subset of G_CTF/G_HOOK/G_TECH now compiles (it did not before — see below).
  • make dist (fails), and CI green on linux + windows.

The architecture holds up. The mechanism/content split, the chainable hooks with their _Common tails, and the install-once-per-image discipline are consistent across all 13 game hooks and the one cgame hook; doc/game-module-hooks.md matches the code. lithium really does demonstrate the seam — no source shared with ctf, and the composition is visible in the built objects. The engine-side race the PR set out to fix is fixed: moving Fs_SetGame ahead of Cl_Init plus Com_SetGame inside Sv_InitEntities closes it on dedicated startup, latched mid-session change, listen-server map change and shutdown, and PhysFS mount order is right.

Fixed and pushed (7 commits)

Area Defect
Windows/macOS install lithium was never installed, and installing it clobbered ctf. Both lithium projects ran COPY_GAME_CTF/COPY_CGAME_CTF, which hard-code bin/<build>/ctflib/ctf, while lithium's OutDir is lithium\. Parameterized the scripts by module name.
Windows quetoo.sln had the ctf projects but not lithium's, so the primary solution built 4 of 6 modules. Only quetoo_all.sln, which CI uses, built them all.
macOS apple/Makefile enumerated the dylibbundler -x list by hand and stopped at ctf — lithium's modules would keep link-time Homebrew paths in a signed bundle and fail to dlopen off the build machine. Now generated from MODULEDIR, as move-resources and sign already do.
Hook contract G_CTF or G_TECH without G_HOOK did not compile at all. g_module.h declares PrepareMove in terms of pm_move_t but included only g_types.h, and ctf's manifest had dropped bg_pmove.h. ctf built only because g_local.h pulls g_hook.h in first.
Create Server menu The teams CvarSelect wrote a truncated cg_team_mode_t * into g_teams. Option values became pointers while the control stayed bound to the cvar, so opening the menu set g_teams to a large nonzero value and started team play regardless of what was shown; updateBindings compared the same pointer against an integer, so no mode ever appeared selected.
Create Server menu The "Default" gameplay option was dropped, but g_gameplay still defaults to "default" meaning "defer to the map" — so nothing matched out of the box and there was no way back from Instagib.
Startup game->modified was left set by Cvar_Add, so autoexec.cfg executed twice on every client launch, including plain default.
HUD STAT_CHASE admitted MAX_ENTITIES itself (one past entities[]); STAT_TECH indexed cg_items unchecked and dereferenced the icon found; CROSSHAIR_HEALTH_LAST named a nonexistent enumerator.
Seams g_effect.h was the only game/common header without the __G_LOCAL_H__ guard; the G_CTF teams announcement could only ever say "enabled"; g_ctf.c was out of alphabetical order; cgame-lithium's filters still filed lithium's bg_item under src\game\ctf.

Raised inline, not fixed — these are yours to call

Two I would want resolved before merge:

  1. drop flag only works for the red flag carrier (g_ctf.c:153). All four flags are named "Enemy Flag", so resolving the carried flag to a name and looking it back up always returns FLAG_RED. A regression — G_Drop_f used to call G_TossFlag on the carried flag. The name round-trip is the root cause; the hook probably wants to take a const g_item_t *.
  2. All three modules advertise PROTOCOL_MINOR 1044 despite incompatible wire layouts, and Sys_OpenLibrary silently falls back to default/cgame.so when a module is missing. Together those turn "you don't have this mod" into a garbage HUD rather than a refused connection. The silent fallback is worth fixing on its own merit.

Also raised: the cgame hot-swap while connected to a remote server (cl_main.c:640, no media load, no protocol recheck); the new fatal BSP check firing for listen-server hosts, who were explicitly exempt on main (cl_media.c:38); the loss of the server-authoritative BSP cross-check with CS_BSP_SIZE (cl_predict.c:307); make dist broken by the vpath source lists (verified, but nothing in CI or packaging runs it); Cvar_ForceSetString("game", ...) discarding a pending latch (cl_parse.c:291); a leaked cgame_handle + stale class registration on failed init (cl_cgame.c:316); and the hook stranding an attached client when disabled mid-level (g_hook.c:447).

Three more that had no diff line to attach to:

  • src/server/sv_client.c:48 advertises Cvar_GetString("game") rather than Fs_Game(). Since Fs_SetGame silently rejects invalid names and the client now treats a bad value as fatal, a one-character admin typo drops every connecting client instead of falling back.
  • src/common/filesystem.cFs_SetGame cannot report failure. PHYSFS_unmount fails while files are open (a demo under the old game), and the failure path returns with fs_state.game stale and the search path half dismantled, which makes Cl_InitCgame re-fire on every server_data. Also leaks paths. Worth making it return bool and propagating through Com_SetGame.
  • configure.ac:146Objectively >= 2.0.0 no longer expresses the requirement now that addClassImage/removeClassImage are load-bearing; a conforming 2.0.x without them fails at link time rather than at configure.

Checked and clean

Per-module item invariants hold in all three modules (roster/enum/tag order agree within each; stat counts under MAX_STATS; g_score_t blitting resolves each module's own g_types.h in all three build systems). Every hook chain has a non-NULL _Common initializer, so hooks are safe before G_Init; install order gives the documented composition. Guard balance verified across all 80 guard sites. Objectively class registration is balanced across init/shutdown with removal while the handle is still open, and removeClassImage only unlinks — so a surviving image re-registering on reload is safe. No residual cross-module naming in lithium or default. UI assets fully re-homed from the 12 deleted ui/**/Makefile.am, both directions.

One thing worth recording: the removals of the per-map teams, num_teams, give, hook and techs metadata all read as bugs from the diff alone, and two reviewers flagged them as such. They are deliberate, with the rationale in e7acf31e8. doc/game-module-hooks.md's "Behaviour that changed on purpose" section doesn't mention them — adding them there would save the next reader the same detour.

Nothing I found argues against merging today once (1) and (2) are settled.

jdolan and others added 3 commits August 5, 2026 16:16
DropInventoryItem handed a name down the chain, so a feature answering for a
category had to name the item it meant. All four flags are called "Enemy
Flag", and the tail resolves a name with G_FindItem, which returns the first
available match - always FLAG_RED. A player carrying the blue flag typed
"drop flag" and was told "Out of item: Enemy Flag"; only the red flag carrier
could drop at all.

Make the chain a resolver: ResolveInventoryItem returns the g_item_t a name
means, and ctf and the techs return the item being carried rather than its
name. Dropping is then a plain function over a resolved item, and G_Drop_f
owns the one message that needs the string the client typed.

The techs escaped this only because their five names happen to be unique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sys_OpenLibrary resolves cgame.so through the whole search path, and
lib/quetoo/default is a retained base path, so a client without the server's
module quietly loaded default/cgame.so instead. Nothing caught it: the game
cvar and cls.cgame_game record the module that was asked for, not the one
that was found, and all three modules currently advertise the same protocol
minor. The client then read the server's stats against the wrong layout -
ctf inserts STAT_CAPTURES and STAT_TECH, shifting every later index.

Have the module answer for itself. cg_export_t carries the GAME_NAME its own
g_types.h defines, so one line in common gives each module its own, and
Cl_ParseServerData drops when it disagrees with the game directory the server
advertised. Bump CGAME_API_VERSION, because the new field shifts every
function pointer after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sys_OpenLibrary accepted whatever the search path resolved game.so and
cgame.so to. Every module carries those same two names, and <lib_dir>/default
stays mounted as a base path for the shared UI, so a client or server asked
for a module it does not have silently loaded default's image and ran
deathmatch under the mod's name - with the mod's stats layout expected on the
wire.

Directories are prepended as they are mounted and Fs_SetGame mounts the game's
own last, so the game's copy already wins wherever it exists. Resolving to any
other directory therefore means this game has no module, which makes the whole
check one comparison against the resolved directory's leaf. Should that
ordering ever change, the module is refused rather than silently wrong.

A missing module is now a missing module:

  Error: Sys_OpenLibrary: Couldn't find game.so for game nosuchmod

Verified against the installed tree: default, ctf and lithium each load from
their own directory, spawning 80, 85 and 85 entities on edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdolan
jdolan force-pushed the feature/mod-support branch from b1ceb61 to 567d33b Compare August 5, 2026 21:50
jdolan and others added 6 commits August 5, 2026 19:26
The closing comment restated the macro immediately above it in 106 headers,
which is noise the compiler already checks.
The game was a CVAR_LATCH cvar, which conflated two different facts: what
the user asked for, and what is currently mounted. Seventeen sites touched
it, three of them calling Com_SetGame under different preconditions, and two
frames polling its modified flag - one to remount the filesystem, the other
to reload the client game, neither knowing whether the other should have run.

That split is what let a game change apply mid-session. CVAR_LATCH defers
only while QUETOO_SERVER is set, and that bit means a map is loaded locally,
so a client connected to a remote server took the immediate branch: the
filesystem was remounted and the client game swapped underneath a live
connection, with no media load and no protocol recheck.

Changing games is an operation, not an assignment, so it is a command:

  game            reports the game that is current
  game <name>     validates the name, brings any session down, and switches
  +game <name>    the startup game, replacing +set game <name>

quetoo_t::game holds the state and Com_Game() reads it. Com_SetGame is the
only writer and carries no policy; the command owns the policy, because
main.c is the only place that knows about both the server and the client.
The cvar is gone: nothing parsed it back out of server info, and the readers
that wanted it wanted the current game, which Com_Game() now answers.

Sys_HasLibrary shares Sys_OpenLibrary's resolver so a game that ships no
module is refused before anything is torn down, and a mistyped +game falls
back to default rather than failing inside the load.

Verified against the installed tree: +game ctf and +game lithium each load
their own module and spawn 85 entities on edge against default's 80; a bare
game prints the current one; ../../x is refused; nosuchmod is refused and
rolled back; and a bare +game followed by +map no longer reads +map as the
game name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A game is a directory under the library directory that ships a client game.
Only the game that is current is mounted, so Fs_CompleteGame reads the real
filesystem rather than the search path, and globs cgame.* rather than naming
it, leaving the shared library extension to the platform. A directory with no
module is not offered, which is the same question the game command asks before
it tears anything down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fs_Game() had no callers left once Com_Game() became the source of truth, and
Fs_SetGame has exactly one caller, Com_SetGame, which holds the game that is
current and returns early when it is unchanged. So the filesystem was keeping a
second copy of the state purely to answer a question nobody asked and to repeat
a comparison its only caller had already made.

Fs_SetGame keeps validating its argument: that guards a public function against
its next caller, rather than tracking state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-map gameplay settings, G_CreateTeamSpawnPoints and the g_ctf cvar were
deliberate deletions, but they read as regressions from the diff alone - two
reviewers reported them as bugs, and the reasoning was only in a commit message.

Also record what the loader now guarantees: a module is resolved only from the
directories that are the game that is current, so a mod that ships no cgame is
refused rather than substituted, and the client checks the GAME_NAME the loaded
image advertises against the game the server sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdolan

jdolan commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Review closed out

Resolved on the branch since the review, with the inline threads resolved:

Finding Fixed by
drop flag only worked for the red flag carrier c3f085596 — the chain resolves to a g_item_t *, not to another name
cgame hot-swapped under a live remote connection 1b5e29b81game is a command, so there is no modified flag for two frames to race over
Cvar_ForceSetString("game") discarded a pending latch 1b5e29b81 — the cvar is gone
default/cgame.so silently substituted for a missing mod 567d33b62 — the loader resolves only within the game's own directories
Shared PROTOCOL_MINOR could not detect a module mismatch e539aeefd — each module advertises its GAME_NAME, checked against the game the server sent
Create Server wrote a truncated pointer into g_teams 38145ad8f
autoexec.cfg ran twice at startup ead87a529
lithium never installed on Windows, and clobbered ctf 08d54ce17
lithium omitted from dylibbundler 08d54ce17
G_CTF/G_TECH without G_HOOK did not compile bdbbdac1a

make dist is a won't fix — a relic, and nothing in CI or packaging runs it.

Carried forward as issues, none of which blocks this branch:

doc/game-module-hooks.md now records the deliberate deletions — the per-map gameplay settings, G_CreateTeamSpawnPoints, the g_ctf cvar — since two reviewers read them as regressions from the diff alone, and what a module must ship for the loader to accept it.

Verification. All six module targets build clean with zero warnings; src/tools/verify_projects.py reports autotools/MSVS/Xcode agreement for all six; CI green on linux and windows. Runtime, against the installed tree: default/ctf/lithium each load from their own directory and spawn 80/85/85 entities on edge; game reports the current module; an invalid name and a module-less game are both refused and rolled back.

Not verified by me: anything requiring the GUI client. Every runtime check above drove quetoo-dedicated, so game <name> from the in-game console and the Create Server menu fixes are verified by construction only.

@jdolan

jdolan commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Correction to the review summary above

I called #918 the one thing to take before merging. That was wrong, and @jdolan caught it: quemap writes the manifest unconditionally after every compile (src/quemap/main.c:378) and includes the BSP itself as an entry hashed from the file just written (src/quemap/manifest.c:309), so the .bsp/.mf pair is consistent by construction. #918 is closed as invalid, and its one real residue — the manifest leaking when the drop longjmps out of the enumeration — moved to #919.

So there is no outstanding merge blocker from this review. The carried-forward list is now:

The only thing I could not check myself remains the GUI client: game <name> from the in-game console and the Create Server menu fixes are verified by construction, since every runtime check I ran drove quetoo-dedicated.

CS_BSP_SIZE was removed in favour of the map manifest, but maps/<name>.mf is
read from the client's own filesystem and Cl_CheckOrDownloadFile skips the
download when the file exists, so a locally consistent but stale pair passed:
client on edge v1, server on edge v2, no download, hashes agree locally, and the
client predicting against a different collision model. The check that was
dropped was the only server-authoritative one.

Put it back as a hash rather than a size, which is what the check was always
reaching for - a size passes for same-size different-content. The server hashes
the bsp it actually loaded rather than quoting its own manifest, so a stale .mf
can not have it reject correct clients, and both sides go through Cm_HashFile so
they compute it the same way. Verified against edge: quemap's manifest, md5(1)
and Cm_HashFile all agree on 3d888515a237519027d9bab1507dbc77.

It reclaims the config string slot CS_BSP_SIZE vacated, so CS_MODELS returns to
5 and the numbering matches main again. PROTOCOL_MAJOR was already bumped on this
branch for that shift, so this costs no further break - which is why it is worth
doing before the merge rather than after.

The manifest check drops its fatal case for the bsp with this. It compared our
own file against what our own file claims, so it could only ever report a locally
inconsistent pair, and it fired for the host of a listen server, where client and
server are one process reading one file. It warns like every other asset now, and
no longer errors out from inside the enumeration it was leaking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdolan
jdolan merged commit 22d2ad9 into main Aug 6, 2026
4 checks passed
@jdolan
jdolan deleted the feature/mod-support branch August 6, 2026 00:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants