Hold every docs file to the fifty-line limit it claims for itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkgiSVEPTbsVZ1RpARujF6
This commit is contained in:
vtroc
2026-09-15 01:53:18 +03:00
parent 846ab99a73
commit 25c289bae0
7 changed files with 211 additions and 366 deletions
+26 -43
View File
@@ -1,9 +1,8 @@
# Shipping a release
Players update themselves — but not entirely. What `make deploy` does, what
reaches the Windows folder with no human involved, and what does not. The
contents of that folder are [`WINDOWS.md`](WINDOWS.md). Your own master rather
than ours is [`MASTER.md`](MASTER.md).
What `make deploy` does, what reaches the Windows folder with no human involved,
and what does not. That folder is [`WINDOWS.md`](WINDOWS.md); your own master
rather than ours is [`MASTER.md`](MASTER.md).
```bash
make setup-windows # rustup target + cargo-xwin, once
@@ -16,52 +15,36 @@ make logs prod|dev SINCE=2h
```
Each recipe is one `cargo xtask` command — `windows`, `release`, `publish`,
`provision`, `logs`, `certs` — and `make` only forwards `PROFILE` and
`RELEASE`. `cargo xtask` on its own lists them.
`prod`/`dev` is the publication channel, not the compilation profile. Address
and root are `IW4L_DEPLOY_HOST` / `IW4L_DEPLOY_ROOT` in `.env`. prod: udp/4433
and `https://<host>:8443/prod/`; dev: 4434 and `/dev/`. Git, protocol and SHA
are frozen into `deployment.json` at `release`, not at `publish`.
`provision`, `logs`, `certs` — and `make` forwards only `PROFILE` and `RELEASE`;
`cargo xtask` alone lists them. `prod`/`dev` is the publication channel, not the
compilation profile; address and root are `IW4L_DEPLOY_HOST` /
`IW4L_DEPLOY_ROOT` in `.env`. prod: udp/4433 and `https://<host>:8443/prod/`;
dev: 4434 and `/dev/`. Git, protocol and SHA freeze into `deployment.json` at
`release`, not at `publish`.
An ordinary publish compiles nothing, calls no zstd, rewrites nothing under
`/etc` and does not restart the master if its SHA matched. The uncompressed
`iw4l.exe` stays local. The archive is named `iw4l-<sha>.exe.zst`; the old
`iw4l.exe.zst` on the VPS is not deleted. The manifest is swapped in by rename.
`/etc` and leaves the master running if its SHA matched. The uncompressed
`iw4l.exe` stays local, the archive is `iw4l-<sha>.exe.zst`, the old one on the
VPS survives, and the manifest is swapped in by rename.
`iw4launcher.exe update` reads `IW4L_UPDATE_URL` from the sidecar `.env`,
fetches `manifest.json`, checks size/sha256 and downloads `download`. Without
that verb it starts the `iw4l.exe` already in the folder and makes no request at
all — the archive ships one, so updating is a thing the player asks for rather
than a thing that happens to them. The launcher never updates itself. The
player's `.env` is created once and never overwritten.
Changing `master_protocol::PROTOCOL_VERSION` is also a change of `ALPN`: an old
client is refused at the handshake instead of silently decoding a drifted
format. The master and the clients of such a release ship together; `publish`
restarts the master, because its SHA changed.
Changing `master_protocol::PROTOCOL_VERSION` also changes `ALPN`: an old client
is refused at the handshake instead of silently decoding a drifted format. The
master and the clients of such a release ship together, and `publish` restarts
the master because its SHA changed.
`make launcher windows` builds the portable ZIP in `dist/windows/`; it is not a
deploy. A fix to the updater arrives only by copying `iw4launcher.exe` by hand.
deploy, and a fix to the updater arrives only by copying `iw4launcher.exe` by
hand. What the player's launcher does with `IW4L_UPDATE_URL`: [`WINDOWS.md`](WINDOWS.md).
## Cutting a public release
Tag `v0.1.0-demo.N`, titled `IW4L Technical Demo N`, and mark it a
**pre-release** on GitHub — the runtime is experimental and nothing about it is
production-ready. The release notes carry four things and are not worth writing
without them:
* the commit it was built from;
* the platforms the archive was actually **run** on. A target that compiled is
not a verified platform, and a Linux release does not wait for a Windows
archive that has not been started yet — nor the other way round;
* the demo scenario: which game, which version, which map, what to do;
* the known limitations.
**Cutting a public release.** Tag `v0.1.0-demo.N`, titled `IW4L Technical
Demo N`, and mark it a **pre-release**. The notes carry four things and are not worth writing without
them: the commit it was built from; the platforms the archive was actually
**run** on; the demo scenario (which game, version and map, and what to do); the
known limitations. A target that merely compiled is not a verified platform, and
a Linux release waits for no unstarted Windows archive, nor the reverse.
Every archive carries `LICENSE`, `NOTICE` and the two font licences
(`release.rs::LEGAL_FILES`) and no game data. Check the archive, not just
`git ls-files`: `make publish-check` reads the tracked tree and cannot see what
was staged into a ZIP.
The published tags are not moved afterwards, and the history is not recreated
again once anything has been pushed.
was staged into a ZIP. Published tags are never moved, and history is never
recreated once anything has been pushed.
+24 -28
View File
@@ -3,24 +3,23 @@
## There is one funnel
```text
human ─┐
bot ├─→ TickInput ─→ sim::step ─→ Snapshot ─→ client / theater / demo
replay ─┘
```
human ─┐
bot ├─→ TickInput ─→ sim::step ─→ Snapshot ─→ client / theater / demo
replay ┘
```rust
pub fn step(world: &mut SimWorld, tick: Tick, input: &TickInput, msec: i32) -> Snapshot;
pub fn step(world: &mut SimWorld, tick: Tick, input: &TickInput,
msec: i32, reason: StepReason) -> Snapshot;
```
Headless, prediction, replay and the determinism test call exactly this one.
`msec` is the length of the step; time enters **only** as an argument. Details
in `crates/sim/src/lib.rs`. One funnel in, one snapshot out.
`msec` is the step length; time enters **only** as an argument. The shape of
the funnel is [`SIM-STEP.md`](SIM-STEP.md), the code `crates/sim/src/lib.rs`.
## What can be an entity at all — fixed by the data (`entity_iw4`)
The wiring is ours, the taxonomy comes with the data. The crate neither
serializes nor orders fields "for the network": it pins the layout it reads and
leaves transport to `net`.
serializes nor orders fields "for the network": it pins the layout it reads,
and leaves transport to `net`.
| | |
|---|---|
@@ -28,27 +27,24 @@ leaves transport to `net`.
| `Centity` | client-side slot, stride `0x204` (pose plus snapshot anchors) |
| `ClientState` | per-client slot, stride `0x7c` (command, `name[16]`) |
| `CorpseInfo` | server-side corpse slot, stride `0x53c`, 8 of them |
| `ET_PLAYER` / `ET_PLAYER_CORPSE` / `ET_ITEM` / `ET_MISSILE` | |
| `Trajectory` | `BG_EvaluateTrajectory`: gravity 400, delta 800 |
| events | the `CG_EntityEvent` ring, an 11-bit wrapper |
| `ET_PLAYER` / `ET_PLAYER_CORPSE` / `ET_ITEM` / `ET_MISSILE` | |
| `Trajectory` | `bg_evaluate_trajectory`: gravity 400, delta 800 |
| events | the entity-event ring, an 11-bit wrapper |
| `ENTITYNUM_NONE` | `0x7FF` |
The player and the input are `playerstate_iw4` (`playerState_t`, `usercmd_s`):
the two ends of the funnel.
| `playerstate_iw4` | player and input: the two ends of the funnel |
## Where everything else lives
* `sim/` — `gentity.rs`, `spawn.rs`, `world.rs`, `snapshot.rs`, `damage.rs`,
`bullet*.rs`, `missile.rs`, `item.rs`, `corpse.rs`, `match_state.rs`,
`score.rs`, `rules.rs`, `world_objects.rs`, `identities.rs` (`ScriptModelId`
is the one identity of a script model; a raw `u32` does not get to be a second);
* `frame` — frame and session markers: `AppScreen`, `HasWorld`, `RuntimeRole`,
`LaunchIdentity`. No plugins, no systems;
* `sim/` — `gentity spawn world snapshot damage bullet* missile item corpse
match_state score rules world_objects identities` (`ScriptModelId` is the one
identity of a script model; a raw `u32` is not a second one);
* `frame` — `AppScreen`, `HasWorld`, `RuntimeRole`, `LaunchIdentity`; no
plugins, no systems;
* `session` — standing the match up and tearing it down (`MatchInstalled` /
`MatchTornDown`); `net` — the wire, deltas, prediction; `bots` — host-only
controllers that observe through a sensor adapter, walk a ClipMap-baked
graph, and enter the same `TickInput` funnel. Geometric `sim::step` scenes
in `bots` tests are the mapless check that a `UserCmd` actually moves.
`MatchTornDown`); `net` — wire, deltas, prediction; `bots` — host-only
controllers that observe through a sensor adapter, walk a ClipMap-baked graph
and enter the same `TickInput`. Geometric `sim::step` scenes in `bots` tests
check maplessly that a `UserCmd` moves.
The snapshot publishes the **semantics** of DObj composition, not runtime trees
and not posed vertices — see [`ANIM.md`](ANIM.md).
Snapshots publish the **semantics** of DObj composition, never runtime trees or
posed vertices ([`ANIM.md`](ANIM.md)).
+33 -53
View File
@@ -2,68 +2,48 @@
Three floors: **disk** (`asset_transport`), **format** (`fastfile_*` → the
`asset_iw4` IR), **products** (`asset_*` + `assets`). `assets` knows the disk
through the transport and knows nothing about the format; `fastfile_*` knows
the format and opens no files. Installing the match is not here — it is in
`session`.
## The transaction: who calls whom
through the transport and nothing of the format; `fastfile_*` knows the format
and opens no files. Installing the match is `session`, not here.
```text
console/ui `map` → session::lifecycle SessionSwapRequest → tear down the previous
occupancy, then MapLoadApproved{request_id}
→ assets::match_load approve_map_load find_zone_file / find_runtime_common_mp
start_match_load spawn onto load_pool() (MatchLoadBusy)
poll_match_load → PreparedMatchReady
→ session::match_apply apply_prepared_match → scene + SimWorld + MatchInstalled
console/ui `map` → session::lifecycle SessionSwapRequest, tear the previous
occupancy down, then MapLoadApproved{request_id}
→ assets::match_load approve_map_load / start_match_load onto load_pool()
(MatchLoadBusy) / poll_match_load → PreparedMatchReady
→ session::match_apply apply_prepared_match → scene + SimWorld + MatchInstalled
```
Cancellation is `MatchLoadAbort(request_id)`: the walk returns
`MatchLoadOutcome::Canceled` at its next checkpoint and there is no prepared
match to throw away. Failure is `MapLoadFailed` — from discovery *and* from
install; `LoadingScreen` holds the failed install until teardown and cannot
turn it into a success. Every accepted request ends once: `MatchInstalled` or
`MapLoadFailed`, never both.
`MatchLoadOutcome::Canceled` at its next checkpoint, with no prepared match to
throw away. Failure is `MapLoadFailed`, from discovery *and* from install, and
every accepted request ends once — `MatchInstalled` or `MapLoadFailed`.
## The walk itself: `assets::session_load::load_prepared_match`
## The walk: `assets::session_load::load_prepared_match`
The single entry point; the composition root sees neither `fastfile_*` nor
`AssetSink`. Inside are several parallel walks on `load_pool()`: opening the
archive, startup materials, `common_mp`, the IW5/T5 weapon bundle, language
zones. `common_mp` goes **before** the map: its techset tables are needed
before `build_world_draw`. The output is `MatchLoadOutcome::Ready` with a
`PreparedMatch` (world, one `MatchMaterials` population, clip, weapons,
catalogs, `PreparedMap` — the single owner of spawns and map facts, `report`).
Ownership rules the walk keeps: the zone arenas die with the walk that reads
them (only their size travels), a consumed donor catalog is moved row by row
rather than cloned, and the merged material population is finalized once —
whether or not the map has a `GfxWorld` — and never lives inside the geometry.
The single entry point; the composition root sees no `fastfile_*` and no
`AssetSink`. Inside are parallel walks on `load_pool()`: the archive, startup
materials, `common_mp`, the IW5/T5 weapon bundle, language zones. `common_mp`
goes **before** the map — its techset tables are needed before
`build_world_draw`. Output is `MatchLoadOutcome::Ready` with a `PreparedMatch`
(world, one `MatchMaterials` population, clip, weapons, catalogs and
`PreparedMap`, the single owner of spawns and map facts).
The per-game adapter is `assets::lane` (`ZoneGame` → iw4/iw5/t5), the only
exhaustive `match ZoneGame` in the project; the runtime stays game-blind. A gap
in the lane is a typed `LaneGap`, not silence.
exhaustive `match ZoneGame` in the project; the runtime stays game-blind and a
gap in a lane is a typed `LaneGap`, not silence.
## Installing it: `session::match_apply`
Installing it is `session::match_apply`, in two halves. Preflight builds a
`MatchInstallPlan` — mode, doors, objectives, scene conversion, drawable world —
and publishes nothing until it hands one over, so a refusal leaves no
half-installed resources. Commit publishes it, boots the sim, writes
`MatchInstalled`.
Two halves. Preflight builds a `MatchInstallPlan`: mode, doors, objectives,
scene conversion, drawable world. Nothing of the match is published until it
hands one over, so an ordinary refusal leaves no half-installed resources
behind and completes the swap with `MapLoadFailed`. The commit half then
publishes the plan, boots the sim and writes `MatchInstalled`.
## Cache, and poking it: `iw4l-artifacts/cache/<kind>/<key>`, content-addressed
## Cache
`iw4l-artifacts/cache/<kind>/<key>`, content-addressed, leaf in
`asset_transport::artifact_cache` (`cache_get` / `cache_put`, `fnv1a64`). A
miss is silent: the caller computes the value anyway, and a hit is obliged to
be the **same bytes** a miss would have written. The key names every
input; if the encoder changed, bump the format word at the caller. Live kinds:
`mips` (`asset_material/material_images.rs`), `wgsl`
(`render_frontend/.../wgsl_disk_cache.rs`).
## How to poke it
`IW4L_GAMES` is the root holding the game trees; no folder name is hardcoded
anywhere (T1); the container version picks the decoder, not the folder name
(T2). Live, it is `make map mp_boneyard` ([`RUN.md`](RUN.md)); the stages of the
walk are visible in `LoadProgress` and in the `.pftrace` ([`PERF.md`](PERF.md)).
Leaf in `asset_transport::artifact_cache` (`cache_get` / `cache_put`, `fnv1a64`).
A miss is silent — the caller computes the value anyway — and a hit must be the
**same bytes** a miss would have written. The key names every input; if the
encoder changed, bump the format word. Live kinds: `mips`, `wgsl`. `IW4L_GAMES`
is the root holding the game trees; no folder name is hardcoded and the container
version picks the decoder. Live it is `make map mp_boneyard` ([`RUN.md`](RUN.md)),
with stages in `LoadProgress` and the `.pftrace`.
+29 -59
View File
@@ -1,80 +1,50 @@
# Your own master
`iw4l-master` is a relay and a server browser, not a game server: the host's
client simulates the match. No dedicated server ships in this repository. The
crate pulls in no engine dependency, so a VPS needs neither assets nor a GPU.
Publishing *our* releases is [`DEPLOY.md`](DEPLOY.md).
`iw4l-master` is a relay and server browser, not a game server: the host's client
simulates the match. It pulls in no engine dependency, so a VPS needs no assets
and no GPU. Publishing our own releases: [`DEPLOY.md`](DEPLOY.md).
## Install
Everything runs from the machine holding the clone; the VPS needs only ssh.
## Install — from the machine with the clone; the VPS needs only ssh
```bash
cargo xtask master install root@1.2.3.4
cargo xtask master logs root@1.2.3.4 --since 10min
```
`install` mints a CA and a server certificate under `~/.iw4l/ca`, builds the
static binary (`crt-static`, so the VPS glibc version does not matter), places
it in `/usr/local/lib/iw4l/` and the certificates in `/etc/iw4l/` (key
`0640 root:iw4l`), writes the unit through `iw4l-master print-unit`, opens the
channel port and runs `enable --now`. The CA private key never leaves the
local machine.
`install` mints a CA and server certificate under `~/.iw4l/ca`, builds a static
binary, installs it with the certificates under `/usr/local/lib/iw4l/` and
`/etc/iw4l/`, writes the unit through `iw4l-master print-unit` and runs
`enable --now`. The CA key never leaves the local machine. `master update`,
`status` and `uninstall` follow, each taking `--channel dev` and `--ca DIR`;
`uninstall` keeps the certificates and CA, so a reinstall stays trusted.
The binary prints its **own** unit, so `ExecStart` cannot drift from what
`serve` parses; port and unit name come from `master_protocol::Channel`. Then
`master update` (rebuild, upload, restart), `status`, `uninstall`. Every verb
takes `--channel dev` for the second port and `--ca DIR` for another CA;
`uninstall` keeps both certificates and CA, so a reinstall stays trusted by
everyone who already has your `iw4l-ca.pem`.
## No domain required — the certificate is not tied to one
## No domain required
The client connects by address but checks the certificate against a **separate**
name — `IW4L_MASTER_SERVER_NAME`, a fixed label — so nothing in the SAN depends on
the host; with `IW4L_MASTER_CA_CERT` set it loads only that PEM into an empty
`RootCertStore` (`net/src/transport/master.rs:3107`), bypassing the platform
verifier. `install` signs `San::Labels` alone: the certificate is minted **once
per user, not per server**, and a new IP or VPS keeps it.
The client connects by address but verifies the certificate against a
**separate** name, and with `IW4L_MASTER_CA_CERT` set it puts only that PEM
into an empty `RootCertStore` (`net/src/transport/master.rs:3107`, `:3150`) —
the platform verifier is bypassed entirely.
## What it records — nothing on disk
| path | certificate checked against | host in SAN |
|---|---|---|
| client → master, QUIC | `IW4L_MASTER_SERVER_NAME`, a fixed label | not needed |
| updater → Caddy, HTTPS | host from `IW4L_UPDATE_URL` (`updater:267`) | needed |
Rooms, the advertised match name, peers and room membership live in `ServiceState`
in memory, gone when the room closes or the process restarts. No account, history,
analytics or telemetry. What survives is the systemd journal —
startup and failures, not matches or players — under the VPS's own `journald`
retention. Peer IPs are visible to the kernel and to any packet capture on that
host while a connection is open, as with any server. Run it for others and that is
the honest description: it forwards packets and vouches for nobody.
`San::WithHost` / `cert_covers_host` in `xtask/src/certs.rs` serve the second
row, our CDN. Your own master runs no Caddy, so `install` signs `San::Labels`
alone and nothing in the SAN depends on the host: the certificate is minted
**once per user, not per server** — a new IP or a new VPS keeps the same one.
## Hand out to players — this block plus `iw4l-ca.pem`
## What it records
Nothing on disk. `iw4l-master` opens no file, no database and no log of its own:
the state it holds — rooms, the advertised match name the host typed, peers and
who is in which room — lives in `ServiceState` in memory and is gone when the
room closes or the process restarts. There is no account, no history and no
analytics; the runtime sends no telemetry to it.
What survives is the systemd journal, and only what the process writes to
stderr — startup and failures, not matches or players. Its retention is the
VPS's `journald` configuration, not ours; `cargo xtask master logs --since` just
reads it. The transport is QUIC, so peer IP addresses are visible to the kernel
and to any packet capture on that host for as long as a connection is open, the
same as for any server you connect to.
If you run the relay for other people, that is the honest description to give
them: it forwards packets between clients who are already talking to each other,
and it vouches for nobody.
## Hand out to players
`install` prints the block below; give it and `iw4l-ca.pem` to your friends:
The host opens a match with `IW4L_MASTER_HOST_NAME='name' make map mp_boneyard`,
the rest join from `make menu`, 2..=18 (`IW4L_MASTER_MAX_PLAYERS`). Trust rests
entirely on pinning `ca.pem`, so hand it over a trusted channel.
```dotenv
IW4L_MASTER_ADDR=1.2.3.4:4433
IW4L_MASTER_SERVER_NAME=iw4l-prod
IW4L_MASTER_CA_CERT=/path/to/iw4l-ca.pem
```
The host opens a match with `IW4L_MASTER_HOST_NAME='name' make map mp_boneyard`;
everyone else joins from `make menu`, 2..=18 (`IW4L_MASTER_MAX_PLAYERS`). The
label `iw4l-prod` is identical for everyone, so trust rests entirely on pinning
`ca.pem` — hand it over a channel the players trust.
+36 -73
View File
@@ -1,87 +1,50 @@
# Render: what lives where
An island of nine crates: `render` (composition and diagnostics only),
`render_frontend`, `render_gpu`, `render_frame`,
`render_backend`, `render_material`, `render_anim`, `render_fx`,
`render_scene`. `render_frontend` reads the presented snapshot, `render_gpu`
writes the pixels. The island decides no game logic and never writes
`AuthorityWorld`. No asset decoding happens here.
Nine crates. `render_frontend` reads the presented snapshot, `render_gpu` writes
the pixels and solely owns `wgpu`. The island decides no game logic, never
writes `AuthorityWorld`, decodes no assets.
## What lives where: `render_frontend` (three seams) + neighbours
* `prepare/` — scene/view/cull (`scene/world.rs`, `scene/spawn.rs`,
`scene/cull.rs`, `scene/camera.rs`, `scene/gfx_scene.rs` anim-submission
bridge, `worker_cmds.rs`); occupancy Resources, lighting request/result, frustum planes, and camera
pose — `render_scene`;
* `adapters/` — occupancy out of animation and FX (`adapters/anim/*`,
`adapters/fx/*`);
* `assemble/` — the immutable products of a frame (`drawsurf/retained_list.rs`,
`drawsurf/frame_products.rs`, `drawsurf/material_runtime.rs`,
`drawsurf/tess/`);
* `render_anim` / `render_fx` — producers of poses and FX; `render_scene` —
occupancy storage; `render_frame` — the immutable snapshot of frame products
(`SourceRevisions`, `PackedSegments`, pure packing) at the frontend ↔ GPU border;
`render_material` — administrator of the material tables; `render_backend` —
the list views; `render_gpu` — the sole owner of `wgpu`;
`render::diag/` — observation.
| crate | owns |
|---|---|
| `render_frontend` | `prepare/` scene, view, cull (`scene/{world,spawn,cull,camera}.rs`, `scene/gfx_scene.rs` anim-submission bridge, `worker_cmds.rs`); `adapters/` occupancy out of anim and FX; `assemble/` frame products (`drawsurf/{retained_list,frame_products,material_runtime}.rs`, `drawsurf/tess/`) |
| `render_scene` | occupancy storage: lighting request/result, frustum planes, camera pose. `render_anim` / `render_fx` produce the poses and FX |
| `render_frame` | the immutable frame-product snapshot at the frontend ↔ GPU border (`SourceRevisions`, `PackedSegments`, pure packing) |
| `render_material`, `render_backend` | the material tables; the list views |
| `render` | composition, `extract.rs`, `diag/` |
## The frame path
```text
zone → asset_iw4 IR → prepare/scene/world.rs the world in our own types
→ prepare/scene/spawn.rs GPU spawn
prepare/scene/camera.rs (look/WASD), frustum.rs + cull.rs + dpvs_iw4
→ assemble/drawsurf/list.rs ONE sorted GfxDrawSurf[]
→ setup.rs R_SetupMaterial — techType → technique
→ state.rs R_ChangeState — GfxPassState / blend
zone → asset_iw4 IR → prepare/scene/world.rs (our own types) → spawn.rs (GPU)
camera.rs (look/WASD), frustum.rs + cull.rs + dpvs_iw4
→ assemble/drawsurf/list.rs ONE sorted GfxDrawSurf[]
→ setup.rs techType → technique → state.rs GfxPassState / blend
→ tess/{world,smodel,xmodel,fx,mark,glass,particle_cloud}.rs
→ material_runtime.rs N-pass execution or a typed refusal
→ sm3*.rs SM3 tokens (`d3d9_sm3`) → WGSL → gpu_contract.rs → wgpu
→ material_runtime.rs N-pass execution or a typed refusal
→ sm3*.rs SM3 tokens (`d3d9_sm3`) → WGSL → gpu_contract.rs → wgpu
```
The key point: **one** drawsurf machine, with only the tess emitters forking
per surface type. World, static models and the viewmodel are not three
renderers; they are three emitters into one list. Lighting: `lighting_iw4` (light grid) plus
`prepare/scene/smodel_lighting.rs` and the `model_lighting_atlas.rs` atlas;
shadows are `assemble/drawsurf/sun_shadow.rs`.
**One** drawsurf machine; only the tess emitters fork per surface type. World,
static models and the viewmodel are three emitters into one list, not three
renderers. Lighting is `lighting_iw4` (light grid) with
`prepare/scene/smodel_lighting.rs` and `model_lighting_atlas.rs`, shadows
`assemble/drawsurf/sun_shadow.rs`.
## GPU-side ownership
## GPU-side ownership: `render_gpu/src/drawsurf/colour_submit/`, three owners
`render_gpu/src/drawsurf/colour_submit/` — one list, three owners:
* `geometry.rs` + `residency.rs` — stream residency. Allocation capacity, live
length, allocation generation and the uploaded producer revision are four
different quantities: a frame that shrank does not recreate the buffer, and a
producer segment that changed (`render_frame::PackedSegments`) is uploaded
alone rather than the whole stream;
* `geometry.rs` + `residency.rs` — residency. Capacity, live length, allocation
generation and uploaded revision are four quantities: a shrunk frame keeps its
buffer and a changed `PackedSegments` segment uploads alone;
* `prepare_camera.rs` — shared installation resets generations and publishes
shadow views, then sun, spot and camera prepare in order. Executors and arenas
are pass-local; binding slots and the shadow texture table still share mutable
ownership. The camera's pretess cache is `CameraWorldPretess`; shadows do not
hold it. There is exactly one camera: a second `Camera3d` is the typed
refusal `MultipleCameraViews`;
* `record.rs` + `indirect.rs` — recording. The direct path is `draw_indexed`;
with `IW4L_MULTI_DRAW` plus `INDIRECT_FIRST_INSTANCE` and
`DownlevelFlags::INDIRECT_EXECUTION`, adjacent compatible runs of the same
plan are written as a single `multi_draw_indexed_indirect` (the `multi_draw` /
`multi_draw_cmds` counters). Order and per-command `first_instance` do not
change.
shadow views; sun, spot and camera then prepare in order, on pass-local
executors. A second `Camera3d` is the typed refusal `MultipleCameraViews`;
* `record.rs` + `indirect.rs` — recording. Direct path `draw_indexed`; with
`IW4L_MULTI_DRAW` and the indirect downlevel flags, adjacent compatible runs
merge into one `multi_draw_indexed_indirect`, order preserved.
The `render/src/extract.rs` bridge publishes `InstalledRenderWorld` when static
geometry, ports or SMC maps change, and `PublishedRenderFrame` every frame,
checking frame/world/material identity.
CPU image handles share the `assets::image_handles` Arc with the upload registry.
The loader's decisions are made in
`render_frontend/src/prepare/scene/world_gpu.rs::consume_gpu_load_progress`.
## Neighbours
`d3d9_decl` / `d3d9_sm3` / `d3d9_state` carry D3D9 semantics without a single
word about IW4; the only D3D9 → `wgpu` crossing is the adapter inside `render`.
`session` stands the match up and tears it down, `assets` owns the bytes.
## How to poke it
There are no live `r_*` dvars. Live, it is env
vars (`IW4L_SINGLE_CELL`, `IW4L_SUN_SHADOW_*`, …) and native Perfetto
(`IW4L_PERF=1`, [`PERF.md`](PERF.md)).
`extract.rs` publishes `InstalledRenderWorld` when static geometry, ports or SMC
maps change, and `PublishedRenderFrame` every frame. `d3d9_decl` / `d3d9_sm3` /
`d3d9_state` carry D3D9 semantics with no word about IW4; the only D3D9 → `wgpu`
crossing is the adapter inside `render`, while `session` stands the match up and
`assets` owns the bytes. No live `r_*` dvars: env vars (`IW4L_SINGLE_CELL`,
`IW4L_SUN_SHADOW_*`, …) and Perfetto (`IW4L_PERF=1`, [`PERF.md`](PERF.md)).
+32 -49
View File
@@ -2,66 +2,49 @@
How to touch the live process. Observation is [`PERF.md`](PERF.md).
## Launch
```bash
set -a; . ./.env; set +a # IW4L_GAMES; DISPLAY=:0 if the session has none
make map mp_boneyard CMDS='spawn assault; wait 2s; quit'
cargo run --profile play -p launcher -- map iw5:mp_overwatch --cmds '…'
```
`make` does not pass foreign flags through: in the `Makefile` it is the `CMDS`
variable, on the binary it is `--cmds`. The colon is a GNU make pattern:
`make map iw5:…` fails; write `make map ZONE=iw5:mp_overwatch` or use
`cargo run`. Recipes: `make scenario` / `chaos` / `bench-demo` / `bench-live` /
`lifecycle-*` (`*_CMDS` in the `Makefile`). Live recipes use `[profile.play]`;
for LTO, `PROFILE=release`.
`make` passes no foreign flags through: in the `Makefile` it is `CMDS`, on the
binary `--cmds`. The colon is a GNU make pattern, so `make map iw5:…` fails —
write `make map ZONE=iw5:mp_overwatch` or use `cargo run`. Recipes: `make
scenario`, `chaos`, `bench-demo`, `bench-live`, `lifecycle-*` (`*_CMDS` in the
`Makefile`). Live recipes use `[profile.play]`; for LTO, `PROFILE=release`.
## The match hasn't started — controls are frozen
**Controls are frozen until the match starts.** `sim::step` allows movement only
in `MatchPhase::Playing`; `hold +attack`, `+forward` and `move` do nothing before
then. You wait out `PLAYER_WAIT_MS` 15 s + `MATCH_START_MS` 5 s
(`crates/gamemode_iw4/src/prematch.rs`) — bots (≥ 2 live players) cut the first
timer short, a single-player script waits the full 20 s. The verb is shorter:
`wait world; spawn assault; force_match_start; wait 1s; …`, and a script
involving firing, hits or decals **lies with a zero without it**.
`sim::step` allows movement only in `MatchPhase::Playing`. Before that,
`hold +attack`, `+forward` and `move` do nothing. You have to wait out
`PLAYER_WAIT_MS` 15 s + `MATCH_START_MS` 5 s
(`crates/gamemode_iw4/src/prematch.rs`); the first timer is cut short by bots
(≥ 2 live players), a single-player script waits the full 20 s. The verb is
shorter: `wait world; spawn assault; force_match_start; wait 1s; …` — a script
involving firing / hits / decals **lies with a zero without
`force_match_start`**.
## The script waits on its own (sync-by-default)
Ritual `wait`s are unnecessary: a command holds the FIFO until its own fact —
`map`/`demo`/`play` until the swap plus GPU-ready `WorldScene::spawned`,
`spawn` until `AppScreen::InGame`, `move`/`look`/`tp` until a matching
presented pose, `force_match_start` until `Playing`, `disconnect` until the
hold is torn down. `wait Ns` remains, for pacing and observation windows.
The `&` suffix is a loud refusal to wait (`map mp_rust &`), `!` jumps the queue
(in bash write `disconnect !`, otherwise history expansion bites). The 120 s
gate timeout aborts the rest of the script with an echo. Queue parsing is in
`crates/console/src/`.
Hand-typed `dump`/`clip`/`screenshot` pass through any wait while preserving
the queue; inside `--cmds` that takes an explicit `!`.
**Sync by default.** Ritual `wait`s are unnecessary: a command holds the FIFO
until its own fact — `map`/`demo`/`play` until the swap plus GPU-ready
`WorldScene::spawned`, `spawn` until `AppScreen::InGame`, `move`/`look`/`tp`
until a matching presented pose, `force_match_start` until `Playing`,
`disconnect` until the hold is torn down. `wait Ns` remains, for pacing and
observation windows. The `&` suffix is a loud refusal to wait (`map mp_rust &`),
`!` jumps the queue (in bash write `disconnect !`, or history expansion bites).
The 120 s gate timeout aborts the rest with an echo; parsing is
`crates/console/src/`. Hand-typed `dump`/`clip`/`screenshot` pass through any
wait while preserving the queue, which inside `--cmds` takes an explicit `!`.
## Verbs and traps
`map spawn class give attach name kill damage move tp look nudge press hold
release bind bot wait mark record stoprecord clip demo dump screenshot ui disconnect quit`
plus the debug `force_match_start` / `showpos`.
`bot` is `add | hold | tp | give | fire`; `dump [name]` writes the current
snapshot into `dumps/`.
`clip` writes the last available 45 s into `clips/<ULID>/` (demo + dump) on the
host and on the clients; the host records authority, a client records the
snapshots it received plus its own presented state.
The source is named in the manifest; `demo LATEST` plays the clip back.
* ADS is `hold +speed_throw`, not `+speed`;
* bolt action (`fire_type=1`): `hold +attack` is **one** shot; a full magazine
will not reload itself (`press +attack` ×N, then `press +reload`);
* `look` without `LookState` only writes `ps.viewangles` — the script is not
aiming;
* `give` takes a namespace: `give t5:weapon/psg1_acog`, `give iw5:weapon/msr`.
plus the debug `force_match_start` / `showpos`. `bot` is
`add | hold | tp | give | fire`; `dump [name]` writes the current snapshot into
`dumps/`. `clip` writes the last available 45 s into `clips/<ULID>/` (demo + dump)
on host and clients — the host records authority, a client the snapshots it
received plus its own presented state; `demo LATEST` plays it back.
`make bench-load-session` runs `make menu` with the script
`mark in_menu; map mp_hanoi; spawn; mark in_hanoi; map mp_underpass; spawn; mark in_underpass; quit`.
`python3 scripts/bench-marks.py <log>` computes the intervals between engine
marks; `--json` gives machine-readable output. Build and menu startup are not
part of the intervals.
* ADS is `hold +speed_throw`, not `+speed`;
* bolt action (`fire_type=1`): `hold +attack` is **one** shot, and a full
magazine will not reload itself (`press +attack` ×N, then `press +reload`);
* `look` without `LookState` only writes `ps.viewangles` — no aiming;
* `give` takes a namespace: `give t5:weapon/psg1_acog`, `give iw5:weapon/msr`.
+31 -61
View File
@@ -1,51 +1,15 @@
# Portable Windows folder
`make launcher windows` creates `dist/windows/iw4l-windows-dev.zip` and
`dist/windows/iw4l-windows-prod.zip`. Both archives use the password
`contextrot` and are self-contained — the game binary is in the archive, so the
folder runs with no network at all:
```text
.env
iw4l-ca.pem
iw4launcher.exe
iw4l.exe
LICENSE
NOTICE
OFL-Oxanium.txt
COPYING-FreeFont.txt
```
The four licence files are not decoration: `iw4l.exe` has both fonts compiled
into it with `include_bytes!`, so an archive carrying the binary carries their
licences too. `release.rs::LEGAL_FILES` is the list, and a build that ships
without them is the bug.
Extract the selected archive into any dedicated folder and add ordinary Windows
`.lnk` shortcuts to installed Call of Duty title folders or title executables.
MW2 **Multiplayer** files are required for the menu (`zone/**/common_mp.ff`);
a folder containing only `zone/dlc/*.ff` is insufficient. BO1 and MW3 are optional.
Run the launcher; it starts the game without modifying those installations.
**Updating is something you ask for.** Plain `iw4launcher.exe` starts the
`iw4l.exe` that is already in the folder and touches nothing else. Fetching a
newer build is `iw4launcher.exe update` (or `IW4L_UPDATE=1` for a shortcut that
cannot pass an argument), which needs `IW4L_UPDATE_URL` in the portable `.env`.
No build replaces its own binary on its own. How a release gets published is
[`DEPLOY.md`](DEPLOY.md).
Steam shipped MW2 and MW3 re-releases whose FastFiles are serialized with
64-bit pointers. MW2 is read in both serializations; **MW3 x64 zones are
detected and refused**, so an MW3 shortcut to such an install contributes no
weapons and no maps, and says so in the log
(`IW5 zone is serialized x64 (Steam re-release)`).
`make launcher windows` creates `dist/windows/iw4l-windows-{dev,prod}.zip`. Both
archives use the password `contextrot` and are self-contained — the game binary
is inside, so the folder runs with no network at all.
```text
iw4l-portable/
├── iw4launcher.exe from the archive
├── iw4l.exe from the archive; replaced only by `iw4launcher update`
├── iw4l-ca.pem public trust anchor
├── LICENSE NOTICE … the four licence files, kept with the binary
├── LICENSE NOTICE OFL-Oxanium.txt COPYING-FreeFont.txt
├── .env from the archive; player edits win forever
├── Modern Warfare 2.lnk target: game folder or title .exe
├── Black Ops.lnk optional
@@ -53,28 +17,34 @@ iw4l-portable/
└── iw4l-artifacts/ saves, caches, demos, logs and captures
```
The folder containing the two executables is the process working directory even
when Explorer supplies another one. `IW4L_GAMES` defaults to this folder on
Windows; `.lnk` targets directly inside it are additional read-only search
roots. An explicit `IW4L_GAMES` still works and may itself contain shortcuts.
The four licence files are not decoration: `iw4l.exe` has both fonts compiled in
with `include_bytes!`, so an archive carrying the binary carries their licences
too. `release.rs::LEGAL_FILES` is the list; a build shipping without them is a
bug.
`.env` is loaded before release-manifest defaults. The launcher creates it only
when missing and never overwrites it. `IW4L_UPDATE_URL` is read only by
`iw4launcher.exe update`, never by a plain start; the same `iw4launcher.exe`
serves both channels.
Extract into any dedicated folder and add ordinary Windows `.lnk` shortcuts to
installed Call of Duty title folders or executables. MW2 **Multiplayer** files
are required for the menu (`zone/**/common_mp.ff`); `zone/dlc/*.ff` alone is
insufficient. BO1 and MW3 are optional. The launcher starts the game without
modifying those installations.
```dotenv
IW4L_UPDATE_URL=https://host:8443/prod
IW4L_MASTER_ADDR=master.example.net:4433
IW4L_MASTER_SERVER_NAME=iw4l-prod
IW4L_MASTER_CA_CERT=iw4l-ca.pem
```
**Updating is something you ask for.** Plain `iw4launcher.exe` starts the
`iw4l.exe` already in the folder and touches nothing else. Fetching a newer
build is `iw4launcher.exe update` (or `IW4L_UPDATE=1` for a shortcut that cannot
pass an argument), which needs `IW4L_UPDATE_URL` in the portable `.env`. No
build replaces its own binary on its own. Publishing is [`DEPLOY.md`](DEPLOY.md).
An unreachable master is not a launch error: the browser keeps retrying and
shows the connection error. All writable game state stays below
`iw4l-artifacts/` (including Windows `settings.cfg`, unless
Steam shipped MW2 and MW3 re-releases whose FastFiles are serialized with 64-bit
pointers. MW2 is read in both serializations; **MW3 x64 zones are detected and
refused**, so an MW3 shortcut to such an install contributes no weapons and no
maps, and says so in the log (`IW5 zone is serialized x64 (Steam re-release)`).
The folder holding the two executables is the process working directory even
when Explorer supplies another; `IW4L_GAMES` defaults to it, and `.lnk` targets
inside it are additional read-only search roots. `.env` is loaded before
release-manifest defaults, created only when missing and never overwritten.
`.env` holds `IW4L_UPDATE_URL=https://host:8443/prod` plus the master keys
from [`MASTER.md`](MASTER.md). An unreachable master is not a launch error: the browser keeps retrying. All
writable state stays below `iw4l-artifacts/` (including `settings.cfg`, unless
`IW4L_SETTINGS_PATH` overrides it); shortcut targets are never output paths.
Startup errors print the detected FastFile counts, missing base assets and log
path in the console. On Windows, an interactive console waits for Enter before
closing; redirected input exits immediately with code 2. No extra dialog opens.