The mode picked in the lobby outlived its match: HostMatchRules was dropped on the way back to the menu, HostGameModeSelection was not, so a later `map` from the console started the old mode with default rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Controller buttons bind through the same table as keys: every command on
the controls page shows its key and its button, and either is rebound by
pressing a key or a button. The options gain a Controller page: button
layout (Default, Tactical, Lefty, Bumper Jumper, Bumper Jumper Tactical),
stick layout, look and aim-down-sight sensitivity, invert, response curve,
aim assist, vibration and deadzones, all kept in settings.cfg.
The look stick turns at rates that build up to full deflection, slower
down the sight and scaled by the zoom. Aim assist works over visible
enemies within each weapon's own aim assist ranges, read from the weapon
data: slowdown and lock-on, and on Full a pull onto a target when the
sight comes up. It applies only while aiming with the controller.
Menus take the D-pad or left stick, A and B, and Start opens them. Focus
moves by screen position rather than by menu file order, which jumped
around the options pages for arrow keys too; left and right on a button
cross columns. The Advanced Video page no longer draws the FOV slider
over Bloom.
The controller in use is the last one pressed or moved, so a virtual
joystick or an idle second pad stays out of the way; rumble follows it and
the vibration setting. Use/reload reloads when nothing usable is in reach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
content_digest_v0 and _v1 had no callers; the live digest and components lose their version suffix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auth_ws had no users, map_authority_to_fpv ignored both weapon states, and last_weaponstate / last_sprinting were written and never read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mode picked in the lobby outlived its match: HostMatchRules was dropped on the way back to the menu, HostGameModeSelection was not, so a later `map` from the console started the old mode with default rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Controller buttons bind through the same table as keys: every command on
the controls page shows its key and its button, and either is rebound by
pressing a key or a button. The options gain a Controller page: button
layout (Default, Tactical, Lefty, Bumper Jumper, Bumper Jumper Tactical),
stick layout, look and aim-down-sight sensitivity, invert, response curve,
aim assist, vibration and deadzones, all kept in settings.cfg.
The look stick turns at rates that build up to full deflection, slower
down the sight and scaled by the zoom. Aim assist works over visible
enemies within each weapon's own aim assist ranges, read from the weapon
data: slowdown and lock-on, and on Full a pull onto a target when the
sight comes up. It applies only while aiming with the controller.
Menus take the D-pad or left stick, A and B, and Start opens them. Focus
moves by screen position rather than by menu file order, which jumped
around the options pages for arrow keys too; left and right on a button
cross columns. The Advanced Video page no longer draws the FOV slider
over Bloom.
The controller in use is the last one pressed or moved, so a virtual
joystick or an idle second pad stays out of the way; rumble follows it and
the vibration setting. Use/reload reloads when nothing usable is in reach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Controls list Aim Down the Sight and Hold Aim Down the Sight; pressing hold
clears a toggle latch so release always leaves ADS. MWHEELUP and MWHEELDOWN
bind from the menu, the console and saved settings, and wheel scroll reaches
the game as discrete presses, so weapon cycling works on the wheel.
Closes#6Closes#7
- Pipelined rendering is off by default on macOS. The single-threaded Render
schedule runs `create_surfaces` on the render thread, and AppKit only lets
the main thread touch the NSView behind the Metal surface, so the first
frame panicked in raw-window-metal. IW4L_PIPELINED_RENDERING still
overrides it.
- docs/BUILD.md: macOS needs only the Xcode command line tools; game data is
the Windows depot of a Steam copy fetched with steamcmd.
- README and docs index list macOS (Metal) among the platforms.
- One common set per content profile, shared by every match and the loadout
menu; a restart over the same map keeps its images and static geometry.
- Weapons resolve as families: attachments are chosen from each game's own
tables, checked, and composed into prepared configurations with their
first-person skeleton assembled once.
- Sessions end through one teardown and one process exit; retired resources
are dropped on a worker, not the frame.
- Sound is captured by the walks that already read each zone.
- A loading screen that reports what the load is doing.
- Tests live only in crates/approved_tests (heavy_gameplay_lifecycle);
publish-check refuses them anywhere else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destructibles walk their stage staircase on the host: each stage carries
its model, the parts it throws off, the effects it fires on the way out
and the health it bleeds afterwards, and the loops a prop is speaking are
published as sound aliases the client resolves through the configstring.
The scoreboard, overhead names and the compass draw against the live
roster -- team identity survives player-supplied color escapes, ranks and
prestige come out of the menu tables, and the final snapshot is kept when
it lands ahead of the next scores command.
Film vision presets load from the map and common fastfiles; visionSetNaked
and visionReset transition between them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Host bots bake a navigation graph off the clip map and walk it — drop and
break-glass edges, routes that resume across ticks under a shared per-tick
query budget, firing positions chosen from baked supports rather than a ring
around the bot. They pick a weapon, reload or switch when the magazine runs
out, and plant, defuse and claim flags on their own.
A broken pane now builds a planar crack graph and emits shards carrying their
holes and open cracks, so tessellation and repeated hits read the stored
geometry instead of assuming a convex fan from vertex zero.
Sun cascades are clipped to the view. Class select and the options menu match
the layout players expect. Weapon definitions load from IW5 and T5 fastfiles.
The test suite is removed: it had grown into scaffolding for the dig rather
than a description of the runtime, and new tests will be written against the
behaviour that stayed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Image decode takes the pool it runs on as a parameter and every lane hands it
the loader's. `ComputeTaskPool` is the frame's pool, with its own thread count
and its own affinity, so a load stage that decoded on it ran on the frame's
threads and competed with the frame for them. Material compile chunks move onto
the same load pool instead of raw `std::thread::scope` threads: the pool's
workers set their affinity to the process CPUs when they spawn, while a thread
spawned from an `AsyncComputeTaskPool` worker inherits that pool's narrower set.
The compile scope is explicitly non-cooperative, so the calling thread waits for
its chunks rather than ticking the pool's queue.
The `common_mp` FPV plan is held rather than enqueued, and decodes once the
donor plans have merged. `prune_to` drops every demand whose rows the merge
would not fill — the same predicate `apply` uses, so pruning changes when the
question is asked and not what the catalog ends up holding — and
`release_claims` hands the rows back when nothing survives, which is otherwise
`apply`'s job and would leave later stages skipping every name the plan had
claimed. The census counts `pruned_variants` and `pruned_rows` on their own,
apart from duplicate claims and from discards, and the report line carries what
was cut, what is left and what asking cost.
`presented_state_age` and `presented_frames_behind` measure from a render
frame's extract to the render graph's Finish set: how old the state a frame drew
was, and how many main frames had opened since it was taken. They get their own
table in the counter report, because an age divided by a frame is not a share of
one, and a line in the frames section beside main/render overlap — what
pipelining costs, next to what it buys. Both say where they stop: bevy presents
after the graph schedule returns, so the present, the compositor and the panel
are outside, and neither is an input-to-photon latency.
`IW4L_FRAME_LATENCY` sets the surface's `desired_maximum_frame_latency` without
a rebuild. The backend may clamp it, so the manifest records the number asked
for and never a grant this process cannot read back. Env switches now read
through one rule in `perf::switch`: absent, empty, `0`, `false` and `off` are
off. `IW4L_MULTI_DRAW=0` therefore turns the indirect path off instead of
enabling it, which is what a paired run across it needs.
A lightmap page counts the bytes of all six of its images in `handed_bytes`,
`bytes_total` and against the budget pacing the overlay frames, and a slot that
takes another slot's handle no longer counts as having handed anything over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The split of an image variant into `payload` and `usage` is now what sharing
keys on. A registry holds the texels an archive entry decodes to — the mip
chain in one allocation, laid out as an upload wants it — and the `Image` is
built over them where the row is placed. Two materials that want the same file
under a different colour space or sampler cost one read, one inflate and one
decode, and the second of them a copy of the chain; the last asker copies
nothing, because the decode's own buffer becomes the image's. Asking whether a
name is a cubemap reads the thirty-two byte IWI header out of the entry instead
of inflating the megabyte behind it.
The resident-bytes ceiling is charged per payload rather than per batch, so two
batches pointing at one buffer hold one allocation between them. Load rows
split `produced_bytes` into `prepared_bytes` and `reused_bytes`: only the first
is work, and their sum is what the plan served. The merge census names who won
a disputed claim by plan id, not only that somebody did.
The frame clock closes the row and opens the next at the top of `First`, before
anything else runs, so every main-phase interval falls wholly inside one frame;
the frame the process exits from is closed at exit and its row carries
`partial`. Which spans are a frame's coverage roots is declared by
`Span::coverage_root` rather than read off a thread's stack — an executor is
free to run a schedule's `begin` and its `end` on two workers — and spans a
migration left behind are pruned. A counter measured on another thread is
charged to the frame it ran in through `emit_at`, and a sample that finds no
row is counted rather than moved to whichever frame is open.
`IW4L_PIPELINED_RENDERING=1` runs the render world a frame behind on its own
thread, and it is the only thing the switch moves; the manifest records which
branch a run took. The report gains `main/render overlap`, read off the
intervals rather than off the frame a render stage names as its origin. A
`bevy-trace` build writes bevy's own spans as a Chrome trace, which is the only
place the render graph's submit can be timed from, and
`scheduling.bevy_tracing` says whether a run was one.
Glass keeps its shattered material while damaged, scales the authored UV to it,
picks its shatter effect by whether the piece is landing, and carries the
surface normal on the presentation event.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`mp_boneyard_dom_and_two_bots` ends with the flag still mid-progress: one bot
holds the capture alone while the other reports `BudgetExhausted` on its path,
and the 500 ticks the test allows run out before the flag turns over. It fails
on every run and takes about a minute of the suite to do it.
It is `#[ignore]`d rather than deleted — the walk, the sight and the fight it
covers are real, and the run is one `--ignored` away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An image variant's identity was one digest of the archive entries plus a bag
of option bits, which mixed two unlike things. The map type chooses between
the 2D and the cubemap decode, so it changes the bytes; the colour space and
the sampler state change no byte of the payload, only the `Image` built
around it.
The identity now splits along that line: `payload` digests the resolved
entries and the map type, `usage` carries the colour space and the sampler.
Sharing still keys on the whole variant, because the wrapping lives inside
the `Image` whose buffer would be shared.
The merge census reads the split. A discarded claim whose winner held the
same payload under a different wrapping was counted as overridden by another
source, which reported nothing to share where the same archive entry had in
fact been read into the same bytes twice. It is its own bucket now,
`discarded_same_payload`, on the discard line and in the image demand report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 47.78 ms frame reported a 36.35 ms `Load` inside an `Update` its own row
implied was 11.87 ms, and 364 frames summed their root spans to more than their
own wall. Three mistakes stacked: a span was credited to whichever frame was
open when it *closed*, its parent was whichever span had opened most recently
anywhere in the process, and the remainder was a sum of overlapping intervals
clamped at zero.
Now a span is charged by intersection with the frame's wall, and what it ran
before the frame opened is `carried_in_ns` rather than nothing. Nesting comes
from a thread-local stack, so the render thread no longer adopts the main
thread's spans, and a close out of order is counted. `unclassified` is wall
minus the union of the root intervals, which cannot go negative and counts two
threads inside one nanosecond once. The report prints how many frames still
hold a span longer than its parent.
`present_hud_surfaces` and `present_hud_stage_max` measured wall *between*
systems, which `.chain()` leaves the executor free to fill. They are renamed
`*_schedule_interval`, tabled apart, and given a number that is the HUD's:
`hud_tess_body`, taken inside the tess flush bodies, with `hud_tess_jobs`
beside it. The screenshot flag now stays up through the readback, the encode
and the write instead of only while the request queues.
An image decode gains an identity — the resolved IWD candidate list plus the
recipe bits that change the payload — so two plans that would prepare the same
bytes share one decode through a registry of weak handles, and a claim the
merge drops is reported as the same variant twice or as another game's
different image under the same name. Merge precedence and the final content are
untouched. The load job row splits budget wait from decode, renames
`join_wait_ms` to what it measures, and carries `produced_bytes`; the budget
wait is bounded so it cannot outlive a join order that stops guaranteeing
progress. The manifest walks the nested artifact cache instead of reading zero
files from its top directory, and prints the viewport the passes were sized by
beside the surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make bench` printed two reports and left nothing behind but a text file, so a
number could be compared with another number from the same afternoon and not
much else. It now prints three and writes the run's own directory: `report.txt`,
`manifest.json` and `summary.json` beside the `trace.pftrace`.
Section [3/3] is the counters — the render stages, the GPU passes, and the
per-frame work census. A counter nobody sampled prints MISS rather than
`0.00`, and the counters and spans this build declares but this run never
reached are named at the end, so a hole in the instrumentation cannot read as a
fact about the workload. GPU passes overlap and resolve late, so they are never
summed.
`manifest.json` says what produced the number: the revision, the dirty flag,
the toolchain, the profile cargo actually built under, the locked `bevy` and
`wgpu`, the adapter and whether its device can time a pass, the window, the
pools, the env toggles and the cache state. The build half is baked in at
compile time, because asking `git` at runtime names the tree the process is
standing in rather than the build. A fact nobody could read is `null`, never a
plausible default.
Reading that report, `preparing match audio` was the largest stage of every
load — one row holding one number, which cannot tell a thousand cheap byte
loops from five hundred external processes. It is the second. Section [1/3] now
splits the stage by the decoder each clip took, in worker time, and counts what
each one prepared, failed, held and left resident.
The T5 XWMA decode goes through the artifact cache under `xwma_pcm`, keyed on
every input the decode reads, so a clip is decoded once per machine rather than
once per launch. A cold load still had to pay the first time: 515 `ffmpeg`
processes, 18.9 s of worker time, almost none of it decoding. A worker now
takes the jobs already queued and answers up to 64 clips through one process —
every clip its own input and its own output, nothing concatenated — which is
1.3 s for the same bytes. Failure stays per clip: a batch `ffmpeg` refuses is
re-asked one clip at a time, leaving the bad clip as the only failure.
`make bench-overhead` can point at either recorder, `VAR=IW4L_PERF` or
`VAR=IW4L_BENCH`; they are separate costs and each needs its own pairs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make bench` is one recipe over one run now. `IW4L_BENCH=1` arms an in-process
recorder on the spans that already feed Perfetto, and at exit the run prints
two independent reports and writes them to `iw4l-artifacts/bench/<stamp>.txt`:
the waterfall from the shell command to a playable map, carrying the time each
load stage held the pool alone, and frame time as a span tree whose nesting is
recorded rather than declared. Neither needs a trace file or Trace Processor,
and either can be MISS while the other stands. `bench-demo` and `bench-load`
are gone — a goal after `bench` is a demo, `ZONE=` is a live map.
A bot is no longer minted onto an id a real client already owns. The roster
steps over the ids the authority and the local player hold, and a slot that
still lands on the local player is retired, because `is_bot` would otherwise
claim the player and nothing could drive the slot anyway.
A stretch with no authority ack re-adopts the next snapshot and counts one
stall; three of them still fail the link. Failing on the first turned every
long frame into a permanent disconnect.
The loading overlay comes down once the world is installed rather than waiting
on the class-select hop, which a replay never makes — it was playing the whole
demo behind `UiLayer::Loading`, with map ambience held back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bHwyKwUjaYmtVG1F3Ttku
The release section documented a tagging and notes procedure that has produced
nothing: no GitHub releases exist and the only tag is pre-ship-1818. What it
said that was true and lived nowhere else — unstable wire protocol, playtests
among people who arranged them, nothing phones home — becomes a short Status
block up top. The rest already lives in docs/DEPLOY.md, docs/MASTER.md and
docs/WINDOWS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkgiSVEPTbsVZ1RpARujF6