PORTCLUB
Cabecera de Call of Duty 4: Modern Warfare (cod4-decomp)
Port nativo

Call of Duty 4: Modern Warfare (cod4-decomp)

Port nativo de Call of Duty 4: Modern Warfare (cod4-decomp) para PC.

Call of Duty 4: Modern Warfare (cod4-decomp)

Port nativo basado en su ficha de origen.

README del repositorio

cod4-decomp

A GPL-3.0-only Rust reimplementation of the Call of Duty 4: Modern Warfare (1.7) multiplayer engine. It runs on macOS, Linux, and Windows, and includes a headless server. It contains no game content: you supply your own copy of the game.

Planning and decisions: wayfinder map. Terms: CONTEXT.md. Decisions with lasting effect: docs/adr.

Build

nix develop -c cargo build --workspace
nix develop -c cargo test --workspace

Without Nix, install rustup; rust-toolchain.toml selects the toolchain.

Original install

The engine reads a CoD4 1.7 install directly: main/*.iwd, zone/<language>/*.ff, and localization.txt. cod4e, cod4e-server and the harness find it the same way (crates/install): --install <dir> (the harness: --cod4), COD4_PATH, the folder remembered from an earlier prompt, the Windows registry and Steam libraries, then ./COD4, which is gitignored. cod4e and the harness ask for the folder in a dialog when none is found and remember the answer (always in the platform's default config dir, --config-dir or not); cod4e-server never asks and prints where it looked. Never commit or upload anything from the install. Tests that need the install skip when it is missing.

Logging

Every binary logs through tracing (crate logging): events go to stderr, to a log file per run (the client writes <config dir>/logs/client-*.log for a player's session, the newest 10 are kept; COD4E_LOG_DIR names another folder), and to the browser console in the web client. warn and error events from this workspace also reach the in-game console. The filter is COD4E_LOG or RUST_LOG (EnvFilter syntax; default info for our crates, warn for the rest); at run time the developer cvar (1 is debug, 2 is trace) and the log_filter cvar change it, in the client and in the dedicated server. Levels: error loses a feature or the session, warn is unexpected or missing state with a fallback, info is lifecycle and expected cheat-protection refusals (which still reject the write), debug and trace are per-frame detail. Repeating diagnostics go through logging::dedup! (the 1st, 2nd, 4th... hit of a key with a seen count). Passwords, rcon arguments and CD keys are masked in every sink.

Harness

cod4e-harness runs the staged suite under a watchdog (each stage in a child process; crashes, hangs and panics are caught) and writes cod4e-run-<date>.zip (at most 100 MB, user and host names scrubbed) with manifest.json, summary.json, logs, CSVs, a Perfetto-readable trace.json and minidumps. A run too big for that gives up files until it fits: videos first, then screenshots, then traces and CSVs, the largest file of each kind first so the most evidence stays; the smallest screenshot of each stage goes last of all, so a stage keeps a frame for just review as long as the cap allows. DROPPED.txt in the zip lists what was left out.

Every process of a stage writes its log to <stage>/logs/ in the bundle. A stage that passes but logged a warn or error that is not in the reviewed allow-list (crates/harness/src/logcheck.rs, one reason per entry) is failed, with the first events in its reason; allowed events are counted in the stage's notes. selftest-warn proves the gate.

nix develop -c just harness run --out bundles   # default suite
nix develop -c just harness stages              # list stages
nix develop -c just harness diff a.zip b.zip    # compare two bundles

A running client or dedicated server can be driven from outside through its dev control channel (--control <port>, off unless asked; see docs/control-channel.md). The client channel injects the player's own input, reads the state document (menu, entities, scoreboard, sounds, and the listen server's authoritative server section), waits on it and captures the presented frame. The dedicated channel reads and asserts authoritative state and runs server console lines. The control-channel and server-control stages run that acceptance.

Linux harness displays keep a virtual keyboard and pointer attached to their private headless sway seat (scripts/headless-seat.py): windows receive focus and can lock the pointer for real mouse-look input even without host input devices. The helper sends no game input and lives only as long as that display. client-hud also checks the arrow-key fallback explicitly: aim must settle within four degrees and a shot must consume ammunition before the mouse-driven match begins. The compass grenade check waits for positive server health and the client's live state before handing off to key-only aiming. Night-vision and concussion observation windows hold bots idle, then restore bot play even on failure; life transitions and impact observations are kept in the stage notes. The goggles must be checked in one life, and a new concussion must arrive before the shock check's intentional death and spawn-clear assertion.

client-hud checks synchronous input selection with the player's real UDP commands: a development-channel scheduling fixture collects a backlog before observing one actual server tick, then checks retained input on ticks without new traffic. It does not require rendering faster than sv_fps. Its held-Tab check measures at least thirty rendered frames using atomic counter snapshots, rather than requiring ten frames in two seconds. The synchronous step turns vsync off with vid_restart and back on with a second one; the client must then draw at least half the frames a second it drew before the step, within 15 s (#499). A slot that drew about five frames a second after that vid_restart drew as few with vsync off: the cause was the host's load (other slots' builds and matches), not the present mode, so the weapon-switch crosshair check waits up to 30 s for twenty frames a second before its wheel notch, and says so when the host never gets there. These checks follow KisakCOD (GPL-3.0, CoD4 MP 1.8), g_active_mp.cpp:801–833 (ClientThink/G_RunClient) and cg_consolecmds_mp.cpp:11–22,135–184 (+scores/-scores and showScores). The target remains 1.7; these stage-only corrections change no 1.7 gameplay or scoreboard input behavior, and native 1.7 analysis was unavailable.

Stages that need engine commands read console scripts (scenarios/*.cfg, or --script file); a command the console does not have fails the stage. A stage that reports Skipped (a missing display, GPU, install or build) is not a pass: it fails the run unless --allow-skip <stage> names it, and summary.json lists the skips apart from the stages that ran (skipped, allowed_skips, unexpected_skips); the exit status is 0 only when every stage ran and passed. Every client run writes a render record (material_failures: materials with no techset, no usable technique or a pipeline that failed to build, by name and reason; image_failures: images that did not load) into client.json and the control channel's render state section, and the stages that judge a match or a driven run (client-match, client-dedicated, client-hud, client-session and the stages sharing its verdict) fail on any material failure and on any image that did not load, so a draw the renderer skipped or a placeholder it drew is no longer invisible. No material failures are allowed: cross-zone references (,gfx_smk_white_atlas) resolve while loading, so the smoke, fire, mist and glass sprites of a map's effects must draw without an exception (#453). The counters those stages read from the client's report are required: a counter the report lacks fails the stage instead of reading as zero, and client-ui requires every one of the fourteen menus the tour opens. Double-clicking the program runs the suite with the zip next to it. The CI windows job uploads that portable folder and smoke-tests it with no install. Repository tools are just recipes (nix develop -c just --list). just remote <ref> -- <run options> runs the suite on the Linux test host (COD4E_LINUX_HOST, default artemis.jeiang.vpn) and copies bundles/ back. On that host just slot <n> <ref> <stages> runs stages in slot 1, 2 or 3 (up to three at once, each with its own headless 1920x1080 display, loopback-only network namespace and target dir), just slot-exec <n> <ref> <command> runs a command in a slot with the release binaries on PATH (its out/ is copied back), just serial <ref> [stages] runs timing-gated stages on the host's real display with the host to itself, and just host-status shows which slots are busy.

Host runs queue instead of being dropped. Every scripts/host-run.sh run registers in ~/Projects/cod4e/queue/ (PID and its start time, kind serial or slot <n>, build hash, harness options, state) from start to exit, and just host-status lists the registered runs oldest first (host-run.sh --queue; an entry whose PID is gone or now names another process is not a run and is removed). Serial runs take their turn in that order. A run that has to wait (for its slot, a pending serial run, the stage lock or its serial turn) prints what it waits on, its queue position (queue position 2 of 3) and each run ahead of it, at once and every COD4E_HOST_WAIT_REPORT seconds (300), and the kernel lock holders (lslocks) once. It gives up only after COD4E_HOST_WAIT_TIMEOUT seconds of waiting in all (21600, 6 hours; 0 waits without a bound; build and test time does not count), with exit status 76 and host-run: gave up after <n>s waiting for <what> (queue position …); remote-linux.sh passes both settings on and repeats that line last on stderr (remote: host run gave up waiting: …). A queued guarded slot run holds its slot lease's run lock while it waits, up to that bound: release or reassign the slot only after it ends or is cancelled. .serial-pending records the serial owner's PID; waiters remove a marker with a dead or missing owner and say why. Serial owners queue separately, and marker reads/writes/removal share a short lock, so an owner's cleanup cannot erase the next owner's marker. Builds still share the stage lock and timing runs still take it exclusively after building. Runs of refs older than the queue do not register and keep their 30-minute bound; their lock holders still show.

Every scripts/remote-linux.sh run (the just remote, slot, slot-full, slot-exec and serial recipes) resolves its ref to one commit, ships that commit and passes its hash as the build hash, and keeps its whole output in its own bundles/logs/remote-<UTC time>-<random> file: a header (ref, commit, host, run directory, slot, host-run options), stdout and stderr (still printed apart), the host status and the copied bundle names, ending exit=<status>. Early failures (unset COD4E_LINUX_HOST, a bad ref, ssh) are logged too; the exit status is the run's whatever the caller pipes the output through, and the last line on stderr names the log. Only this run's copied zips are listed (local bundle: bundles/slot-<n>-<zip>), not every zip in bundles/.

Host runs are detached from SSH (nohup setsid encloses the installed lease guard and the entire host command). The private run directory keeps stdout/stderr, the supervisor identity and an atomic real exit status. SSH uses a 10 s connection timeout and 10 s server-alive probes (three missed replies); the Mac reconnects after a lost link and resumes complete log snapshots without replaying output. Ctrl-C, TERM, HUP, a coordinator's proc kill of the local job, or a killed Mac stops only the follower: the host run continues, keeping its queue entry, lease and locks. Recover with COD4E_LINUX_HOST=<host> scripts/remote-linux.sh --attach '<run_dir>' using the directory already printed in the local log; no ref or token is needed. A fresh attach replays the host logs and reports the real exit status, including 255, rather than SSH's status. Bundles and out/ are retrieved before the completed run directory is removed; failed/interrupted collection leaves it recoverable. To intentionally cancel, use the verified host process group/tree and confirm its lock holders are gone before reassigning a slot: see Detached runs: recovery and cancellation in docs/host-notes.md. Killing only the supervisor or registry PID is not cancellation.

All repository Linux-host commands share scripts/host-ssh.py's private per-user connection across worktrees, slots and serial followers (ControlPersist=600, at most eight command channels). It uses -F /dev/null to avoid NetBird's banner-only detector: user and system SSH configs are not loaded, but the agent, default identity files and normal known_hosts verification remain enabled. Set COD4E_LINUX_SSH_CONFIG to a separate detector-free config when an alias, user, port, IdentityAgent, identity, proxy or custom host-key policy is needed; its Includes must also be detector-free. See docs/host-notes.md for the complete contract. Running followers poll every 5 s. Transport exit 255 retries four times with 5/10/20/30 s backoff plus 0–1 s jitter (under 69 s sleeping per operation), then reports the real SSH error and retains the host directory for attach; signals and non-255 command errors are not retried. Complete snapshots preserve output offsets, and a real host exit 255 is collected normally.

Slot leases name who may run in a slot. scripts/host-run.sh comes from the ref under test, so it cannot enforce anything for older refs; the lease check is scripts/slot-guard.py, installed on the host apart from any ref (just slot-guard-install: ~/Projects/cod4e/bin/slot-guard, run by a GC-rooted nixpkgs#python3; just host-status prints its version and leases). The coordinator runs just slot-lease <n> <owner> [--task <text>], which prints a new token (or uses the one in COD4E_SLOT_TOKEN); the worker runs COD4E_LINUX_SLOT_TOKEN=<token> just slot …, and remote-linux.sh starts the host run through the installed guard. The guard refuses the run (exit 75, naming the lease and any live run) unless the token holds the lease, refuses a second run under the same lease rather than queueing it, and hands the command COD4E_SLOT_OWNER but not the token. The token check and the start of the run share one critical section, and the run holds the guard's run lock (inherited by everything it starts) until it exits: while a guarded run is live its lease cannot be released, reassigned (just slot-lease <n> <owner> --replace) or force-released (just slot-release <n> --force). A crash frees the run lock and leaves only the lease, for its owner (COD4E_SLOT_TOKEN=<token> just slot-release <n>) or the coordinator to remove. The guard never signals a run it did not start; it passes SIGINT, SIGTERM and SIGHUP to its own command and exits with its status. The token reaches the host in the run's owner-only command file, never on a command line or in the log. Not done, on purpose: the guard takes none of host-run's slot, stage or serial locks, which keep their meaning, and it covers only launches through it. A slot run without COD4E_LINUX_SLOT_TOKEN, an older ref's remote-linux.sh, serial runs, just original and manual ssh bypass the lease and are kept apart by host-run's locks alone, so leases bind only once every worker launches with a token from a ref that has this script. just script-tests runs the guard's, the logs' and the host queue's tests locally.

Each slot run (and the serial run) on the Linux host also has its own sound server (scripts/virtual-audio.sh, inside the virtual display and outside the network namespace): a private PulseAudio from the dev shell with a null sink for playback and a 440 Hz sine source as the microphone. ALSA_CONFIG_PATH names only that script's configuration, so cpal's default ALSA device is ALSA's PulseAudio plugin on that server rather than the host's sound server, and COD4E_TEST_MIC=1 tells the harness a microphone is there. client-hud then holds +talk and requires the microphone open and at least 25 voice frames sent, and none once the key is up (voice.heard, voice.sent and voice.mic_open of the client's control channel state), and the voice options' record level to rise; without COD4E_TEST_MIC it notes the check as untested (#488).

The web-* stages run the Chromium scripts/find-chromium.sh finds: one on PATH, else the GC-rooted one just chromium-install puts on the Linux host (~/Projects/cod4e/bin/chromium, nixpkgs#chromium). An unrooted Chromium left in the Nix store is not used: any garbage collection can remove it. On the Linux host a just remote/slot/slot-full/serial harness run that selects a web-* stage (the default suite selects all four) refuses to start without one, before it queues or builds: exit 78, with a host-run: no Chromium for <stages> on this host message naming just chromium-install. Naming every such stage in --allow-skip accepts their skips instead; checks-only and slot-exec runs need no Chromium. Elsewhere the stages skip without one, which fails the run unless --allow-skip names them.

The exported HUD includes sprint_facts (weapon, weapon duration scale, ps.perks, player_sprintTime, perk_sprintMultiplier) next to the current sprint budget and sprint_spawn with the same inputs captured at the most recent spawn. Session-verdict stages check the stock BG_GetMaxSprintTime formula (4 seconds × weapon scale, doubled only by Extreme Conditioning, capped at 16383 ms); client-custom-class checks the spawn record too. A grenade throw step counts only missile entities owned by the local player, never another player's grenade.

just review <bundle.zip> [--reference <bundle.zip>] [--out review.md] (scripts/footage-review.py) reviews a bundle's screenshots and every second of its videos with two llama-server vision models: an EmbeddingGemma 2 server with its vision projector (COD4E_EMBED_URL) names each frame's scene, skips near-identical video frames and, with --reference, scores each frame against the frame of the same name in that bundle; a Qwen3.5 server with its projector (COD4E_REVIEW_URL) lists the defects it sees in each frame. The recipe points both at the Linux test host. Its findings are leads to check by eye (it also reports defects that are not there), not a gate. Videos need ffmpeg, which the dev shell provides.

Coordinator scripts (just script-tests runs their checks with real git and a fake gh/just): just serial-log <ref> [stages] (scripts/serial-log.sh) fetches, resolves the ref to one commit and runs just serial on it, keeping the whole output in bundles/serial-logs/serial-<sha12>-<UTC time>.log (first lines sha=, stages=, checks=, last line exit=<status>); it prints only the stage and bundle lines and exit=<status> log=<path>, and exits with the run's status. COD4E_LINUX_CHECKS (default 0) is passed on. just merge-pr <n> <sha> (scripts/merge-pr.py) is a dry run unless CONFIRM=yes: it stops when the PR is not open or its head is not <sha> (exit 3), when windows did not pass or any other check neither passed nor was skipped (exit 4), or when GitHub's mergeable is not MERGEABLE (exit 5; it polls while UNKNOWN, every MERGE_PR_POLL s up to MERGE_PR_WAIT s, default 10 and 180). It then prints git diff --stat origin/<base>...<sha> and the files the PR deletes, and with CONFIRM=yes runs gh pr merge <n> --squash --match-head-commit <sha> and fast-forwards the checked-out base branch. just review-delta <n> <old-sha> (scripts/review-delta.py) shows what the PR's own change gained since the head last reviewed: the PR commits since then (merge commits listed apart), and the old change replayed onto the new base (git merge-tree) diffed against the new head, so changes that came in from main drop out; same bases give the plain git diff old new, and a replay that conflicts falls back to a per-file interdiff of the two PR patches. An old head that was force-pushed away is fetched by SHA. It flags logcheck.rs, stage.rs, stage modules, scenarios and README.md in the delta. .github/pull_request_template.md holds the PR fields every review needs: Reference, Negative control, Weakened checks and Stages run.

just evidence-report --pr <n> --stage <a,b,...> <bundle.zip>... [--review <file>] [--check ci/windows] [--repo owner/name] [--json] (scripts/evidence-report.py) reports how the named bundles and CI bear on the PR's current head. It is a report, never a merge approval, and it never fetches, merges, pushes, comments, uploads or writes: it runs one gh pr view and the read-only git commands rev-parse, cat-file and merge-base, and refuses any other. It reads only the bundles named (manifest.json and summary.json, with the zip's CRCs), and needs a manifest build hash equal to the summary's, consistent ok and skip lists, and schema 1. It resolves the build hash to exactly one local commit and places the bundle against the head: EXACT (the head commit), SAME_TREE (another commit with the head's tree), STALE (another tree), DIRTY (built with uncommitted changes), AMBIGUOUS, UNKNOWN (hash unknown, or the commit or head is not in the local repo: fetch it yourself) or MALFORMED. Each required stage must have passed in an EXACT or SAME_TREE bundle; a failed, crashed, hung, skipped (even an allowed skip) or missing stage, or a run that failed, is a failure. The check (default the windows job of the ci workflow) must have succeeded on the head; a skipped or neutral check is not a pass, and a pending or missing one leaves the report incomplete. It says whether each bundle's commit contains the current base and whether the tested tree is the tree a merge would produce now: GitHub's potential merge commit of the current base and head has that tree, or the head contains the current base. When neither holds, update the branch so its head contains the current base and rerun on the new head; a bundle of a local merge of the old head is another tree (STALE), not evidence. The verdict is CONSISTENT (exit 0), FAIL (1), INCOMPLETE (3) or ERROR (4, gh failed or answered oddly). Even CONSISTENT lists what a merge approval still lacks: a bundle records no run mode or host exclusivity, so a serial run cannot be proven from it; it does not record whether the Linux cargo test, clippy and fmt ran (slot-full runs them, slot skips them); --review files are recorded with their SHA-256 as supplied and unverified; PR comments are not read. The facts are as read when it runs; the head, base and checks can move after it. python3 -m unittest discover -s scripts -p 'test_*.py' runs its checks (also part of cargo test).

just stages-for [base] [head] (scripts/stages-for.py, defaults origin/main and HEAD) prints, on one line and comma separated in suite order, the stages the head's own change since its merge base with base can affect, for just slot, slot-full and serial stage lists; stderr names, for each changed path, the rule that decided it. The rules are the checked-in scripts/stage-map.txt (first matching path glob wins). Shared files select the full default suite: logcheck.rs, stage.rs, stages/mod.rs, the control channel (harness control.rs, client control/, devctrl), client netplay, crates/net, the server, sim, gsc, assets, logging and install crates, the rest of the harness runner, the host scripts (host-run.sh, remote-linux.sh, netns.sh, virtual-display.sh, virtual-audio.sh, headless-seat.py), Cargo.toml, Cargo.lock, flake.nix, flake.lock and rust-toolchain.toml. A path no rule matches also selects the full suite, never nothing. A stage module, helper or scenario selects the stages whose code includes it (its module, the stage modules it names and theirs, its scenario), read from the head's stage.rs and stages/. A client, render, audio, fx or sm3 change selects every default stage except the map's no-client: list, plus the web stages; any change to an engine crate adds the timing set (client-load, client-match, client-flythrough, headless-bots-32). Docs, tests, examples and the named tooling scripts select nothing. A map naming a stage the head does not have is an error (exit 2). just script-tests checks the rules, that every tracked file has one, that the no-client: stages start no client, and that #520's and #547's merges select the stages their hand-picked lists missed.

Stage 3 (client-flythrough) needs a GPU and a display: it runs the cod4e client ($COD4E_CLIENT, else next to the harness) on mp_crash once per display mode (native borderless, windowed 1080p and 720p, plus an exclusive run at another native-size refresh when the monitor has one), 12 s each (COD4E_FLYTHROUGH_SECS), and records one short video (a few MB), a screenshot and per-frame timings per mode. It also runs mp_crash uncapped at native size (--present immediate and mailbox) and two more stock maps (mp_bog, mp_crash_snow), so the bundle shows sun shadows, fog and the film/glow post effects on three maps. frames.csv carries GPU pass time from timestamp queries where the device has them (gpu_ms, a few frames behind the render call, matched by frame number). Without the client, an install or a display it is skipped.

Native video targets 30 fps and fits within 1280x720, preserving the source aspect ratio subject to rounding down to even dimensions, without upscaling. A 5120x1440 source therefore produces 1280x360 footage: lower-detail review video, not a native-resolution capture. Screenshots and the game's rendering resolution are unchanged. The GPU scales the recording image before its small readback; a bounded readback ring and a bounded background-encoder queue use nonblocking submission, dropping capture slots rather than waiting for capacity on the render path. Sample durations preserve elapsed capture-slot timing across gaps. If the window changes size during recording, capture pauses and releases the old GPU scaling/readback resources; pending readbacks count as gpu_reset losses. Returning to the original size recreates those resources and resumes the same source-slot timeline, counting intervening missed slots as pacing losses.

The dedicated serial acceptance target for each recorded mode is at most 5% missing capture slots, including pacing misses, not just encoder drops (#442); this is a requirement, not a measured performance claim. Recording statistics break losses down into ring_full, queue_full, map_failed, gpu_reset and pacing_missed. dropped is the sum of the first four; missing slots are dropped + pacing_missed, divided by last_slot - first_slot + 1 (equivalently frames + dropped + pacing_missed). first_encoded_slot and last_encoded_slot bound the encoded timeline, with duration_ticks = last_encoded_slot - first_encoded_slot + 1 (zero when no frames were encoded). Missing or inconsistent statistics, no encoded frames, and recording/encoder/finalization errors fail acceptance rather than counting as ordinary slot losses.

Every --out run records the camera it drew each frame from in frames.pose.csv (same rows as frames.raw.csv: frame,x,y,z,yaw,pitch,roll,cg_fov,fov_x), and a --screenshot run writes the screenshot's camera to client.json as pose (#431): the map, the frame number, the eye's origin, the angles as the original takes them (degrees, yaw within -180 to 180, pitch positive looking down), cg_fov (degrees at 4:3), the horizontal field of view it was widened to (Hor+) and the frame size, plus a ready setviewpos line. To draw the same picture on the original: run it at the same width and height, devmap <map> (or sv_cheats 1), spectate or spawn, cg_fov <cg_fov> (an archived dvar, 65 to 80 in 1.7), then the setviewpos x y z yaw pitch line; setviewpos takes the eye, as it lowers the origin by the player's view height before moving the player (KisakCOD game_mp/g_cmds_mp.cpp:1084-1127), so the numbers go in as recorded. --fly-at <secs> holds the flythrough at one time of its path, so every frame and the screenshot share that pose. client-flythrough checks each run's pose: it names the run's map, its setviewpos line carries its eye and angles, fov_x is its cg_fov widened for the frame size, frames.pose.csv has a row per timed frame, the row for the screenshot's frame is the same camera, and the camera moved along the tour.

client-garage covers the parked green car and loose tyre at mp_crash's garage (#357). It starts a real match with the player's devmap, team/class selection, noclip and setviewpos commands, then compares the issue-pose image against the same camera with script models and dynamic props separately hidden (drawscriptmodels 0, drawprops 0, diagnostic commands analogous to drawvehicles). Both objects must change their own image regions. Before the images, the client counts the car in every snapshot from the first one built after the move (the control channel's watch): at least 40 consecutive snapshots must arrive, and each must carry the car with its model. The serverless --flythrough is a tour of the static world, not a match: it does not spawn script models or client-side dynamic props, so their absence there is not evidence of a missing model in play. The control state names snapshot models (entities.list[].model_name) for diagnosis.

Headless server

COD4_PATH=/path/to/install nix develop -c just server +set g_gametype war +exec server.cfg +map mp_crash

cod4e-server boots like the original's dedicated server: command-line sets, default_mp.cfg, the dedicated zone set, then +exec and +map. It runs the stock gametype scripts at 30 Hz (sv_fps) on one thread. Console commands include bots N (server-side test clients that pick a team and class and play: they roam a navigation mesh generated from the map's collision data, shoot enemies they see, and inject usercmds directly, with no netchan; they join at once but wait until five seconds of level time, on every level start including a restart, before picking a team, as the stock scripts add their test clients after wait 5; this keeps them from spawning before the spawn logic's first sweep of the level's spawn points), status and expect <stat> <min>. Harness stage 2 (headless-bots, plus headless-bots-sd for Search and Destroy; every server stage fails over 512 MiB peak RSS) plays an 18-bot team deathmatch round on mp_crash to its time limit and the map rotation, and records ticks.csv (per-tick total and gsc/bot/client/game columns), RSS, map-load and navigation-generation time and match counters; headless-bots-32 is the 32-player budget run. Stage script-pool plays 32 Search and Destroy rounds and a two-map team deathmatch rotation with bots and fails if the script variable pool (the original's 0xFFFE-value limit) does not return to the same level every round.

Entity numbers follow G_Spawn. A freed spawned entity's number waits 500 ms, then returns oldest first. Until then a spawn takes a new number, unless the table is full. A client therefore always sees an entity leave before its number holds another entity, so the new entity never inherits the old one's events or model; this happened to an airstrike plane's playfxontag tags (#492).

Script builtins

The developer-only builtins are the stock game's (#409; KisakCOD game_mp/g_scr_main_mp.cpp, script/scr_compiler.cpp:468-563,871-891,1000-1022), the methods devaddpitch/devaddyaw/devaddroll included (game/g_scr_mover.cpp:1263-1265). Outside a /# ... #/ block a call to one is a developer statement of its own: its value cannot be used ("return value of developer command can not be accessed if not in a /# ... #/ comment"), and without developer_script it is compiled, so its errors show, and then dropped. A function defined inside a top-level /# ... #/ is developer code throughout. print/println write to the console unless g_no_script_spam is set. createprintchannel/setprintchannel keep the stock channel table (25 engine channels; only script and the channels scripts open may be printed to; setprintchannel returns the previous one); the console does not filter by channel. assert, assertex and assertmsg fail with the stock messages. line and print3d reach only a listen server's own player, drawn over the world view for their duration in server frames (2048 lines, 1024 strings of up to 95 bytes); line's depth test is not honoured (#515, warned once). The client counts the lines and strings that reached the screen in its last frame (hud.stats.debug_lines/debug_strings of the control channel's state, cgis=debug_lines/cgis=debug_strings). The development-only server hook devline <client|human> [frames] (not a stock command; needs developer 1) runs a script compiled for developer that calls line and print3d 100 units in front of that player for frames server frames (default 400). The file functions (openfile, fprintln, fprintfields, freadln, fgetarg, closefile) return nothing, as in the 1.7 release build.

The precache builtins follow the stock rules (#374): precacheshader, precacheshellshock and a new loadfx error once the level has passed its first wait; loadfx takes names under fx/ without the folder; a material, menu, icon or effect the loaded zones lack still registers and is warned about once; a missing shock/<name>.shock falls back to shock/default.shock. bots refuses a count that is not a number or is out of range, with its usage. g_banIPs warns when set (the stock game filters no IPs either). g_motd reaches the clients as stock sends it: each level spawn puts it in configstring 10 (CS_MOTD), which the client holds (net.motd of its control state) and its configstrings console command lists; as in stock nothing on screen shows it. client-host-console sets it on the listen server, runs map_restart and requires the client to hold it (#523). A bullet into a player's corpse strikes flesh (game/bullet.cpp:58-67). The script-builtins stage runs a check script against a live stock level, then starts a listen client, runs devline on its server and requires the client to have drawn at least one debug line and one debug string.

The match builtins are the stock game's (#410, #411; KisakCOD game_mp/g_scr_main_mp.cpp, game/g_scr_mover.cpp:796-852). updatescores sends the player both team scores, even unchanged; updatedmscores sends the player their own score and the best other player's (clientscore), which the client sorts into its scoreboard, asking for the full scoreboard when it lacks a row. setclientnamemode("manual_change") holds a connected player's userinfo name back until updateclientnames; getclanid is "0". laseron/laseroff flag a script model whose laser clients draw through night vision from tag_laser (or tag_flash) with gfx_laser, fading over its 1500-unit reach (cgame/cg_laser.cpp). With cg_laserLight on (archived, default 1) each laser also casts its post light, gfx_laser_light, as CG_Laser_Add_Core and FX_PostLight_GenerateVerts do (#516; EffectsCore/fx_postlight.cpp): from 13 units past the source to 3 past what it strikes (15 more into a player or actor), 3 units in radius and widening with the viewer's distance like the beam's end, drawn as an octagonal prism whose shader lights the surfaces the scene's float depth puts inside it (CODE_MESH_ARG_0/1 carry the light's start and span; at most 96 a frame). The menu zones keep their technique sets, so gfx_laser and gfx_laser_light, whose laser and post_light shaders only localized_common_mp defines, draw rather than failing on an empty stub. Harness stage client-gfx aims a test laser (laser=1) at the mp_crash garage floor: the post light must draw only with cg_laserLight on (gfxis=postlights) and brighten the floor where the laser strikes. enableaimassist/disableaimassist make a brush model a target of the melee aim assist, boxed by its inline model's bounds. setleftarc/setrightarc/settoparc/setbottomarc set a turret's arcs, which never cross its front. vibrate swings a mover's angles on a sine, and grenadeexplosioneffect raises the default weapon's explosion on the surface below. The script-builtins stage checks their errors, the laser flag and the explosion event on a live level.

Perks

Perks are the stock game's: the engine reads the bits the scripts set with setPerk/unsetPerk/clearPerks (bg_perkNames order, playerState_t.perks) for Sleight of Hand (perk_weapReloadMultiplier), Double Tap (perk_weapRateMultiplier), Extreme Conditioning (perk_sprintMultiplier), Steady Aim (perk_weapSpreadMultiplier), Iron Lungs (perk_extraBreath), Deep Impact (perk_bulletPenetrationMultiplier), Dead Silence (the q sound families), UAV Jammer (off the radar), Eavesdrop (perk_parabolic*), Martyrdom (perk_grenadeDeath dropped by DeathGrenadeDrop) and Last Stand (lastStand and its half-second grace in G_Damage, PM_LASTSTAND movement). The rest are the stock scripts' (_class.gsc): Juggernaut, Stopping Power and Sonic Boom in cac_modified_damage, Bandolier's giveMaxAmmo, Frag x3 and Special Grenades x3 counts, Bomb Squad, Overkill. suicide() and the console kill go straight to player_die (PlayerCmd_Suicide, Cmd_Kill_f), so the stock Last Stand gives the pistol, the health held down by its overlay thread and a ten second bleed-out, and a grenade kill is no Last Stand (mayDoLastStand). The net-perks stage asserts each of these effects on a headless server with two UDP clients whose uploaded profiles carry custom classes.

Use triggers

+activate works as in the original (src/activate.rs): each frame the server picks the player's use trigger (trigger_use by the view and range, trigger_use_touch by standing in it, a trigger switched off for the player's team or claimed by another player does not count) and puts its cursor hint (cursorHint, cursorHintString, cursorHintEntIndex) in the player state; a fresh press on a hinted trigger sends it trigger with the player once g_useholdspawndelay and g_useholdtime allow, and a held button is not a fresh press until it is let go. The client draws the hint (owner-draw 72, cg_cursorHints). Search and Destroy and Sabotage plant and defuse through this: bots walk to a zone, pickup or defuse trigger that is on for their team and hold the button there (a free bomb pickup first; a carrier or a defender sent to a just-planted bomb does not stop for far-off enemies; they stand still, not sprinting, so the server takes the press). Harness stages headless-sd-objective and headless-sab-objective (8 and 16 bots, the server runs unpaced with until plants 1 <seconds>), net-objective (two real UDP clients; one plants holding +activate at the zone's hint, the other defuses; the stage places them with devtele) and client-objective (the real client plants; needs a display).

Dropped weapons and live grenades (src/items.rs, sim::weapon::pickup): dropitem (and the stock dropWeaponForDeath) leaves an item entity holding the player's magazine and the reserve their other weapons could not carry; it falls with the g_drop*Speed* velocities, at most g_maxDroppedWeapons stay (the one farthest from every player is freed), and the dropper cannot take it back for a second. Clients are sent it as an ITEM entity and draw the weapon's world model. Walking over one feeds a weapon the player owns (or shares ammunition with) up to its limit and plays ammoPickup/pickup sounds; +activate on a hinted one takes the weapon with its ammunition (swapping out the primary in hand when both slots are full). A live frag at rest within player_throwbackInnerRadius shows the throw-back hint; the grenade key takes it with the fuse left (player_throwbackOuterRadius, bg_maxGrenadeIndicatorSpeed). A player who dies cooking a grenade drops it with the fuse left, and Martyrdom (perk_grenadeDeath) drops another. ItemWeaponSetAmmo sets what an item holds. Not built: dropitem's second argument (the weapon tag the item leaves from), the pickup HUD icon (CG_ItemPickup), and map weapon_* entities settling onto the floor.

Smoke grenades with an authored explosion effect retain a broadcast GENERAL cloud for 61 seconds, carrying the original burst time and expiry. A client joining or first seeing it after 200 ms reconstructs the effect at that age without replaying the burst sound; clients seeing it within 200 ms get the burst once. This follows KisakCOD (GPL-3.0, CoD4 MP 1.8), game/g_missile.cpp:399, game_mp/g_main_mp.cpp:1506, cgame_mp/cg_main_mp.cpp:1593 and cgame_mp/cg_snapshot_mp.cpp:419–422; native 1.7 parity is not verified (REA unavailable). Our native wire protocol is now 26: entity deltas carry the custom explosion start and expiry times, retaining stock's 0x10000 smoke flag meaning rather than inventing another smoke event. The default-suite client-smoke stage joins the stock class whose install loadout carries smoke, throws it through +smoke, then connects a second client after the burst; it asserts original-age visible smoke, one on-time burst and no late burst. Server unit coverage retains the cloud through its complete lifetime and frees it strictly after its deadline.

Mountable turrets (src/turret.rs, misc_turret and misc_mg42; the stock maps place saw_bipod_* guns on mp_backlot, mp_carentan, mp_convoy, mp_farm, mp_overgrown and mp_pipeline): the entity takes its arcs from its weapon (leftarc, rightarc, toparc and bottomarc in the map override them) and is a use target within player_MGUseRadius (128) for a player on the ground, holding no grenade, standing behind the gun within half its yaw arc. Holding use mounts it: EF_TURRET_ACTIVE goes in the player state with the weapon's stance, the movement keeps the view inside the arc (viewAngleClampBase and Range), the gun swings with the view (gunAngles in the entity state; clients turn tag_aim, tag_aim_animated and tag_flash by them), the gunner is held at the swung turret's tag_player, the attack button fires the weapon's bullets from the swung tag_flash along the view, and the crosshair shows the weapon's drop hint. Using again, dying, going down, respawning or disconnecting lets go (a living gunner gets their stance and place back). The gunner is placed from the swung tag_player rather than blended through the gunner animation, and spawnTurret (no stock script calls it) and non-bullet turret weapons are not implemented. Each round raises the turret's own fire event (EV_FIRE_WEAPON_MG42, naming the turret and the gunner) rather than a player's: clients play the muzzle flash at the muzzle (the weapon's first-person flash for the gunner), the weapon's fireSoundPlayer for the gunner and fireSound for everyone else from the muzzle, and shake the view of anyone within 100 units of the gun for 100 ms per round (CG_FireWeapon, CG_StartShakeCamera). Harness stage net-turret: two real clients on mp_convoy, one mounts a stock turret, is held in its arc, fires and lets go while the other is shown the gun, swung off its rest pose, and the gunner on it, and both receive the turret's fire event; audio asserts that the event plays the turret weapon's fire sound for the gunner and for a bystander, and client-fx that it flashes at the muzzle without a shell and shakes the view only within its radius. The gun's model (weapon_saw_mg_setup) names its material instead of defining it, as models of a map zone do for what common_mp defines; the client content resolves such names so the turret is drawn (client unit tests a_stock_turrets_model_has_materials_to_draw and a_stock_turret_is_drawn).

Action slots, hardpoints and votes

setactionslot(n, "weapon" name | "altmode" | "nightvision" | "") fills one of the four slots of the player state; +actionslot N on the client selects the slot's weapon (pressed again, the one held before), toggles the alternate weapon or the night vision (an altmode slot raises the alternate fire mode of the weapon in hand, else of a primary the inventory holds, as the original's CG_AltWeaponToggleIndex reads the weapon last picked: the Assault rifle's slot key raises its underbarrel launcher), and the d-pad pieces (owner-draws 165-169) show them. The server now honours the weapon a person asks for (the weapon keys and slots). The killstreak UAV is earned into slot 4 by the stock scripts and called in by selecting it (harness stage headless-hardpoint: bots do it and the script's use is counted). The airstrike picks its target on the full-screen map: beginLocationSelection puts the selector in the player state, the mouse moves the point (owner-draw 186 draws the selector circle), the attack key confirms and the server answers confirm_location with the world point (setMiniMap supplies the map's size and north) or cancel_location (melee or reload); harness stages headless-airstrike (bots) and client-hardpoint (the real client: +actionslot 4, the d-pad pieces 166/168/169, the map pick).

The helicopter hardpoint runs on the stock scripts: spawnHelicopter makes a script vehicle that flies toward the goal setVehGoalPos gives it (speed, acceleration, yaw rate, pitch and roll limits, stops and hover as the original's AI move), a turret tracks setTurretTargetEnt and notifies turret_on_target, fireWeapon shoots its 20 mm bullets and homing rockets, and G_Damage on it reaches the script as damage, so it can be shot down (bots shoot enemy helicopters). level.heli_paths come from the map's heli_start, heli_dest and heli_loop_start entities. Clients are told of it as a vehicle entity. spawnPlane makes the airstrike's planes. Harness stage headless-helicopter: a bot calls it in, it flies, fires and is shot down. Not simulated: the body jolt when hit, the turret's moving barrel bone on the server (aim uses rest-pose tags), map collision for vehicles, hellfire and javelin guidance.

The client draws script vehicles (VEHICLE entities, the helicopters): interpolated between snapshots like the other players, the model's rotors turning with the bh_rotors animation (the rest of the model stays at its rest pose), the vehicle's sound loops following it and its weapon's fire sound and muzzle flash at the muzzle. Harness stage client-heli: a bot calls the helicopter in, the player is held behind and beside it every frame (devheli view human) and the picture must differ from the same view with drawvehicles 0.

The client draws missiles (MISSILE entities: thrown grenades, rockets, C4, claymores) as the weapon's projectile model along the entity's angles, with its trail effect pointing along the networked velocity, and loops the weapon's projectileSound on a missile in flight. An explosion event carries the surface it sat on: the client plays the impact table's blast and sound for that surface and then the weapon's projExplosionEffect and projExplosionSound (a flashbang or a dud has only its own); a missile that will not go off sends a dud event (projDudEffect, projDudSound). Harness stage client-equipment works the equipment from the player's keys: key 5 raises the Assault rifle's underbarrel launcher and a shot puts its shell in the air, and on Spec Ops the C4 is planted with the throw button (the pack shows on the ground and one is spent) and set off with the attack button (PM_Weapon_CheckForDetonation raises EV_DETONATE, which the scripts answer). devgrenade <human|client> <weapon> [speed] [fuse_ms] throws one for tests; harness stage client-missile asserts the model is drawn and the explosion is received and heard.

callvote and vote run in the server (src/vote.rs, the original's g_oldVoting): map, typemap, g_gametype, map_restart, map_rotate, kick and tempBanUser (which also refuses the address for sv_kickBanTime seconds, 300 by default) pass on a majority of the people playing (bots count as voters, spectators do not; an abstention weighs g_voteAbstainWeight, 0.5, when time runs out) and run three seconds later, or fail after 30 s; g_allowvote switches them off. Harness stage net-vote has two real UDP clients kick one of them and change the gametype and map. map_restart loads the level afresh with the latched g_gametype and sv_maxclients (so a gametype vote works), fast_restart restarts in place unless one of them changed, and map_rotate skips words it does not know and wraps. Across any level change the connected people keep their client slot and profile stats, and when the script left the level with savepersist (a round's map_restart(true) in Search and Destroy or Sabotage) their self.pers script array too, as the original hands each client's kept script array back to ClientConnect and frees it only without savepersist (SV_MapRestart, SV_SpawnServer, G_ShutdownGame); the stock Callback_PlayerConnect then spawns them on their team with their class instead of opening the team menu, and the attacker and defender sides swap as sd.gsc toggles game["switchedsides"]. Harness stage client-sd-rounds checks this through the menus on a listen server. getinfo answers with the original's SVC_Info keys in its order (the 1.7 iw3mp.exe's; challenge first, then protocol, hostname, mapname, clients, sv_maxclients, gametype, pure, minPing, maxPing, game, sv_allowAnonymous, con_disabled, pswrd, ff, kc, hc, od, hw, mod, voice, pb, where hc and od, scr_hardcore and scr_oldschool when not 0, are 1.7's and not in 1.8; a key with an empty or unset value is left out): hw is 2 on a dedicated server and 6 on a listen server (dedicated 0), pure follows sv_pure, and mod is 1 unless the server is pure, runs no fs_game and has only main's stock iw_00-iw_24 IWDs on its search path (a custom map's IWD counts; unlike the original, which counts an extra IWD of main only once a file was read from it, every loaded one counts). The original's listen server registers sv_pure 0, so it says mod 1; ours runs pure (below) and says mod 0. Harness stage net-info asks seven kinds of server and checks each answer. getstatus carries the server info and the players, and the configstrings SERVERINFO, SYSTEMINFO and CODINFO are published. Like stock CG_ParseCodInfo, the client applies every named CODINFO dvar after the complete initial gamestate and on later configstring changes; an absent value slot means an empty string. Our chunked gamestate carries an explicit gamestate_end reliable command (also emitted in demo initial state) so names arriving before their values cannot briefly clear scr_allies and make the scoreboard ask for scorebar_. g_log (games_mp.log) records InitGame, ShutdownGame, chat and the scripts' logPrint lines. A client's userinfo (name through ClientCleanName, rate, snaps) is read by the server, with sv_maxRate pacing; the client sends it on connect and the name command sends a new name. g_inactivity, the cheat/init/range rules of console variables, pingPlayer and the night vision notifies are in too. Voice chat and the talker icons (owner-draws 193-196) are not built: no voice is sent or played, and the pieces draw nothing.

The server console reads stdin, and with rcon_password set a UDP rcon <password> <command> runs the same commands with their output returned as print packets (1294-byte chunks; one request per 500 ms; bad attempts are logged). Operator commands: status (num score ping guid name lastmsg address qport rate; ping is BOT, CNCT or ZMBI when there is none to show; guid and rate are placeholders, this server has neither), kick/tempBanUser/clientkick/tempBanClient (the first and third by name or number; a brief ban of sv_kickBanTime seconds, or none for onlykick/clientkick), banUser/banClient (permanent, kept by address in sv_banFile, default ban.txt), unbanUser <ip>, say, tell, systeminfo, dumpuser, toggle, reset, setfromdvar, cvarlist; heartbeat only says there is no master server. Harness stage net-rcon drives them from a UDP socket. A client the server drops is told why (Player kicked, Server command overflow) and the others read <name> left the game / was kicked / timed out; a client that hears nothing for cl_timeout seconds (default 40) returns to the menu with Server connection timed out, and the reconnect console command rejoins the last server. The server registers each dvar with its stock type, range and flags (KisakCOD MP, the 1.7 binary where they differ; crates/server/src/stock_dvars.rs lists them): a console, rcon or config set reads the value as the type (a Bool keeps 1/0, an Int %i, a Float %g), refuses one outside the range with the stock "is not a valid value" line and a cheat dvar while sv_cheats is off with "is cheat protected", and the server info carries the stock SERVERINFO dvars (fs_game and sv_floodProtect among them). Deliberate differences: sv_fps 30, sv_maxRate 0, sv_punkbuster 0, g_log and nextmap defaults, and sv_cheats init protected and in the systeminfo.

Network limits: the peer table follows sv_maxclients at each map start (a value latched in a cfg applies at the first map, and a later set replaces what is waiting, even when it gives the value already running). A client leaves with its sequence-checked reliable disconnect; a connectionless disconnect is ignored, so a forged one cannot kick anybody. Silence drops a client after sv_timeout seconds (default 240, at most 1800 as in the original) or sv_connectTimeout (45) if it never sent a packet since connecting. A client that has acknowledged a snapshot of this level and then leaves 29 or more (and 1.45 s worth) unacknowledged is sent them at a random, doubling spacing (up to 256 intervals of its snaps), and its scoreboard ping is the mean time it took to acknowledge the last 32 snapshots, at most 999 (999 while none were). A userinfo without rate or snaps gets 5000 B/s and 50 ms. sv_maxRate stays 0 (no server cap, a client rate of 25000 or more counting as 90000) because our snapshots are larger than the original's and its default of 5000 would pace them at a few a second; set it to opt in. Lag compensation covers up to 400 ms of lag (plus the 100 ms draw delay its time base already includes); a shot from further back is judged against the live bodies, as in the original. A closed or reloaded browser tab frees its slot when its WebTransport session closes. A client whose NAT changed its source port keeps its slot (matched by IP and qport); a connect from the same IP and qport frees the old session, and sv_reconnectlimit seconds (3) must have passed since it joined. getchallenge, getinfo and getstatus are rate limited per address outside the local network (16 burst, 8 a second) and for all addresses together; one poll reads at most 1024 datagrams; sv_maxClientsPerIP (0: no limit) caps clients per address outside the local network. sv_privateClients slots are only for a connect giving sv_privatePassword (which also skips g_password; g_password none means no password), getinfo hides the free ones, and sv_minPing/sv_maxPing refuse by the challenge round trip (local network exempt). Client commands are limited by sv_floodProtect (800 ms).

However a player leaves (their disconnect, a timeout, kick, g_inactivity or a removed bot), the entity is freed as soon as the disconnect callback has run, as stock ClientDisconnect frees it right after Scr_PlayerDisconnect (KisakCOD MP 1.8 game_mp/g_client_mp.cpp:420-450): nothing can damage them afterwards, and a script holding them sees an undefined reference in that same frame. Kept until the end of the server frame, a leaving player could still be hurt, and the stock _missions::playerDamaged thread started after the disconnect notify it ends on failed on the freed player ("entity is not an entity", #505).

Master server and the Internet list

cod4e keeps its own wire protocol, so it has its own master server: cod4e-master [--port N] [--bind addr] (UDP, default port 28950, no game files needed; run it on a public host and open that port). It is modelled on the stock flow (SV_MasterHeartbeat, the client's globalservers) in this game's out-of-band format.

The current protocol is 28, which names effects and looped sound aliases in configstrings (cs::EFFECT_NAMES 1598 and cs::SOUND_ALIASES 1342, stock's CS_EFFECT_NAMES and CS_SOUNDALIASES) instead of the fx and sndname reliable commands, which could trail the snapshots that played them: the gamestate now names every loadfx effect before any snapshot (#553). Protocol 27 added a player's position trajectory type (g_smoothClients) to entity deltas; protocol 26 added persistent smoke explosion start and expiry times to entity deltas; protocol 25 moved playfxontag effects to the target entity's event ring and added an effect-tag configstring table; protocol 24 added the initial gamestate's gamestate_end boundary. Demos and peers recorded or built with protocol 27 or earlier are incompatible and rejected with a message naming both versions, rather than silently misreading entity deltas.

  • A server lists itself only when asked: set sv_public 1 and sv_master1..sv_master5 (host[:port]; all empty by default, so nothing is sent until you name a master). cod4e-server then sends heartbeat <protocol> every 60 s, and flatline when sv_public is turned off, a master is removed from the settings or it shuts down (quit, Ctrl-C or SIGTERM on Unix; a crash or SIGKILL sends nothing and the server expires in 180 s); the heartbeat console command sends one at once. The master names are looked up on a background thread (a failed lookup is retried after 15 s, a good one kept 10 minutes), never on the game frame. A listen server (started from the client's menus) never announces itself.
  • The master answers a heartbeat with getinfo <challenge> to the address it came from and lists the server only when that address replies with the challenge, so a forged source cannot add a server or keep a dead one listed. The protocol it is listed under is the one its own getinfo answer reports, not the one the heartbeat claims. A flatline is checked the same way: the sender is probed and dropped when it does not answer, or when its answer says public 0 (a dedicated server with sv_public 0 says so); removing one master from a live server's settings only stops its heartbeats to that master, which then expires it after 180 s. A server with no verified heartbeat for 180 s expires.
  • Clients ask with getchallenge and then getservers <challenge> <protocol>; the answer lists the servers of that protocol, 40 addresses to a packet (at most 320). The client then sends each server a getinfo, as its LAN scan does, and shows the ones that answer.
  • Abuse limits: a per-IP and a global token bucket on heartbeats and on client queries, at most 8 servers per IP address, 4096 listed and 1024 being verified at once, and the challenge on both heartbeats and getservers. The buckets are keyed by the claimed source address, as any stateless UDP limit is, so someone forging a victim's address can use up its heartbeat allowance for a while; it cannot add, relabel or remove a listing.
  • The client's Internet tab asks the masters in cl_masters (host[:port] words, up to five, looked up on a background thread; empty by default, and the tab then says no master is set, or that none could be found). Example: set cl_masters master.example.org in the client console, and +set sv_public 1 +set sv_master1 master.example.org on the server. Harness stage net-master starts a master, two public servers and a private one and checks that a client lists exactly the public two and that a stopped server drops off.

Voice chat, demos and map downloads

Wire compatibility with the original is not a goal, so these are native designs (net::voice, net::demo, net::download). Voice: hold +talk (bound to v by default; cl_voice 0 turns the microphone off) and 8 kHz mono audio goes to the server as 20 ms IMA ADPCM frames (33 kbit/s) inside the ordinary client packets; with sv_voice 1 the server relays each frame as the original's G_BroadcastVoice does: to the speaker's team (everyone with voice_global 1, everyone when the speaker is in a free-for-all, and enemies in front of and within perk_parabolicAngle/perk_parabolicRadius of a player with Eavesdrop, specialty_parabolic), and only between players in the same session state (living with living, dead with dead, spectators with spectators) unless voice_deadChat 1, minus the players who muted the speaker. cl_voice 0 also silences the voices the client receives (the mute menu sends mute <n> / unmute <n>), and flags the speaker EF_TALK (the talk balloon, script istalking). The browser build captures the microphone with getUserMedia into an AudioWorklet (a secure context and the person's permission are required; a refusal is printed once). Demos: record [name] / stoprecord write what the connection receives to <config dir>/demos/<name>.c4edemo; playdemo <name> loads the recorded map and plays it back on the recorded clock (cl_freezeDemo 1 pauses). Downloads and sv_pure: with sv_allowDownload 1 (on by default, as stock) a client that joins asks the server for its manifest (getmanifest: the mod's IWDs and zones, and the running map's zone, _load zone and IWD as usermaps/<map>/…, each with its size and CRC-32; the server serves those names and no others, and stock IWDs never) and fetches what it lacks or has a different copy of before joining (cl_allowDownload 0 refuses). A mod goes to its mods/<name> folder and a map to usermaps/<map>/; nothing is written anywhere else, whatever the server lists, and a file whose size or CRC differs from the list is thrown away. A file of the same name already in the player's folder is replaced only when its CRC differs from the server's, and the client says so on its console; a link in the way of a write is refused. The list holds at most 64 files, 256 MB each and 2 GB together, with the map's files first; a server leaves out (and logs) what would pass that, and the client checks the disk has room before it starts. With sv_wwwDownload 1 and sv_wwwBaseURL "http://host/path" (quote it: // starts a comment in the console; a tree laid out as mods/<name>/… and usermaps/<map>/…, as CoD4X documents) the client fetches over HTTP(S) instead (redirects are not followed, and the web server must be at the game server's own address or a public one, never a loopback, private or link-local address), and falls back to the server's own out-of-band getfile transfer if the web server fails or gives other bytes. Clients here always download before they connect, so sv_wwwDlDisconnected (accepted, as the stock cvar) changes nothing. The server's sv_pure (on by default for cod4e-server and for a server started from the menu, +set sv_pure 0 turns it off; the library default is 0) refuses a connect whose IWD digest differs from its own: every non-localized IWD of players, main_shared, main, the map's usermaps/<map> and the mod folder counts, by name and by the CRCs of the files inside, so a changed or extra IWD is refused with a message saying so (the same list as the stock sv_iwds/sv_referencedIwds checksums, sent in one number). The join list's ui_browserShowPure 1 (the stock default) lists only servers with sv_pure 1.

Console, bind and menu commands

Every word a bind, a menu button or the console runs goes the original's way (Cmd_ExecuteSingleCommand): a command runs, a cvar's name shows or sets it, and anything else is forwarded to the server while in a match (which answers an unknown one) or called Unknown command; a menu's exec is never dropped (the stock menus' updategamerprofile and uploadstats are console commands, and g_friendlyfiredist and g_friendlynamedist dvars of an earlier game, that the PC build does not register either, so they take that path as in the original; loc_warnings and loc_warningsAsErrors are dvars, as the stock string table code registers them). cmd <words> forwards its words as one client command (the Call Vote menus' Restart Map and Next Map use it); mp_QuickMessage (B) opens the quick message menu for a player in the game, whose keys answer through the stock scripts as team chat; centerview levels the view; record [name] and stoprecord write and end a demo (demos/<name>.c4edemo, the native format) with the stock messages; setFromLocString localizes a string into a cvar. Multiplayer has no pause: cl_paused is read only, and the stock Pause key's toggle cl_paused says so. The video options' Recommended button runs configure_mp.csv as Com_SetRecommended does (the CPU class is taken from the core count, not a benchmark), the language menu checks the selection against the languages the install has files for and restarts the renderer, and the PunkBuster and CD key buttons say they are not supported. The client-commands stage drives each of these as the player does and checks the effect.

The console's openmenu <name> and closemenu <name> open and close the named menu, also when executed by a bind or menu script (UI_OpenMenu_f, UI_CloseMenu_f). The getAutoUpdate menu action reports that the automatic updater is unsupported; it is not a console command. Xbox-only party commands (acceptInvite, splitscreencontrols, xstopparty, xstopprivateparty) in shared stock menus remain on the stock PC unknown-command path.

One table (src/console.rs, the client's commands of KisakCOD's Cmd_AddCommand calls in stock case) says who carries out each command, so a typed line, a bound key and a config line take the same path and print the same thing. Bad arguments print the stock usage lines (bind <key> [command] : attach a command to a key, "x" isn't a valid key, unbind <key> : remove commands from a key, USAGE: set <variable> <value> and its seta/sets/setu forms, invalid variable name: x, USAGE: reset <variable>, USAGE: setfromdvar <dest_dvar> <source_dvar>, USAGE: openscriptmenu <parent menu name> <script menu response>, usage: connect [server], USAGE: logo ...). bind <key> shows the binding, bindlist lists them (lower-case key names; the original upper-cases named keys), reset restores the registered value, togglep says the new value, cmdlist [filter] lists the commands that match (*/?) and their count, clear empties the console, openmenu/closemenu/openScriptMenu/startSingleplayer act as in the original, and viewpos prints the drawn eye in a match. con_showchannel, con_hidechannel, con_channellist and con_visiblechannellist keep the stock channel list; the client's console hides its warnings and errors while the error channel is hidden. A +word that is no stock button (+nightvision is not one: night vision is +actionslot 1) is Unknown command, typed or bound; an unknown -word is silent, as in the original.

The other stock console commands answer as the original's. dvar_bool, dvar_int and dvar_float register a typed dvar whose range a later value must keep (Domain is any integer from 0 to 10), only when the name is new or a command made it; an engine dvar of another type is refused (dvar 'mapname' is not an integer dvar) and one of the same type is left as it is; setenv sets or shows an environment variable; in_restart reopens the input; writedefaults writes every engine dvar's reset value (not the read-only, cheat or command-made ones). clientinfo prints the connection state, the server and the userinfo; configstrings lists the game's config strings; serverstatus [server] asks a server for its settings and players; ping, localservers and globalservers query the browser; showip prints the local address; meminfo the process memory. dir, fdir, path, fullpath and fs_openedList list the search path and its files. readstats reloads and uploadstats writes the profile's stats (resetstats does nothing, as in 1.7). voiceChat/voiceTeamChat send a quick voice line (refused to spectators). snd_playLocal plays an alias at the player, and snd_setEq, snd_setEqFreq, snd_setEqGain, snd_setEqQ, snd_setEqType and snd_deactivateEq set a channel's EQ bands. imagelist lists the loaded images (the menus' first, then the match's) with their format, size, track and memory and the totals; reloadmaterialtextures <material> loads a loaded material's images again (a name nothing loaded is refused, never made); r_savesun <name> writes the 21 sun dvars to sun/<name>.sun in the user folder, and r_loadsun <name>, with cheats on and a map loaded, reads it back from the user folder or the game files, applying it to the world's sun only when the file names every one. phys_stop stops the map's moving props where they are (the pieces of a broken prop, a launched model and the corpses go on), and phys_go gives a stopped prop its collision back but leaves it at rest until a force (a bullet, an explosion) moves it again. As in stock, a leading / in a sun name stays inside sun/, a save name with .. is refused with refusing to create relative path and Failed to open, and a load name with ../ is not read (couldn't load sun file). Intentionally unlike stock, which refuses only .. and :: on save and ../ on load, a single : (a drive) or a NUL in a sun name is refused too: on save with the same two lines, on load as a file that cannot be read. The dedicated server's console and rcon carry out setPerk <player> <perk>, dvar_bool/dvar_int/dvar_float, writeconfig, writedefaults and meminfo. The two writers put the file under the log folder whatever the name: it is cut to 63 bytes, a leading / stays inside the folder (stock always builds the path under the game folder), and a name with .. or : is refused with stock's refusing to create relative path. Stock commands this build does not carry out yet are accepted and log a warning that names them and their issue: the static model cache tools (r_smc_flush, r_smc_stats) and cubemapShot (#527), imagelist all, which would also list the renderer's generated images (#581), updatehunkusage (#528), ragdoll tuning (ragdoll_*, #529), touchFile and fs_referencedList (#530), net_dumpprofile and net_restart (#531), the sound tools (snd_list, snd_setEnvironmentEffects, ... #532), cg_shellshock* (#533), timedemo (#534), and the server's scriptUsage and stringUsage (#535).

devgui takes the normal unknown-command path (at the front end, Unknown command "devgui"; in a match, normal forwarding). No retail 1.7 command registration was recovered: its string is used by a binding comparison and the console channel table, not a registered toggle (#528). updatehunkusage remains explicitly unimplemented and warns with stock command not implemented and issue 528: this engine has no stock hunk allocator, so it cannot truthfully write Hunk_Used.

The cgame's server commands (give, noclip, follow, ...) have no handler on the client, so, as in the original, a dvar of the same name is shown or set instead of forwarding the line, typed or bound. connect <server> joins from the front end; in a game it prints Already connected to a server. Disconnect first unless the third word is reconnect (CL_Connect_f).

The dedicated server's console has the same variable and channel commands (togglep, dvarlist, dvardump, cmdlist, con_*) and the stock usage lines for set, reset, setfromdvar, kick, banUser and banClient; its stdout is never filtered by channel, as the original's dedicated server prints every line. Commands that are not MP stock stay unknown: echo (a connectionless packet only), condump, cvarlist's client form, cvar_restart, play, timescale (a dvar), nextlevel (single player).

On a listen server, the host's console also reaches the server's command table and registered dvars: status, kick, clientkick, map_restart, fast_restart, map_rotate, serverinfo, cvarlist and other server commands run on the server of this process and print back into the console. set/seta of a registered server variable (scr_war_scorelimit, g_gametype, sv_hostname, g_password, bot_count) reaches that server; words neither side recognizes are forwarded as client commands (Server::host_line, #366). The cheat client commands follow KisakCOD's CoD4 MP 1.8 game_mp/g_cmds_mp.cpp (src/cheats.rs): give all|health [n]|weapons|ammo [n]|allammo <n>|<weapon>, take all|health [n]|weapons|ammo [n]|allammo <n>, god, demigod, notarget, noclip and ufo require sv_cheats and a living player. A refusal is the localized GAME_CHEATSNOTENABLED or GAME_MUSTBEALIVECOMMAND game message, not a raw key; kill uses that same gate. devmap <map> enables cheats and map <map> disables them. setviewpos x y z [yaw] [pitch] requires cheats but not life, and puts the player's eye at the requested point; entitycount requires cheats and prints on the server console. Stock printentities and visionsetnaked/visionsetnight <name> [seconds] do not use CheatsOk; mr follows the existing menu-response handler. The default-suite client-host-console stage checks host status, kick and map_restart, six cheat refusals without cheats, and the enabled commands' state changes after devmap (#370).

Mods and custom maps

fs_game (+set fs_game mods/<name> on the server, --game mods/<name> on the client; the folder must be one folder directly under mods/) puts mods/<name> on the search path above main, as the original does: its IWDs (every one, not only iw_*), then its loose files. The mod's mod.ff is loaded after the boot zones and outranks every other zone, the map's included. Fastfiles are looked up in the mod folder first, then zone/<language>, then usermaps/<map>/. Under a mod the server reads each script from the search path before the fastfiles (Scr_ReadFile): a mod's loose or IWD maps/mp/gametypes/*.gsc replaces the stock script of that name or adds a new one, and so does main_shared. Scripts with the CoD4X .gsx extension are not read (no CoD4X compatibility). The client's mods menu lists the folders of mods/ (description.txt or the folder name); RunMod sets fs_game and restarts the menus on the mod's mod.ff (menus, fonts, strings, materials) and IWDs, ClearMods goes back to stock. Joining a server that runs a mod makes the client run it too (the server's game info key; a mod the install lacks is downloaded first, see Downloads). A listen server (--listen, or the menus) runs the client's mod.

An asset one zone stores as a name behind a comma (,gfx_smk_white_atlas) is a reference to the asset of that name a zone loaded before it defines; the zones are decoded in load order into one table per consumer (assets::zone::Db, the original's DB_LinkXAssetEntry), and such a reference becomes that definition as it is decoded, so a map's effects draw the smoke, fire, mist and glass sprites and the model materials common_mp defines, and a map's props and weapons use the models and effects of the zones before them. A later zone's definition shadows an earlier one's of the same name when its rank is at least as high (the boot zones below a map, mod.ff above all), and when a map or a mod is unloaded its definitions leave the table and the ones they shadowed are in use again (the original's override chain). When no loaded zone defines the requested asset, the linked loader silently creates an entry under the requested name using the type's lowest-ranked stock default (DB_CreateDefaultEntry; materials use $default, models void, effects misc/missing_fx; a sound's alias list is cleared). The renderer therefore draws the stock default for missing mc/lambert1, rather than dropping its draw. If the type's default itself is missing, loading fails, as the original does. The menus, the renderer's technique sets and the sound bank resolve by name as well, which also covers the case the table cannot patch retroactively: a reference stored before its definition (a menu icon only a weapon defines), which the original fills in later in place.

Both the client's effect/model content decode and the renderer's independent world/static-model decode link materials through that shared implementation. A linked material retains its presentation payload even when first decoded inside an asset the consumer otherwise drops; otherwise a stripped definition could poison the table. The renderer's install-gated mp_carentan test walks its surface, static-model, sun and post-process materials, rejecting any comma-prefixed name or missing techset and requiring the formerly unresolved mc/lambert1 to be present.

A custom map made with the stock tools lives in usermaps/<map>/ (<map>.ff, <map>_load.ff, <map>.iwd); its IWD is on the search path while that map runs, it is listed with the maps of the server settings, and a server with sv_allowDownload 1 gives all three files to clients that lack them (see Downloads).

Browser clients (WebTransport)

--webtransport [host:]port (cvar net_wt, empty = off) also serves WebTransport next to UDP: a browser sends and receives the same datagrams as a UDP client (connect packets and netchan), peers named by their QUIC address. A datagram up to the session's maximum datagram size (capped at 1200 bytes, the browsers' limit) is one WebTransport datagram; a longer one, up to 8192 bytes, is one unidirectional stream carrying exactly those bytes, ended by FIN; both forms are accepted in both directions (net::wt module docs). Anyone may connect; bans, rate limits and the password apply to the connect packets as for UDP.

Without --wt-cert cert.pem --wt-key key.pem (cvars net_wt_cert, net_wt_key) the server makes an ECDSA P-256 certificate valid 13 days at each start and logs its SHA-256 (base64 and hex). With --wt-info file.json (net_wt_info) it writes what the page needs for new WebTransport(url, { serverCertificateHashes: [{ algorithm: "sha-256", value }] }): {"url":"https://localhost:4433/","certHashSha256Base64":"..."} (localhost when bound to every address, which includes IPv6; a loaded certificate has no hash field). The page decodes the base64 to the 32-byte value.

COD4_PATH=/path/to/install nix develop -c just server --webtransport 4433 --wt-info wt.json +map mp_crash

Playing

cod4e with no options starts in the stock main menu, drawn from the install's own menu assets (ui_mp/common_mp menus, fonts, localized strings and images; src/ui). Mouse and keyboard drive it like the original: Start New Server picks a map and gametype and starts a listen server (alone unless the bot_count setting, archived and 0 by default, asks for bots: set it in the console, and it is read at every map start; bots <n> in the console sets it and changes the bots of a server started from this client at once; --bots <n> overrides the setting), Join Game takes a server (its list is the LAN, the internet or the favorites, cut to a game mode; the line above it says how the refresh is going; Server Info shows the selected server's settings and players from a getstatus query), Options and Controls edit the dvars and binds (click a key slot, press the new key; Backspace clears it; the choices survive in the config file). The default binds are the install's own default_mp.cfg (read from its archives, as the original does), then the profile's config_mp.cfg; only keyboard look, the mouse wheel and gamepad binds are added by the client, Esc opens the in-game menu. The 640x480 menu space is anchored to a 16:9 safe area at any window shape. --ui-tour <dir> opens the menus one by one with no world and saves a screenshot of each (harness stage client-ui); The harness drives them like a player through the control channel (--control, docs/control-channel.md; Driven in the harness).

A map loads in the background: one thread decodes the zone, uploads the world and builds the renderer with all its pipelines (on half the cores) while others decode the client content and boot the listen server, and the window keeps presenting the map's loadscreen_<map> picture with a progress bar (Esc cancels). The load prepares, per material, the scene chains (lit, sun, spot, omni, down to emissive and unlit) and, only where the material's techset has the slot, the optional passes (shadow map, shadow cookies, dynamic lights, floatz), as the original asks a material for those techniques only where it has them (MTL_GAMEFLAG_CASTS_SHADOW, the light surface lists); a stock techset without one of them casts no such pass and is not a warning. A material with no scene technique at all logs no usable technique and the log gate fails the stage. A warm-up frame primes the renderer and the player models, so the first frames in the world do not stall either. The models the server precached beyond those (planes, vehicles, props the scripts make) arrive in its gamestate once connected; as the original registers them under its load screen (CG_RegisterGraphics, KisakCOD cgame_mp/cg_main_mp.cpp:1842-1911, from CL_InitCGame in CA_LOADING), the load screen stays up (showing EXE_AWAITINGGAMESTATE on a join) until the gamestate is complete (gamestate_end) and their passes are prepared, 16 ms a load-screen frame so the screen keeps presenting, with their pipelines compiled on the worker threads; a match the command line starts (--listen, --connect), which loads before its first frame, waits there for the gamestate (up to 5 s) and prepares them in one go. Frames of play prepare nothing of it (#457). Only a model a server of ours precaches after its gamestate (stock refuses precacheModel after the first wait, game_mp/g_scr_main_mp.cpp:3742) is prepared in frames of play, a few per frame (2 ms). Harness stage client-load asserts the longest gap between presented frames during the load stays under 100 ms on mp_crash and the largest map.

In a match the stock HUD menus are drawn with the client's own owner-draw pieces (src/ownerdraw.rs): magazine graphic and reserve, weapon name, grenade counts, sprint meter, low-health overlay with the original's pulse phases (hud_healthOverlay_*), hold-breath, mantle and no-ammo hints, and the corner compass with the level's map image (setMiniMap/northyaw, src/compass.rs). The compass:

  • scrolls with the player and turns with the view;
  • shows teammates, and for a couple of seconds enemies that fire;
  • marks helicopters (155) and airstrike planes (156, PLANE entities from spawnplane) with friendly/enemy icons by team (by owner in free-for-all), rotating with vehicle yaw. Stock 1.7 places 155 only in compass_old, one of the ui_mp/hud.txt HUD menus, where client-hud asserts a helicopter icon; client-hardpoint asserts plane marks during an airstrike;
  • sweeps a radar line across the map while setteamradar or hasradar gives radar (ps.radarEnabled, compassRadarUpdateTime), and shows the enemies it passes for compassRadarPingFadeTime;
  • marks script objectives at any distance, on the rim when off the minimap. Sizes: compassObjectiveWidth/Height × 20 × compassSize on the minimap, compassObjectiveIconWidth/Height 16 on the full map. A 2-unit cross-hair crosses the map through the current objective;
  • spans compassMaxRange in height (multiplayer default 2500) and keeps a player on it for 1.5 s after their last update.

The full-screen map is used to pick an airstrike location.

Talker slots 193–196 and 267–270 show received, unmuted speakers in client-number order, with voice_on and their name, for 300 ms after each voice packet (Voice_IsClientTalking). cg_drawTalk filters the cgame slots and takes the stock names or their index (NONE, ALL, FRIENDLY, ENEMY; another value warns and draws all). client-hud joins a second, talking client to its listen server, checks that the slot names it, and that cg_drawTalk hides and shows it by side. Local talking (266) follows the held talk key with client and server voice enabled, even if the microphone cannot open (which warns); client-hud checks it through the control channel. Voice transport stays native, not compatible with 1.7 voice packets.

The hud_fade_*, hud_enable, compass* and cg_draw* dvars apply. Health arrives in the player state, so the bar (cg_drawHealth) and overlay follow the server. Not done: use-trigger cursor hints (the server does not yet say what the crosshair is on).

The voice options' record level (265) is the stock meter (UI_DrawRecordLevel, Voice_GetVoiceLevel). The level is the mean sample size of the microphone's last block after winvoice_mic_scaler (clamped to 0.5–1.5) and winvoice_mic_reclevel (0–65535, default 65535; text is read as stock's atof reads it, so a value with no leading number is 0 and mutes), over 32767, averaged over the last six draws, inside a white outline, in menus and in a match. Intentional difference: recording level is software gain (winvoice_mic_reclevel / 65535) on both the captured voice and the meter, standing in for stock's OS microphone volume control (Voice_Init/Voice_Playback, KisakCOD win32/win_voice.cpp:597-606,687-690; 1.7 0x57a8f0, 0x57ae10); this build never changes or restores the OS mixer settings. As in stock, the meter sits at the item's rectangle moved by its text offset, without the item's alignment. client-hud opens the voice options and checks the outline's pixels on screen. Stock records whenever its UI runs; this client opens the microphone only while the meter is drawn or +talk is held, which is all the level can show. A host with no capture device warns that the microphone cannot be opened and the meter stays empty. With the Linux slots' test-tone microphone (COD4E_TEST_MIC, see the slot runs above) client-hud requires a settled level of at least 0.1, half recording gain to give 0.4–0.6 of that level, zero gain to give at most 0.01, and full gain to recover to 0.8–1.2 of the baseline (#498).

An enemy's firing mark comes only from fire events of players in the snapshot: unsilenced, non-grenade weapons ping, while silenced shots and grenade fire events still play sound and flash without a ping. The mark stays fully visible for the actually played sound's length (zero when unknown), then fades over compassSoundPingFadeTime (default 2 seconds). Late audio playback can supply the length on the next frame. References are KisakCOD MP 1.8 (GPL-3.0) cgame/cg_weapons.cpp:2552-2558, sound/snd.cpp:710-731, cgame_mp/cg_compassfriendlies_mp.cpp:246,589-600, and cgame/cg_compass.cpp:161-163; 1.7 binary behavior is not verified. Outside the snapshot, the server sends compass positions only for teammates or players revealed by UAV/personal radar or g_compassShowEnemies, never because they fired or carry pingPlayer; spectators receive none (game_mp/g_client_mp.cpp:460, G_GetNonPVSPlayerInfo). Non-PVS positions carry no fire events; a shown player's script pingPlayer bit is forwarded, as stock does (game_mp/g_active_mp.cpp:1244-1251, cgame_mp/cg_compassfriendlies_mp.cpp:437-438). An offhand throw raises EV_USE_OFFHAND, not a fire event (bgame/bg_weapons.cpp:3061-3081). Harness stage client-hud requires an unsilenced mark lifetime matching independently observed playback length plus fade (200 ms frame tolerance, rejecting a fixed 2000 ms hold), and no pings for silenced fire. Its frag case asks the listen-server bot to throw a cooked frag and requires the throw proven on the server (a grenade entity, read from the control reply's value), zero FireWeapon shots, and unchanged fire pings, flag pings and firing marks (#486); a failure carries the bot's offhand trace, and a bot that respawned without a seen grenade retries. HUD reports expose last_fire_ping_ms (hold), last_fire_played_ms (audio observation), and last_fire_mark_ms (age on the last drawn frame).

Development-only devbotfire <client|axis|allies> <weapon|frag> <ms> grants/selects the weapon and, once the bot has it raised (it keeps asking for it, and lets go after 5 s if it never comes up), holds the bot's attack for that interval; with frag it holds the frag key for the interval at once. The interval starts at the raise because a switch's drop and first raise outlast a short burst: the client-hud compass step once saw a bot that did not hold the pre-raised weapon when the burst was asked for (a respawn resets the inventory) spend its whole 500 ms burst raising it and never fire (#494). The compass step puts the bot 96 to 224 units in front of or behind the person with devtele <bot> near <person> <dist> (floor and a clear sight line), and keeps a spot only if, over two samples 500 ms apart, the bot stays alive in the same life, loses no health, and stands that far from the person; otherwise it tries the next distance. It then raises the weapon once, and fails if a living bot has not raised it (clients[].weapon_name) within the hook's 5 s. An attempt in which the bot died at its spot (#584: a bot put back on a spot that killed it only died again until the deadline) or the bot or the person died during the placement, the settle or the burst, is made again, up to three times; the failure names the bot's health and origin right after devtele. The fire mark's lifetime is checked between its age on the last frame that drew it and on the first that did not (hud.report.last_fire_gone_ms), so a slow frame (#499) does not read as a short mark. It requires explicit developer 1 (default 0) and prints a console warning on every invocation. Harness and integration tests opt in explicitly. This uses the existing server development-mode dvar rather than trying to change write-protected sv_cheats after map start. Both a headless server (crates/server/tests/dev_bot_fire.rs) and the bot in the client-hud listen-server match throw the frag (#486).

Test hook devobjective <index> <state> [x y z [icon]] does what the scripts' objective_add does (state empty, active, current, ...; the icon is set only with an origin), so client-hud can put a current and an active objective at chosen distances and check the minimap's scale, icon size, cross-hair lines and rim clipping.

COD4_PATH=/path/to/install nix develop -c just client --listen --bots 9   # solo: a team deathmatch against bots
COD4_PATH=/path/to/install nix develop -c just client --connect host:28960 # a `cod4e-server` (set net_port; default 28960)

The client also takes the original's launch line (Com_ParseCommandLine), as launchers and server browsers write it: every + word starts a console line and the words after it are that line's arguments, beside the client's own -- options (a -- word ends the line; a - word such as a negative value does not). The set and seta lines are applied before the configs run and again after them, so they outrank the profile's config (Com_StartupVariable); sets and setu are treated the same way, an extension of ours (stock applies only set and seta early, sets/setu only when the line runs), as the server does. +set fs_game mods/<name> picks the mod as --game does. Every line then runs as a console line once the main menu is up (Com_AddStartupCommands), so +exec, +name, +connect, +map and +devmap do what they do in the console. The joins go under the name variable: the profile's config sets it, --name sets it after the configs, and +name or +set name set it last (the + lines run after --name), so a + name wins; with none of them it is player. Stage client-session starts the client with +set cg_fov 75 +exec <file> +name <nick> +connect <server> against a cod4e-server and asserts the join, the name the server lists, the variable over the profile's seta cg_fov 65 and the file's marker variable.

The split differs from stock on purpose. Stock cuts the one Windows command-line string at every + and newline, even inside a word or a quoted argument, and keeps at most 32 lines; the client gets its arguments already split into words by the shell, so only a word that starts with + starts a line: a + inside a word or inside a quoted argument (+name "a+b", +set g_x 1+2) stays part of the line, where stock would cut it, and a whole command line passed as one argument ("+set a 1 +set b 2") is one line, not split. A quoted argument that itself starts with + still starts a line. There is no line limit.

cod4e +set cg_fov 75 +exec mine.cfg +name Player1 +connect host:28960

Player heads use stock DObj root melding: an empty attachment tag shares matching bone names with the body, rather than looking up a missing tag or placing the head at the feet. Tags are case-insensitive exact names (the j_ prefix is part of a bone name), resolved from each model's own 1.7 zone script-string table. An explicit missing held-weapon tag warns and retains the model at entity root, as KisakCOD's DObj construction does; normal players already have a bind pose before their first animation tick. A script attachment whose non-empty tag none of the earlier models has is warned about by the server (explicit attachment tag did not resolve, naming the parent model and the tag, as DObjCreateDuplicateParts warns), because the attachment wire carries both an empty tag and such a miss as 0. client-models asserts loaded heads meld and all nine held weapons sit at their hand tags before that tick; an install-backed unit test sweeps the heads of every stock map. The attachment wire remains this engine's compact bone-index protocol, not stock's script-string attachment protocol.

FX model elements with zero authored visuals are not unresolved XModels: they draw nothing (as FX_DrawElement skips a zero visualCount) and run their element lifecycle; model physics for them is not spawned, which stock's FX_SpawnModelPhysics also cannot do without a visual. Elements with actual visuals still resolve their models across the loaded zones. client-fx requires positive drawn-model and zero-visual-element counters, zero genuinely unresolved model visuals, and records effect/model names in fx.json.

The client talks to the server over UDP (net: sequenced, fragmented netchan, delta-coded snapshots about 36 per second, up to 3 usercmds per packet with redundancy). --listen runs the same server inside the client process. The player's own movement is predicted with the very sim::pm::run_usercmd the server runs (other players are collision boxes from the snapshot; server corrections fade over 100 ms, a teleport shown as one; the server's g_speed and non-stock movement and weapon dvars such as jump_height, friction and player_meleeRange reach the client in a configstring; own footsteps, jumps, landings and shots are played from the predicted events, once each however often the replay raises them); other players are drawn 100 ms behind the server clock between two snapshots, and the usercmds are stamped with that same moment, as stock's CL_FinishMove stamps the time cgame draws at. With g_antilag 1 (the default, archived and in the server info) a bullet shot or a knife swing is judged at its command's time: the server moves every other playing player's origin back to where it was then (from a history of origins, when the command is more than one server frame and at most 400 ms old and the player was alive all the way back), keeps their live stance box and animation pose, judges the shot and its damage, and puts them back; the killcam starts that much earlier. With g_antilag 0 shots are judged against the live bodies. Rockets and grenades are not compensated, as in stock. Keys, mouse, gamepad and the config file are the input layer's (bind w +forward). In a match the mouse is locked whenever no menu is open (no click needed after a menu closes); Esc with no menu to open releases it (it never quits) and a click takes it again. COD4E_INPUT_DEBUG=1 prints one input: line per second to stderr: events per second from GCMouse and winit, which source feeds the look, the GCMouse devices and whether our handler is on each, window focus, pointer lock, and how many frames of motion the focus/lock gate threw away.

The server's g_smoothClients (Bool, default 1, as stock registers it) picks how each player's entity state is built, as ClientThink and ClientEndFrame do: on, the stock extrapolating builder (G_PlayerStateToEntityStateExtrapolate), which sends the position as a TR_LINEAR_STOP trajectory; off, the plain one (BG_PlayerStateToEntityState), TR_INTERPOLATE. In the stock game (KisakCOD and the 1.7 binary alike) the plain builder that the extrapolating one calls last clears the 50 ms velocity trajectory it set up, so only the type differs, and the client interpolates a player for either type; the entity state carries the type (pos_type) all the same. Harness stage net-match asserts that the clients see moving players as TR_LINEAR_STOP and, once the server sets g_smoothClients 0, as TR_INTERPOLATE.

Stage 4 (client-match) is the real client with --listen --autoplay: a scripted player walks toward and shoots the bots for 90 seconds. It asserts connected and spawned, walked, saw the bots, prediction rarely corrected, the view model drawn, every script_model the server published drawn, no magenta in the last frame, no stretched skinned triangles, no script errors (but the one the stock _missions::ch_kills raises for a player their own frag killed, which the stock scripts raise too, let through as many times as the server counted such a death), no frame that compiles a pipeline itself, no frame that prepares more than 64 material passes itself (a precache list taken in one frame), no frame of play that prepares any of the server's precache list (precache.in_game_entries in client.json is 0: the load screen prepared it, #457) and no frames far over the median cost (a hitch), shots fired and their impacts and muzzle flashes reaching the client as events and effects, with one blood effect per bullet hit on a player (the bullet impact's on flesh, CG_BulletHitEvent; the victim's pain is player state, the flinch and the damage direction, and draws nothing of its own) (hit registration is the server tests' job, not this stage's, which runs under host load), every effect the game played named, and every effect the match named already named when the gamestate completed (fx.names_at_gamestate in client.json above 0 and equal to fx.names, as stock's CG_RegisterGraphics has them all from CS_EFFECT_NAMES, #553); it reports bandwidth, snapshot rate, frame times and the server's tick cost. frames.raw.csv's cpu_ms is the frame's whole work (input, the network frame, the warm-up, the render, the UI and the present) without the wait for the surface texture, and frames.phases.csv says where each frame went; when the frames hitch the failure lists the phases that held the excess and the worst frame's, and says whether one phase held the stalls (a call the client blocks in) or every phase stretched alike (the process not being scheduled: the machine, not the client). It needs a display and skips without one. net-loopback and net-match cover the protocol and 4 clients with 12 bots without a display; net-ui plays the menus a person would (team, class) against Domination and checks the hud elements, objectives, configstrings, client dvars and print lines the stock scripts send (net::ui is the client-side contract); net-killcam has a bot kill the client and checks the stock killcam replays the killer's view from the server's state ring and ends. net-perks has two UDP clients upload profiles with custom classes and checks the perk bits, grenade and ammo counts, the Juggernaut, Stopping Power and Sonic Boom damage numbers, Last Stand (pistol only, crawl, the health written back to 1 by lastStandKeepOverlay within its cycle whenever the stock regeneration adds to it, ten second bleed-out credited to the attacker, and a grenade kill on a Last Stand class dying at once) and Martyrdom's dropped grenade; the hits come from the devkill <victim> <attacker> [damage] [mod] [weapon] console hook, which runs G_Damage and the scripts' damage callback. net-antilag has one UDP client, stamping its usercmds as the game client does, tap-fire an MP5 at another strafing across an open lane of mp_crash: with g_antilag 1 (checked in the server info) at least 75% of the shots hit, with g_antilag 0 at most 35%. net-mover has a UDP client ride a script_brushmodel platform up and down at 300 units/s on mp_cargoship and checks its predicted feet stay on the platform as the client draws it (median at most 2.5 units off, 95th percentile 4). client-match also checks the client stamps its usercmds at the moment it draws the others (median and 95th percentile within 35 and 50 ms).

Out of sight of an enemy, the autoplay walks to the nearest one along the server bots' navigation mesh (server::nav, generated from the client's own copy of the map's collision), so that it does not stay pinned to a wall between it and the enemy (#464). Its track (position, enemies in the snapshot and the nearest one's distance, once a second) is net.autoplay_track in client.json; a run in which the player never fired says how many frames it had an enemy in sight.

Stage client-dedicated is the portable folder's way to play: the real cod4e-server binary (next to the harness, or COD4E_SERVER) on a free UDP port with bot_count 3, and the windowed cod4e client joining with --connect --autoplay for 45 seconds. It asserts connected, spawned, moved, fired, snapshots arriving, prediction rarely corrected, no pipeline built mid-play and no clock jumps (the share of hitching frames is reported as a metric, not gated: client-match gates it, and here the server and the host's other load compete with the client), and then that the server's status no longer lists the player and logged that it left the game (a clean disconnect, not a timeout).

The client draws what the original's cgame draws over the stock HUD menus (src/hud): the script hud elements (text, values, timers, materials, clocks, waypoints with an off-screen pointer, with their alignment, move, fade, scale and glow), the iprintln and kill-feed message windows (a message is the original's key-and-arguments string: the first localized key, then each argument fills the next &&1, &&2; the stock gamemessages, boldgamemessages and subtitles menus place them), team-coloured kill lines with the weapon's kill icon, chat, the centre string, the scoreboard rows under the stock top bar (held up on the +scores bind as cg.showScores is: the scroll keys are the scoreboard's and go no further, every other key stays the game's while Tab is down; rows ask the server every 2 s; each open lands on the viewer's row as CenterViewOnClient does, and the wheel and PgUp/PgDn move the list cg_scoreboardScrollStep lines, 3 by default), the killcam (the stock killcam menu is shown while the server replays a kill, with the killer's archived hud elements and the watcher's own live ones, such as the stock skip prompt, as CG_Draw2dHudElems draws both sets), the night vision goggles (their full-screen nightvision_overlay_goggles picture over the world and under the HUD, and the d-pad slot's hud_icon_nvg, lit while they are on; CG_DrawNightVisionOverlay, CG_DrawPlayerActionSlot; the picture changes to the night vision set on the frame the goggles go on, as multiplayer's VisionFadeValue is always 1, and nightVisionDisableEffects 1 keeps both the night vision set and the overlay off, CG_LookingThroughNightVision; the vision sets themselves follow cg_visionsets.cpp: a naked and a night channel, visionsetnaked/visionsetnight blending eased by sin with the destination's glow and film switches from the first frame, and a set with no file falling back to vision/default.vision with a warning naming the file), the name of the player a spectator follows with the key help for following (cg_descriptiveText; a spectator who follows nobody flies freely, the server running the movement at speed 400 when the scripts allow freelook), teammates' names over their heads with the rank icon (fading with distance and line of sight; cg_drawFriendlyNames, cg_overheadNames*), the name of the player under the crosshair (cg_drawCrosshairNames, cg_enemyName*/cg_friendlyName* fades) and head icons from the scripts' headicon/headiconteam (bomb and flag carriers) with the connection-interrupted and killcam "you" markers (cg_scriptIconSize, cg_headIconMinScreenRadius). Harness stage client-hud plays a bot match through the control channel as a person does (the team and class picked with the pointer, the player walked and aimed with the keys and the mouse, a class changed from the in-game menu, chat and console lines typed, the night vision goggles put on and off with the action slot 1 bind while the player stands still, Tab held for the scoreboard, a grenade thrown with its key, the killcam's kill made by an enemy bot put near the player where the two see each other (devtele <bot> near <client> <dist>) and given time to turn to them before devkill, a further distance tried when the replay never showed the body, since left to the match no bot might kill the player before the match ended, #494, and, if no shot has been fired, a live enemy bot put 200 units from the player in sight of them the same way and held still with bot_idle 1 until the walker has aimed and fired at it, since the feed and killcam steps can end before it met anyone, #464, and the match might offer no target, #494) and checks what the HUD drew (hud.stats of the state document, written as hud_draw in the stage's control-run.json, including the overhead and crosshair names, the goggles' overlay and icon, and the killcam's skip prompt and "you" marker) and, from matched captures with the goggles off and on, that the overlay's scanlines cover the screen but not the compass minimap drawn above them.

The scoreboard held up with +scores is drawn over the game and takes nothing from it: the keys held keep moving the player (the scoreboard's own scroll keys aside) and letting go of Tab runs -scores, as CG_ScoresDown and CL_KeyEvent do. Any other menu takes the keys and the mouse, but a key let go while it is open still runs its - bind (CL_KeyEvent), so a button held when the menu opened is released with the key rather than when the menu opened. The browser stage web-match holds the scoreboard and asserts it stays up.

A match started from the menus runs on to its end: the scoreboard stays up until the server rotates the map, and the client then loads the next map in the same process (the server's map command carries the level; the connection and the player stay), returning to the team and class menus. Start New Server rotates through the stock maps after the chosen one and passes the scr_* dvars the player set (time and score limits) to the server, which takes a standard port (28960-28963) when one is free. Join Game asks the server (getinfo) which map it plays before loading anything, so a typed address or a listed server joins the right map; its list shows LAN servers (a broadcast and loopback getinfo on the standard ports; Refresh List rescans) and favorites (Create Favorite with an address, or Add Favorite on a listed server; kept in <config dir>/favorites.txt, one address:port a line, at most 128 (lines that are not an address, such as a host name, are kept as written), where the stock game dumps its structs into servercache.dat). The Filter popup's settings (ui_browserShowFull, ShowEmpty, ShowPassword, ShowPure, ShowDedicated, Mod, Friendlyfire, Killcam, ShowPunkBuster, and 1.7's Hardcore and OldSchool against hc and od; ShowPure is the stock default 1, which the popup has no control for, so it lists only sv_pure servers) cut the list as in the stock browser, and every column header sorts it. The hardware column draws the stock server_hardware_* material (KisakCOD ui_main_mp.cpp:4144-4149,4699-4704; 1.7 lookup 0x5484fc): hw is parsed as a decimal prefix and stored as an unsigned byte, as stock does; normalized slots 0 (also missing) / 1 / 2 / 3 / 6 / 7 show unknown / Linux dedicated / Windows dedicated / Mac dedicated / Windows listen / Mac listen, and other normalized slots draw no icon. Our dedicated and listen servers advertise 2 and 6. A passworded server shows an X in the first column; the Password button, or the password dvar, sets the password a join sends, and joining such a server with none set opens the Password popup first, and a refused join (wrong password, full server) returns to the menus with the reason, asking for the password again if it was wrong. Every other failed load or join ends the same way, as the original's Com_Error(ERR_DROP) does: the session ends, the main menu comes back and the stock error popup (error_popmenu, com_errorMessage) names the reason: a map change to a map the install lacks (Could not find zone '<zone>'), an address nothing answers, a connect the server never answers (20 s), a server that goes silent for cl_timeout (40 s) and a kick with its reason; reconnect from there rejoins the last server. A map or devmap of a map the install lacks starts nothing, as the original's SV_Map_f checks the zone first, and shows Can't find map "<name>". in the same popup (the original prints that line to the console only); typed during a match it leaves the match running, and a bare map does nothing. Harness stage client-error drives each through the console and asserts the popup and its text. Stats (rank, unlocks, class setups: the menus' stat(n)) belong to the player profile: the profiles of the install's players/profiles/<name>/mpdata are listed in Select Profile next to the user's own (<config dir>/players/profiles), active.txt names the one used last, and New and Delete work (the install is only read: a changed profile is saved to the user's folder, as a decrypted mpdata (the CoD 4 X ice0 form); see the paragraph below for the original's encrypted iwm0 files). The client loads it at start, uploads it to the server when it joins a level, and takes the server's later setstat changes back into it.

Player profiles (rank, unlocks, custom classes) are read from the install's players/profiles/<name>/mpdata, in either format: the stock game's encrypted iwm0 file and CoD 4 X's decrypted ice0 file. An iwm0 file is opened with the key the original derives from the install's CD key (codkey in the registry on Windows, or in the system.reg of the Wine prefix named by WINEPREFIX, else ~/.wine, elsewhere) and is rejected when its hash does not match, as the original rejects it (without the key the profile is listed and starts fresh, with a message on stderr; the key is never printed or stored). Everything the client saves goes to its own copy under the config folder (--config moves it), in the ice0 format, and shadows the install's profile; the install's profile is never written, so the stock game keeps reading it unchanged.

Every windowed harness stage runs the client with --config-dir <stage dir>/config --harness-profile: the folder gets a fully unlocked profile harness (all perks, weapons and attachments, Create a Class open, top rank; kept if it exists, so a stage's own saves carry into its next run) and only that profile is listed, so a run never loads the tester's or the install's profile (the stock main menu asks which to use when several are listed). A weapon and its attachments are unlocked by the low 16 bits of its stat 3000 + statsTable row (bit 0 the weapon, the attachment table's bit each attachment, the same bits shifted up 16 flag them new), which the stock Create a Class popups test before a weapon row is a live button; the harness profile sets them for rows 0 to 99 with nothing flagged new, so client-custom-class clicks the P90 row of the SMG popup and its attachment popup opens. The headless stages headless-sab-objective and headless-airstrike do not wait for bot luck: idle bots (bot_idle, which hold +activate under a use trigger's hint) are placed with devtele and devrun <seconds> runs the server between steps, and the airstrike is handed out with devhardpoint as in headless-helicopter.

Harness stage client-sd-rounds starts a three-round Search and Destroy from the menus, with the sides switching every round, and after each round restart asserts the player spawns again on the same team with no team menu, that the server's objective text names the other role in round two and the first one again in round three (the sides do not switch after the last round), and that the team survives into the final intermission: the intermission, the open scoreboard menu with player rows and the team are read together while the client is still in round three's level (written to end-scoreboard.json), since the stock end-of-game popups hold the scoreboard menu back until shortly before the level rotates and the next map's player is a spectator again. Harness stage client-menu-match runs the client with no flags and drives it through the control channel (Driven): Start New Server with a three-kill score limit, team, class, the end-of-match scoreboard, the rotation into the next map, then team and class again and a second spawn. It needs a display and skips without one. Harness stage client-custom-class plays from a custom class with a profile's stats: the P90 in the class spawns, the profile's perk and experience stats survive the join, a class change in the grace period applies and every other player is drawn (the client sends its profile right after connecting and the server holds begin until it has it, as the original holds the game state until the stats are in). Harness stage client-ingame-menu changes team and class mid-round through the in-game menu with clicks through the pointer and checks that Escape and every pick return to the game, the team changes and the new class spawns, and that each menu lists the stock items. Harness stage client-frontend opens the main menu, Select Profile and Create a Class with the install's own profile, steps the game mode chooser with the pointer and checks that the install's players/ folder is unchanged.

The menus match stock MP 1.7 where #372 found them apart (KisakCOD ui_mp/ui_main_mp.cpp UI_DrawBuildNumber, UI_Refresh, UI_RegisterDvars; qcommon/common.cpp Com_Init): the front end draws the build number 1.7.568 in grey at ui_buildLocation (-100 52, right-aligned) and ui_buildSize (0.3) while no match runs; version (CoD4 MP 1.7 build cod4e-<version> <os>-<arch>, read-only; an intentional difference: stock names its own build and win-x86, this names the cod4e build and the platform it runs on) and shortversion (1.7, read-only, sent in the server info) exist on client and server; ui_multiplayer is 1 and read-only, so Options lists Voice Chat and Multiplayer Options and not the single-player Cheats; snd_outputConfiguration (Windows default) and monkeytoy (0: the console is always open here) exist with their one value, so the Sound and Game Options pages show a setting; a menu's write (an item, setfromdvar) is refused as a console's is, with the same console lines and a warning, for a read-only dvar and for a value other than the one such a dvar has, so Enable Console cannot turn monkeytoy on; winvoice_mic_reclevel backs the Voice Chat recording gain described above. voicechat_level_indicator is only an unused field of the stock meter menu item: neither stock nor this build registers or reads it. Intentional difference: PunkBuster does not exist in this build; cl_punkbuster (default 1, as stock registers it) backs the Multiplayer Options control and is saved, but is not sent in userinfo, is not console-write-protected, and does not gate joining or starting servers (#498). A start that loads its map before any menu (--map, --listen, a browser join) sets mapname, so the team, class and in-game menus title the map instead of a bare @. Create a Class follows the profile's stat 260: a fresh profile shows it locked, as stock does. The menu section of the control channel's state document gives each item of the open menus with its click rectangle, whether it is visible or only decoration, its shown setting (value), owner-draw and list choice (owner_draw, choice), the rows the Join Game list shows (server_rows, counted without polling the network), the hardware icons actually painted last frame (list_icons, with feeder, row, col and material), and the build number drawn last frame (build). Harness stage client-frontend reads these to assert the build number, Create a Class locked and unlocked by stat 260, distinct dedicated/listen LAN hardware icons, the Options rows, a setting on every options page, and that clicking Enable Console leaves monkeytoy 0; client-session asserts the direct start's in-game menu names mp_crash.

The graphics options take effect: Apply runs vid_restart, which moves the window (r_mode, r_displayRefresh, r_fullscreen, and r_monitor, the 1-based monitor fullscreen goes to, 0 automatic; windowed stays on the monitor it is on, and the mode lists are the chosen monitor's; --monitor <n> at start; exclusive fullscreen is borderless on Wayland, which has no mode switch), sets the present mode (r_vsync; a --present choice wins) and the screen shape (r_aspectRatio, which sets the projection's aspect), and changes a running match's antialiasing (r_aaSamples, up to 16x multisampling of the scene pass, stepping down one sample at a time to what the device offers), shadows (sm_enable), the sun's specular (r_specular), depth of field (r_dof_enable) and glow (r_glow_allowed). Brightness (r_gamma, default 0.8) works as the original's display ramp does: it maps the whole picture through pow(x, 1 / r_gamma) only in full screen (exclusive or borderless) and not with r_ignorehwgamma 1; a window shows the picture as drawn. r_distortion switches the heat haze and shockwave materials, which sample a copy of the scene taken before the emissive surfaces; soft (z-feathered) particles fade where they meet geometry. r_zFeather off draws soft smoke with hard edges, and r_drawWater off freezes the water's motion. Pixels nothing draws (a camera inside a wall or outside the sealed world, a view past the map's edge) are black, as the stock engine shows them: its colour buffer is cleared only as r_clear says (R_GetClearColor; the shipped default dev-only blink never clears without developer, so the target keeps what it held, black on the reference host, and a stale frame is nothing to copy) and never to the fog colour. r_clear (never, dev-only blink, blink, steady, fog color, by name or index), r_clearColor and r_clearColor2 (r g b a in 0..1, blinking every 512 ms) are read with the other graphics dvars at map load and vid_restart. Harness stage client-clear holds the client at the #356 viewpoints on mp_crash and mp_backlot and checks the undrawn regions are dark and the walls are not. Harness stage client-foliage holds the #358 mp_farm tree pose with sun shadows on and off: shadow-only (cameraRegion 3) cards stay out of the camera draw and the canopy has no black cutout silhouettes. Texture size (r_picmip for colour maps, r_picmip_bump for normal maps and r_picmip_spec for specular maps, all with r_picmip_manual) and anisotropic filtering (r_texFilterAnisoMax) apply to the next map that loads. com_maxfps (stock default 85, 0 to 1000, 0 for no cap) paces frames in whole milliseconds (1000 / com_maxfps) with r_vsync 0 only. Unlike stock it is not applied while vsync is on: the display paces the frames, and a cap below the refresh rate (85 on a 120 or 144 Hz screen) would give an uneven cadence. cg_drawCrosshair 0 hides the crosshair, ragdoll_enable 0 leaves corpses in the pose their death clip ends in, and fx_marks 0 hides bullet holes and blood marks. r_rendererPreference, r_multiGpu and ai_corpseCount are kept for the menus only: they choose a Direct3D 9 code path, an SLI/Crossfire mode and the corpse cap of single-player actors, none of which exist here. The HUD layout dvars (cg_hudVotePosition, cg_hudSayPosition, cg_hudChatPosition, con_gameMsgWindowN*, cg_hudDamageIcon*, cg_scoreboard*) are read as the stock cgame reads them. The saved choices apply at start unless --size, --refresh or --fullscreen was given. r_vsync starts at 1 here, not the stock 0. Harness stage client-gfx sets them in the menu, applies them with vid_restart, starts a match and turns them back, checking the surface's and renderer's own state each time.

The weapon crosshair follows AllowedToDrawCrosshair and CG_DrawCrosshair (KisakCOD MP 1.8 cg_draw_reticles.cpp:115-197,601-627; the weapon-state values are the same in 1.7): it is hidden while the weapon is raising or dropping (including the alternate-mode switch and quick drop), reloading or meleeing, when fully aimed with the gun drawn, and while a flashbang blinds the view. It stays up while ready or firing. Harness stage client-hud fires, reloads, changes weapons and melees with the player's binds and checks the predicted weapon state against the drawn crosshair alpha. A switch to a pistol uses the quick drop and raise, as stock PM_BeginWeaponChange does (bgame/bg_weapons.cpp:2491-2587), about half a second in all. The weapon change is one wheel notch, and it is checked against the client's per-frame counts too: player.crosshair_state_frames gives, per weapon state, the frames drawn and those with a visible crosshair, so polling cannot miss a short state (#494).

Harness stage client-gfx checks cg_crosshairAlpha/cg_crosshairAlphaMin through what the crosshair drew (cgame.crosshair_alpha of the state document): with the alpha at 0 a floor of 0.75 holds at the hip; fully aimed with the gun drawn the crosshair is hidden, as the stock posLerp != 1.0 || !cg_drawGun gate in CG_DrawCrosshair hides it, and the floor is back once the sights come down (#494).

The client-gfx blood checks (blood and brass, blood limit) each hold bots still with a host set bot_idle 1, wait a second before counting injected hits and set bot_idle 0 afterwards. Their result notes record every bullet impact in the check window; fx.impacts in the client state distinguishes locally injected hits from network events, including the source entity and weapon, so an unrelated real hit cannot masquerade as an effect played twice.

Over a player the crosshair takes their team's colour (#414, KisakCOD MP 1.8 cg_draw_mp.cpp:324-402 CG_ScanForCrosshairEntity and cg_draw_reticles.cpp:483-544 CG_CalcCrosshairColor; the 1.7 iw3mp.exe scan does the same): each frame the client clears and sets the crosshair bits of its predicted weapFlags. A player on a side compares the team of the previous crosshair player (crosshairClientNum) with their own: the same side sets 8; otherwise the hit is an enemy and sets 0x10 only within the weapon's enemyCrosshairRange, and out of it the scan stops with the crosshair player unchanged. A weapon with crosshairColorChange draws its reticle in g_TeamColor_MyTeam once bit 8 has held for cg_friendlyNameFadeIn ms, or in g_TeamColor_EnemyTeam once bit 0x10 has for cg_enemyNameFadeIn ms while cg_crosshairEnemyColor (cheat, archived, default on) is set; otherwise it is white. The crosshair name's fade-out follows the same branch. A cook-off-hold grenade grows the centre dot by a unit per 100 ms left in the fuse's current second while it cooks (#419, CG_DrawReticleCenter, cg_draw_reticles.cpp:717-766). In multiplayer the two bits exist only on the client: the server-set friendly-fire marker (cg_drawFriendlyFireCrosshair, hudfriendlyfire, Player_UpdateLookAtEntity) is single-player code in KisakCOD and has no strings in the 1.7 iw3mp.exe, so it is not drawn (#416). Harness stage client-hud cooks a grenade until the dot has grown (then waits out the throw, which can bounce back and kill the player). With sv_cheats turned on at the listen server (the control channel's forced server cvar write, as devmap sets it) and the player in god mode holding fire, it holds its sights on an enemy and on a teammate until the crosshair is drawn in each team's colour, never tinted with nobody under it, and with cg_crosshairEnemyColor 0 holds an enemy past the fade-in with the reticle white throughout. For each of the three, a live bot of the wanted side is put near the player with bots held still (bot_idle 1, restored afterwards) and the walker aims at that bot alone: left to the match it turned to the nearest bot of the side, which with every bot still could stand behind a wall for the whole step (#494). The server picks the bot's spot (devtele <bot> near <client> <dist>, Game::teleport_near): of 16 directions round the player, along the view first and then ever further to either side, the first where a standing player lands on walkable floor with a clear eye-to-eye sight line (the bots' sight trace), since a fixed offset could put the bot behind a wall or inside geometry, where neither the crosshair trace nor a bot finds the other. Three distances are tried, 25 s each, until one holds: 200, 150 and 250 units. A failure names, for each try, the bot as the client's snapshot and the server have it and the player's aim and crosshair bits; the killcam step likewise names where its bot stood and looked when no replay showed the body. The walker aims at a crouched or prone player's body (the control channel's entity list gives each player's stance).

The stock cgame and renderer switches that only stored a value now act (issue #369; KisakCOD cg_draw_debug.cpp, cg_draw_mp.cpp, cg_weapons.cpp, r_draw_method.cpp, r_state.cpp): cg_drawFPS draws the frame rate counter at the top right of a match in the small developer font (Off, Simple, SimpleRanges, Verbose; every mode but Off prints the rate with the slowest and fastest frame and the frame-time deviation, then the shortest, longest and average frame time in ms, as CG_DrawFPS does; from SimpleRanges on a -Scene- caption and the frame's batch count follow; the original's free memory, server frame time, triangle and texture counts measure what this engine does not keep; red below 55, white from 65 fps; cg_drawFPSLabels and cg_debugInfoCornerOffset apply; the default is Off, as a stock 1.7 profile saves it, where the 1.8 source registers Simple). cg_blood 0 plays the no-blood flesh and knife effects (impacts/flesh_hit_noblood, impacts/flesh_hit_knife_noblood), cg_brass 0 throws no shells, cg_subtitles 0 shows no subtitles, r_texFilterMipMode (Unchanged, Force Trilinear, Force Bilinear, Force MipMaps Off) and r_texFilterAnisoMin shape the texture samplers with r_texFilterAnisoMax (like it, they take effect with the next map). Cheat dvars are cheat protected as stock has them: sv_cheats is the server's (it comes with the systeminfo at every join and level start; devmap starts the listen server with it on, the menu's Start and map with it off; a console set sv_cheats is refused as write protected), a set or toggle of a cheat dvar while it is off is refused with the stock "is cheat protected" line, and when a server without cheats is joined every cheat dvar goes back to its registered value (Dvar_SetCheatState). With cheats on: cg_drawGun 0 leaves the view model out of the scene, cg_draw2D 0 draws none of the match's 2D (the open menus and the console stay), cg_drawShellshock 0 drops the blurred and flashed screen overlays, and r_fullbright 1 draws every surface with its unlit technique, with no glow and no dynamic lights, live. Accepted but refused with the stock "is not a valid value" lines, because this build cannot act on them: snd_khz other than 44 (the mixer runs at the device's rate), snd_enableEq 0 (the script equalizer cannot be switched off) and cg_drawLagometer 1 (no lagometer). r_depthPrepassModels is written by the graphics menu and read by nothing, as in the 1.8 source; gpad_rumble and input_viewSensitivity belong to the console menus and have no PC reader in stock either.

The remainder of #369 follows KisakCOD cg_view_mp.cpp:233-380,461-544, cg_servercmds_mp.cpp:547, cg_draw_mp.cpp:88 and r_scene.cpp:1833-1861 (GPL-3.0; CoD4 MP 1.8 reference, not an independently verified 1.7 binary match): cg_crosshairAlpha/cg_crosshairAlphaMin (cheat) scale and floor crosshair opacity; cg_centertime times centre prints (the 1.7 default here remains 3000 ms, versus 1.8's 5 s); cg_bloodLimit/cg_bloodLimitMsec limit blood effect frequency (330 ms by default, range 1–2000); cg_teamChatsOnly hides incoming global chat without rerouting what the player says; cl_noprint suppresses new console output without deleting old lines; vid_xpos/vid_ypos place the window at startup and vid_restart; r_brightness/r_contrast/r_desaturation (cheat) adjust the vision set's film without accumulating across frames, including stock's double desaturation application. The cg_heliKillCam*/cg_airstrikeKillCam* cheat dvars set the killcam cameras' distance, field of view, height, focus ranges and near/far Gaussian radii. cg_airstrikeKillCamCloseXYDist is registered but has no reader in the 1.8 reference either. The server registers g_redCrosshairs, g_banIPs, g_friendlyfireDist (256), g_friendlyNameDist, sv_iwds, sv_iwdNames, sv_showCommands, sv_padPackets and g_dumpAnims with stock defaults, flags and numeric domains so configs and scripts can read them; registrations alone do not implement packet padding or animation debugging.

Renderer tweak registrations follow r_dvars.cpp:615-633,781-821,1258-1358,1427-1452: r_filmUseTweaks and r_glowUseTweaks (cheat) replace the vision set's film and glow blocks. Film defaults are contrast 1.4, desaturation 0.2, dark tint 0.7 0.85 1 and light tint 1.1 1.05 0.85; its r_filmTweak* values are saved by ordinary set, while the cheat gate resets on joining a non-cheat server. Glow uses the stock r_glowTweakRadius0 (5), r_glowTweakBloomIntensity0 (1), cutoff (0.5) and desaturation (0), not aliases without the 0 suffix. r_blur (cheat, 0–32) adds in quadrature to menu/view blur (RB_GetBlurRadius); killcam depth-of-field remains separate. The depth-of-field, glow and screen blur filters take the stock vertical radius, the horizontal one times the scene pixel's shape for r_aspectRatio (RB_VirtualToSceneRadius, #402). cg_fov defaults to 65 as iw3mp.exe 1.7 registers it (--fov too); a set outside 1.7's registered 65 to 80 is refused with the stock lines and the value kept (Dvar_SetVariant). The vision and shellshock server commands reach the cgame's look again: the session no longer swallows them with the interface's commands, so visionSetNaked from scripts and the visionsetnaked console command change the picture; a shell shock ends at the next spawn or level, as stock's does. A grenade's blast keeps its entity until the damage callbacks it raised have run, as stock's G_ExplodeMissile frees it only after G_RadiusDamage, so the stock concussion script reads the grenade's position and shocks the player.

Clip, outdoor and sun controls are read every frame, with no vid_restart: r_znear (cheat, default 4) moves the scene's near plane, r_znear_depthhack (cheat, default 0.1) moves the view-model near plane, and r_zfar (cheat) is the culling distance, with zero off. r_outdoor, r_outdoorAwayBias (32), r_outdoorDownBias (0) and r_outdoorFeather (8) are saved controls for the outdoor height-map lookup; r_outdoor 0 keeps the outdoor mask from hiding the snow. New worlds seed the r_lightTweak* current and reset values from map lighting (r_bsp.cpp:97-121,165-207). The cheat sun intensity, byte-quantized color and direction replace directional sunlight while preserving the map's ambient floor; a lower intensity is clamped back to that floor, and the read-only diffuse fraction reflects it. Ambient strength, ambient color and diffuse color are read-only map diagnostics, not freely writable extra color multipliers. r_sun_from_dvars separately updates the sun sprite, flare, blind/glare curves, timings and direction from the r_sunsprite_*, r_sunflare_*, r_sunblind_*, r_sunglare_* and r_sun_fx_position sources (r_sky.cpp:58-244). It resolves changed material names through the linked asset DB, halves flare sizes, converts seconds to milliseconds and angles to dot thresholds, and retains the effective override when the gate is turned off, as stock does.

writeconfig <filename> exports archived settings and key bindings under the user config directory's players folder, adding .cfg when there is no extension (qcommon/common.cpp:1628-1657). It does not implicitly save or retarget the active profile, or clear pending profile changes; missing or extra arguments print usage without writing. The server reads developer_script (/# ... #/ blocks compile), g_synchronousClients (the latest received usercmd is retained and simulated each server frame with serverTime = level.time, including frames with no arriving command; the asynchronous path keeps queued input order and times, g_active_mp.cpp:801-833, g_client_mp.cpp:387-415) and sv_keywords (in getstatus, prefixed demo under fs_restrict; the original's getinfo does not carry it); sv_wwwDlDisconnected still has no reader because clients here download before they connect. Default stages client-gfx, client-commands and client-hud assert these consumers through untouched registration defaults, protected console writes, live renderer state, exported config contents, server command selection, output, chat and camera/DOF builders.

Wayland does not expose or permit global window positioning: there vid_xpos/vid_ypos still reach the video request but the compositor chooses placement. The slot's client-gfx position assertion checks that request on Wayland, and actual outer coordinates where the window system exposes them.

client-gfx hosts two listen-server matches with bots, so it also lets through the stock _missions::ch_kills error for a player their own frag killed (#557): after each match the stage asks the server for its script errors and counted frag suicides (state script), and fails on any other script error or on more ch_kills errors than frag suicides, as client-match does (#539).

screenshotJpeg (the stock F12 bind) and screenshot write the frame as shotNNNN.jpg / shotNNNN.tga into screenshots/ of the user's folder (screenshot <name> names the file, silent prints nothing). A gamepad button can be bound from the Controls menu: while an item waits for a key, the next pad button is the binding (Start cancels). Not done, on purpose: cinematic, unskippablecinematic and logo print "not supported (Bink video)" (the stock videos are Bink and there is no decoder we may use, so the startup intro does not play); the screenshot levelshot and screenshot savegame forms do nothing; a screenshot name with a path separator or .. is refused, so files stay in screenshots/; gamepad rumble, aim assist and glyphs are IW3x extras, not stock.

The view follows the original's cgame beyond first person: a dead player sees their body from behind, turned the way the blow came from (cg_thirdPersonRange, cg_thirdPersonAngle, a collision trace keeps the camera out of walls), and a killcam whose script named a killing entity hangs the camera on it (helicopter or airstrike views with their own field of view and blur). Depth of field comes from the player state: setDepthOfField / setViewModelDepthOfField write it (the stock scripts call it at every spawn), and with no scripted range the sights blur the near and far field around what they point at. Fog is the server's: setExpFog publishes it in a configstring and clients blend to it over the transition time; the createart text scan only serves the viewer modes with no server.

A level start fades the sound in as SND_MapInit does (issue #407; KisakCOD sound/snd.cpp:4487-4510, cgame/cg_servercmds.cpp:2222): every channel of the base volume group starts silent and rises to its volume over snd_levelFadeTime ms (cheat, default 250, 0–5000). stopsounds stops the voices and resets the other volume groups only, as SND_StopSounds does, so a running level fade carries on. The eye and gun bob follow the stock bob dvars (issue #408; KisakCOD bgame/bg_misc.cpp:621-657,2104-2108, bgame/bg_weapons.cpp:3622-3666): bg_bobMax (cheat, default 8, 0–36) caps the eye bob, and bg_bobAmplitudeStanding (0.007 0.007), bg_bobAmplitudeDucked (0.0075 0.0075), bg_bobAmplitudeProne (0.02 0.005) and bg_bobAmplitudeSprinting (0.02 0.014) give each stance's horizontal and vertical bob per unit of speed for the eye, the gun and the aimed view bob; the gun's and the aimed view's own caps stay the stock constants (10 and 45 degrees). The server sends them to clients with the other movement dvars, so prediction and the server's shots bob alike. client-commands launches with a non-default snd_levelFadeTime and checks that the initial level's sound faded in over that time; client-gfx, in its devmap match, checks that walking bobs the eye and that set bg_bobMax 0 on the host stops it.

Browser

The client crate also builds for wasm32-unknown-unknown (web/): one build, WebGPU when the browser has it and WebGL2 otherwise (--backend / the page's Graphics choice overrides; the F3 overlay shows the backend, frame times and memory). The page reads your install in the browser and never uploads it: pick the install folder (or drop it); with "keep a copy" on, the files the client reads (about 4 GB) are also saved in the browser's private storage (OPFS) so the next visit needs no pick. The wasm runs on the page's main thread and reads the install synchronously through a reader worker (web/reader.js, shared memory), so the page needs cross-origin isolation (COOP and COEP headers; web/serve.py sends them).

nix develop -c just web-build          # wasm-bindgen + wasm-opt into web/pkg
nix develop -c just web-serve 8080     # then open http://localhost:8080/

For automated runs web/serve.py --install <dir> also serves the install files under /install/ (development only, never on a public host) and ?dev-install=/install&autostart makes the page use them without a picker; ?flythrough flies the camera, ?report=N posts the overlay values to the server log after N frames. In the browser the pipelines are built a few milliseconds per frame (WebGL2 links shaders serially, 3232 of them on mp_crash) instead of all before the first frame: draws whose pipeline is not ready yet are skipped (the F3 overlay shows pipelines built and draws skipped), and ?args=--fly-at 3 holds the flythrough at a fixed time so pictures compare across builds. Harness stage web-client does this with a headless Chromium when one is found (COD4E_CHROME) and skips otherwise. Harness stage web-join starts a native server in the harness (WebTransport on a free port, nine bots in a team deathmatch on mp_crash, certificate hash from --wt-info), loads the page with ?connect=127.0.0.1:<port>&cert=<hash>&args=--autoplay --duration 100000, and waits for the page's report 15 frames after the server put the player in the world (?report=N counts from the spawn when ?connect is given). It passes when the page's net phase overlay field (connecting, connected, spawned) says spawned, snapshots arrived, surfaces were drawn, and the server itself lists a person (not a bot) playing; it records connect time, snapshots, page CPU ms, wasm memory and frames. It runs web/serve.py with python3 from the PATH (the devshell's), and skips like web-client.

client-fx runs cod4e --fx-selftest (no display, no GPU, the install needed): on mp_crash an explosion draws sprites and then ends, a bullet into the floor plays its impact effect, a shot plays its muzzle flash and ejects a shell that falls under phys_gravity and comes to rest, and the vision and shell shock files the stock scripts name are in the install and change the picture. Effects are drawn from server events: impacts, explosions, flesh, playfx, and a weapon-fire event for the flash (bolted to the gun's tag_flash and tag_brass, first person and third, following the gun while they play; a bolt-action throws its shell on the rechamber, and the last round of a clip has its own). Each bullet shot is a tracer with the client's cg_tracerchance (0.2; cg_firstPersonTracerChance 0.5 for the player's own), drawn as a corkscrewing beam in gfx_tracer that flies from the muzzle to what the shot's aim meets within 8192 units (cg_tracerScale* widen far ones); the selftest asserts a burst of five fired shots draws five beams, and that every sprite, decal and model material of every effect in the loaded zones is defined in full (none is left a reference to another zone's, #453). A knife blow announces what it struck (flesh or world, for the stock melee sounds and the blood on the attacker's knife), and a swing at an enemy near the middle of the screen lunges at them and pulls the view onto them (melee aim assist). Dead players leave a corpse entity (clonePlayer) that every client draws as a joint-limited rigid-body ragdoll (the install's ragdoll.cfg: capsule bones, hinge and ball joints with angle limits, limb self-collision) that blasts wake and throw and the map's clutter props are thrown by shots and blasts and effect models with USE_MODEL_PHYSICS (brass, debris) and models the script physicslaunched are PhysPreset rigid bodies (mass, bounce, friction, the model's physics geometry; each client's own simulation, on the map's collision, not shared); visionsetnaked, visionsetnight and shellshock reach the client as commands and drive the glow, film and saved-screen overlays (a shell shock also ducks the sound by channel, fades in its room reverb, rings its tinnitus with end stings and caps how fast the view may turn); the night vision goggles (n) blend to the night set. --fx-demo <effect> plays one effect in front of the camera every 1.5 s of a --listen run. Map maps/createfx/*_fx.gsc placements and maps/mp/*_fx.gsc effect mappings start with the level as the original's client-side effects do, including fire, rain, snow and dust; the selftest looks directly at every placement on mp_bog, mp_farm, mp_bloc, mp_crash_snow, mp_crash and mp_backlot and requires the named families to draw sprite quads. The match report's fx.ambient_quads counts those quads by effect name.

playfxontag(fx, entity, tag) validates the effect id (1–99), a model and an existing lowercase bone name, stores the two-digit effect id and tag in a 256-slot effect-tag configstring table (slot 0 unused), and raises EV_PLAY_FX_ON_TAG on the target entity. It therefore shares the entity's visibility rather than broadcasting a detached temporary effect. As CG_ResetEntity does, a client seeing an entity for the first time (a late join, or the entity re-entering view) replays none of its earlier events, except for general entities and missiles, which start from sequence 0. Stock's one exception (cg_snapshot_mp.cpp:419–422) covers the persistent smoke-grenade cloud entity seen more than 200 ms after it burst: its burst is not replayed, while its smoke effect resumes at the original age. A known entity's events are counted as CG_CheckEvents (cg_event.cpp:1366–1409) counts them on the 8-bit sequence. A sequence of 0 plays nothing. The last sequence counts as 256 lower only when it is more than 64 ahead of the new one, which is a wrap. A sequence just behind the last one therefore plays nothing, and at most the last four events play. Leaving a killcam resets every entity in the first live snapshot as new (CG_TransitionKillcam, cg_snapshot_mp.cpp:541–560). A number that held another entity in the replayed past therefore replays none of the live entity's events. Entering a killcam resets nothing, as in stock: a replayed entity plays its events only when its sequence is ahead of the live entity's at its number (#524). The client bolts the effect to the animated world tag each frame for its authored lifetime, on whatever the client draws the entity as: a script model, plane, turret, vehicle, player, corpse, item or projectile (CG_GetBoneIndex reads the entity's DObj). The bolt waits until the entity is first drawn with the model it had in the snapshot that carried the event. Entities are drawn 100 ms behind that snapshot, so the number may still show the entity's old model or another entity, such as the live world under a killcam's replayed past. If the entity is still not drawn after 1 s, the client warns effect attachment entity was never drawn and drops the bolt. Stock MP scripts bolt only to script models, planes and helicopters (C4 and claymore lights are spawnFx at the tag); existing local-space elements move with the tag, not just newly emitted particles. When the entity disappears, teleports or no longer has the tag (a model change), the effect is stopped and unbolted as FX_UpdateEffectBolt and FX_StopEffect do: it spawns nothing more, and what it already spawned plays out where the tag last was. Missing tags raise tag '<tag>' does not exist on entity with model '<model>'; a model-less entity raises cannot play fx on entity with no model. This follows KisakCOD (GPL-3.0, CoD4 MP 1.8) game_mp/g_scr_main_mp.cpp:4221–4254, cgame/cg_event.cpp:1286–1310, cgame_mp/cg_snapshot_mp.cpp:415–479 (CG_ResetEntity) and EffectsCore/fx_update.cpp:2205–2232; no 1.7 binary comparison was available. Our native event numbers and configstring layout remain our protocol's, not the original engine's wire format.

The client-fx stage also runs a real GSC playfxontag on a script model, poses it only from 100 ms after the event (the match draws entities that far behind the snapshot the event came in, so a bolt waits for its entity to be drawn, as stock reads the event when the entity's snapshot becomes current), moves it after 500 ms, and requires both its tag and already-live effect elements to move together (within one unit). It also bolts an effect to a projectile through the projectile model the match draws, requiring it to follow a 32-unit move, and requires a bolt on an entity never drawn to be reported once. It frees that script model and, in the same server frame, spawns an airstrike plane that plays stock playPlaneFx's four playfxontag calls. The plane must take a new entity number (#492). After the plane is freed, a script model takes its number, and two killcams replay the plane's flight:

  • In the first, the script model has no events yet, so its sequence is behind the plane's. Entering the killcam must play the plane's four events. These must bolt to the replayed plane once it is drawn, not to the script model drawn under the number 100 ms behind.
  • Before the second, the script model plays six events of its own live, which takes its sequence ahead of the plane's. Entering the killcam must play none of the plane's events.
  • Leaving either killcam must replay none of the script model's events (#524).

No tag may be reported missing or warned about anywhere in the run. The stage checks the target event and the missing-tag script error as well; absent measurements fail the stage instead of counting as a pass.

Those ambient effects are loaded as KisakCOD's CG_StartClientSideEffects and CG_ParseEffect do: the maps/mp/<map>_fx.gsc mapping names each effect key, the generated maps/createfx/<map>_fx.gsc gives each placement's origin, angles and delay, and the client plays each in the shared effect pool (the same culling, budgets and sounds as event effects), so looping effects keep their authored life; they restart with each level clock (map load and map_restart), and a looping effect late to its first spawn catches up at most a bounded number of loops. As CG_ParseSound does, the file's createLoopSound blocks and a oneshot effect's sound alias play as client entity sounds at their origins (at most 128, MAX_CLIENT_ENT_SOUNDS). The clientSideEffects cheat dvar (default 1) turns all of them off for the next level when set to 0 with sv_cheats on. Listen and autoplay reports count them by effect name in fx.ambient_played and fx.ambient_quads (every draw kind, credited to the placed effect's lower-case root, including the effects its runners spawn), list those that never drew in fx.ambient_without_quads, and record fx.ambient_live_effects_max and fx.ambient_sounds; the selftest's ambient_<map>_* metrics fail the stage if a map has no placements, live effects or sounds, its family (fire, rain or snow) draws no sprites, or the named snow effects of mp_bloc and mp_crash_snow do not draw.

client-viewmodel runs cod4e --viewmodel-tour 20 on mp_backlot, mp_crash and mp_strike (a GPU adapter and the install needed, no window): the first-person M16 and hands are drawn headless from the spawn points and from points on the way between them, with and without, and the pixels they change must not be dominated by one colour channel and the spawn views must be neither black nor blown out. It guards the reflection probe of moving models, which follows the original's R_CalcReflectionProbeIndex (the nearest probe of the cell holding the model, never the solid-red default probe 0 unless the cell has no other); a nearest-anywhere search once picked the default probe near the map's centre and painted the gun red.

Water materials animate with an ocean simulation (--fx-selftest checks that the surface moves and spreads on mp_farm's puddles); projectiles in flight play their weapon's trail effect, and trail elements draw as ribbons through the points the effect passed (the selftest flies a rocket and checks that the strip draws and ends). The portal walk clips at the eye rather than the near plane, so a doorway beside the camera no longer hides the view beyond it (mp_crash's alley). The remaining unknowns are settled as follows: mp_bog's blocky facades are the original's nearest-filter, no-mip sampler state, and iw3mp.exe writes D3DRS_SRGBWRITEENABLE as 0 with no sRGB sampler read found, so textures stay sampled as plain unorm.

just map-sweep <cod4e> <install> <out> [seconds] (scripts/map-sweep.sh) plays every stock map in war, dm, dom, koth, sab and sd with the windowed client (--listen --autoplay, five bots) and prints one table row per run (spawned, bots seen, shots fired by anyone, script errors, missing effect/vision/shock files); a run in which nobody fires is repeated at three times the length and fails if it still stays silent (big maps need a while before bots meet). just slot-exec runs it on the Linux test host with a display.

Controls in the page: click the game to lock the mouse (Esc gives it back and opens the menu; the page drops the lock on its own when the browser takes it, and the next click locks again), F11 or the togglefullscreen command switches fullscreen (the canvas and the field of view follow the new size), and a gamepad works through the browser's Gamepad API (gilrs; the same binds as native). F3 toggles the overlay, which also shows the view, the pointer lock and the sound output (running, frames_played, underruns, buffered, peak, shared). Sound starts with the page; until the AudioContext is allowed to run (a click or key) frames_played stands still and "Click to enable sound" shows. The sound tables load on the main thread in the browser (a one-off stall at the map load, which counts as underruns).

What the browser keeps: the settings and binds are written as a config_mp.cfg per profile to the browser's localStorage (key cod4e:players/profiles/<profile>/config_mp.cfg) two seconds after they change, and read back at the next visit, so a tab needs no exit to save them (the window size and fullscreen are the page's and are not kept). The install's language is line 1 of its localization.txt (default english): the page reads that language's zone/<language> folder, so a non-English install works as it does natively. Joining a server whose map (or mod) the install lacks fetches it first when cl_allowDownload allows (the page's connect field, ?connect=): the client asks the server's manifest over the WebTransport session, downloads the files it lacks (checked by size and CRC-32), mounts them in the install it reads and has the page keep them in the browser's private storage (cod4e-downloads, next to the saved install copy), so the next visit has them; "Delete the saved copy" removes both. The fast-download web server (sv_wwwDownload) is not used by the browser: its fetch needs CORS headers on that server, so the browser takes the files from the game server. The graphics menu's resolution list holds the sizes that fit the screen: a windowed choice resizes the canvas, fullscreen takes the screen (the browser grants it only right after a click or key press); the browser gives no say over the refresh rate. Harness stage web-download joins a server whose custom map the browser lacks, then opens the page again and checks it downloads nothing.

What the browser does not do, on purpose: it hosts no listen server (no threads and no sockets; the stock Start New Server path and the map command open the error popup "Starting a server is not available in the browser. Use Join Game." instead of a load), it does no UDP master lookup and keeps no favorites (crates/client/src/serverlist.rs; the server list is the connect field), the harness's captures come from the canvas rather than a GPU read-back, and mouse look needs the pointer lock, which the browser grants only to a click the player made (the harness gets one through Chromium's DevTools pipe; injected deltas before it are thrown away as "pointer free"). Everything else a match asks of the client runs from the same code as the native build, which harness stage web-match proves: a headless Chromium joins a native server over WebTransport without --autoplay, and the stage drives it through the dev control channel's browser transport (?control: the page long-polls web/serve.py's GET /control/next and posts replies to POST /control/reply; the harness sends one request per POST /control, the same JSON as the native socket, see docs/control-channel.md): it clicks Marines and Assault in the stock menus, clicks the canvas and turns with a mouse delta through the channel, walks 64 units (turning a quarter when a wall stops it), fires (clip down, EV_FIRE_WEAPON delivered), throws a grenade (its explosion arrives), requires the ammo, weapon name and compass map ownerdraws, needs a sound voice started, holds the scoreboard (it must stay up), is killed by a bot and sees the killcam and the kill line, ends the match on a score limit and follows the rotation into mp_backlot in the same page, leaves through the Escape menu (the server drops it), and opens Start New Server to assert the popup. It then runs the native client on the same map, gametype and bot count and records the measured parity (web-match/native/client.json against web-match/browser-parity.json): distance walked, shots, and the HUD ownerdraws each drew that the other did not. The bridge is off unless the page has ?control, and a POST /control to a page without it answers 504 after 30 s.

The default web-match stage first asserts that bridge-off behavior with a real wasm page without control. CDP independently proves runtime readiness, then a native HTTP request must receive the exact 504 diagnostic after at least 30 seconds (45-second read deadline). web-match/control-disabled.json records readiness, status/body, elapsed seconds and cleanup; the isolated browser/process group and profile are removed before the normal player-driven match begins. The accelerated python3 -m unittest web.test_serve unit check (python in Windows CI; included in just check) is distinct from this real-delay Linux browser acceptance.

The parity run records the full native/browser ownerdraw-id difference, not an equality claim between different play paths: cursor hints (72) depend on nearby usable objects, and the full-map compass (181/182/183/185/188) depends on which menus were shown. Both clients must draw the gameplay ammo, weapon-name and compass ownerdraws. The sound assertion requires a currently active voice as well as a voice-start count; the native shot measurement comes from the listen server's player statistics.

Both browser stages probe navigator.gpu.requestAdapter() and record its outcome and adapter identity, rather than treating API presence or a missing render report as proof of availability. web-match fails if an adapter is offered but the client selects WebGL2; web-client requires a WebGPU render report whenever its probe succeeds. The movement measurement must stay alive in one spawn: death, killcam or a changed spawn count fails the attempt instead of counting a respawn teleport as walking.

The harness Chromium runs headless with SwiftShader behind both APIs (--enable-unsafe-webgpu --enable-features=Vulkan --use-angle=swiftshader --use-vulkan=swiftshader --use-webgpu-adapter=swiftshader --enable-unsafe-swiftshader), so it offers a WebGPU fallback adapter (architecture: swiftshader, isFallbackAdapter: true) and web-match plays on WebGPU. Without the Vulkan feature the GPU process cannot create shared images, and ANGLE on Vulkan fails with an internal Vulkan error. The browser WebGPU backend of wgpu does not pass a render pass's occlusionQuerySet to the browser, so the sun-flare visibility probe would invalidate the command buffer; on browser WebGPU (as on GL) the sun uses its screen-rect fallback instead of an occlusion query.

Layout

craterole
assetsVFS, IWD, fastfile decoding, IWI, typed assets
gscGSC compiler and bytecode VM
simcollision traces, player movement, weapons
netnetchan, snapshot delta codec, transport trait
servermatch simulation and cod4e-server; no GPU, window, or audio dependencies
mastercod4e-master, the master server: the list of public servers (no game files needed)
sm3D3D9 SM3 bytecode to WGSL
renderwgpu renderer
audioordered-zone sound alias tables (including name-only sample references), realtime-safe mixer (Mixer::fill, AudioWorklet-ready), WAV/MP3 decoding, cpal output (AudioWorklet in the browser)
clientcod4e, the player-facing client (native, and wasm for web/)
harnesscod4e-harness, scenario runner and run bundles

The default asset-load stage also samples every decoded MP animation at each frame and midpoint, including the held non-looping endpoint (pb_runjump_takeoff must be present). It fails on any out-of-range data/index access or non-finite pose; animation read warnings are not allow-listed. Endpoint sampling reads only the last quaternion, following KisakCOD's XAnimCalcNonLoopEnd (src/xanim/xanim_calc.cpp:921-956, GPL-3.0; CoD4 MP 1.8). The installed 1.7 fastfile uses the same 88-byte XAnimParts layout (src/xanim/xanim.h:151-182, src/database/db_load.cpp:1316-1377) and byte/short frame-index boundary (db_load.cpp:1080-1096, 256 frames); this fix changes neither the loader nor delta/notify data. Native 1.7 binary analysis was unavailable, so this is a source-reference fix checked against the installed 1.7 content.

Comentarios (0)

Inicia sesión para dejar un comentario.

Sé el primero en comentar sobre este juego.