From 2accb2489a30ede985d446bf47accafbbcc7e5ff Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:00:07 +0700 Subject: [PATCH] Comments in benilla-app, capture, sound, net, spells and portraits: one line of fact each, no history, no record numbers (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #41: the fourth of five `benilla-app` pieces. This one is capture and its probes, sound, the network layer and remote movement, spells, and portraits. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 23,383 comment lines become 9,669. Fixes #35. Fixes #110. Fixes #111. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - The probes' run blocks pointed at tooling outside this repository; each now points at `docs/CONTRIBUTING.md`, "Running it unattended". The protocol examples show placeholders for an account's password. - The dismount kit's name-match string is `SpiritWolf (DONOTRENAME)` (`0x8627bc`); the stun and jump-start vocals are never dispatched, and jump-end finds no kit in the shipped data. - The cast bar's flash and fade step once per `OnUpdate` (`CastingBarFrame.lua:132-147`); there is no 30 Hz normalisation. - vmangos sends the OK cast result before the GO (`Spell.cpp:3681`, `:3715`), and a creature can levitate from a script (`boss_onyxia.cpp:679`). - A live unit whose model is not ready has a blank portrait in the reference (`0x519fcc`); the stand-in path is for a unit with no object. - Docs sitting on the wrong item, removed names, and vmangos line citations checked against the pinned revision. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly. - **Verification.** Every file strips to identical code, apart from two test literals in `net.rs` that now use placeholder account names, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#319, #324, #329, #330 and #331 among them). String literals are untouched; they are #92. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/capture/depth_probe.rs | 219 +- crates/benilla-app/src/capture/fixtures.rs | 556 ++--- crates/benilla-app/src/capture/fxview.rs | 70 +- crates/benilla-app/src/capture/live_shot.rs | 80 +- crates/benilla-app/src/capture/mod.rs | 706 ++---- crates/benilla-app/src/capture/phase_probe.rs | 153 +- crates/benilla-app/src/capture/pick_probe.rs | 156 +- .../benilla-app/src/capture/probe_auction.rs | 327 +-- crates/benilla-app/src/capture/probe_bank.rs | 138 +- crates/benilla-app/src/capture/probe_bg.rs | 619 ++---- .../benilla-app/src/capture/probe_bg_queue.rs | 80 +- .../benilla-app/src/capture/probe_binder.rs | 210 +- crates/benilla-app/src/capture/probe_book.rs | 98 +- crates/benilla-app/src/capture/probe_cam.rs | 69 +- .../src/capture/probe_castcancel.rs | 50 +- .../src/capture/probe_charcreate.rs | 28 +- .../benilla-app/src/capture/probe_charter.rs | 524 ++--- crates/benilla-app/src/capture/probe_chest.rs | 118 +- crates/benilla-app/src/capture/probe_clam.rs | 127 +- .../benilla-app/src/capture/probe_crossing.rs | 107 +- crates/benilla-app/src/capture/probe_env.rs | 113 +- .../src/capture/probe_gm_ticket.rs | 287 +-- .../benilla-app/src/capture/probe_goquest.rs | 134 +- .../src/capture/probe_guard_poi.rs | 46 +- crates/benilla-app/src/capture/probe_look.rs | 42 +- crates/benilla-app/src/capture/probe_mail.rs | 171 +- crates/benilla-app/src/capture/probe_melee.rs | 20 +- .../src/capture/probe_model_camera.rs | 105 +- .../benilla-app/src/capture/probe_partner.rs | 23 +- crates/benilla-app/src/capture/probe_pitch.rs | 60 +- crates/benilla-app/src/capture/probe_rig.rs | 245 +-- .../benilla-app/src/capture/probe_service.rs | 204 +- crates/benilla-app/src/capture/probe_stone.rs | 106 +- crates/benilla-app/src/capture/probe_taxi.rs | 106 +- .../src/capture/probe_vendor_swap.rs | 88 +- crates/benilla-app/src/capture/probes/act.rs | 347 +-- .../src/capture/probes/dress_census.rs | 63 +- .../src/capture/probes/fx_draw_census.rs | 29 +- .../src/capture/probes/ground_census.rs | 100 +- .../benilla-app/src/capture/probes/jitter.rs | 260 +-- .../src/capture/probes/lift_census.rs | 77 +- .../src/capture/probes/live_fps.rs | 239 +- crates/benilla-app/src/capture/probes/mod.rs | 103 +- .../src/capture/probes/particle_census.rs | 83 +- .../benilla-app/src/capture/probes/reveal.rs | 51 +- crates/benilla-app/src/capture/probes/run.rs | 94 +- .../src/capture/probes/sched_census.rs | 46 +- .../benilla-app/src/capture/probes/stall.rs | 97 +- .../src/capture/probes/trail_census.rs | 79 +- .../src/capture/probes/visual_census.rs | 101 +- .../src/capture/probes/world_census.rs | 60 +- crates/benilla-app/src/capture/scenarios.rs | 736 ++----- crates/benilla-app/src/capture/waterfx.rs | 90 +- crates/benilla-app/src/creature_anim.rs | 5 +- crates/benilla-app/src/entities/dest_fx.rs | 3 +- crates/benilla-app/src/net.rs | 1926 +++++------------ crates/benilla-app/src/net/apply.rs | 30 +- .../benilla-app/src/net/apply/seam_tests.rs | 27 +- crates/benilla-app/src/net/handlers.rs | 77 +- crates/benilla-app/src/net/io.rs | 632 ++---- crates/benilla-app/src/net/motion.rs | 81 +- crates/benilla-app/src/net/motion/facing.rs | 283 +-- crates/benilla-app/src/net/motion/modes.rs | 65 +- crates/benilla-app/src/net/motion/relay.rs | 155 +- crates/benilla-app/src/net/motion/remote.rs | 687 ++---- crates/benilla-app/src/net/motion/spline.rs | 1058 +++------ crates/benilla-app/src/net/motion/tests.rs | 373 +--- crates/benilla-app/src/net/objects.rs | 580 ++--- crates/benilla-app/src/net/session.rs | 227 +- crates/benilla-app/src/net/world.rs | 22 +- crates/benilla-app/src/portrait/booth.rs | 833 ++----- crates/benilla-app/src/portrait/dressup.rs | 144 +- crates/benilla-app/src/portrait/framing.rs | 608 ++---- crates/benilla-app/src/portrait/glue_booth.rs | 1006 +++------ crates/benilla-app/src/portrait/light.rs | 212 +- crates/benilla-app/src/portrait/mod.rs | 1719 ++++----------- crates/benilla-app/src/portrait/test_bake.rs | 109 +- crates/benilla-app/src/probe_shield.rs | 2 +- crates/benilla-app/src/run_mode.rs | 3 +- crates/benilla-app/src/sound/anim_events.rs | 190 +- crates/benilla-app/src/sound/cinematic.rs | 101 +- crates/benilla-app/src/sound/combat.rs | 517 ++--- crates/benilla-app/src/sound/creature.rs | 345 +-- crates/benilla-app/src/sound/death_thud.rs | 156 +- crates/benilla-app/src/sound/emitter_pool.rs | 419 +--- crates/benilla-app/src/sound/emote.rs | 53 +- crates/benilla-app/src/sound/footsteps.rs | 142 +- crates/benilla-app/src/sound/gameobject.rs | 107 +- crates/benilla-app/src/sound/glue.rs | 126 +- crates/benilla-app/src/sound/greeting.rs | 141 +- crates/benilla-app/src/sound/interior.rs | 24 +- crates/benilla-app/src/sound/kit.rs | 894 +++----- crates/benilla-app/src/sound/limiter.rs | 166 +- crates/benilla-app/src/sound/liquid_loop.rs | 97 +- crates/benilla-app/src/sound/math.rs | 79 +- crates/benilla-app/src/sound/message.rs | 97 +- crates/benilla-app/src/sound/meter.rs | 85 +- crates/benilla-app/src/sound/missile.rs | 21 +- crates/benilla-app/src/sound/mix_tap.rs | 73 +- crates/benilla-app/src/sound/mixer.rs | 636 ++---- crates/benilla-app/src/sound/mod.rs | 495 +---- crates/benilla-app/src/sound/money.rs | 39 +- crates/benilla-app/src/sound/mount.rs | 26 +- crates/benilla-app/src/sound/net.rs | 15 +- .../benilla-app/src/sound/output/coreaudio.rs | 125 +- crates/benilla-app/src/sound/output/cpal.rs | 223 +- crates/benilla-app/src/sound/output/mod.rs | 210 +- crates/benilla-app/src/sound/probe.rs | 149 +- crates/benilla-app/src/sound/reverb.rs | 52 +- crates/benilla-app/src/sound/sheathe.rs | 35 +- crates/benilla-app/src/sound/spell.rs | 37 +- crates/benilla-app/src/sound/ui.rs | 144 +- crates/benilla-app/src/sound/vocal.rs | 193 +- crates/benilla-app/src/sound/water.rs | 62 +- crates/benilla-app/src/sound/weather.rs | 22 +- crates/benilla-app/src/sound/zone.rs | 634 ++---- crates/benilla-app/src/spell/cast_send.rs | 637 ++---- crates/benilla-app/src/spell/cast_target.rs | 411 +--- crates/benilla-app/src/spell/cooldowns.rs | 406 ++-- crates/benilla-app/src/spell/inflight.rs | 480 ++-- crates/benilla-app/src/spell/mod.rs | 48 +- crates/benilla-app/src/spell/mods.rs | 292 +-- crates/benilla-app/src/spell/net.rs | 881 +++----- .../benilla-app/src/spell/targeting/cursor.rs | 153 +- .../benilla-app/src/spell/targeting/item.rs | 185 +- crates/benilla-app/src/spell/targeting/mod.rs | 232 +- .../benilla-app/src/spell/targeting/world.rs | 170 +- crates/benilla-app/src/spell/usable.rs | 387 ++-- crates/benilla-app/src/spell/validator.rs | 365 ++-- .../examples/addon_chat_probe.rs | 8 +- .../examples/cooldown_probe.rs | 6 +- .../examples/faction_probe.rs | 6 +- .../examples/horde_chat_probe.rs | 9 +- 133 files changed, 8795 insertions(+), 22518 deletions(-) diff --git a/crates/benilla-app/src/capture/depth_probe.rs b/crates/benilla-app/src/capture/depth_probe.rs index 3f06b9d1..b712a2a6 100644 --- a/crates/benilla-app/src/capture/depth_probe.rs +++ b/crates/benilla-app/src/capture/depth_probe.rs @@ -1,56 +1,18 @@ -//! `WOW_DEPTH` β€” **what depth actually won this pixel, and how far away is it?** +//! `WOW_DEPTH`: reads back the depth that won a pixel in the opaque pass, and how far away it is. //! -//! The last unread link in B38. There, two surfaces of the Far Watch Post tower trade places: the -//! awning is nearer, writes depth, compares `GreaterEqual`, is not discarded, is not culled, keeps -//! the same mesh/material/texture every frame, and is submitted into `AlphaMask3d` at a stable draw -//! position on every single frame (decisions 0662, 0665, 0667) β€” and on some frames the plank behind -//! it wins the pixel anyway. Every one of those facts was established on the **CPU** side. None of -//! them says what value the GPU wrote into the depth buffer, which is the one thing that decides the -//! pixel and the one thing we could not read. +//! `WOW_DEPTH=",[;,…]"` logs, per frame, the raw reverse-Z value at each screenshot +//! pixel and the surface's distance both along the pixel's ray (comparable with `WOW_PICK`'s hit +//! distances) and to the camera plane. `WOW_DEPTH_AT=` (default 20) and +//! `WOW_DEPTH_COUNT=` (default 1) shape the sampling like the screenshot burst and the ray pick. +//! Pixels are used as given: the depth texture is allocated in physical pixels. //! -//! So: `WOW_DEPTH=",[;,…]"` copies the view's depth texture back after the main pass and -//! logs, per frame, the raw reverse-Z value at each pixel and **where that puts the surface** β€” both -//! as a distance along the pixel's ray and as the perpendicular distance to the camera plane, because -//! those are different numbers and only the first is comparable with a ray cast. -//! `WOW_DEPTH_AT=` (default 20) / `WOW_DEPTH_COUNT=` (default 1) shape the sampling like the -//! screenshot burst and the ray pick, so the three line up frame for frame. +//! `WOW_DEPTH_QUADS=[,…]` (empty value = every quad emitter) samples a grid inside +//! each live particle quad's projected corners instead, and logs the fraction that survives the +//! depth test and how deep the occluder sits in front, in yards. Frames with no live quad are +//! skipped and not counted. //! -//! **Run it together with `WOW_PICK` at the same pixels.** This probe answers *what depth won*; the -//! ray pick answers *what is standing there* β€” every hit along the ray with its distance. Reading the -//! won distance against the hit distances is what turns a number into "**whose** depth won": if a -//! frame's depth matches hit 1's distance rather than hit 0's, the nearer surface never wrote depth -//! there, and no argument about draw order or culling survives that. Splitting the two keeps each -//! probe single-purpose, and because the geometry is static the pick's distances hold across the -//! whole burst. -//! -//! Coordinates are **screenshot pixels** β€” the same space `benilla-visual` reports boxes in and -//! `WOW_PICK` takes β€” and here they are used directly, because the depth texture is allocated in -//! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in -//! logical units. Same input space, different reason.) -//! -//! ## `WOW_DEPTH_QUADS` β€” the same reading, taken at a particle quad's OWN pixels -//! -//! A hand-written pixel list is the wrong instrument for a *moving* subject. B16's eye glow is two -//! additive quads a few dozen pixels across, riding an animated bone: name a pixel and by the frame -//! the readback lands the quad is somewhere else, and a grid coarse enough to catch it samples the -//! head, the ground and the neighbouring unit instead (that mis-measurement is retracted). -//! -//! `WOW_DEPTH_QUADS=[,…]` (empty value = every quad emitter) reads the emitter's **live -//! vertex buffer** β€” the four corners `expand_quads` actually wrote, the ones the GPU is about to -//! rasterize β€” projects them with the frame's own matrices, and samples the depth buffer on a -//! bilinear grid *inside that quad*. What comes back is the number the offline model predicts and -//! the reference measures: the **surviving area fraction** of the quad under `GreaterEqual`, plus -//! how deep the winning surface sits in front of it, in yards. No camera hunting, no pixel guessing, -//! and no way to accidentally measure a different surface. -//! -//! Frames with no live quad are skipped entirely (no copy, no frame counted), so the burst lands on -//! the frames where the subject exists rather than on a wall clock. -//! -//! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer -//! at all, and there is no single "the" depth at a pixel to report if it could β€” there are four. The -//! probe refuses rather than reporting one of them, because a number that looks like the measurement -//! you wanted, taken slightly wrong, is how this bug has already produced four confident wrong -//! answers (0667). +//! MSAA must be off (`WOW_MSAA=off`): a multisampled depth texture cannot be copied and has no +//! single depth per pixel, so the probe refuses. use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d}; use bevy::ecs::query::QueryItem; @@ -88,9 +50,7 @@ impl Plugin for DepthProbePlugin { ); return; } - // Quad mode arms at once: the subject is a particle pool that does not exist until its - // model spawns, and the frame gate below (report only frames that HAVE quads) is a - // sharper window than any wall clock the caller could guess. + // Quad mode arms at once: frames without a live quad are skipped anyway. let at = std::env::var("WOW_DEPTH_AT") .ok() .and_then(|v| v.parse().ok()) @@ -128,26 +88,15 @@ impl Plugin for DepthProbePlugin { Render, ( prepare_staging.in_set(RenderSystems::PrepareResources), - // The node encodes the copy inside the graph; by the time the graph has run and - // submitted, the staging buffer holds it and can be mapped. + // The node encodes the copy in the graph; after submit, staging maps. read_depth.after(RenderSystems::Render), ), ) .add_render_graph_node::>(Core3d, DepthReadbackLabel) - // Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the - // transmissive/transparent ones. That is the depth as it stood when the opaque fight was - // decided, which is the question β€” see [`DepthReadbackNode`] for what reading it later - // gets you instead. The retained static pass draws BEFORE the opaque pass (2016), so - // its walls are in this read too; while it ran after, the graph left the two - // unordered and a probe could read a wall-less depth on some frames. - // - // `WOW_DEPTH_AFTER=1` moves the copy to AFTER the transparent pass β€” deliberately - // measuring the thing the placement note warns about: the depth the transparent pass - // itself wrote. B38 first observed that phantom ("5–8 yd at pixels where the ray cast - // finds the tower at 32–43 yd … from something that tracks the camera") and never named - // it; B16's eye quads lose a depth compare that the opaque-pass buffer says they win, - // so the same writer is now the suspect. Before-vs-after at the same pixels names its - // depth, and its depth names it. + // After the opaque pass (`Opaque3d` and `AlphaMask3d`), before the transmissive one. + // The retained static pass draws before the opaque pass, so its walls are in the read. + // `WOW_DEPTH_AFTER=1` copies after the transparent pass instead, to read the depth the + // transparent pass itself wrote. .add_render_graph_edges( Core3d, if std::env::var_os("WOW_DEPTH_AFTER").is_some() { @@ -167,8 +116,7 @@ impl Plugin for DepthProbePlugin { } } -/// The pixels to read and the sampling window. Extracted to the render world as a plain clone (a -/// handful of coordinates once per sampled frame). +/// The pixels to read and the sampling window. #[derive(Resource, Clone)] struct DepthWatch { pixels: Vec<(u32, u32)>, @@ -192,24 +140,19 @@ struct QuadWatch(Option>); #[derive(Clone, Copy)] struct QuadProbe { bone: u16, - /// Index within the emitter's mesh β€” quads are written oldest-first, so 0 is the smallest, - /// dimmest particle and the last is the one born this frame (the big, bright one that decides - /// whether the glow reads at all). + /// Index within the emitter's quads, written oldest-first: the last was born this frame. index: u32, /// The four corners in physical pixels, in `expand_quads`' own vertex order. corners: [Vec2; 4], - /// The NDC depth the corners carry. Read from the projected corners, not assumed constant β€” - /// the *spread* is reported, because a billboard that is not a constant-depth plane is a - /// different bug wearing the same symptom (`0x7b2a50`'s plain billboard gives all four - /// corners one depth). + /// The corners' mid NDC depth; `dspread` is their spread, which the reference's plain + /// billboard (`0x7b2a50`) keeps at zero. dquad: f32, dspread: f32, - /// The quad centre's distance to the camera plane, yards β€” the unit the burial is reported in. + /// The quad centre's distance to the camera plane, yards. viewz: f32, } -/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. Empty on every frame in every run that -/// did not ask for the mode. +/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. #[derive(Resource, Clone, Default)] struct QuadProbes(Vec); @@ -220,14 +163,9 @@ impl ExtractResource for QuadProbes { } } -/// Project every in-scope emitter's live quads into pixels, once a frame. -/// -/// Deliberately reads the **shared effect stream** (0732 P1), not the simulation: these are the -/// vertices the draw will consume, so a quad that was mis-built (collapsed, mis-billboarded, -/// left at last frame's anchor) is measured as itself rather than as what the sim intended. Runs -/// after `BillboardPlace`, the set that fills the stream, so the read is this frame's. An -/// emitter's CHILD-pool quads ride its draw records too (same `main_entity`), attributed to the -/// parent's bone β€” the old per-mesh read never saw them at all. +/// Project every in-scope emitter's live quads into pixels, once a frame, from the shared effect +/// stream `BillboardPlace` fills, so each quad is measured as drawn. Child-pool quads share the +/// emitter's draw records and count under its bone. fn collect_quads( watch: Res, mut probes: ResMut, @@ -245,8 +183,7 @@ fn collect_quads( let Some(vp) = camera.physical_viewport_size() else { return; }; - // The frame's own matrices, the same pair `depthdump` projects with β€” so a quad's `dquad` here - // and its `dquad` there are the same number, and the two logs can be read against each other. + // The frame's own matrices, the same pair `depthdump` projects with. let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse(); for (entity, emitter) in &emitters { if !bones.is_empty() && !bones.contains(&emitter.bone()) { @@ -298,8 +235,7 @@ fn collect_quads( } } -/// `"60,61"` β†’ the bone scope; an empty or absent value means every quad emitter. `None` when the -/// variable is unset at all, which is what turns the whole mode off. +/// `"60,61"` β†’ the bone scope; an empty value means every quad emitter, an unset one `None`. fn parse_bones(spec: Option<&str>) -> Option> { Some( spec? @@ -309,9 +245,7 @@ fn parse_bones(spec: Option<&str>) -> Option> { ) } -/// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the -/// world camera and the UI camera on the same window share it β€” but picking a view by viewport size -/// would be picking by coincidence, and this bug is made of numbers taken slightly wrong. +/// Marks the one view whose depth to read; the UI camera shares the world camera's depth texture. #[derive(Component, Clone, Copy, ExtractComponent)] struct DepthProbeView; @@ -323,10 +257,8 @@ struct DepthStaging { height: u32, } -/// Wait for the sampling window, then mark the world camera: opt its depth texture into `COPY_SRC` -/// (the default is `RENDER_ATTACHMENT` alone, which cannot be copied), and refuse the whole probe if -/// MSAA is on. Checking the live `Msaa` component rather than `$WOW_MSAA` means the refusal tracks -/// what the renderer is actually doing, not what the environment asked for. +/// Once the sampling window opens, opt the world camera's depth texture into `COPY_SRC` and mark +/// it; the live `Msaa` component being anything but off disables the probe. fn arm( mut watch: ResMut, time: ProbeClock, @@ -373,8 +305,7 @@ fn prepare_staging( } let Ok(depth) = view.single() else { return }; let size = depth.texture.size(); - // `Depth32Float` is 4 bytes a texel, and a buffer copy's row stride must be 256-aligned β€” so the - // stride is the aligned *byte* count, which is not the aligned pixel count times four. + // `Depth32Float` is 4 bytes a texel; a buffer copy's row stride is 256-byte aligned. let bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize * 4) as u32; if staging.is_some_and(|s| s.bytes_per_row == bytes_per_row && s.height == size.height) { return; @@ -394,17 +325,8 @@ fn prepare_staging( #[derive(RenderLabel, Debug, Clone, Copy, PartialEq, Eq, Hash)] struct DepthReadbackLabel; -/// Copies the depth texture aside **right after the opaque pass** β€” the moment the question is about. -/// -/// Placement is the whole correctness of this probe, and getting it wrong is quiet. Read at the end of -/// the main pass instead and the numbers come back 5–8 yd at pixels where the ray cast finds the tower -/// at 32–43 yd, with a distance even at pixels that have **no** geometry along the ray: by then the -/// *transparent* pass has written depth of its own, from something that tracks the camera. Those were -/// stable, plausible, reproducible numbers about the wrong thing β€” which is worse than no probe, -/// because it looks like a measurement. -/// -/// `MainOpaquePass` draws `Opaque3d` and then `AlphaMask3d`, which is exactly the pair the B38 fight is -/// between, so this is the depth that decided it. +/// Copies the depth texture right after the opaque pass. Read at the end of the main pass instead, +/// it also holds depth the transparent pass wrote, from something that tracks the camera. #[derive(Default)] struct DepthReadbackNode; @@ -424,12 +346,11 @@ impl ViewNode for DepthReadbackNode { ) else { return Ok(()); }; - // `read_depth` counts the frames; the copy is cheap but not free, so stop with it. + // `read_depth` counts the frames; stop copying once the burst is done. if !watch.armed || world.resource::().0 >= watch.count { return Ok(()); } - // Quad mode with nothing live this frame: no copy, and `read_depth` will not count it β€” - // the burst spends its frames on the ones that have the subject in them. + // Quad mode with nothing live this frame: no copy, and `read_depth` does not count it. if watch.pixels.is_empty() && world .get_resource::() @@ -438,14 +359,13 @@ impl ViewNode for DepthReadbackNode { return Ok(()); } let size = depth.texture.size(); - // `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the - // first armed frame still has the old one. Skip it rather than trip wgpu validation. + // `COPY_SRC` lands only on the texture allocated after `arm`, so the first armed frame + // still has the old one. if !depth.texture.usage().contains(TextureUsages::COPY_SRC) { return Ok(()); } // Depth-stencil formats reject partial copies (wgpu-core `validate_texture_copy_range`), so - // the whole texture goes across even though we want a handful of pixels. At one debug frame - // each, the simple thing and the correct thing are the same. + // the whole texture goes across. render_context.command_encoder().copy_texture_to_buffer( TexelCopyTextureInfo { texture: &depth.texture, @@ -471,7 +391,7 @@ impl ViewNode for DepthReadbackNode { } } -/// How many frames [`read_depth`] has reported β€” read by the graph node, which cannot hold a `Local`. +/// How many frames [`read_depth`] has reported; a resource because the graph node reads it. #[derive(Resource, Default)] struct DepthFramesRead(u32); @@ -491,8 +411,7 @@ fn read_depth( if !watch.armed || read.0 >= watch.count { return; } - // Mirrors the node's quad-mode skip: no copy was encoded, so there is nothing to read and this - // frame does not spend one of the burst's slots. + // Mirrors the node's quad-mode skip: nothing was copied, and the frame is not counted. let quads = quads.map(|q| q.0.clone()).unwrap_or_default(); if watch.pixels.is_empty() && quads.is_empty() { return; @@ -507,17 +426,14 @@ fn read_depth( let size = depth.texture.size(); let slice = staging.buffer.slice(..); slice.map_async(MapMode::Read, |_| {}); - // Block. A probe run is not gameplay, and a frame's numbers are worth nothing if they arrive - // attached to a later frame's index. + // Block, so a frame's numbers never arrive under a later frame's index. if let Err(e) = device.poll(bevy::render::render_resource::PollType::wait_indefinitely()) { error!("depth: poll failed: {e}"); return; } let frame = read.0; read.0 += 1; - // The projection the frame was actually drawn with, once per burst. Stated rather than assumed: - // the reported distances are only as good as this matrix, and an instrument whose calibration is - // invisible is one whose numbers cannot be audited later. + // The projection the frame was drawn with, once per burst: the distances derive from it. if frame == 0 { info!( "depth: {}x{} view, clip_from_view Pβ‚‚β‚‚ {} P₃₂ {} Pβ‚€β‚€ {} P₁₁ {}", @@ -543,10 +459,8 @@ fn read_depth( let at = (y * staging.bytes_per_row + x * 4) as usize; let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]); match view_point(&view_from_clip, ndc_of(x, y, size.width, size.height), d) { - // Both distances, because they are not the same number and the difference is 15% at - // the edge of the frame: `along` is measured along the ray (what `WOW_PICK` reports, - // so this is the one to compare), `z` is the perpendicular distance to the camera - // plane (what the depth buffer natively encodes). + // Along the ray (what `WOW_PICK` reports) and to the camera plane (what depth + // encodes); they differ by 15% at the frame edge. Some(p) => info!( "depth#{frame} ({x}, {y}): {d:.9} = {:.4} yd along the ray ({:.4} yd view z)", p.length(), @@ -563,17 +477,12 @@ fn read_depth( staging.buffer.unmap(); } -/// Samples in each direction across a quad's own area β€” 16Γ—16 = 256 tests, fine enough that the -/// fraction is stable to well under a percent against the offline model's 48Γ—48 and 64Γ—64 grids, -/// cheap enough to run on every quad of a pool. +/// Samples per side across a quad's own area: 16x16, stable to under a percent. const QUAD_GRID: usize = 16; -/// Run one quad's depth contest and log it. -/// -/// Reverse-Z, `GreaterEqual` (the particle material takes Bevy's default compare): the fragment -/// survives iff `dquad >= dbuffer`. Sampling walks the quad's own `(u, v)` by bilinear interpolation -/// of its four projected corners, so every sample is inside the quad by construction β€” including a -/// spun quad, whose screen AABB is up to 41 % larger than the quad itself. +/// Run one quad's depth contest and log it. Reverse-Z with Bevy's default `GreaterEqual`: a +/// fragment survives iff `dquad >= dbuffer`. Samples interpolate the four projected corners, so +/// each lies inside the quad even when it is spun. fn report_quad( frame: u32, q: &QuadProbe, @@ -583,8 +492,7 @@ fn report_quad( height: u32, ) { let (mut passed, mut total) = (0usize, 0usize); - // Reverse-Z: the LARGEST buffer value is the NEAREST surface, so `dmax` is the closest thing - // standing in the quad's way and `dmin` the furthest. + // Reverse-Z: `dmax` is the nearest occluder, `dmin` the furthest. let (mut dmin, mut dmax) = (f32::MAX, f32::MIN); let mut cleared = 0usize; for iy in 0..QUAD_GRID { @@ -619,8 +527,7 @@ fn report_quad( ); return; } - // `dquad` and the buffer values live on the same reverse-Z curve, so the ratio converts either - // to yards without re-deriving the projection: view z scales as 1/d. + // On the reverse-Z curve view z scales as 1/d, so the ratio to `dquad` converts to yards. let yd = |d: f32| q.viewz * q.dquad / d; let (near, far) = (yd(dmax), yd(dmin)); info!( @@ -648,17 +555,8 @@ fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 { ) } -/// Unproject a pixel's depth back to where it is in **view space**, in yards. -/// -/// It has to be the whole point, not just the depth: a depth value alone linearises to the distance -/// to the camera *plane*, while a ray cast measures along the *ray*, and off-axis those differ by -/// `1/cos ΞΈ` β€” 15% at the edge of a 45Β° frame, six yards at this bug's range. Comparing the two -/// without converting is the same axis mix-up that mis-measured B38's surface gap twice (0665), and -/// it is 100Γ— the gap the readback exists to resolve. So unproject the actual pixel and hand back the -/// point; the caller reports both lengths. -/// -/// Going through the inverse of the live matrix, rather than a reverse-Z formula, means the reading -/// cannot silently disagree with the projection the frame was drawn with. +/// Unproject a pixel's depth through the live matrix to its view-space point, in yards. The whole +/// point is needed: off axis, the ray length exceeds the plane distance by `1/cos ΞΈ`. fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option { let p = *view_from_clip * Vec4::new(ndc.x, ndc.y, d, 1.0); // Behind the camera or at infinity (a reverse-Z clear reads 0 β‡’ w 0) means nothing drew here. @@ -667,8 +565,7 @@ fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option { .filter(|v| v.is_finite() && v.z < 0.0) } -/// `"x,y;x,y"` β†’ pixels. Malformed pairs are dropped with a warning rather than failing the run: -/// a typo in one coordinate of a list of eight should not cost a capture. +/// `"x,y;x,y"` β†’ pixels; a malformed pair is dropped with a warning. fn parse_pixels(spec: &str) -> Vec<(u32, u32)> { spec.split(';') .filter(|s| !s.trim().is_empty()) @@ -729,7 +626,6 @@ mod tests { #[test] fn off_axis_the_ray_is_longer_than_the_perpendicular_distance() { - // The bug this test exists for: reporting view-space z as if it were the ray-cast distance. // Straight ahead the two agree; at the frame edge they must not. let inv = proj().inverse(); let d = depth_of(46.0); @@ -759,9 +655,8 @@ mod tests { #[test] fn the_awning_and_the_plank_are_thousands_of_ulps_apart() { - // The whole instrument rests on this: at the B38 pin the two surfaces are 1.4 cm apart - // perpendicular, and a readback can only name the winner if that gap survives `f32`. - // If this ever fails, the probe cannot answer the question it exists for. + // Two measured surfaces 1.4 cm apart perpendicular: the readback names the winner only if + // that gap survives `f32`. let (awning, plank) = (46.0253f32, 46.0897f32); let (da, dp) = (depth_of(awning), depth_of(plank)); let ulps = ((da.to_bits() as i64) - (dp.to_bits() as i64)).abs(); diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 0cf1f04b..110b6047 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1,38 +1,31 @@ -//! The capture UI fixtures (split from `mod.rs`, file-size budget): each arm seeds one -//! window's synthetic-but-realistic state β€” see [`seed_ui_fixture`]'s doc. +//! The capture UI fixtures: each arm seeds one window's synthetic but realistic state. use super::*; -/// The `vplates` fixture's wolf β€” the reference client's own screenshot subject (vmangos -/// `creature_template` 69 "Timber Wolf": level 2, faction template 32, display 604), standing at -/// the scenario's look point. +/// The `vplates` fixture's wolf, the reference screenshot's subject (vmangos `creature_template` +/// 69 "Timber Wolf": level 2, faction template 32, display 604), at the scenario's look point. const WOLF_ENTRY: u32 = 69; const WOLF_GUID: u64 = (0xF130u64 << 48) | ((WOLF_ENTRY as u64) << 24) | 0x69; const WOLF_POS: [f32; 3] = [-8949.95, -132.49, 83.9]; const WOLF_DISPLAY: u32 = 604; const WOLF_FACTION: u32 = 32; -/// The `name-water` fixture's unit: the same wolf, re-seated 25 yd along the water scenario's own -/// look bearing (`WATER_EYE` β†’ `WATER_LOOK`) at the river surface, so its overhead name projects -/// onto the water *beyond* it. +/// The `name-water` fixture's wolf, 25 yd along the water scenario's look bearing at the river +/// surface, so its overhead name projects onto the water beyond it. pub(super) const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4]; -/// The lighting matrix's chest (decision 0744): `GameObjectDisplayInfo` 259, -/// `World\SkillActivated\Containers\TreasureChest01.mdx`. GameObject guids carry the `0xF110` high -/// word, and the descriptor is left at its defaults β€” an unstated `GAMEOBJECT_STATE` holds the -/// closed rest pose, which is the frame we want. +/// The lighting matrix's chest: `GameObjectDisplayInfo` 259, `TreasureChest01.mdx`. GameObject +/// guids carry the `0xF110` high word; the default descriptor holds the closed rest pose. const CHEST_DISPLAY: u32 = 259; const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744; -/// Which way a lighting-matrix subject faces (Bevy yaw). The matrix's `front` camera sits on the -/// lighting sun's bearing (azimuth 45Β°), so the subject is turned to meet it: `front` reads the -/// face, `rear` the tail β€” the same body, its lit and unlit sides. +/// A lighting-matrix subject's Bevy yaw, facing the `front` camera on the sun's bearing (azimuth +/// 45Β°), so `front` shows the lit side and `rear` the unlit one. const SUBJECT_YAW: f32 = 2.36; -/// Seed the fixture window's state resources once, right after the scene goes resident β€” the real -/// feeds then push it into the VM during the settle window exactly as live wire data would. Item -/// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that -/// catalog is known to carry); names land directly in the caches (no server to ask). +/// Seeds the fixture window's state once the scene is resident; the real feeds push it into the VM +/// during the settle window as live wire data would. Icons resolve through the offline +/// `ItemDisplayCatalog`, and names go straight into the caches. pub(super) fn seed_ui_fixture( mut ctx: ResMut, mut commands: Commands, @@ -42,8 +35,8 @@ pub(super) fn seed_ui_fixture( mut quest: ResMut, mut quest_log: ResMut, mut loot: ResMut, - // The item store and the object index as one param (the 16-SystemParam ceiling): the item - // objects are entities in the index (2334), and the fixture spawns them like the wire does. + // One param under Bevy's 16-param cap: item objects are entities in the index, spawned as + // the wire does. store: (ResMut, ResMut), mut names: ResMut, icons: Option>, @@ -51,7 +44,7 @@ pub(super) fn seed_ui_fixture( mut vplates: ResMut, mut selection: ResMut, mut player: ResMut, - // Bundled: Bevy systems cap at 16 top-level params β€” a nested tuple is one param. + // A nested tuple, one param under Bevy's 16-param cap. (mut actions, mut bank, mut exit, mut loading): ( ResMut, ResMut, @@ -72,13 +65,8 @@ pub(super) fn seed_ui_fixture( } ctx.ui_seeded = true; - // **A UI capture with no script VM is not a capture β€” refuse it.** Every seed below opens its - // window by calling into the in-game UI, so with no VM they all fail the same way: a nil - // global, one `warn!` in a log full of pipeline chatter, a valid-looking PNG of a UI-less - // world, and exit 0. That is the false-negative shape `docs/METHOD.md` Β§6 exists to prevent, and it - // burned a session. `scenario_wants_ui` removed the cause (a `ui:` scenario no longer needs - // `WOW_CAPTURE_UI=1`); this is the tripwire for whatever else could leave the VM absent, and - // it exits non-zero the way the window-size refusal does (`video::warn_if_window_mismatch`). + // A UI capture with no script VM would be a UI-less world under the scenario's name, so it + // exits non-zero, as the window-size refusal does. if script.is_none() { error!( "capture: REFUSING this capture β€” scenario {:?} declares a UI fixture but no script VM exists, so its window cannot be opened and the shot would be a UI-less world wearing the scenario's name.", @@ -88,12 +76,11 @@ pub(super) fn seed_ui_fixture( return; } - // A creature guid whose entry bits (24–47) carry 90001 β€” the NameCache resolves vendor/NPC - // names by that entry, so inserting the name by entry makes the title path run for real. + // A creature guid whose entry bits (24-47) carry 90001; the NameCache resolves NPC names by + // entry. const NPC_ENTRY: u32 = 90_001; const NPC_GUID: u64 = (0xF130u64 << 48) | ((NPC_ENTRY as u64) << 24) | 0x42; - // Display ids the offline icon catalog is known to resolve (crates/benilla-formats items.rs - // anchors): sword, food, shield, hearthstone. + // Display ids the offline icon catalog resolves: sword, food, shield, hearthstone. const DISP_SWORD: u32 = 1542; const DISP_FOOD: u32 = 2473; const DISP_SHIELD: u32 = 18730; @@ -155,14 +142,9 @@ pub(super) fn seed_ui_fixture( }; match fixture { - // Nothing to open β€” the UI being loaded (and `demo_unit_feed`'s synthetic player/target) - // IS the fixture. The `script.is_none()` refusal above still guards it: these scenarios - // photograph the player UI, so a run without a VM is as wrong for them as for any other. + // Nothing to open: the loaded UI and `demo_unit_feed`'s player and target are the fixture. UiFixture::Bare => {} - // The one fixture that opens no window: it raises the world-entry loading screen over the - // settled scene and pins it there, tip and all. `hold_for_capture` sets the `Pick` edge; - // `crate::game_tip::drive_game_tip` paints it on the next frame, and the stability watch - // settles on the held image. + // Holds the world-entry loading screen, tip and all, over the settled scene. UiFixture::LoadingTip => loading.hold_for_capture(scenario.map), UiFixture::Merchant => { names.insert_creature( @@ -179,13 +161,9 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - // Eight rows across both columns: long names to exercise the wrap, mixed prices to - // exercise the coin denominations (75c, 1s 23c, 998g-class purse comes from set_money). - // The last two columns per row are requiredLevel and remaining stock: boots + gloves - // are level-gated past the fixture player (level 12, matching the standing player - // frame in the scene) so their rows render the ref's UNUSABLE red (plate/socket - // 1,0,0 Β· icon 0.9,0,0), and the Small Shield is sold out so its row renders the - // 0.5 gray. + // Eight rows: long names for the wrap, mixed prices for the coins. The last two + // columns are required level and stock: boots and gloves are gated past the level-12 + // player so they render the unusable red, and the sold-out Small Shield the 0.5 gray. let stock: [(&str, u32, u32, u32, u32); 8] = [ ("Tarnished Chain Vest", DISP_SWORD, 75, 0, u32::MAX), ("Tarnished Chain Leggings", DISP_FOOD, 75, 0, u32::MAX), @@ -216,10 +194,8 @@ pub(super) fn seed_ui_fixture( }) .collect(); merchant.open(NPC_GUID, rows); - // The usable gate's player half, pushed by hand: there is no SelfPlayer in capture - // (same reasoning as the bag fixture's manual snapshot below), so feed_player_req - // never runs and this seed is not clobbered. Level 12 human warrior β€” the standing - // player frame's level, under the boots/gloves gate above. + // The usable gate's player half, by hand: with no SelfPlayer in capture, + // `feed_player_req` never runs to overwrite it. A level-12 human warrior. if let Some(s) = script.as_mut() { s.set_player_req_state(benilla_ui::script::PlayerReqState { level: 12, @@ -246,24 +222,16 @@ pub(super) fn seed_ui_fixture( ); gossip.npc = Some(NPC_GUID); gossip.text_id = 1; - // The greeting of the live menu the overflow was reported on: long enough that the - // wrapped options below it run past the parchment, so this capture stands on the - // scrolling path rather than beside it. + // A greeting long enough that the wrapped options below run past the parchment. gossip.greeting = Some( "A man has been caught stealing corn from the fields of a noble, a lord known \ for his harsh taxes throughout the land.$B$BMake your choice!" .into(), ); - // Quest rows ride above the options (decision 0088) β€” an available quest (its own - // AvailableQuestIcon dot) so the capture covers the quest-row icon/text seating, not - // just the option rows. + // An available quest row above the options, for the quest-row icon and text seating. gossip.quests = vec![(783, 0, 5, "Eagan Peltskinner".into())]; - // One short option AND four that WRAP β€” the live shape both gossip bugs came in as - // (the director's screenshots), and a menu deliberately TALLER than the parchment so - // the capture covers the whole chain: the stock `GossipResize`'s per-row auto-height, - // the scroll frame that contains the overflow, and the - // scrollbar that appears with it. A fixture of one-line labels showed none of this β€” - // every row fit the template's 16 px and nothing ever overflowed. + // One short option and four that wrap, taller than the parchment, so the capture covers + // the stock `GossipResize` per-row height, the scroll frame and its scrollbar. let judgement = [ "I slay the man on the spot as my liege would expect me to, as he has broken the \ law of the land and it is my sworn duty to enforce it.", @@ -292,9 +260,8 @@ pub(super) fn seed_ui_fixture( })); } UiFixture::Bank => { - // The banker (a pure banker β€” the Ironforge vault's own name) + the vault fed the REAL - // way: everything below lands in the descriptor/caches, and `feed_bank`/`ui_items` push - // it into the VM over the settle window exactly as live wire data would. + // A pure banker and the vault, fed through the descriptor and caches that `feed_bank` + // and `ui_items` push into the VM as live wire data would. use benilla_protocol::messages::ObjectFields; names.insert_creature( NPC_ENTRY, @@ -310,10 +277,8 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - // Vault items (bank wire slots 0, 1, 2, 7 β†’ fields 564+2i) + the held bank bag in bag - // slot 0 (field 612). Icons resolve through the offline catalog's known display ids; - // the bag reuses the hearthstone display (no bag icon among the capture anchors β€” - // structural stand-in, not a look claim). + // Vault items (bank slots 0, 1, 2, 7 at fields 564+2i) and the bank bag in bag slot 0 + // (field 612); the bag borrows the hearthstone display as a stand-in. const G_SHIELD: u64 = 0x2001; const G_JERKY: u64 = 0x2002; const G_SWORD: u64 = 0x2003; @@ -338,9 +303,8 @@ pub(super) fn seed_ui_fixture( } items.insert_template(entry, Some(t)); } - // The held bank bag is a real CONTAINER object (its contents stream on the bag item β€” - // decision 0604): 6 slots, one occupied, so the POPOUT window (container 5) is in the - // shot too β€” its snug-fit stitch, lit bag button, and own-icon portrait. + // The bank bag is a container object (its contents stream on the bag item): 6 slots, + // one occupied, so its popout window (container 5) is in the shot too. crate::items::spawn_item( &mut commands, &mut index, @@ -353,18 +317,17 @@ pub(super) fn seed_ui_fixture( ]), true, ); - // The self player: 4 occupied vault slots, the bank bag, TWO bought bag slots - // (`PLAYER_BYTES_2` byte 2 β€” bag button 1 owned, 2 bought-but-empty, 3–6 the red - // unpurchased tint), and a 12g 34s 56c purse β€” the 10g third-slot cost from the real - // `BankBagSlotPrices.dbc` renders affordable-white under it. + // The self player: 4 vault slots, the bank bag, two bought bag slots (`PLAYER_BYTES_2` + // byte 2: button 1 owned, 2 bought and empty, 3-6 unpurchased red) and a 12g 34s 56c + // purse, under which the 10g third-slot cost (`BankBagSlotPrices.dbc`) is affordable. const PLAYER_GUID: u64 = 0x51; let fields = ObjectFields::from_pairs(&[ - (194, 2 << 16), // PLAYER_BYTES_2 β€” bankBagSlots (byte 2) = 2 + (194, 2 << 16), // PLAYER_BYTES_2: bankBagSlots (byte 2) = 2 (564, G_SHIELD as u32), // BANK_SLOT_1 (vault slot 0) (566, G_JERKY as u32), // vault slot 1 (568, G_SWORD as u32), // vault slot 2 - (578, G_STONE as u32), // vault slot 7 β€” a gap, like a real vault - (612, G_BANKBAG as u32), // BANK_BAG_SLOT_1 β€” bag button 1's icon + (578, G_STONE as u32), // vault slot 7, after a gap + (612, G_BANKBAG as u32), // BANK_BAG_SLOT_1: bag button 1's icon (1176, 123_456), // PLAYER_FIELD_COINAGE ]); names.insert_player(PLAYER_GUID, "Benilla".into(), None); @@ -373,13 +336,10 @@ pub(super) fn seed_ui_fixture( crate::net::SelfPlayer, crate::net::Guid(PLAYER_GUID), )); - // Open the session β€” `feed_bank` fires BANKFRAME_OPENED (title + portrait) on the - // next frames exactly as a live SMSG_SHOW_BANK would. + // `feed_bank` then fires BANKFRAME_OPENED as a live SMSG_SHOW_BANK would. bank.open(NPC_GUID); - // One-shot popout: open bank bag 1 as soon as its container feed lands (the click - // path needs `GetContainerNumSlots(5) > 0`, which arrives over the settle frames β€” - // the window's OnUpdate polls, flag-guarded, exactly once). `BankFrameBag1`'s own - // `GetID()` is 5, the container id, which is what its handler passes to `ToggleBag`. + // Opens bank bag 1 once its container feed lands (`GetContainerNumSlots(5) > 0`); + // `BankFrameBag1`'s `GetID()` is 5, the container id its handler passes to `ToggleBag`. if let Some(s) = script.as_mut() { if let Err(e) = s.run( "BankFrame:SetScript(\"OnUpdate\", function()\n\ @@ -409,9 +369,8 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - // A QUEST_DETAILS-shaped view (the accept panel): a description + objectives, one choice - // reward, one fixed reward, and a money reward β€” the same offline caches the live feed - // reads resolve the row names/icons. + // A QUEST_DETAILS view (the accept panel): description, objectives, two choice rewards, + // one fixed reward and money. items.insert_template(92_001, Some(template("Brackwater Cudgel", 2))); items.insert_template(92_002, Some(template("Militia Warhammer", 1))); items.insert_template(92_003, Some(template("Bandit Cloak", 1))); @@ -454,8 +413,7 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - // The multi-quest greeting: two AVAILABLE quests (not in the log β†’ bullet rows under the - // "Available Quests" header), matching the director's live screenshot subject. + // The multi-quest greeting: two available quests, bullet rows under "Available Quests". quest.open( NPC_GUID, crate::ui_quest::QuestView::Greeting(benilla_protocol::messages::QuestGiverList { @@ -481,10 +439,9 @@ pub(super) fn seed_ui_fixture( ); } UiFixture::QuestLog => { - // The log is fed from the self player's PLAYER_QUEST_LOG descriptor slots (decision - // 0109: descriptor-as-truth) β€” so the fixture spawns a synthetic self player carrying - // two occupied slots and lets `feed_quest_log` run the real chain. Slot layout per the - // 0109 wire pin: id / packed 6-bit counters + state byte / timer, base field 198. + // The log reads the self player's PLAYER_QUEST_LOG slots, so a synthetic self player + // carries two and `feed_quest_log` runs the real chain. A slot is id, packed 6-bit + // counters plus a state byte, and timer, from field 198. use benilla_protocol::messages::{ObjectFields, QuestObjective, QuestTemplate}; const KOBOLD_ENTRY: u32 = 90_002; names.insert_creature( @@ -501,8 +458,8 @@ pub(super) fn seed_ui_fixture( display_id: 0, }), ); - // The item objective's target + the reward rows (log icons resolve through the item - // template's display_info_id, not a wire display id β€” 0109). + // The item objective and reward rows; log icons come from the template's + // `display_info_id`. let mut with_icon = template("Chipped Boar Tusk", 0); with_icon.display_info_id = DISP_STONE; items.insert_template(93_001, Some(with_icon)); @@ -520,12 +477,9 @@ pub(super) fn seed_ui_fixture( item_count: 0, text: String::new(), }; - // Entry 1 (selected by first-valid auto-selection): in progress, one creature - // objective at 3/10 (the slot counter below) + one item objective (bags are empty in - // capture β†’ 0/5), choice + fixed rewards, money. `quest_type: 1` is QuestInfo.dbc's - // "Elite" β€” the real 783 is a plain quest, but this fixture is the only thing that - // puts a row TAG in front of the capture, and the tag and the "(Complete)" state word - // are different branches of the same row string (entry 2 covers the other). + // Entry 1, auto-selected: a creature objective at 3/10, an item objective at 0/5, + // rewards and money. `quest_type: 1` is `QuestInfo.dbc`'s "Elite", though the real 783 + // is plain, so a row tag is in the shot. quest_log.insert_template(QuestTemplate { quest_id: 783, method: 2, @@ -574,7 +528,7 @@ pub(super) fn seed_ui_fixture( blank.clone(), ], }); - // Entry 2: whole-quest COMPLETE β€” exercises the row's "(Complete)" tag. + // Entry 2: complete, for the row's "(Complete)" tag. quest_log.insert_template(QuestTemplate { quest_id: 7, method: 2, @@ -613,9 +567,8 @@ pub(super) fn seed_ui_fixture( ], }); - // The synthetic self player: slot 0 = quest 783 (counter0 = 3), slot 1 = quest 7 - // (counter0 = 10, state byte COMPLETE) β€” the packing `SetQuestSlotCounter`/ - // `SetQuestSlotState` write (0109 pin). + // Slot 0 is quest 783 (counter 0 at 3), slot 1 quest 7 (counter 0 at 10, state + // COMPLETE), packed as vmangos's `SetQuestSlotCounter` and `SetQuestSlotState` write. let fields = ObjectFields::from_pairs(&[ (198, 783), (199, 3), @@ -624,8 +577,7 @@ pub(super) fn seed_ui_fixture( (202, 10 | (0x01 << 24)), (203, 0), ]); - // A player guid + cached name ride along β€” the feeds resolve the player identity - // (chat-macro substitution) through the same (ObjectStore, Guid) self query as live. + // A guid and cached name, as the feeds resolve the player through the self query. const PLAYER_GUID: u64 = 0x51; names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( @@ -634,8 +586,7 @@ pub(super) fn seed_ui_fixture( crate::net::Guid(PLAYER_GUID), )); - // Open the book (the L binding's path). The feed pushes on the following frames of the - // settle window; OnShow + QUEST_LOG_UPDATE repaint exactly as live. + // The L binding's path; the feed pushes over the settle window. if let Some(s) = script.as_mut() { if let Err(e) = s.run("ToggleQuestLog()") { warn!("capture: ui-questlog seed failed to open the log: {e}"); @@ -648,7 +599,7 @@ pub(super) fn seed_ui_fixture( loot.open( NPC_GUID, benilla_protocol::messages::loot_type::CORPSE, - 4, // 4 copper β€” the coin row + 4, // 4 copper, the coin row vec![ benilla_protocol::messages::LootItem { slot: 0, @@ -687,8 +638,7 @@ pub(super) fn seed_ui_fixture( return; }; seed_cooldown_filmstrip(&mut script, icons.as_deref()); - // The pet bar's autocast shine, raised by hand: no pet is fed here, so the bar's own - // show path never runs. The shine is the subject, not the bar's chrome. + // The pet bar's autocast shine, raised by hand, as no pet is fed. if let Err(e) = script.run( "PetActionBarFrame:Show()\n\ for i = 1, 4 do\n\ @@ -703,15 +653,12 @@ pub(super) fn seed_ui_fixture( let Some(mut script) = script else { return; }; - // Server-less, Player defaults to the origin β€” off every zone rect, so the blip - // projects to the (0,0) hide sentinel and the arrow never shows. Park the avatar at - // the scenario's Northshire spot so the arrow lands on the Elwynn map for real. + // At the default origin the player is off every zone rect and the arrow hides, so park + // it at the scenario's Northshire spot. player.pos = benilla_assets::coords::wow_to_bevy(scenario.eye); - // Alternating explore bits: roughly half of every zone's overlays reveal, so the - // capture shows fog doing its job (some sub-areas drawn, some parchment). + // Alternating explore bits, so about half of each zone's overlays reveal. script.set_world_map_explored(vec![0x5555_5555; 64]); - // Open the map (fullscreen since 0221) at the Elwynn zone map (continent 2, zone 10 β€” - // "Elwynn Forest" in the alphabetical zone list). + // The Elwynn zone map: continent 2, zone 10 in the alphabetical zone list. if let Err(e) = script.run("ToggleWorldMap(); SetMapZoom(2, 10)") { warn!("capture: ui-worldmap seed failed to open the map: {e}"); } @@ -720,9 +667,7 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // Raised through the real registry entry, not a hand-built frame: the text comes from - // the chain's own `INVITATION` GlobalString and the two buttons from ACCEPT/DECLINE, - // so the capture exercises the same Show path a real invite takes. + // Raised through the real `StaticPopupDialogs` entry, as a real invite is. if let Err(e) = script.run(r#"StaticPopup_Show("PARTY_INVITE", "Brisca")"#) { warn!("capture: ui-partyinvite seed failed to raise the dialog: {e}"); } @@ -732,12 +677,9 @@ pub(super) fn seed_ui_fixture( return; }; seed_bag_window(&mut script, icons.as_deref()); - // The hovered shield's full template view + a player state that FAILS its level - // requirement β€” the capture demos the whole 0274 P1 line law: quality name, bind, - // slot|type, armor/block, stats, durability, a RED requirement line, a LONG green - // Use: line (WRAPS at the wrap column β€” the shape whose two-step re-measure never - // converged under the hover re-enter loop, the live bread/hearthstone spill), a - // charges line AFTER the wrap (the spill's canary), and the quoted flavor text. + // The hovered shield's full template and a player under its level requirement: quality + // name, bind, slot and type, armor and block, stats, durability, a red requirement, a + // long green Use: line that wraps, a charges line after it, and the flavor text. script.set_player_req_state(benilla_ui::script::PlayerReqState { level: 12, class_id: 1, @@ -771,9 +713,8 @@ pub(super) fn seed_ui_fixture( ..Default::default() }, ); - // The item-SET block (`0x52b650`; real Defias Leather shape, 5 members, one equipped): - // gold "(1/5)" header + spacer, cream/gray member ladder, green (2)-bonus vs gray - // (4)-bonus β€” the whole block's visual regression instrument. + // The item-set block (`0x52b650`), Defias Leather with one of 5 equipped: the gold + // "(1/5)" header, the member ladder, a green (2) bonus and a gray (4) one. let mut inv: benilla_ui::script::InventorySlots = Default::default(); inv[4] = Some(benilla_ui::script::InvSlotView { durability: None, @@ -802,15 +743,9 @@ pub(super) fn seed_ui_fixture( ..Default::default() }, ); - // Force the tooltip open over the top-left bag button (`Item16` β‡’ game slot 1, the - // green Small Shield β€” see `seed_bag_window` on the backwards numbering) via the same - // OnEnter path a hover fires β€” deterministic, no synthetic mouse. A top-left seat keeps - // the ANCHOR_RIGHT tooltip on-screen; the engine's auto-size pass settles it over the - // capture window. - // - // **Which window the backpack landed in is ASKED, never assumed**: the reference - // recycles twelve `ContainerFrame`s across every container, so the index depends on - // what else is open. `IsBagOpen` is its own published scan. + // Opens the tooltip over the top-left button (`Item16`, game slot 1, the Small Shield) + // through the hover's OnEnter path. `IsBagOpen` names the backpack's window, as the + // twelve `ContainerFrame`s are recycled across containers. if let Err(e) = script.run( "local i = IsBagOpen(0)\n\ if i then\n\ @@ -824,12 +759,9 @@ pub(super) fn seed_ui_fixture( let Some(mut script) = script else { return; }; - // A PvP-flagged friendly guard (3 lines: name / level+type / PvP) under the cursor, - // pushed the way the mouseover feed pushes it, then - // the engine's world drive β€” the same call `drive_mouseover_tooltip` makes on a hover - // change. The ONLY thing this instrument is for is WHERE the plate sits: the default - // corner (bottom-right, βˆ’13/+70 with the load-time offsets), per the now-wired - // OnTooltipSetDefaultAnchor. Content (name/level/health bar) rides along. + // A PvP-flagged friendly guard under the cursor, pushed as the mouseover feed does, + // then the call `drive_mouseover_tooltip` makes. The subject is the placement, the + // `GameTooltip_SetDefaultAnchor` corner (`GameTooltip.lua:73-77`). script.set_unit( "mouseover", Some(benilla_ui::script::UnitState { @@ -840,8 +772,7 @@ pub(super) fn seed_ui_fixture( level: 25, reaction: 5, creature_type_name: Some("Humanoid".into()), - // The faction-name line ("Stormwind", white, between level and PvP) β€” the - // director's Marshal McBride reference shape. + // The faction-name line, white, between level and PvP. faction_name: Some("Stormwind".into()), pvp: true, ..Default::default() @@ -852,14 +783,12 @@ pub(super) fn seed_ui_fixture( } } UiFixture::Character => { - // The paper doll fed the REAL way (the QuestLog pattern): a synthetic self player whose - // descriptor carries the full 0208 stat block β€” the `ui_char` feed then builds the - // snapshots and fires the events exactly as live. Values are a plausible level-12 - // warrior; one positive (stamina, fire) and one negative (spirit) buff exercise the - // green/red stat coloring. All indices are the 0208-verified constants. + // A synthetic self player carrying the full stat block, which the `ui_char` feed turns + // into snapshots and events as live. A level-12 warrior; positive (stamina, fire) and + // negative (spirit) buffs exercise the green and red stat colours. use benilla_protocol::messages::ObjectFields; const PLAYER_GUID: u64 = 0x51; - // Equipped item guids (chest / main hand / ranged) + an arrow stack in the backpack. + // Equipped item guids (chest, main hand, ranged) and an arrow stack in the backpack. const G_CHEST: u64 = 0x1001; const G_SWORD: u64 = 0x1002; const G_BOW: u64 = 0x1003; @@ -867,7 +796,7 @@ pub(super) fn seed_ui_fixture( let fields = ObjectFields::from_pairs(&[ (34, 12), // UNIT_FIELD_LEVEL - (36, 4 | 1 << 8), // UNIT_FIELD_BYTES_0 β€” night elf warrior, male, mana + (36, 4 | 1 << 8), // UNIT_FIELD_BYTES_0: night elf warrior, male, mana (126, 2400), // BASEATTACKTIME[0] ms (128, 2000), // RANGEDATTACKTIME ms (134, 13.0f32.to_bits()), // MINDAMAGE @@ -895,25 +824,22 @@ pub(super) fn seed_ui_fixture( (722, 24 | 60 << 16), (724, 45), (725, 55 | 60 << 16), - // Stat buffs (INT on the wire β€” decision 1397): +10 stamina, βˆ’5 spirit; +10 fire - // resistance. The βˆ’5 is the two's-complement word an x86-hosted server sends; an - // arm64 host saturates that same debuff to a flat 0, so the RED leg of the sheet is - // exercisable here and not on this deploy. + // Stat buffs (INT on the wire): +10 stamina, -5 spirit, +10 fire resistance. The + // -5 is the two's-complement word an x86 server sends; an arm64 one sends 0. (1179, 10), // POSSTAT2 (1186, (-5i32) as u32), // NEGSTAT4 (1189, 10), // RESISTANCEBUFFMODSPOSITIVE[2] (fire) // Equipment guids (INV_SLOT_HEAD base 486 + 2Β·slot): chest 4, main hand 15, - // ranged 17; the first backpack slot (PACK_SLOT_1 base 532) holds the arrows. + // ranged 17; the first backpack slot (PACK_SLOT_1, 532) holds the arrows. (494, G_CHEST as u32), (516, G_SWORD as u32), (520, G_BOW as u32), (532, G_ARROWS as u32), - (1223, 93_012), // PLAYER_AMMO_ID β€” the arrows' entry + (1223, 93_012), // PLAYER_AMMO_ID: the arrows' entry ]); - // The item objects (entry + stack) and their ask-once templates. Icons resolve through - // the offline catalog's known display ids; the bow reuses the hearthstone display (no - // bow icon among the capture anchors β€” structural stand-in, not a look claim). + // The item objects and their templates; the bow borrows the hearthstone display as a + // stand-in. let obj = |entry: u32, stack: u32| { ObjectFields::from_pairs(&[(3, entry), (14, stack)]) // OBJECT_ENTRY, STACK_COUNT }; @@ -926,7 +852,7 @@ pub(super) fn seed_ui_fixture( chest.inventory_type = 5; items.insert_template(93_010, Some(chest)); let mut sword = template("Militia Shortsword", 2); - sword.class = 2; // weapon: sword 1h β†’ skill 43 (the Attack row's skill line) + sword.class = 2; // weapon: 1h sword, skill 43 (the Attack row's skill line) sword.subclass = 7; sword.display_info_id = DISP_SWORD; sword.inventory_type = 21; @@ -935,14 +861,14 @@ pub(super) fn seed_ui_fixture( sword.delay_ms = 2400; items.insert_template(93_011, Some(sword)); let mut bow = template("Cracked Shortbow", 1); - bow.class = 2; // weapon: bow β†’ skill 45 (the ranged block's skill line) + bow.class = 2; // weapon: bow, skill 45 (the ranged block's skill line) bow.subclass = 2; bow.display_info_id = DISP_STONE; bow.inventory_type = 15; items.insert_template(93_013, Some(bow)); let mut arrows = template("Rough Arrow", 0); - arrows.class = 6; // projectile β€” the ammo slot's icon + bag-summed count (200) - arrows.inventory_type = 24; // INVTYPE_AMMO β€” the equip drains' SET_AMMO fork (0526) + arrows.class = 6; // projectile: the ammo slot's icon and bag-summed count (200) + arrows.inventory_type = 24; // INVTYPE_AMMO: the equip drains' SET_AMMO fork arrows.display_info_id = DISP_STONE; items.insert_template(93_012, Some(arrows)); @@ -953,8 +879,7 @@ pub(super) fn seed_ui_fixture( crate::net::Guid(PLAYER_GUID), )); - // Open the window (the C binding's path). The feed pushes on the following frames of - // the settle window; OnShow + the UNIT_* events repaint exactly as live. + // The C binding's path; the feed pushes over the settle window. if let Some(s) = script.as_mut() { if let Err(e) = s.run("ToggleCharacter(\"PaperDollFrame\")") { warn!("capture: ui-char seed failed to open the window: {e}"); @@ -963,30 +888,24 @@ pub(super) fn seed_ui_fixture( } UiFixture::VPlates => { use benilla_protocol::messages::ObjectFields; - // The synthetic self player, at the camera eye (the 20 yd plate gate measures from - // here): level 2 human β€” the wolf cons YELLOW, the reference screenshot's digit. + // The self player at the camera eye (the 20 yd plate gate measures from here): a + // level-2 human, so the wolf cons yellow as in the reference screenshot. const PLAYER_GUID: u64 = 0x51; names.insert_player(PLAYER_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 2), // UNIT_FIELD_LEVEL - (35, 1), // UNIT_FIELD_FACTIONTEMPLATE β€” human - (36, 0x0101), // UNIT_FIELD_BYTES_0 β€” race human, class warrior - // UNIT_FIELD_FLAGS bit 3 (`PLAYER_CONTROLLED`) β€” which every real player unit - // carries and this hand-built snapshot did not. `CanAttack` picks its terminal - // arm on this bit (1530): without it the observer reads as a creature, the pair - // takes the both-uncontrolled arm β€” which needs a HOSTILE reaction in one - // direction, and a wolf's is neutral β€” and the scenario's plate stopped being - // drawn at all. A fixture is a snapshot of the live component set; this field - // was simply missing from it. + (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human + (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior + // UNIT_FIELD_FLAGS bit 3 (`PLAYER_CONTROLLED`), which every player carries; + // `CanAttack` picks its arm on it, and without it no plate draws on the wolf. (46, 0x8), ])), crate::net::SelfPlayer, crate::net::Guid(PLAYER_GUID), Transform::from_translation(wow_to_bevy(scenario.eye)), )); - // The wolf: the live spawn's component set (net/apply.rs) with the descriptor seeded - // directly, standing at the look point facing the camera, at full health. + // The wolf: the live spawn's component set, at the look point facing the camera. names.insert_creature( WOLF_ENTRY, Some(crate::names::CreatureRecord { @@ -1024,9 +943,7 @@ pub(super) fn seed_ui_fixture( )) .id(); vplates.enemies = true; - // The wolf is the TARGET: the plate draws lit β€” the bar's uniform brighten (the - // watcher's target leg) β€” plus the target's ring/emissive, the real targeted look - // this fixture regression-pins. + // The wolf is the target, so its plate draws lit with the target ring. selection.target = Some(wolf); selection.guid = Some(WOLF_GUID); } @@ -1034,10 +951,7 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // Static window, opened through the live panel path β€” nothing else to seed. What the - // capture pins: the era chrome (nine-slice seams, right-edge straddle), the tab - // plates, the search-box seat, the category list art with Controls selected (the - // OnShow default), and the window's fit scale. + // Opened through the live panel path, with Controls selected by its OnShow. if let Err(e) = script.run("ShowUIPanel(BenillaOptionsFrame)") { warn!("capture: ui-options seed failed to open the window: {e}"); } @@ -1046,9 +960,8 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // The Audio page (0957): register the real CVar set first β€” the hermetic capture has - // no CvarPlugin file load to race, and the rows must read real values, not the - // nil-tolerant zeros β€” then open and select through the live paths. + // The Audio page: the real CVar set first, so the rows read real values, then the + // live open and select paths. script.register_cvars(crate::cvars::registered_pairs()); if let Err(e) = script.run( "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAudio:Click()", @@ -1060,8 +973,7 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // The Graphics page (0959), same posture as the Audio fixture: real CVar set, live - // open-and-select paths. + // The Graphics page, as the Audio fixture. script.register_cvars(crate::cvars::registered_pairs()); if let Err(e) = script.run("ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowGraphics:Click()") @@ -1073,10 +985,8 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // The Chat page (1589), the page fixtures' posture: the real CVar set, then the live - // open path. Its Remove Chat Hover Delay row reads a saved-variable global that - // `ChatFrame.xml` declares at file scope, so a hermetic capture sees the shipped "0" - // and the row paints unchecked β€” which is the shipped default, not a missing load. + // The Chat page, as the Audio fixture. Its Remove Chat Hover Delay row reads a saved + // variable `ChatFrame.xml` declares, so it paints unchecked, the shipped "0". script.register_cvars(crate::cvars::registered_pairs()); if let Err(e) = script.run( "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowChat:Click()", @@ -1088,11 +998,8 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // The corpus's own opening move (Dewdrop-2.0's colour row, `ColorPickerFrame.xml`'s - // header): set the colour, ask for opacity, show the window. A saturated mid-hue at - // three-quarter brightness puts BOTH markers off their defaults β€” the wheel's off - // centre and off the axes, the strip's a quarter down β€” so a mirrored axis or a - // swapped marker is visible in the still. + // An addon's usual opening: set the colour, ask for opacity, show the window. The + // colour puts both markers off their defaults, so a mirrored axis shows. if let Err(e) = script.run( "ColorPickerFrame.hasOpacity = 1; ColorPickerFrame.opacity = 0.3; \ ColorPickerFrame:SetColorRGB(0.15, 0.55, 0.75); \ @@ -1105,12 +1012,9 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same - // posture as the page fixtures: real CVar set, the live open-select-toggle path. The - // list's width lands inside the click that opens it β€” the stock kit's - // `UIDropDownMenu_Refresh` sizes every button from `normalText:GetWidth() + 60` - // (`UIDropDownMenu.lua` l.395-422) and the engine's measurer answers that getter in - // the call that asked, so there is no settle to wait out. + // The Camera Following Style list open. Its width lands inside the opening click: + // `UIDropDownMenu_Refresh` sizes buttons from `normalText:GetWidth() + 60` + // (`UIDropDownMenu.lua:405`), which the engine answers in the same call. script.register_cvars(crate::cvars::registered_pairs()); if let Err(e) = script.run( "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowControls:Click(); \ @@ -1123,11 +1027,8 @@ pub(super) fn seed_ui_fixture( let Some(mut script) = script else { return; }; - // The Keybindings page (1008 β€” the Options window's category), the page fixtures' - // posture: the real command registry first (hermetic capture β€” the plugin's - // PostStartup seed isn't raced, the register_cvars precedent), then the live open - // path; CVars registered too so the sibling category rows behave. Movement is - // expanded so the lens sees both a header row and the byte-real default capsules. + // The Keybindings page: the real command registry and CVar set first, then the live + // open path, with Movement expanded to show a header row and the default bindings. script.register_cvars(crate::cvars::registered_pairs()); script.register_bindings(&crate::bindings::registry_commands()); if let Err(e) = script.run( @@ -1142,12 +1043,8 @@ pub(super) fn seed_ui_fixture( let Some(script) = script else { return; }; - // Mid-search (0984), same posture as the page fixtures: real CVar set, then the - // live open path and a typed query β€” "volume" reflows the four volume sliders - // under the Audio head (Master matched by the token too, so no pull-in here; the - // pull-in has its unit test). Focused (0989): captures pin the caret visible, so - // this baseline also pins the caret hugging the text's end β€” the drawn-space - // advance law's visual regression guard. + // Mid-search: "volume" gathers the four volume sliders under the Audio head. The box + // is focused, so the caret shows at the text's end. script.register_cvars(crate::cvars::registered_pairs()); if let Err(e) = script.run( "ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameSearchBox:SetText(\"volume\"); BenillaOptionsFrameSearchBox:SetFocus()", @@ -1157,12 +1054,10 @@ pub(super) fn seed_ui_fixture( } } UiFixture::SpellBook => { - // The director's own report reproduced (decision 0228): a HUMAN WARRIOR (race 1, - // class 1) who learned Fireball + Mind Flay via a GM command during testing. The book - // resolves through the REAL chain (live 1.12 spell ids; names/icons/ranks from the - // local Spell.dbc, tab lines from SkillLineAbility.dbc, the General collapse from - // SkillRaceClassInfo.dbc keyed on the self player's race/class β€” nothing pushed by - // hand). The self player's descriptor carries the race/class the tab classifier reads. + // A human warrior who also knows two cross-class spells. The book resolves through + // the real chain: names, icons and ranks from `Spell.dbc`, tabs from + // `SkillLineAbility.dbc`, the General collapse from `SkillRaceClassInfo.dbc` keyed on + // the descriptor's race and class. use benilla_protocol::messages::ObjectFields; const PLAYER_GUID: u64 = 0x51; const G_SWORD: u64 = 0x1002; @@ -1170,13 +1065,13 @@ pub(super) fn seed_ui_fixture( commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 12), // UNIT_FIELD_LEVEL - (36, 1 | 1 << 8), // UNIT_FIELD_BYTES_0 β€” human (race 1), warrior (class 1) + (36, 1 | 1 << 8), // UNIT_FIELD_BYTES_0: human (race 1), warrior (class 1) (516, G_SWORD as u32), // main-hand item guid (INV_SLOT_HEAD 486 + 15Β·2) ])), crate::net::SelfPlayer, crate::net::Guid(PLAYER_GUID), )); - // The equipped main-hand weapon β€” the auto-attack borrows its icon (decision 0230). + // The equipped main-hand weapon, whose icon the auto-attack borrows. crate::items::spawn_item( &mut commands, &mut index, @@ -1191,29 +1086,25 @@ pub(super) fn seed_ui_fixture( return; }; actions.spells.extend([ - // The auto-attack β€” its icon must be the equipped sword, NOT spell 6603's `Temp` - // placeholder face (decision 0230). Lands in General (no skill line). + // The auto-attack shows the equipped sword's icon, not the placeholder of spell 6603, + // in General (no skill line). 6603, // Attack - // Warrior class abilities β†’ their own class-line tabs (flag clear for a warrior): - // Charge/Heroic Strike/Rend on Arms, Battle Shout on Fury. + // Warrior abilities go to their class-line tabs: Arms and Fury. 100, // Charge (Arms) 78, // Heroic Strike (Arms) 772, // Rend (Arms) 6673, // Battle Shout (Fury) - // A human racial (Racial - Human line) β†’ collapses to the General tab. + // A human racial collapses to General. 20600, // Perception - // The cheated cross-class test spells: no Fire/Shadow SkillRaceClassInfo row admits - // a warrior, so BOTH collapse into General β€” the capture must show NO Fire/Shadow - // tab (the director's spurious tabs), those spells sitting in General instead. + // Cross-class spells: no Fire or Shadow `SkillRaceClassInfo` row admits a warrior, + // so both collapse into General and no Fire or Shadow tab appears. 133, // Fireball (Fire) 589, // Shadow Word: Pain (Shadow) - // Add-gate check: a language + an armor proficiency a live warrior carries, both - // DO_NOT_DISPLAY β€” must NOT appear at all (decision 0227). + // A language and an armor proficiency, both DO_NOT_DISPLAY, so neither appears. 668, // Language: Common 9078, // Cloth proficiency ]); - // Open the book (the P binding's own path). The feed pushes the model on the - // following frames of the settle window; SPELLS_CHANGED repaints exactly as live. + // The P binding's path; the feed pushes over the settle window. if let Err(e) = script.run("ToggleSpellBook(BOOKTYPE_SPELL)") { warn!("capture: ui-spellbook seed failed to open the book: {e}"); } @@ -1222,12 +1113,9 @@ pub(super) fn seed_ui_fixture( let Some(script) = script.as_mut() else { return; }; - // Made through the LIVE `CreateMacro` path, not by pushing a table in β€” so the capture - // exercises engine table β†’ `UPDATE_MACROS` β†’ window exactly as a player's own edit - // does. Icon indices are into the real `SpellIcon.dbc` catalog `ui_macro` loads at - // PostStartup, so they resolve to real art. The bodies are the shapes that matter: a - // plain `/cast`, a MULTI-LINE macro (what the body box has to lay out), and a - // `/script` line (the non-`/cast` case the bar's bound-spell resolve must decline). + // Made through the live `CreateMacro` path, so `UPDATE_MACROS` reaches the window as a + // player's edit does; icons index `SpellIcon.dbc`. The bodies: a plain `/cast`, a + // multi-line macro, and a `/script` line the bar's bound-spell resolve must decline. const SEED: [(&str, u32, &str); 4] = [ ("Charge", 1, "/cast Charge"), ( @@ -1242,14 +1130,12 @@ pub(super) fn seed_ui_fixture( for (name, icon, body) in SEED { seed.push_str(&format!( "CreateMacro(\"{name}\", {icon}, \"{}\", 1, nil)\n", - // Lua-escape the body: a `/script` line carries its own quotes, and a macro - // body's newlines have to survive as newlines. + // Lua-escape the body's quotes and newlines. body.replace('"', "\\\"").replace('\n', "\\n") )); } - // Slot 2 selected: the multi-line body, so the detail pane and the body box both have - // something in them. The popup fixture then opens the chooser over that selection via - // the ref's `MacroEditButton` path β€” an EDIT, so the name box arrives pre-filled. + // Slot 2, the multi-line body, selected. The popup fixture opens the chooser through + // `MacroEditButton`, an edit, so the name box arrives filled. seed.push_str("ShowMacroFrame()\nMacroButton2:Click()\n"); if fixture == UiFixture::MacroPopup { seed.push_str("MacroEditButton:Click()\n"); @@ -1259,8 +1145,7 @@ pub(super) fn seed_ui_fixture( } } UiFixture::Social => { - // Nothing to seed: the stray capsule (B264) rode the pane's *declaration*, not its - // contents β€” an empty friends list opens the same frames a full one does. + // Nothing to seed: an empty friends list opens the same frames a full one does. let Some(script) = script.as_mut() else { return; }; @@ -1272,10 +1157,8 @@ pub(super) fn seed_ui_fixture( let Some(script) = script.as_mut() else { return; }; - // The director's repro shape: a say/yell mix behind the OPEN edit box. The box goes - // through the live open path (focus + a typed draft); `chat_edit_live` then drives the - // header text/color and the `15 + headerWidth` insets over the settle frames exactly - // as in-game, so the capture checks header seating AND typed-text visibility. + // Say and yell lines behind the open edit box, opened through the live path; + // `chat_edit_live` then drives the header and the `15 + headerWidth` insets as in-game. for (text, r, g, b) in [ ("[One] says: testing the box", 1.0, 1.0, 1.0), ("[One] says: a second line to stack", 1.0, 1.0, 1.0), @@ -1284,14 +1167,10 @@ pub(super) fn seed_ui_fixture( script.add_chat_message("ChatFrame1", text, r, g, b); } script.focus_editbox("ChatFrameEditBox"); - // A draft with "northshire" selected (`HighlightText(6,16)`): the capture shows the - // opaque-gray selection highlight (ctor 0xFF606060) under the glyphs AND the white - // caret at the selection's end β€” the whole text-UI overlay stack in one golden. - // …then pin the hover-revealed chrome: in-game the tab + the black window textures - // follow the OS cursor (FCF_OnUpdate), which a headless capture can't hover β€” - // replace the OnUpdate with a fixed-reveal one so the golden also locks the window - // tint (chat-cache COLOR 0 0 0) and the text-sized tab (BenillaFCF_TabResize, which - // must keep retrying until the label's measure lands). + // A draft with "northshire" selected: the gray selection highlight (`0xFF606060`) + // under the glyphs and the caret at its end. The tab and window tint follow the + // cursor in `FCF_OnUpdate`, so a fixed-reveal OnUpdate pins them; it calls + // `PanelTemplates_TabResize` every frame until the label's measure lands. if let Err(e) = script.run( "ChatFrameEditBox:SetText(\"hello northshire\")\n\ ChatFrameEditBox:HighlightText(6, 16)\n\ @@ -1308,31 +1187,21 @@ pub(super) fn seed_ui_fixture( let Some(script) = script.as_mut() else { return; }; - // Lines so the window has content, the Combat Log selected, then the cursor parked - // on the GENERAL tab β€” the unselected-tab hover the director reported. - // - // A capture CAN hover: the app only pumps the OS cursor into the VM on a non-synthetic - // run (`ui_script::input`'s `.filter(|_| !ui_hidden && !synthetic)`), so a fed - // `mouse_move` is what the whole run then sees. That is the hover instrument decision - // 0254 wrote down as missing ("No capture scenario exercises a hover state, so nothing - // in the harness covers the very texture this decision is about") β€” and 0254's own - // named residual is exactly what this scenario turned out to show. + // Lines for content, the Combat Log selected, and the cursor on the General tab: an + // unselected tab's hover. A capture can hover, as the OS cursor only reaches the VM + // on a non-synthetic run, so a fed `mouse_move` stands. for (text, r, g, b) in [ ("[One] says: hello northshire", 1.0, 1.0, 1.0), ("[One] says: a second line to stack", 1.0, 1.0, 1.0), ] { script.add_chat_message("ChatFrame1", text, r, g, b); } - // `$WOW_TABHOVER` picks which of the five dock states to shoot. It is an A/B rig, - // not a setting: the four alternates are what let a change to the composite be read - // as a difference rather than judged from one frame. Default "1" is the reported - // case, so the golden is stable without the variable. - // 1 (default) Combat Log selected, hovering GENERAL β€” the report - // 3 General selected, hovering COMBAT LOG β€” its mirror - // 2 Combat Log selected, hovering COMBAT LOG β€” hover on the SELECTED tab - // 0 revealed, hovering neither tab β€” the no-glow control - // 9 cursor off the dock β€” bare scene, the baseline - // a tab quad is measured against + // `$WOW_TABHOVER` picks one of five dock states for an A/B: + // 1 (default) Combat Log selected, hovering General + // 3 General selected, hovering Combat Log + // 2 Combat Log selected, hovering Combat Log (the selected tab) + // 0 revealed, hovering neither tab (no glow) + // 9 cursor off the dock (bare scene, the baseline) let mode = std::env::var("WOW_TABHOVER").unwrap_or_else(|_| "1".into()); let select = if mode == "3" { 1 } else { 2 }; if let Err(e) = script.run(&format!("FCF_SelectDockFrame(ChatFrame{select})")) { @@ -1345,7 +1214,7 @@ pub(super) fn seed_ui_fixture( "return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \ (ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2" } - // 9: park far away β€” the dock conceals itself, so the tab band is bare scene. + // 9: park far away, so the dock conceals itself. "9" => "return 2000, 2000", _ => "return ChatFrame1Tab:GetCenter()", }; @@ -1367,32 +1236,28 @@ pub(super) fn seed_ui_fixture( } UiFixture::NameWater => { use benilla_protocol::messages::ObjectFields; - // The subject is an NPC's overhead name, and `UnitNameNPC` registers "0" (1804's - // byte-read default), so without this the shot contains no name at all β€” which is - // exactly what it had contained since 1804 landed. Set through Lua, the way a player - // turns it on, so the sync drains it into `NameConfig` like any other CVar write. + // The subject is an NPC's overhead name, and `UnitNameNPC` defaults to "0"; set + // through Lua as a player would, so the sync drains it into `NameConfig`. if let Some(script) = script.as_deref_mut() { if let Err(e) = script.run("SetCVar(\"UnitNameNPC\", \"1\")") { warn!("capture: name fixture could not enable UnitNameNPC: {e}"); } } - // The synthetic self player at the eye (the reaction lookup reads its store, and the - // name colour is that verdict). + // The self player at the eye; the name colour is its reaction to the wolf. const SELF_GUID: u64 = 0x51; names.insert_player(SELF_GUID, "Benilla".into(), None); commands.spawn(( crate::net::ObjectStore(ObjectFields::from_pairs(&[ (34, 2), // UNIT_FIELD_LEVEL - (35, 1), // UNIT_FIELD_FACTIONTEMPLATE β€” human - (36, 0x0101), // UNIT_FIELD_BYTES_0 β€” race human, class warrior + (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human + (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior ])), crate::net::SelfPlayer, crate::net::Guid(SELF_GUID), Transform::from_translation(wow_to_bevy(scenario.eye)), )); - // The named unit out in the river (the `vplates` wolf, re-seated): 25 yd along the - // scenario's own look bearing, so its overhead name lands on the water surface - // BEYOND it β€” the geometry that catches a plate sorting before the liquid. + // The wolf out in the river, its name over the water beyond it, which catches a name + // sorting before the liquid. names.insert_creature( WOLF_ENTRY, Some(crate::names::CreatureRecord { @@ -1427,15 +1292,11 @@ pub(super) fn seed_ui_fixture( }, Visibility::default(), )); - // The floating NAME is the subject, so no V-plate may exist (a plated unit never - // draws one β€” the ShouldShowName exclusivity) even though enemy plates boot ON. + // A plated unit draws no floating name, so enemy plates go off. vplates.enemies = false; } - // The lighting matrix (decision 0744). One spawn, through the same component set a streamed - // entity gets, at a position whose light lane was read out of the data (see the matrix note - // in `scenarios`). Deliberately ANONYMOUS: no name is registered and plates are forced off, - // so no glyph rides over the body β€” the diff of these cells must be about light and nothing - // else. + // The lighting matrix: one spawn with a streamed entity's component set, anonymous and + // with plates off, so no glyph rides over the body. UiFixture::Subject { kind, at } => { use benilla_protocol::messages::ObjectFields; let transform = Transform { @@ -1481,33 +1342,22 @@ pub(super) fn seed_ui_fixture( } } -/// Seed + open the backpack window with a fixed item set β€” shared by the `Bag` and `Tooltip` -/// fixtures. The bag is a standalone addon (no `ShowUIPanel` path): drive the container snapshot -/// and purse directly, then open and paint. The feed (`crate::ui_items`) leaves bag 0 alone when -/// there is no `SelfPlayer` (net is disabled in capture), so this manual snapshot is not clobbered. -/// The pinned `GetTime()` value the cooldown filmstrip parks the VM's session clock at, seconds. -/// Large, because the reference's own `CooldownFrame_SetTimer` refuses a `start <= 0` and the -/// starts here are `now βˆ’ fraction Β· span`. +/// The `GetTime()` the cooldown filmstrip pins the VM's session clock at, seconds. Large, as +/// `CooldownFrame_SetTimer` ignores a start not above 0 (`Cooldown.lua:2`) and the starts here +/// are `now - fraction * span`. const COOLDOWN_NOW_S: f64 = 100_000.0; -/// Each filmstrip slot's cooldown duration, seconds. Long enough that the settle window's few -/// seconds of VM clock move a phase by `~5e-4` of a step, so the shot is reproducible. +/// Each filmstrip slot's cooldown, seconds; long enough that the settle window barely moves it. const COOLDOWN_SPAN_S: f64 = 10_000.0; -/// **The cooldown sweep as a filmstrip** (B379): sixteen backpack slots, each parked at its own -/// fraction of one very long cooldown, so the window shows sixteen points of the 1000 ms sweep at -/// once β€” the instrument for "what does the indicator actually draw", which no test can reach -/// (the engine tests prove the scrub, not the picture). -/// -/// Game slot 1 renders TOP-LEFT and slot 16 bottom-right (`ContainerFrame_GenerateFrame` numbers -/// backwards β€” see [`seed_bag_window`]), so ascending slot is reading order and ascending -/// fraction reads as a filmstrip. +/// The cooldown sweep as a filmstrip: sixteen backpack slots, each at its own fraction of one long +/// cooldown, in reading order since game slot 1 renders top-left. fn seed_cooldown_filmstrip( script: &mut benilla_ui::script::UiScript, icons: Option<&crate::entities::ItemDisplays>, ) { - // Park the session clock FIRST: `set_container` stores each triple against it, and - // `GetContainerItemCooldown`'s cold-at-expiry guard reads it. + // The clock first: `set_container` stores each triple against it, and + // `GetContainerItemCooldown`'s expiry guard reads it. if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) { warn!("capture: ui-cooldown failed to pin the session clock: {e}"); } @@ -1519,8 +1369,7 @@ fn seed_cooldown_filmstrip( let now_ms = COOLDOWN_NOW_S * 1000.0; let mut slots = std::collections::HashMap::new(); for slot in 1u32..=16 { - // Sixteen phases across the sweep, biased off both ends: 0 is the uniform disc and 1 is - // the flash, and neither is what this instrument is looking at. + // Sixteen phases, off both ends (0 is the uniform disc, 1 the flash). let fraction = (f64::from(slot) - 0.5) / 16.0; slots.insert( slot, @@ -1558,11 +1407,13 @@ fn seed_cooldown_filmstrip( } } +/// Seeds and opens the backpack with a fixed item set, for the `Bag` and `Tooltip` fixtures. With +/// no `SelfPlayer` in capture the `ui_items` feed leaves bag 0 alone. fn seed_bag_window( script: &mut benilla_ui::script::UiScript, icons: Option<&crate::entities::ItemDisplays>, ) { - // Display ids the offline icon catalog is known to resolve (crates/benilla-formats items.rs). + // Display ids the offline icon catalog resolves. const DISP_SWORD: u32 = 1542; const DISP_FOOD: u32 = 2473; const DISP_SHIELD: u32 = 18730; @@ -1594,13 +1445,10 @@ fn seed_bag_window( enchants: Vec::new(), } }; - // ~5 items scattered across the 16 slots, a mix of stacked and unstacked, under their real - // vanilla template ids (the Tooltip fixture seeds a full template view for the hovered one). - // Game slot 1 carries the green-quality Small Shield the Tooltip fixture hovers, and it is - // rendered TOP-LEFT: `ContainerFrame_GenerateFrame` numbers backwards (`index = size - j + 1`) - // and anchors `Item1` at the window's BOTTOMRIGHT, filling leftwards then upwards β€” so for a - // 16-slot bag the top-left button is `Item16` and it carries slot 1. A top-left seat keeps its - // ANCHOR_RIGHT tooltip on-screen. + // Five items under their real template ids. Game slot 1, the Small Shield the Tooltip + // fixture hovers, renders top-left: `ContainerFrame_GenerateFrame` numbers backwards + // (`index = size - j + 1`) from `Item1` at the bottom right (`ContainerFrame.lua:426-442`), + // so its button is `Item16`, where the ANCHOR_RIGHT tooltip stays on-screen. let mut slots = std::collections::HashMap::new(); slots.insert(1, slot(DISP_SHIELD, 1, 2362, "Small Shield", 2)); slots.insert(3, slot(DISP_FOOD, 5, 117, "Tough Jerky", 1)); @@ -1617,21 +1465,16 @@ fn seed_bag_window( ); // Player money nonzero so the purse renders all three denominations (1g 23s 45c). script.set_money(12_345); - // Open the window with the reference's own verb β€” it builds and paints one in the same call - // (`ToggleBag` β†’ `OpenBag` β†’ `ContainerFrame_GenerateFrame`), which is why nothing has to - // repaint it afterwards. `OpenBag(0)` rather than `OpenBackpack()`: only the backpack is fed - // here, and 0561 makes the latter open every equipped bag. + // `OpenBag` builds and paints the window in one call (`ContainerFrame_GenerateFrame`). Not + // `OpenBackpack()`, which `ContainerFrameAdapters.xml` makes open every equipped bag, and only + // the backpack is fed. if let Err(e) = script.run("OpenBag(0)") { warn!("capture: bag window seed failed: {e}"); } - // Seed a few chat lines into ChatFrame1 (the bottom-left docked window) β€” the look-pass - // instrument for the chat-line drop shadow + the ChatFontNormal wiring (Slice C). This rides the - // Bag/Tooltip fixtures, NOT the merchant/gossip A/B pair, so the DPI before/after captures stay - // identical. The pinned per-type colors: SYSTEM yellow, SAY white, LOOT green. The LOOT pair is - // also the regression baseline for the item link inside a coloured line (`ui_loot::receive_line` - // β€” the quality escape wins over the line colour for the bracketed name, and the `x2` after the - // `|r` falls back to LOOT green): a common/white Tough Jerky and an uncommon/green stack. + // Chat lines in ChatFrame1 for the line shadow and `ChatFontNormal`: SYSTEM yellow, SAY white, + // LOOT green. In the loot lines the item's quality escape wins for its bracketed name, and the + // `x2` after `|r` falls back to the line's green. for (text, r, g, b) in [ ("Welcome to Northshire Valley.", 1.0, 1.0, 0.0), ("[Marshal McBride] says: Well met, citizen.", 1.0, 1.0, 1.0), @@ -1652,12 +1495,9 @@ fn seed_bag_window( } } -/// Seed + open three equipped-bag windows at DIFFERENT sizes so the capture shows the snug-fit -/// background stitch (BenillaBagWindow_FitBackground) across its cases: a 6-slot pouch (2 rows, -/// partial top row β€” the director's Small Brown Pouch), an 8-slot bag (2 rows, full top row), and a -/// 10-slot bag (3 rows, partial top row). They stack up-and-left from the backpack via the window -/// stack, exactly as in-game β€” so the capture is the look-check for both the per-bag height and the -/// stack layout. A couple of items each verify the slot rings still seat on the baked wells. +/// Seeds and opens three equipped bags of different sizes, for the background fit +/// `ContainerFrame_GenerateFrame` does: 6 slots (a partial top row), 8 (full) and 10 (three rows, +/// partial top). They stack up and left from the backpack as in-game. fn seed_equipped_bags( script: &mut benilla_ui::script::UiScript, icons: Option<&crate::entities::ItemDisplays>, @@ -1691,10 +1531,9 @@ fn seed_equipped_bags( enchants: Vec::new(), }; - // Seed the four equipped-bag EQUIPMENT slots (live inv ids 20..23 = Bag0Slot..Bag3Slot) with a - // bag item icon each, so every bag window's portrait shows its OWN bag icon (the real client's - // SetBagPortaitTexture, now wired) instead of the default backpack β€” the char feed does this - // live; capture has no feed, so seed it here. + // The equipped-bag slots (inv ids 20-23, `Bag0Slot`-`Bag3Slot`) get an icon each, so each + // window's portrait shows its own bag through `SetBagPortaitTexture`; live, the char feed + // does this. let mut inv: benilla_ui::script::InventorySlots = Default::default(); for (id, icon_name) in [ (20usize, "INV_Misc_Bag_08"), @@ -1714,7 +1553,7 @@ fn seed_equipped_bags( for (bag_id, name, size, items) in [ ( - // The director's case β€” every slot filled so the well/ring alignment is visible on all six. + // Every slot filled, so the slot alignment shows on all six. 1, "Small Brown Pouch", 6u32, @@ -1758,8 +1597,7 @@ fn seed_equipped_bags( slots, }), ); - // The reference's own verb, which builds the window as it opens it β€” and it must run - // AFTER `set_container`, since `OpenBag` refuses a container with no slots. + // After `set_container`: `OpenBag` refuses a container with no slots. if let Err(e) = script.run(&format!("OpenBag({bag_id})")) { warn!("capture: equipped bag {bag_id} seed failed: {e}"); } diff --git a/crates/benilla-app/src/capture/fxview.rs b/crates/benilla-app/src/capture/fxview.rs index e02de46b..95c5efea 100644 --- a/crates/benilla-app/src/capture/fxview.rs +++ b/crates/benilla-app/src/capture/fxview.rs @@ -1,7 +1,5 @@ -//! The `fxview` fixture's **driver** β€” the half of the effect-viewer instrument that stands the -//! subject up in the world (the request, its knobs and the camera are [`super`]'s). It lives here -//! because a fixture's driver belongs with its fixture: `entities::spell_fx` is gameplay, and -//! gameplay never depends on the capture harness. +//! The `fxview` fixture's driver: stands the effect-viewer's subject up in the world; the +//! request, its knobs and the camera are [`super`]'s. use bevy::prelude::*; @@ -15,20 +13,15 @@ use crate::entities::spell_fx::{ attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims, SpellFx, FALLBACK_SPAN, }; -/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) β€” a high-word `0xF130` creature guid -/// like the wire's, in a serverless capture where nothing else claims one. +/// The unit lane's synthetic guid (`WOW_FX_DISPLAY`): a wire-shaped `0xF130` creature guid. const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE; -/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) β€” the wire's `0xF110` high word, -/// which is what `crate::net` reads a GO's identity out of. +/// The GameObject lane's synthetic guid (`WOW_FX_GO`): the `0xF110` high word `crate::net` reads +/// a GO's identity from. const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE; -/// Drive the `fxview` capture fixture (see [`super::FxViewRequest`]) β€” the effect- -/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the -/// fixture point, create the model's [`SpellFx`] cache entry, attach the full visual set -/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the -/// root along its facing so trails extend. Inert outside fxview captures (the request resource -/// only exists then). +/// Drives the `fxview` fixture once armed: spawns a root, attaches the model through the game's +/// own [`attach_effect_visuals`], and flies a missile along its facing so trails extend. pub(crate) fn drive_fx_view( req: Option>, state: Option>, @@ -58,11 +51,8 @@ pub(crate) fn drive_fx_view( }; let root = *state.root.get_or_insert_with(|| { let mut pos = benilla_assets::coords::wow_to_bevy(req.at.unwrap_or(FXVIEW_POS)); - // `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets - // (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary - // unit pipeline build the model β€” so what this shoots is a creature, not a model, and - // every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host) - // is in the picture. Always seated on the terrain: a creature stands on the ground. + // `WOW_FX_DISPLAY`, the unit lane: the component set `net::apply` gives a streamed + // creature, so the unit pipeline builds it with every unit-only term; seated on terrain. if let Some(display) = req.display { if let Some(hit) = spatial.cast_ray( pos, @@ -87,7 +77,7 @@ pub(crate) fn drive_fx_view( (22, 100), // UNIT_FIELD_HEALTH (28, 100), // UNIT_FIELD_MAXHEALTH (34, 60), // UNIT_FIELD_LEVEL - (35, 35), // UNIT_FIELD_FACTIONTEMPLATE β€” friendly, so no combat pose + (35, 35), // UNIT_FIELD_FACTIONTEMPLATE: friendly, no combat pose ], )), Transform::from_translation(pos) @@ -96,12 +86,9 @@ pub(crate) fn drive_fx_view( )) .id(); } - // `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through - // `crate::go_anim`'s state machine (`0x5f3cb0`), never through the effect pool, so this is - // the only lane that reproduces one. The descriptor carries the three fields the machine - // reads β€” display, TYPE_ID (the `go_animates` gate) and STATE (the substate) β€” and - // nothing else, so an unset knob renders exactly what an omitted wire field renders. - // Always seated on the terrain: a GO stands on the ground. + // `WOW_FX_GO`, the GameObject lane: a placed GO animates through `crate::go_anim`'s state + // machine (`0x5f3cb0`), not the effect pool. The descriptor carries only display, TYPE_ID + // and STATE, the fields the machine reads; seated on terrain. if let Some(display) = req.go { if let Some(hit) = spatial.cast_ray( pos, @@ -133,9 +120,7 @@ pub(crate) fn drive_fx_view( )) .id(); } - // `WOW_FX_GROUND=1`: seat the fixture ON the terrain β€” a ground-anchored effect's flat - // quads render as projected surface decals and need ground inside their vertical slab. - // The scene settled before arming, so the streamed tile's collider is there to hit. + // `WOW_FX_GROUND=1`: a ground-anchored effect's quads decal onto terrain inside their slab. if req.ground { if let Some(hit) = spatial.cast_ray( pos, @@ -156,8 +141,7 @@ pub(crate) fn drive_fx_view( )) .id() }); - // A missile only trails in motion: fly the root along its model-forward (local βˆ’Z). - // WOW_FX_TURN keeps the fixture rotating β€” the attached-model heading-since-birth fan. + // A missile only trails in motion: fly along model-forward (local -Z); `WOW_FX_TURN` spins it. if req.fly > 0.0 || req.turn != 0.0 { if let Ok(mut t) = transforms.get_mut(root) { let fwd = t.rotation * -Vec3::Z; @@ -168,11 +152,8 @@ pub(crate) fn drive_fx_view( } } } - // The one-pass reap (the game's completion callback, `fx_attach`): a discrete kit - // instance dies at exactly one pass of sequence 0 and its emitters DRAIN β€” the fixture - // mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a - // persistent hold instead (reaped by its spell edge in game, so no clock here). - // The one-pass reap is an effect-instance rule; a unit β€” or a placed GameObject β€” stands there. + // The game's one-pass reap (`fx_attach`): a one-shot kit instance dies after one pass of + // sequence 0. Not for `WOW_FX_HOLD=1` (reaped by its spell in game) or a unit or GO. if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() { if let Some(at) = state.attached_at { let span = fx @@ -187,9 +168,7 @@ pub(crate) fn drive_fx_view( } } if state.attached_at.is_none() { - // The unit and GameObject lanes have no attach step β€” the entity pipeline builds the model - // on its own. Their "age" is therefore seconds since the entity appeared, which is what the - // animation phase is measured in. + // The unit and GO lanes have no attach step: their age counts from the entity's spawn. if req.display.is_some() || req.go.is_some() { state.attached_at = Some(time.elapsed_secs()); return; @@ -204,14 +183,11 @@ pub(crate) fn drive_fx_view( root, &fx.models[&req.model_path], time.elapsed_secs(), - true, // the fixture plants at a world point β€” ground quads decal onto the terrain - // The fixture previews kit effects, which are attached models β€” but it hangs on - // nothing (there is no host model in a preview), so its pool keeps the drain. + true, // planted at a world point: ground quads decal onto the terrain + // No host model in a preview, so the pool keeps the drain. EffectHost { parent: None }, - // `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent - // lifecycle β€” birth then `Hold` β€” or the instrument would report a freeze the game - // does not have. Without it the fixture previews a one-shot, which runs its birth once - // and is reaped by the fixture's own span clock below. + // `WOW_FX_HOLD=1` shows the persistent lifecycle (birth then `Hold`); otherwise a + // one-shot, reaped by the span clock above. Some(if req.hold { FxStage::State } else { @@ -225,7 +201,7 @@ pub(crate) fn drive_fx_view( }, &ibps, &mut palettes, - None, // the fixture previews a kit effect on its model's own `Stand` + None, // a kit effect on its model's own `Stand` ) { state.attached_at = Some(time.elapsed_secs()); } diff --git a/crates/benilla-app/src/capture/live_shot.rs b/crates/benilla-app/src/capture/live_shot.rs index 2f83bf35..3d5f5ff0 100644 --- a/crates/benilla-app/src/capture/live_shot.rs +++ b/crates/benilla-app/src/capture/live_shot.rs @@ -1,8 +1,5 @@ -//! The LIVE probe shot β€” one screenshot (or an adjacent-frame burst) of a NORMAL connected run, -//! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost -//! avatar renders the whole world through the death filter) and the subject gate -//! (`WOW_SHOT_REQUIRE` β€” a frame that cannot contain the reported subject is not a measurement of -//! it). Split from `probes` when the gates outgrew it; the sibling probe one-shots stay there. +//! The live probe shot: a screenshot or adjacent-frame burst of a connected run, gated on a live +//! avatar (a ghost renders through the death filter) and on the subject being in frame. use bevy::prelude::*; use bevy::render::view::screenshot::{save_to_disk, Screenshot}; @@ -14,43 +11,22 @@ use crate::names::NameCache; use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer}; use benilla_world::view::WorldCamera; -/// How far the subject gate's WARN looks when listing what *is* nearby β€” wide enough to catch the -/// "right room, wrong wing" mis-aim even when `WOW_SHOT_REQUIRE_DIST` is tight. +/// How far the subject gate's warning looks when listing what is nearby. const REQUIRE_NEARBY_RANGE: f32 = 100.0; -/// Seconds between subject-gate WARNs while waiting. Frequent enough to be read live, rare enough -/// that a log grep isn't wading through it. +/// Seconds between subject-gate warnings while waiting. const REQUIRE_REWARN_SECS: f32 = 5.0; -/// The LIVE probe shot (`WOW_LIVE_SHOT=`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12): -/// one screenshot of the primary window on a NORMAL connected run β€” the "what does the live scene -/// actually look like" instrument (server GameObjects, event spawns, NPCs β€” everything the -/// server-less harness deliberately excludes). The app keeps running after the save; pair with -/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike -/// [`super::CapturePlugin`], nothing is pinned β€” the shot shows the scene as a player would see it. +/// The live probe shot (`WOW_LIVE_SHOT=`, after `WOW_LIVE_SHOT_AT` seconds, default 12): the +/// primary window of a connected run, nothing pinned; the app keeps running after the save. /// -/// **`WOW_LIVE_SHOT_COUNT=` makes it a burst** β€” `n` shots written as `-000.png`, -/// `-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**, -/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is -/// unchanged. +/// `WOW_LIVE_SHOT_COUNT=` makes a burst of `-000.png`, ... spaced `WOW_LIVE_SHOT_EVERY` +/// seconds (default 0, adjacent frames), so a flicker from a parked camera (`WOW_PROBE_CAM`) can +/// be measured with `benilla-visual flicker `. /// -/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one** -/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had: -/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7 -/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it -/// into arithmetic: `benilla-visual flicker ` collapses the burst to the envelope of what -/// would not hold still, and the lit pixels are the defect. -/// -/// **`WOW_SHOT_REQUIRE=` is the SUBJECT gate** (substring, case-insensitive; range via -/// `WOW_SHOT_REQUIRE_DIST`, default 60): no shot fires until a unit with that name is inside the -/// viewport and range β€” the same contract as the death gate below, because a frame that cannot -/// contain the reported subject is not a measurement of it either. Ten banshee runs were once read -/// as "can't reproduce" with the banshee never in frame, and a voidwalker crop-negative was a model -/// that had wandered out of a fixed window (decision 0705). While the subject is missing the gate -/// WARNs with what *is* nearby β€” names and distances β€” so a mis-aimed run re-aims itself instead of -/// ending as a silent blank; a run that exits with 0 shots written names its reason in the log. -/// The unit's feet anchor decides "in frame", and in frame does not mean unoccluded β€” the gate -/// closes the aim trap, not line-of-sight. +/// `WOW_SHOT_REQUIRE=` (case-insensitive substring; range `WOW_SHOT_REQUIRE_DIST`, default +/// 60) holds every shot until a unit so named has its feet in the viewport and in range, and warns +/// with what is nearby while it waits. In frame does not mean unoccluded. pub(crate) struct LiveShotPlugin; impl Plugin for LiveShotPlugin { @@ -89,9 +65,7 @@ impl Plugin for LiveShotPlugin { } } -/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing -/// (`0` = one per frame), how many have gone out, and when the next one may β€” seeded to -/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock. +/// [`LiveShotPlugin`] state; `next_at` starts at `WOW_LIVE_SHOT_AT`, then steps by `every`. #[derive(Resource)] struct LiveShot { out: String, @@ -104,8 +78,7 @@ struct LiveShot { require_dist: f32, } -/// `stem-007.png` for shot 7 of a burst β€” the bare path when the burst is one shot, so the -/// single-shot filename (which existing probes and docs name literally) never moves. +/// `stem-007.png` for shot 7 of a burst; the bare path for a single shot. fn burst_path(out: &str, index: u32, count: u32) -> String { if count <= 1 { return out.to_string(); @@ -116,16 +89,8 @@ fn burst_path(out: &str, index: u32, count: u32) -> String { } } -/// Fire the live screenshot(s) once the startup delay has elapsed β€” one per frame by default, so a -/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide. -/// -/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a -/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the -/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated -/// against it β€” pixel series, hotspot runs, depth readback, the phase census β€” silently describes the -/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted -/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud -/// no-op is worth far more than a burst that has to be recognised as garbage after the fact. +/// Fires the live shots once the delay has elapsed. Refuses while the avatar is dead or a ghost: +/// the world then renders through the death filter, and every frame would measure the filter. #[allow(clippy::type_complexity)] // one Bevy system's full input set fn fire_live_shot( mut shot: ResMut, @@ -163,11 +128,8 @@ fn fire_live_shot( return; } } - // The SUBJECT gate β€” same contract as the death gate above: a frame that cannot contain the - // reported subject is not a measurement of it, and a "clean" burst of such frames is the - // costliest false negative a live run produces (decision 0705). Every shot of a burst - // re-passes the gate, so a subject that wanders off mid-burst pauses the burst instead of - // padding it with blanks. + // The subject gate: every shot of a burst re-passes it, so a subject that wanders off pauses + // the burst. if let Some(want) = shot.require.as_deref() { let Ok((cam, cam_pose)) = camera.single() else { return; @@ -175,13 +137,11 @@ fn fire_live_shot( let cam_tf = GlobalTransform::from(*cam_pose); let viewport = cam.logical_viewport_size().unwrap_or(Vec2::ZERO); let want_lc = want.to_lowercase(); - // One pass: find the nearest in-frame match, and keep the neighbourhood for the WARN β€” - // `resolve` sends the name query on a miss, so just scanning fills the cache within frames. + // `resolve` sends the name query on a miss, so scanning fills the cache within frames. let mut found: Option<(String, f32, Vec2)> = None; let mut nearby: Vec<(f32, String)> = Vec::new(); for (guid, tf) in &subjects { - // Only name-bearing families: a GameObject can never resolve (its name rides its own - // unmodeled query), so it would sit in the listing as noise and never match the gate. + // Only name-bearing families: a GameObject's name rides its own query, not this cache. if !guid::is_player(guid.0) && !guid::is_creature_or_pet(guid.0) && guid::pet_number(guid.0).is_none() diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 51e87b91..d057af43 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -1,71 +1,34 @@ -//! Deterministic capture mode β€” the machine half of the Phase-5 visual A/B harness (decision 0008). +//! Deterministic capture mode: with `$WOW_CAPTURE=` the app boots server-less, pins the +//! game clock and the camera to a named viewpoint, waits for the rendered image to stop changing, +//! writes one PNG of the primary window to `$WOW_CAPTURE_OUT` and exits. `scripts/visual.sh` +//! drives it and `benilla-visual` diffs the shots against baselines. //! -//! With `$WOW_CAPTURE=` set, the app boots server-less (net disabled in `main`), pins the -//! game-clock and the camera to a named viewpoint, waits for the rendered image to stop changing, -//! writes one PNG of the primary window to `$WOW_CAPTURE_OUT`, and exits. The render rework (decision 0008) is the -//! single riskiest change in the architecture β€” it can regress the whole look at once β€” so it goes -//! behind this harness: baselines are captured on the *current* pipeline, then every rework step is -//! diffed against them by the `benilla-visual` tool, catching regressions by machine before the -//! director's eye. Driven by `scripts/visual.sh`. +//! Captures are rendered game art: they live under the gitignored `target/visual/`. //! -//! Captures contain Blizzard-derived imagery (rendered terrain/models), so they live under the -//! gitignored `target/visual/` and are never committed. +//! Two mechanisms make a capture reproducible: +//! 1. The shutter waits for the image itself to stop changing ([`STABLE_FRAMES`], [`FrameWatch`]), +//! compared byte for byte off the framebuffer, so streaming, pipeline warm-up and late loads +//! are all waited out. +//! 2. The game clock is frozen ([`CAPTURE_FRAME_DT`], [`hold_clock`]) while the scene builds, then +//! released for exactly [`age_frames`] fixed steps, so the shot's sim age is the same on any +//! machine. //! -//! ## What makes a capture reproducible β€” two mechanisms, and that is all -//! "Deterministic" is the whole claim: a golden diff is evidence only if two runs of one unchanged -//! build agree. Pinning the camera, the game clock and the clutter seed is not enough. -//! -//! 1. **The shutter waits for the IMAGE to stop changing** ([`STABLE_FRAMES`], [`FrameWatch`], -//! decision 0815). The scene is built when the rendered frame stops moving β€” measured, by reading -//! the framebuffer back and comparing bytes. This replaced four *proxies* for the same question -//! (tile residency, `scene_ready`, outstanding placements, and a world-entity-count quiescence -//! counter), each of which was partial by construction and each of which had been added after the -//! previous one was caught missing something. Streaming, pipeline warm-up, late placements, late -//! M2 loads and the loading-screen fade are all *visible in the frame* β€” or they do not affect the -//! shot, in which case they were never the harness's business. -//! 2. **The game clock is frozen** ([`CAPTURE_FRAME_DT`], [`hold_clock`], decision 0723), because the -//! sims integrate in *seconds* while the harness counts *frames*. Held while the scene is being -//! built, then released for exactly [`age_frames`] fixed steps, so the shot's sim age is the same -//! duration on any machine. -//! -//! **Neither one fixes the known residual flake, and no gate here can.** `scripts/visual.sh -//! selfcheck` still finds a small number of scenarios that land in one of exactly two states. The -//! cause is not timing: it is that our draw order among *coplanar, equal-depth* batches follows spawn -//! order, and spawn order varies because asset loads complete on a thread pool (0723's Open section, -//! reached again by 0810 and 0815). Both states are perfectly stable β€” so waiting longer, waiting for -//! quiescence, or waiting for pixel stability cannot help. It is a renderer defect that the harness -//! correctly *reports*; do not try to gate it away here. Read 0815 before touching this file's phases. -//! -//! **`creature-indoor-front` is one of them** (named 2026-09-01, while A/B-ing 1787's fog gate): two -//! runs of ONE unchanged build land in the two states at MAE 4.518, the whole difference confined to -//! the subject's own brightness with the room around it pixel-identical, and the settle counts differ -//! (235 vs 211 frames). If a change you are A/B-ing shows exactly 4.518 here, re-run the baseline -//! before believing it β€” the number is the flake's, not yours. +//! Neither fixes the residual flake: a few scenarios land in one of two stable states, because +//! draw order among coplanar equal-depth batches follows spawn order, which follows thread-pool +//! load completion. It is a renderer defect the harness reports; waiting longer cannot gate it +//! away. `creature-indoor-front` is one: two runs of one build differ at MAE 4.518, confined to +//! the subject's brightness, so a 4.518 there is the flake, not the change under test. //! //! ## Running one capture by hand -//! **Run through Cargo β€” never the built binary directly:** +//! Run through Cargo, never a bare `target/debug/benilla`, which may be stale: //! ```text //! WOW_CAPTURE=ui-unitframes \ //! WOW_CAPTURE_OUT=/tmp/shot.png cargo run -q -p benilla //! ``` -//! (`WOW_DATA` is only needed for a non-standard install β€” the client finds one in the project -//! folder or beside the binary on its own, `benilla_formats::wow_data`, decision 1175.) -//! -//! `cargo run` rebuilds first; a bare `target/debug/benilla` can silently be **stale code** β€” the -//! classic way a capture "disproves" a fix that was never in the binary. Staleness is now the -//! *only* trap here, and this note is worth keeping for why. Before 0993, `assets/` resolved -//! through Cargo's runtime `CARGO_MANIFEST_DIR`, so a bare run loaded **no** WGSL at all and every -//! custom-shader layer β€” the entire player UI, sky, liquid, models β€” rendered blank; it was read -//! as a UI bug for hours once. 0993 patched that by baking an absolute source-tree path in at -//! compile time, which fixed the capture and left a binary that worked only on the machine that -//! built it. 1175 deleted the path instead: every shader is compiled into the binary and addressed -//! `embedded:///shaders/…`, so there is no asset root left to resolve wrongly. -//! A **`ui-*` scenario opts the player UI in on its own** β€” it declares a `ui:` fixture, which is -//! this table saying the window is the subject ([`scenarios::ui_opted_in`], and read its doc -//! for why that is a correctness fix and not a convenience). `WOW_CAPTURE_UI=1` remains, for what -//! it was always actually for: painting the UI over a **world** scenario's shot. World baselines -//! stay UI-free by default either way. `WOW_CAPTURE=list` -//! prints the scenario names. `scripts/visual.sh` wraps all of this. +//! `WOW_DATA` is needed only for an install the client does not find on its own +//! (`benilla_formats::wow_data`). A `ui-*` scenario opts the player UI in by declaring a `ui:` +//! fixture ([`scenarios::ui_opted_in`]); `WOW_CAPTURE_UI=1` paints the UI over a world scenario, +//! whose baselines are UI-free by default. `WOW_CAPTURE=list` prints the scenario names. use std::path::Path; use std::time::{Duration, Instant}; @@ -82,9 +45,6 @@ use benilla_world::schedule::WorldStage; use benilla_world::terrain_stream::WorldLoadProgress; use benilla_world::view::WorldCamera; -// The UI fixture seeding (the synthetic window states), the scenario table, and the live-run -// probe instruments (`probes`) each live in their own file β€” the server-less harness (settle, -// screenshot, perf probe) is this one's concern. mod depth_probe; mod fixtures; mod live_shot; @@ -159,10 +119,8 @@ use scenarios::GlueScreen; use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS}; pub(crate) mod fxview; -// The three scripted probe drivers, which lived in `player/` until decision 1174: they turn the -// avatar's aim, aim its swim pitch, and park the camera rig, and all three order themselves BEFORE -// `player::control`. An instrument may name the gameplay system it runs against; gameplay may not -// name the instrument. +// The scripted aim, swim-pitch and camera-rig drivers order themselves before `player::control`: +// an instrument may name the gameplay system it drives, gameplay never names the instrument. mod probe_cam; mod probe_look; mod probe_pitch; @@ -172,15 +130,11 @@ pub(crate) use probe_cam::ProbeCamPlugin; pub(crate) use probe_look::ProbeLookPlugin; pub(crate) use probe_pitch::ProbePitchPlugin; -/// Which screen a capture starts the client on β€” the dev arm of [`crate::run_mode::start_state`], -/// which is what `main` actually calls. A glue capture boots onto the screen it photographs; any -/// other capture boots straight in-world (no net, no picker); with no capture at all this is the -/// ordinary login screen. +/// The screen a capture starts on, the dev arm of [`crate::run_mode::start_state`]: a glue capture +/// boots onto its screen, any other capture straight in-world, no capture the login screen. /// -/// The **third** independent reader of `$WOW_CAPTURE`, and deliberately so: `run_mode` asks it for -/// the app and `dev_state::deterministic_run` asks it for the engine, because after 1160's split -/// each layer must be able to ask with nothing above it. One environment variable, three readers, -/// no shared symbol across either boundary. Keep them in step. +/// One of three readers of `$WOW_CAPTURE`, with `run_mode` and `dev_state::deterministic_run`, +/// one per layer and sharing no symbol: keep them in step. pub(crate) fn start_state() -> crate::char_select::ClientState { match glue_screen() { Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate, @@ -190,9 +144,8 @@ pub(crate) fn start_state() -> crate::char_select::ClientState { } } -/// Is `$WOW_CAPTURE` naming a **glue** screen? Consulted before the plugins build, because -/// the answer decides which screen the client starts on β€” a glue capture is the one kind of -/// capture that must NOT boot straight into the world. +/// The glue screen `$WOW_CAPTURE` names, if any; read before the plugins build, since it decides +/// the start screen. fn glue_screen() -> Option { let name = std::env::var("WOW_CAPTURE").ok()?; GLUE_SCENARIOS @@ -201,81 +154,41 @@ fn glue_screen() -> Option { .map(|g| g.screen) } -/// The `fxview` fixture request β€” the **effect-viewer instrument**: spawn one effect/missile -/// model (full rig + emitters + ribbons + cards, the same `attach_effect_visuals` body the game -/// uses), let it run `age` seconds, and shoot it from a chosen angle. The agent's own eye on -/// spell visuals: every "this effect looks wrong from angle X" report becomes a reproducible -/// headless capture instead of a director round-trip. Not a golden scenario (deliberately -/// excluded from `print_scenario_names` β€” output depends on the model/age/angle knobs): +/// The `fxview` effect viewer: spawn one effect or missile model through the game's own +/// `attach_effect_visuals`, let it run `WOW_FX_AGE` seconds and shoot it from a chosen angle. Not a +/// golden scenario: its output depends on the knobs. /// /// ```text -/// WOW_DATA= WOW_CAPTURE=fxview WOW_FX_MODEL='Spells\DemonArmor_Impact_Head.mdx' \ +/// WOW_CAPTURE=fxview WOW_FX_MODEL='Spells\DemonArmor_Impact_Head.mdx' \ /// WOW_FX_AGE=1.2 WOW_FX_AZ=60 WOW_FX_EL=15 WOW_CAPTURE_OUT=/tmp/fx.png cargo run -q -p benilla /// ``` /// -/// **`WOW_FX_DISPLAY=` swaps the subject onto the UNIT path** β€” the same -/// component set a streamed creature gets, seated on the terrain, with everything that hangs off -/// being a unit (tag alpha, the distance-fade gate, anim LOD, the emitter's sequence host). Model -/// lane vs unit lane at one knob is what turns "does this creature look wrong?" into a headless -/// A/B instead of a director round-trip: +/// Three lanes: the effect pool (`WOW_FX_MODEL`), a unit (`WOW_FX_DISPLAY`, the component set a +/// streamed creature gets) and a GameObject (`WOW_FX_GO`, whose sequence `crate::go_anim`'s state +/// machine picks, `0x5f3cb0`). A missing `GAMEOBJECT_STATE` reads as 0, ACTIVE, which on a model +/// with no `Opened` sequence resolves elsewhere. /// -/// ```text -/// WOW_DATA= WOW_CAPTURE=fxview WOW_FX_DISPLAY=1132 WOW_FX_AGE=6 WOW_FX_EL=20 \ -/// WOW_FX_DIST=12 WOW_CAPTURE_OUT=/tmp/vw.png cargo run -q -p benilla -/// ``` -/// -/// **`WOW_FX_GO=` swaps the subject onto the GAMEOBJECT path** β€” the -/// third lane, and the one a placed trap/door/chest actually takes: the wire component set a -/// streamed GO gets, so `crate::go_anim`'s state machine (`0x5f3cb0`) β€” not the effect pool β€” -/// chooses the sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and -/// `WOW_FX_GO_TYPE` (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate; -/// `benilla-extract goanimscan` *predicts* what that resolves to, and this *shows* it. The lane -/// matters because an absent `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a -/// model with no `Opened` sequence lands somewhere else entirely: -/// -/// ```text -/// WOW_DATA= WOW_CAPTURE=fxview WOW_FX_GO=3073 WOW_FX_GO_STATE=1 WOW_FX_AGE=4 \ -/// WOW_FX_DIST=3 WOW_CAPTURE_OUT=/tmp/go.png cargo run -q -p benilla -/// ``` -/// -/// Knobs: `WOW_FX_MODEL` (required, internal path), `WOW_FX_AGE` (seconds after attach, default -/// 1.0), `WOW_FX_AZ`/`WOW_FX_EL` (camera orbit degrees, default 0/10), `WOW_FX_DIST` (yards, -/// default 5), `WOW_FX_FLY` (yd/s along the model's facing β€” a missile only trails in motion; -/// default 0), `WOW_FX_YAW` (model facing, degrees, default 0), `WOW_FX_TURN` (deg/s the fixture -/// keeps turning after spawn β€” a host that changes heading mid-effect, which is how you see that -/// a world-mode cloud does NOT swing with it (decisions 1585/1591; the "heading-since-birth fan" -/// this knob was built for turned out not to exist); default 0), `WOW_FX_GROUND` (=1 seats the -/// fixture ON the terrain via a down-ray β€” required to see a ground-anchored effect's projected -/// surface decals, `crate::ground_fx`; default 0 = the mid-air point), `WOW_FX_HOLD` (=1 keeps -/// the fixture alive past one sequence pass β€” previewing a persistent HOLD kit's steady state; -/// default 0 = the game's discrete-instance reap at one pass, then the pool drains), -/// `WOW_FX_AT` (`x,y,z` β€” plant the fixture at a real world point instead of the Northshire -/// hillside, with `WOW_MAP` picking the map and `WOW_FX_MINUTE` the clock; the ground-decal lanes -/// need real terrain under the subject), -/// `WOW_FX_UP` (yards to raise the fixture above its resolved seat β€” for models authored below -/// their anchor, whose opening frames the terrain otherwise swallows; default 0). +/// Knobs, defaults in parentheses: `WOW_FX_AGE` s after attach (1.0), `WOW_FX_AZ`/`WOW_FX_EL` +/// orbit degrees (0/10), `WOW_FX_DIST` yd (5), `WOW_FX_FLY` yd/s along the facing (0), +/// `WOW_FX_YAW` degrees (0), `WOW_FX_TURN` deg/s after spawn (0), `WOW_FX_GROUND=1` seats it on +/// the terrain for ground decals, `WOW_FX_HOLD=1` keeps it past one sequence pass, +/// `WOW_FX_MINUTE` the clock (720). #[derive(Resource)] pub(crate) struct FxViewRequest { pub(crate) model_path: String, - /// `WOW_FX_DISPLAY=` β€” spawn the subject as a real **unit** (the live - /// `NetEntity` component set, the same path a streamed creature takes) instead of attaching it - /// as an effect. The two lanes differ in everything that hangs off being a unit β€” material tag - /// alpha, the distance-fade gate, anim LOD, the emitter's sequence host β€” so switching this one - /// knob is the A/B that says whether a defect belongs to the model or to the unit path. - /// `WOW_FX_MODEL` is then optional (the display id names the model). + /// `WOW_FX_DISPLAY`: spawn the subject as a unit with this `CreatureDisplayInfo` id, the A/B + /// that says whether a defect is the model's or the unit path's; `WOW_FX_MODEL` is optional. pub(crate) display: Option, - /// `WOW_FX_GO=` β€” spawn the subject as a real **GameObject**, so - /// [`crate::go_anim`]'s state machine picks the sequence instead of the effect pool's - /// "play clip 0". A placed trap renders through this lane and nothing else, so it is the only - /// honest A/B for "does the trap look right". + /// `WOW_FX_GO`: spawn the subject as a GameObject with this `GameObjectDisplayInfo` id, the + /// lane a placed trap renders through. pub(crate) go: Option, - /// `WOW_FX_GO_STATE` β€” the `GAMEOBJECT_STATE` the fixture's descriptor carries (default 1 = - /// READY, what vmangos spawns a trap in). Set it to 0 to see what an omitted field renders as. + /// `WOW_FX_GO_STATE`: the descriptor's `GAMEOBJECT_STATE`, default 1 READY, the state vmangos + /// creates a spell-cast trap in (`SpellEffects.cpp:5655`); 0 shows what an omitted field reads. pub(crate) go_state: u32, - /// `WOW_FX_GO_TYPE` β€” the `GAMEOBJECT_TYPE_ID` (default 6 = TRAP). Decides whether - /// [`crate::go_anim::go_animates`] puts the instance on the machine at all. + /// `WOW_FX_GO_TYPE`: the `GAMEOBJECT_TYPE_ID`, default 6 TRAP; decides whether + /// [`crate::go_anim::go_animates`] runs the state machine at all. pub(crate) go_type: u32, - /// `WOW_FX_SCALE` β€” the unit lane's `NetEntity::scale` (the wire scale a creature carries). + /// `WOW_FX_SCALE`: the unit lane's wire scale. pub(crate) scale: f32, pub(crate) age: f32, pub(crate) az_deg: f32, @@ -283,78 +196,50 @@ pub(crate) struct FxViewRequest { pub(crate) dist: f32, pub(crate) fly: f32, pub(crate) yaw_deg: f32, - /// See `WOW_FX_TURN` above (deg/s). pub(crate) turn: f32, - /// See `WOW_FX_GROUND` above. pub(crate) ground: bool, - /// See `WOW_FX_HOLD` above. pub(crate) hold: bool, - /// `WOW_FX_AT=x,y,z` β€” plant the fixture at this raw WoW point instead of [`FXVIEW_POS`] - /// (with `WOW_MAP` picking the map, like `vista` and `waterfx`). The ground-decal lanes β€” - /// blob shadow, footprints, the selection ring β€” only exist *on* terrain, and which terrain - /// decides whether they exist at all (footprints gate on the surface's `TerrainType.Flags & 1`, - /// i.e. snow and sand) and how hard the coplanar tie is (a steep slope seen at a grazing - /// angle is the whole difficulty). A fixture nailed to one Northshire hillside can photograph - /// none of that. + /// `WOW_FX_AT=x,y,z`: a raw WoW point in place of [`FXVIEW_POS`], `WOW_MAP` the map. Ground + /// decals depend on the surface: footprints need `TerrainType.Flags & 1` (snow, sand). pub(crate) at: Option<[f32; 3]>, - /// `WOW_FX_UP` β€” raise the fixture this many yards above its resolved seat (default 0). - /// The escape hatch for models authored BELOW their anchor (Arcane Intellect's star cluster - /// starts 1.6 yd under its attach point): at the default seat the terrain swallows the - /// opening of the animation, and no camera angle can look through the ground. + /// `WOW_FX_UP`: yards above the resolved seat, for a model authored below its anchor (Arcane + /// Intellect's star cluster starts 1.6 yd under it) whose opening the terrain would swallow. pub(crate) up: f32, } -/// The fixture's live state, written by `fxview::drive_fx_view` and the phase -/// driver below. +/// The fixture's live state, written by `fxview::drive_fx_view` and `drive_capture`. #[derive(Resource, Default)] pub(crate) struct FxViewState { - /// Set by the capture driver once the scene has settled β€” the fixture spawns only then, so - /// a one-shot effect's age at the shot is the REQUESTED age, not age + settle time. + /// Set once the scene has settled; the fixture spawns only then, so its age is the one asked. pub(crate) armed: bool, pub(crate) root: Option, - /// `time.elapsed_secs()` at the frame the visuals attached β€” the age clock's zero. + /// `time.elapsed_secs()` when the visuals attached: the age clock's zero. pub(crate) attached_at: Option, - /// The fixture ran its one sequence pass and the root was reaped (the game's discrete-kit - /// completion callback, mirrored so captures past the span tell the truth: emitters drain, - /// they don't pour). `WOW_FX_HOLD=1` disables the reap. + /// The root was reaped after one sequence pass, as the game reaps a discrete kit; + /// `WOW_FX_HOLD=1` disables the reap. pub(crate) expired: bool, } -/// Where the fxview effect spawns: mid-air over the Northshire slope (raw WoW coords), inside -/// the ground scenario's streamed tiles, high enough that terrain never intersects the model. +/// Where the fxview effect spawns: mid-air over the Northshire slope, inside the ground +/// scenario's streamed tiles. pub(crate) const FXVIEW_POS: [f32; 3] = [-8960.0, -145.0, 90.0]; -/// How far the `vista` fixture seats its eye above the position it is given (yd) β€” a standing human's -/// camera pivot, so pasting a `.go xyz` straight off the debug panel frames what the director saw -/// rather than a worm's-eye view from inside the ground. +/// How far the `vista` fixture seats its eye above the given position (yd): a standing human's +/// camera pivot, so a `.go xyz` off the debug panel frames what a player there saw. const VISTA_EYE_HEIGHT: f32 = 2.0; -/// Print the BASELINE scenario names, one per line β€” the single source of truth -/// `scripts/visual.sh` reads so the driver never drifts from the code. Invoked by `main` for -/// `WOW_CAPTURE=list`. On-demand fixtures (the UI look-pass windows, sun/moon/sky, house-compass) -/// are deliberately absent: the blessed sweep is the director-chosen spotΓ—time set only, so a -/// `visual.sh baseline` opens six windows on their screen and not thirty (decision 0632). +/// Print the baseline scenario names, one per line, for `WOW_CAPTURE=list`; `scripts/visual.sh` +/// reads its sweep from this. On-demand fixtures are not listed. pub(crate) fn print_scenario_names() { for s in SCENARIOS { println!("{}", s.name); } } -/// Consecutive byte-identical framebuffer readbacks that mean "the scene is built" (decision 0815). -/// -/// This is the harness's ONE residency test, and it is a measurement rather than a proxy: whatever is -/// still arriving β€” a tile, a placement, an M2 that just finished loading, a pipeline wgpu has not -/// specialised yet (until it is warm Bevy draws only the clear colour, which is why a blind settle -/// could photograph a uniform fog-blue frame) β€” either changes the image, and resets this counter, or -/// does not affect the shot at all. -/// -/// 30 frames (~0.5 s of held-clock frames) is the same confidence window the entity-count counter it -/// replaced used. Raise it with `$WOW_CAPTURE_STABLE` for a scene with a very slow tail; this is the -/// knob that used to be `WOW_CAPTURE_SETTLE`, and unlike that one it buys *evidence* rather than a -/// guess at a duration. +/// Consecutive byte-identical framebuffer readbacks that mean the scene is built: anything still +/// arriving changes the image or does not affect the shot. `$WOW_CAPTURE_STABLE` overrides it. const STABLE_FRAMES: u32 = 30; -/// The effective stability window β€” see [`STABLE_FRAMES`]. fn stable_frames() -> u32 { std::env::var("WOW_CAPTURE_STABLE") .ok() @@ -362,34 +247,16 @@ fn stable_frames() -> u32 { .unwrap_or(STABLE_FRAMES) } -/// Hard cap on held frames spent waiting for [`STABLE_FRAMES`], so a scene whose image never settles -/// (a UI fixture with a live animation) shoots anyway rather than hanging β€” forfeiting the guarantee, -/// loudly. Deliberately generous: reaching it means the shot is not reproducible. +/// Cap on held frames waiting for [`STABLE_FRAMES`], so a scene that never settles (a live UI +/// animation) shoots anyway, with a warning that the shot is not reproducible. const BUILD_CAP_FRAMES: u32 = 1800; -/// Wall-clock ceiling on a whole capture run, deadline exceeded β†’ `AppExit::error()`. -/// -/// **Every other bound in this harness counts FRAMES, and that is only a bound while frames keep -/// arriving.** On 2026-08-26 macOS stopped granting drawables to our window β€” `-[CAMetalLayer -/// nextDrawable]` parked on its own ~1 s internal timeout, over and over, verified in the stall -/// sampler's own captures (758 of ~1000 samples in `semaphore_timedwait_trap` beneath -/// `CAMetalLayerPrivateNextDrawableLocked`, with `frame hitch: 1005..1019 ms` repeating in the log). -/// At that rate [`BUILD_CAP_FRAMES`] is not 30 seconds of patience, it is **thirty minutes**, and -/// every agent-driven capture that session read as a dead terminal with no error and no clue. -/// -/// So the ceiling that matters is the one measured in the units the failure is measured in. It is -/// not a retry, a fallback or a heuristic: a capture either produces its image or fails, and this is -/// the line that makes the second one *happen* instead of hanging. The message it prints carries the -/// observed frame rate, because "47 frames in 300 s" is the diagnosis and a bare timeout is not. -/// -/// The starvation itself is macOS's, not ours β€” the compositor stops recycling presented drawables -/// for a window it is not compositing, and nothing in-process can hand them back. We bound our own -/// instrument; we do not fight the window server. Decision 1637. +/// Wall-clock ceiling on a capture run, past which it exits with an error. The other bounds count +/// frames, and macOS can stop granting drawables to a window it is not compositing (about 1 s per +/// `-[CAMetalLayer nextDrawable]`), which stretches the frame caps to half an hour. const DEADLINE_SECS: u64 = 300; -/// The effective wall-clock ceiling β€” `$WOW_CAPTURE_DEADLINE=`, `0` to disable. A healthy -/// capture is seconds (`ui-bag` 7 s, a settled world scenario ~10 s), so 300 s is ~30Γ— headroom and -/// cannot fire on a run that is merely slow. +/// The effective ceiling: `$WOW_CAPTURE_DEADLINE=`, `0` to disable. fn capture_deadline() -> Option { let secs = std::env::var("WOW_CAPTURE_DEADLINE") .ok() @@ -398,17 +265,11 @@ fn capture_deadline() -> Option { (secs > 0).then(|| Duration::from_secs(secs)) } -/// Steps of [`CAPTURE_FRAME_DT`] the sims run, clock released, once the scene is built and -/// quiescent β€” the shot's *sim age*. 150 = 2.5 s: past the 2 s spawn appear-fade -/// (`model_fade::APPEAR_FADE_SECS`) and long past a flame pool's particle lifetime, so the scene is -/// at steady state and not mid-transient. -/// -/// Because it is counted in frozen frames it is a **duration**, identically on any machine β€” which -/// is the whole point. `$WOW_CAPTURE_AGE` overrides it for a scene that needs longer to fill (snow: -/// flakes sink at 2-6.5 yd/s from ~22 yd up, so the column wants ~11 s β‰ˆ 660). +/// Steps of [`CAPTURE_FRAME_DT`] the sims run once the scene is built: the shot's sim age. 150 is +/// 2.5 s, past the 2 s spawn fade (`model_fade::APPEAR_FADE_SECS`) and a flame pool's particle +/// lifetime. `$WOW_CAPTURE_AGE` overrides it for a scene that fills slowly (snow wants ~660). const AGE_FRAMES: u32 = 150; -/// The effective aging window β€” see [`AGE_FRAMES`]. fn age_frames() -> u32 { std::env::var("WOW_CAPTURE_AGE") .ok() @@ -416,23 +277,12 @@ fn age_frames() -> u32 { .unwrap_or(AGE_FRAMES) } -/// The frozen capture clock's frame step: every capture frame advances the game clock by exactly -/// this, whatever the frame really cost. Anything integrated in *seconds* β€” particle pools, ribbon -/// trails, animation, weather, fades β€” therefore reaches the shutter in the same state every run. -/// -/// Without it the harness is only **frame**-pinned: the build window counts frames while the sims -/// advance on `time.delta_secs()`, i.e. real wall-clock, so two runs spend different real time per -/// frame (streaming, pipeline warm-up, whatever else the machine is doing) and photograph the flames -/// in different places. Measured on this tree before the change β€” the golden six captured twice from -/// one **unchanged** build: MAE up to 0.009 with max pixel deltas of 180, the same band a real render -/// change lands in. That noise floor is why decision 0721 could not read its own flame pixels, and -/// why 0719 read signal out of some that were partly run-to-run churn. -/// -/// 1/60 s, so every settle window keeps the duration its comment claims (150 frames β‰ˆ 2.5 s) instead -/// of buying however much sim time the machine happened to grant. +/// The frozen clock's step, 1/60 s per capture frame, so everything integrated in seconds reaches +/// the shutter in the same state every run. On a live clock two runs of one build differ by up to +/// MAE 0.009, as much as a real render change. const CAPTURE_FRAME_DT: Duration = Duration::from_nanos(16_666_667); -/// Hard cap on how long to wait for the screenshot save to land, so a capture never hangs the harness. +/// Cap on frames waiting for the screenshot save, so a capture never hangs. const SAVE_TIMEOUT_FRAMES: u32 = 120; /// A couple of grace frames after the save completes before exiting, so the file is flushed. const EXIT_GRACE_FRAMES: u32 = 3; @@ -440,22 +290,17 @@ const EXIT_GRACE_FRAMES: u32 = 3; /// Frames the perf probe discards after uncapping vsync (present-mode switch + pipeline settle). const PROBE_WARMUP_FRAMES: u32 = 60; -/// Marks a framebuffer readback taken to test scene stability, so the real shot's `Capturing` wait -/// (see [`Phase::Saving`]) cannot mistake one for the screenshot it is waiting on. +/// Marks a stability readback, so [`Phase::Saving`]'s wait cannot mistake it for the shot. #[derive(Component)] struct StabilityShot; -/// The image-stability tracker: the previous framebuffer, and how many consecutive readbacks have -/// matched it byte for byte (decision 0815). Bytes, not a hash β€” the buffer is tens of MB at most, -/// a `memcmp` is free next to the readback itself, and an exact compare needs no collision argument. +/// The image-stability tracker. Compares whole bytes, not a hash: a `memcmp` is free next to the +/// readback and needs no collision argument. #[derive(Resource, Default)] struct FrameWatch { - /// The last framebuffer read back, or `None` before the first one lands. prev: Option>, - /// Consecutive readbacks identical to their predecessor. Reset to 0 by any change. stable: u32, - /// A readback is outstanding. Only ever one at a time, so `stable` counts *distinct* frames - /// rather than however many requests the GPU happened to retire together. + /// A readback is outstanding; only one at a time, so `stable` counts distinct frames. in_flight: bool, } @@ -463,7 +308,7 @@ struct FrameWatch { fn watch_frame(shot: On, mut watch: ResMut) { watch.in_flight = false; let Some(bytes) = shot.image.data.as_ref() else { - return; // no readback payload (a zero-sized or unavailable target) β€” treat as no evidence + return; // no payload (a zero-sized target): no evidence }; watch.stable = if watch.prev.as_deref() == Some(bytes.as_slice()) { watch.stable + 1 @@ -476,21 +321,17 @@ fn watch_frame(shot: On, mut watch: ResMut) { /// Capture lifecycle. Advances one step per frame in [`drive_capture`]. #[derive(Clone, Copy)] enum Phase { - /// Clock held; waiting for the rendered image to stop changing ([`STABLE_FRAMES`]). This one - /// phase replaced `Streaming` + `Settling` and the four world-state proxies they waited on β€” see - /// this module's header. + /// Clock held; waiting for the image to stop changing ([`STABLE_FRAMES`]). Building(u32), - /// Scene built, clock released: running exactly [`age_frames`] steps of - /// [`CAPTURE_FRAME_DT`] so every second-driven thing is at a known, machine-independent age. + /// Clock released: running exactly [`age_frames`] steps of [`CAPTURE_FRAME_DT`]. Aging(u32), - /// fxview only: scene settled, fixture armed; waiting for the effect to attach and run its - /// requested age before the shot. + /// Fixture viewers only: fixture armed, waiting for it to attach and reach its age. FxAging, - /// Perf-probe mode (`$WOW_FPS_PROBE`): vsync off, discarding warm-up frames. + /// Perf probe (`$WOW_FPS_PROBE`): vsync off, discarding warm-up frames. ProbeWarmup(u32), - /// Perf-probe mode: sampling frame times until the target count, then print + exit. + /// Perf probe: sampling frame times, then print and exit. Probing(u32), - /// Screenshot requested; waiting for the async save (`Capturing` marker) to appear and clear. + /// Screenshot requested; waiting for the async save's `Capturing` marker to appear and clear. Saving { frames: u32, seen: bool }, /// Save done; a few grace frames, then `AppExit`. Done(u32), @@ -498,83 +339,51 @@ enum Phase { #[derive(Resource)] struct CaptureCtx { - /// The in-world viewpoint β€” `None` for a glue-screen capture, which has no world, no camera - /// and no map. Every reader of it sits on a world path; the shutter itself needs neither. + /// The in-world viewpoint; `None` for a glue-screen capture, which has no world. scenario: Option, - /// The scenario's name, whichever table it came from β€” for the output path and the probe line. + /// The scenario's name, for the output path and the probe line. name: &'static str, out: String, phase: Phase, - /// UI fixture already seeded (once, at residency) β€” see [`seed_ui_fixture`]. + /// The UI fixture is seeded ([`seed_ui_fixture`]). ui_seeded: bool, - /// `$WOW_FPS_PROBE` β€” sample this many frames (vsync off) instead of screenshotting, then print - /// frame-time stats + scene counts and exit. The repeatable perf instrument: same scenario, same - /// settle, numbers instead of pixels. 0 = normal capture. + /// `$WOW_FPS_PROBE`: sample this many frames, vsync off, and print frame-time stats and scene + /// counts instead of screenshotting; 0 is a normal capture. probe_frames: u32, - /// The frozen clock is still in force ([`CAPTURE_FRAME_DT`]). True for the whole of every run - /// that ends in a screenshot; a perf probe **starts** frozen and drops this the moment the scene - /// is built and aged, because its entire measurement *is* the real frame cost. - /// - /// It used to be `probe_frames == 0`, decided once at build β€” i.e. a probe ran its whole - /// `Building` phase on a live clock. That silently broke when 0815 replaced the settle proxies - /// with the image-stability gate: on a live clock the sky drifts and the flames burn, so the - /// image *cannot* stop changing and `watch.stable` never leaves 0. Every probe since has spent - /// the full [`BUILD_CAP_FRAMES`] β€” 1800 frames, ~30 s, and 1800 full-resolution readbacks of a - /// stability test that could not pass β€” and then printed a warning about a shot it never takes. - /// Worse for a *perf* instrument: the scene it measured was however old 1800 real frames made - /// it, which is a different age on every machine. Frozen build β†’ deterministic [`age_frames`] β†’ - /// release is the same three steps a capture takes, and it is what makes the probe repeatable. - /// Decision 1637. + /// The frozen clock ([`CAPTURE_FRAME_DT`]) is in force. A screenshot run keeps it to the end; a + /// perf probe builds and ages frozen like a capture, so it measures a scene of the same age on + /// any machine, then drops it because its measurement is the real frame cost. frozen_clock: bool, - /// `$WOW_RESIZE=WxH` already applied (once, at first settle) β€” see the `Building` arm. + /// `$WOW_RESIZE` is applied (once, at first settle). resized: bool, - /// Probe samples (frame ms). probe_samples: Vec, - /// Process CPU seconds at the first sampled frame β€” the window baseline for the probe line's - /// `cpu_ms`/`cpu_pct` (the load-robust metric, decision 0711; the scenario probe lacked it). + /// Process CPU seconds at the first sample, the baseline for the probe line's `cpu_ms`. probe_cpu_start: Option, - /// Wall-clock start of the run, and how long it may take β€” see [`capture_deadline`]. A real - /// [`Instant`], never `Time`: under the frozen clock `Time` is itself manual - /// ([`CAPTURE_FRAME_DT`] per frame), so a harness that timed itself by it would measure the - /// very frame count it is trying not to trust. + /// Wall-clock start of the run ([`capture_deadline`]). A real [`Instant`]: under the frozen + /// clock `Time` is itself manual and would only count frames. started: Instant, deadline: Option, - /// Frames [`drive_capture`] has run, all phases β€” the denominator of the rate the deadline - /// message reports. + /// Frames [`drive_capture`] has run, the denominator of the deadline message's rate. frames: u32, /// The deadline already fired; `AppExit` is written and no phase advances again. bailed: bool, } -/// `$WOW_RESIZE=WxH` β€” resize the window to this (logical px) once the image first settles, -/// then settle again before shooting. The mid-session resolution-change instrument (see the -/// `Building` arm). `None` when unset or malformed. +/// `$WOW_RESIZE=WxH`: resize the window (logical px) once the image first settles, then settle +/// again before shooting. fn resize_request() -> Option<(u32, u32)> { let v = std::env::var("WOW_RESIZE").ok()?; let (w, h) = v.split_once('x')?; Some((w.parse().ok()?, h.parse().ok()?)) } -/// The present mode a perf probe uncaps to (also the live probe's β€” see `probes/live_fps.rs`). +/// The present mode a perf probe uncaps to, shared with the live probe. /// -/// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap β€” A/B'd -/// 2026-07-27 (overlook-noon, release, twice each), it *rails* near 16.6 ms AND takes 1.0–1.5 s -/// stalls (the `-[CAMetalLayer nextDrawable]` timeout β€” drawable starvation), while `AutoNoVsync` -/// genuinely uncaps when macOS grants it (p50 12.7 ms on the same scene). 0362's "AutoNoVsync -/// doesn't uncap" was the *power state* withholding the grant, not the mode β€” no present mode -/// escapes that; `cpu_ms` on the probe line is the rail-proof metric. `WOW_PROBE_UNCAP=immediate` -/// re-runs the losing arm when macOS/wgpu move. -/// -/// `WOW_PROBE_UNCAP=vsync` does not uncap at all: the leg keeps the player's present mode and -/// rails at the display's rate on purpose. It was built because the WindowServer's grant is not -/// ours to schedule β€” one sitting on the M2 Air read 123.5, 60.0, 60.0 and 87.5 fps across four -/// otherwise identical uncapped legs β€” and 1442's "railed-60.0 pairs only" rule discards every -/// pair the grant split. **Measured the same day, it is not a neutral instrument:** two -/// interleaved rounds at the Goldshire pin, no crowd, read `cpu_ms` 11.80 / 11.55 uncapped -/// (both railed 60.0 by the WindowServer anyway) against 14.65 / 12.43 under this arm β€” the -/// vsync wait costs the process CPU, and unevenly. So the default stays uncapped and a pair is -/// still accepted only when both legs happened to rail; this arm is for a sitting whose legs -/// refuse to rail at all, with the tax read against a same-arm baseline. +/// `AutoNoVsync` by default: on macOS/Metal explicit `Immediate` rails near 16.6 ms and takes +/// 1.0-1.5 s drawable stalls, while `AutoNoVsync` uncaps when the window server grants it; when +/// it does not, `cpu_ms` on the probe line is the metric. `WOW_PROBE_UNCAP=immediate` selects +/// `Immediate`. `WOW_PROBE_UNCAP=vsync` keeps vsync for a sitting whose legs never rail; the vsync +/// wait costs process CPU unevenly, so read it only against a same-mode baseline. pub(crate) fn probe_uncap_mode() -> bevy::window::PresentMode { match std::env::var("WOW_PROBE_UNCAP").as_deref() { Ok("immediate") => bevy::window::PresentMode::Immediate, @@ -587,9 +396,7 @@ pub(crate) struct CapturePlugin; impl Plugin for CapturePlugin { fn build(&self, app: &mut App) { - // The `waterfx` fixture drives its synthetic wading dummy before the foam emitter reads - // the motion β€” registered here, against the engine's ordering handle, because the - // instrument is the one that knows it is an instrument. + // The `waterfx` dummy moves before the foam emitter reads its motion. app.add_systems( Update, (waterfx::spawn, waterfx::drive) @@ -597,10 +404,8 @@ impl Plugin for CapturePlugin { .before(benilla_world::water_fx::WaterFoamSet) .in_set(benilla_world::schedule::WorldStage::Present), ); - // The `fxview` fixture's driver, same principle and the same shape (decision 1174): it - // creates the subject's display-cache entry, so it runs before the frame's build, inside - // the entity-visuals set and after the net stage β€” exactly the slot it held while it was - // an element of `EntitiesPlugin`'s chain, now stated rather than positional. + // The `fxview` driver creates the subject's display-cache entry, so it runs before the + // frame's display build, after the net stage. app.add_systems( Update, fxview::drive_fx_view @@ -609,24 +414,19 @@ impl Plugin for CapturePlugin { .after(benilla_world::schedule::WorldStage::Net), ); let name = std::env::var("WOW_CAPTURE").unwrap_or_default(); - // A **glue** capture short-circuits everything below: no map to seed, no viewpoint to - // pin, no fixture to open. Only the shutter is shared, and the shutter wants no world. + // A glue capture has no map, viewpoint or fixture; only the shutter is shared. let glue = GLUE_SCENARIOS.iter().find(|g| g.name == name).copied(); if let Some(g) = glue { - // The preview pick goes through the existing `WOW_CHARCREATE_PICK` instrument rather - // than a second path into `CreateSelection` β€” same reason the map is seeded by env - // (decision 0743): one route into a fact, whoever is asking. - // …and an explicit pick in the environment outranks the scenario's default β€” the - // per-race lever, so one scenario photographs every `UI_*` stage. + // The preview pick rides `WOW_CHARCREATE_PICK`, and a pick already in the environment + // wins, so one scenario photographs every race's stage. if let Some((race, sex, class)) = g.pick { if std::env::var_os("WOW_CHARCREATE_PICK").is_none() { std::env::set_var("WOW_CHARCREATE_PICK", format!("{race},{sex},{class}")); } } } - // The fxview instrument: a synthetic scenario (ground scene, noon) + the fixture - // request from env. Not in SCENARIOS β€” `scripts/visual.sh`'s golden sweep must never - // run it (its output depends on the model/age/angle knobs, not just the name). + // `fxview`, `waterfx`, `vista` and `name-close` are built from knobs, not the tables, and + // are never in the golden sweep. let scenario: Option = if glue.is_some() { None } else { @@ -643,7 +443,7 @@ impl Plugin for CapturePlugin { .and_then(|v| v.trim().parse().ok()); let model_path = match (std::env::var("WOW_FX_MODEL"), display.or(go)) { (Ok(p), _) => p, - (Err(_), Some(_)) => String::new(), // the id lanes name their model by display id + (Err(_), Some(_)) => String::new(), // the id lanes name their own model (Err(_), None) => { eprintln!( "WOW_CAPTURE=fxview needs WOW_FX_MODEL=, \ @@ -676,11 +476,6 @@ impl Plugin for CapturePlugin { ground: knob("WOW_FX_GROUND", 0.0) > 0.5, hold: knob("WOW_FX_HOLD", 0.0) > 0.5, up: knob("WOW_FX_UP", 0.0), - // `WOW_FX_AT=x,y,z` plants the fixture anywhere in the real world, with - // `WOW_MAP` picking the map β€” the same pair `vista` and `waterfx` take. The - // default hillside can photograph an effect but not a ground decal: which - // surface is under the subject decides whether footprints exist at all, and - // how steeply it falls away decides how hard the coplanar tie is. at, }) .init_resource::(); @@ -693,12 +488,10 @@ impl Plugin for CapturePlugin { ui: None, } } else if name == "waterfx" { - // The water-foam viewer (see `capture::waterfx`): a wading unit over a synthetic - // wet lattice β€” or, with WOW_WFX_AT=x,y,z, in the real streamed liquid at that pin - // β€” framed by a fixed orbit around the rig centre. Knobs: WOW_WFX_MODE - // (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_HEAD (deg), WOW_WFX_AGE (s), - // WOW_WFX_DEPTH (yd), - // camera WOW_WFX_AZ/EL/DIST. Not a golden scenario. + // The water-foam viewer: a wading unit over a synthetic lattice, or in real + // liquid with `WOW_WFX_AT`, on a fixed orbit. Knobs: `WOW_WFX_MODE` + // (ring|wake|turn), `_SPEED` yd/s, `_HEAD` deg, `_AGE` s, `_DEPTH` yd, and the + // camera's `_AZ`/`_EL`/`_DIST`. let knob = |k: &str, d: f32| { std::env::var(k) .ok() @@ -710,11 +503,8 @@ impl Plugin for CapturePlugin { Ok("turn") => waterfx::WfxMode::Turn, _ => waterfx::WfxMode::Ring, }; - // Rig centre in raw WoW coords. Default: over the Northshire ground scene, the - // synthetic surface a few yards above the terrain so the backdrop plane reads - // clean. `WOW_WFX_AT=x,y,z` moves it anywhere and switches the rig to the REAL - // streamed liquid there (`z` = that water's surface height) β€” a synthetic square - // of water has no bank to clip against and no neighbour to be sorted against. + // Rig centre, raw WoW coords: a synthetic surface over the Northshire ground by + // default; `WOW_WFX_AT` puts it in the real liquid there, `z` its surface. let at = std::env::var("WOW_WFX_AT").ok().and_then(|v| { let c: Vec = v.split(',').filter_map(|p| p.trim().parse().ok()).collect(); (c.len() == 3).then(|| [c[0], c[1], c[2]]) @@ -741,8 +531,7 @@ impl Plugin for CapturePlugin { .init_resource::(); Scenario { name: "waterfx", - // The synthetic lattice sits over the Northshire slope; a live rig - // (`WOW_WFX_AT`) goes wherever its pin is, so its map is a knob like `vista`'s. + // `WOW_MAP` picks the map for a live rig. map: None, eye, look: center, @@ -750,21 +539,15 @@ impl Plugin for CapturePlugin { ui: None, } } else if name == "vista" { - // The **arbitrary-viewpoint** instrument: stand anywhere on the map, face any heading, - // at any clock β€” the world half of what `fxview` is for effects. A director report that - // arrives as "look at this horizon, here" (position, facing and time are all on the debug - // panel, and `copy .go xyz` puts the position on the clipboard) becomes a reproducible - // headless capture instead of a round-trip. Pair it with `WOW_FARCLIP` to match their - // slider β€” horizon and fog artifacts live and die by the far-clip wall. Not a golden - // scenario (its output depends on the knobs, not the name). + // The arbitrary-viewpoint instrument: any position, heading and clock, e.g. // // WOW_CAPTURE=vista WOW_VISTA_AT=-5841.9,-3802.4,-59.7 WOW_VISTA_FACE=24 \ - // WOW_VISTA_MIN=1052 WOW_FARCLIP=320 WOW_CAPTURE_OUT=/tmp/v.png cargo run -q -p benilla + // WOW_VISTA_MIN=1052 WOW_FARCLIP=320 cargo run -q -p benilla // - // Knobs: `WOW_VISTA_AT` (required, raw WoW `x,y,z` β€” the PLAYER position; the eye seats - // `VISTA_EYE_HEIGHT` above it), `WOW_VISTA_FACE` (heading in degrees β€” the panel's - // "facing" in its `(24Β°)` form; 0 = +X, counter-clockwise), `WOW_VISTA_PITCH` (degrees, - // + = up, default 0 = level), `WOW_VISTA_MIN` (game minute of day, default 720 = noon). + // `WOW_VISTA_AT` is the player's feet (the eye sits `VISTA_EYE_HEIGHT` above), + // `WOW_VISTA_FACE` the debug panel's facing in degrees (0 = +X, counter-clockwise), + // `WOW_VISTA_PITCH` degrees up (0), `WOW_VISTA_MIN` the game minute (720). + // `WOW_FARCLIP` matches a player's far-clip slider. let knob = |k: &str, d: f32| { std::env::var(k) .ok() @@ -785,8 +568,7 @@ impl Plugin for CapturePlugin { let d = 500.0_f32; Scenario { name: "vista", - // The arbitrary-viewpoint instrument goes anywhere, so its map is a knob: a - // horizon report from Kalimdor is `WOW_MAP=1` (a `Map.dbc` id). + // `WOW_MAP` picks the map (a `Map.dbc` id). map: None, eye, look: [ @@ -798,9 +580,7 @@ impl Plugin for CapturePlugin { ui: None, } } else if name == "name-close" { - // The magnified overhead-name instrument (see `scenarios::NAME_CLOSE_AT`): the - // `name-water` wolf, orbited by knob and looked straight at, so the ONE variable - // is how many device pixels a glyph texel is drawn into. Not a golden scenario. + // The magnified overhead-name instrument ([`scenarios::NAME_CLOSE_AT`]). let knob = |k: &str, d: f32| { std::env::var(k) .ok() @@ -808,8 +588,7 @@ impl Plugin for CapturePlugin { .unwrap_or(d) }; let at = scenarios::NAME_CLOSE_AT; - // The name hangs `h` above the unit's FEET (the posed PlayerName attachment plus - // the block's own line of rise) β€” that point is what the camera orbits and aims at. + // The camera orbits and aims at the name, `WOW_NAME_H` above the unit's feet. let name_at = [at[0], at[1], at[2] + knob("WOW_NAME_H", 1.4)]; let (dist, az, el) = ( knob("WOW_NAME_DIST", 4.0), @@ -828,8 +607,6 @@ impl Plugin for CapturePlugin { minute: 720, ui: Some(UiFixture::NameWater), } - // By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is - // narrowed β€” every old viewpoint is still capturable by name (decision 0632). } else if let Some(&s) = SCENARIOS .iter() .chain(scenarios::ON_DEMAND.iter()) @@ -849,15 +626,8 @@ impl Plugin for CapturePlugin { std::process::exit(2); }) }; - // Seed the continent the scenario names, BEFORE `world_map::load_world_map` reads it at - // `Startup` β€” that is the single place `CurrentMap` is set for a server-less run, and the - // terrain/WDL streamers and per-map lighting all key off it. Raw WoW coords repeat on every - // continent, so a scenario that could not say which map it meant would stream the wrong - // world's tiles and photograph a void (Felwood's tile `33_24` exists in Azeroth, empty). - // The arbitrary-viewpoint instruments (`vista`, `waterfx`, `fxview`) carry `map: None` - // instead: their map IS the knob, so writing it back would be a round trip through a second - // parser β€” and a second parser is how `WOW_MAP=Kalimdor` came to photograph Azeroth in - // silence. `world_map` is the one reader (decision 0743). A glue screen has no map. + // Seed the map before `world_map::load_world_map` reads `WOW_MAP` at `Startup`, the one + // place `CurrentMap` is set server-less; a `map: None` instrument leaves the knob alone. if let Some(m) = scenario.as_ref().and_then(|s| s.map) { std::env::set_var("WOW_MAP", m.to_string()); } @@ -877,12 +647,8 @@ impl Plugin for CapturePlugin { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0); - // The frozen capture clock ([`CAPTURE_FRAME_DT`]) β€” now for every run, probe included, and - // released at a phase boundary rather than never installed (see `CaptureCtx::frozen_clock`). - // A probe still measures a live clock: `drive_capture` restores - // `TimeUpdateStrategy::Automatic` on the way into `ProbeWarmup`, before a single frame is - // sampled, because `time.delta_secs()` under a manual strategy would report a flawless - // constant 16.67 ms for any scene, however slow. + // The frozen clock for every run; a probe switches back to `Automatic` before its first + // sample, or it would measure a constant 16.67 ms ([`CaptureCtx::frozen_clock`]). app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT)) .add_systems(Startup, hold_clock); app.insert_resource(CaptureMode) @@ -904,19 +670,15 @@ impl Plugin for CapturePlugin { bailed: false, }) .add_systems(Update, pin_scene.in_set(WorldStage::Present)) - // Before the UnitFeed pass: the seed stands in for wire data that in live play - // filled the app caches on EARLIER frames, so the same frame's feeds (item-template - // / player-req pushes, then the merchant paint) must all see it. Unordered, the - // one-shot MERCHANT_SHOW paint races feed_item_stats and the usable reds are - // flaky in the capture. + // Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier + // frames, so the same frame's feeds and the one-shot `MERCHANT_SHOW` paint must see it. .add_systems(Update, seed_ui_fixture.before(crate::ui_unit::UnitFeed)) .add_systems(Last, drive_capture); } } -/// Each frame, force the deterministic capture conditions: pinned time-of-day, no perf HUD, and the -/// fixed camera pose. Runs in `WorldStage::Present` (after `control` is gated off and after terrain -/// streaming reads the camera), so the harness is the sole, stable author of the view. +/// Each frame, force the capture conditions: pinned time of day, no perf HUD, the fixed camera. +/// Runs in `WorldStage::Present`, after terrain streaming reads the camera. fn pin_scene( ctx: Res, mut debug: ResMut, @@ -927,21 +689,16 @@ fn pin_scene( roots: Query<&Transform, Without>, mut cam: Query<&mut Transform, With>, ) { - perf.visible = false; // hidden by default since 2099, but a session may have chorded it up β€” - // a capture is pristine and UI-free whatever the run did - // A glue screen has no world to light, no clock to pin and no camera to place. The shutter - // above needs none of that β€” it is watching the framebuffer. + perf.visible = false; + // A glue screen has no world, clock or camera to pin. let Some(scenario) = ctx.scenario else { return; }; debug.lighting.follow_server_time = false; debug.lighting.manual_minute = scenario.minute; - // `WOW_MM_PROBE=x,y,z` (raw WoW coords) drops the player at that point and marks them active, so - // the interior minimap (which keys off `player.active` + `player.pos`) renders in a headless - // capture β€” the harness otherwise leaves the player inactive at spawn, so interiors never show. - // The camera looks straight down from above so the WMO streams in around it. Interior-minimap - // debugging instrument (decision 0203 arc), not a golden scenario. + // `WOW_MM_PROBE=x,y,z` (raw WoW coords) places an active player there, so the interior + // minimap renders server-less, with the camera above so the WMO streams in around it. let probe = std::env::var("WOW_MM_PROBE").ok().and_then(|s| { let v: Vec = s.split(',').filter_map(|x| x.trim().parse().ok()).collect(); (v.len() == 3).then(|| [v[0], v[1], v[2]]) @@ -950,7 +707,7 @@ fn pin_scene( player.pos = wow_to_bevy(p); player.active = true; player.detached = false; - // Above and off to the side (not straight down β€” that degenerates `looking_at` with Y up). + // Off to the side: straight down degenerates `looking_at` with Y up. let eye = wow_to_bevy([p[0] - 25.0, p[1] - 25.0, p[2] + 40.0]); for mut t in &mut cam { *t = Transform::from_translation(eye).looking_at(wow_to_bevy(p), Vec3::Y); @@ -959,9 +716,8 @@ fn pin_scene( } let (eye, look) = match (&fx_req, &fx_state) { - // fxview: orbit the fixture's LIVE root (a flying missile moves; the camera tracks it) - // at the requested azimuth/elevation/distance, aimed one yard up β€” the effect models - // author their bodies ~0.5–1.5 units above their root. + // fxview: orbit the fixture's live root (a missile moves), aimed one yard up, where the + // effect models author their bodies. (Some(req), Some(state)) => { let root_pos = state .root @@ -982,22 +738,15 @@ fn pin_scene( /// Hold the game clock at zero until the image stops changing; [`drive_capture`] releases it. /// -/// The fixed frame step alone is not enough, because it only makes each frame the same *size* β€” a -/// torch flame still ages from the frame its tile happened to spawn on, and tiles spawn under a -/// wall-clock budget (`terrain_stream`'s `SPAWN_BUDGET`) against asset I/O that finishes on a -/// different frame every run. Held through the whole build, every emitter in the scene is zero-age -/// when the clock is released, so at the shutter each has run exactly [`age_frames`] steps no matter -/// when it appeared. Frame-driven work (streaming itself, the spawn budget, the screenshot save) is -/// on the real clock and runs on regardless β€” which is what lets the scene finish arriving while the -/// sims stand still. +/// Tiles spawn under a wall-clock budget, so without the hold an emitter would age from whichever +/// frame its tile arrived on; held, every emitter has run exactly [`age_frames`] steps at the +/// shutter. Streaming and the save are frame-driven and run on regardless. fn hold_clock(mut clock: ResMut>) { clock.pause(); } -/// The three scene-population queries the `FPS_PROBE` line prints β€” bundled because they are one -/// concern (how much world is resident, and how much of it survived the cull) and because -/// `drive_capture` sits against Bevy's 16-parameter ceiling, which the CVar host's old knob -/// bundle hit first (retired by 2303). +/// The scene-population queries the `FPS_PROBE` line prints, bundled because `drive_capture` sits +/// at Bevy's 16-parameter ceiling. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct ProbeCensus<'w, 's> { particles: Query<'w, 's, &'static benilla_world::particles::ParticleEmitter>, @@ -1005,39 +754,33 @@ pub(crate) struct ProbeCensus<'w, 's> { entities: Query<'w, 's, ()>, } -/// Drive the capture lifecycle: wait for streaming, settle, screenshot, exit. +/// Drive the capture lifecycle: settle, age, screenshot or probe, exit. fn drive_capture( mut ctx: ResMut, mut watch: ResMut, - // Disjoint from the stability readbacks by the marker: without it, one of those in flight would - // satisfy this wait and the harness could call the real shot done before it was written. + // Excludes stability readbacks, or one in flight could satisfy the real shot's wait. capturing: Query<(), (With, Without)>, mut commands: Commands, mut exit: MessageWriter, - // `ResMut` for one reason: re-anchoring the clock at the probe's release β€” see `Phase::Aging`. + // `ResMut` to re-anchor the clock at the probe's release (`Phase::Aging`). mut time: ResMut>, mut windows: Query<&mut Window, With>, census: ProbeCensus, fx_req: Option>, wfx_req: Option>, mut fx_state: Option>, - // **The harness's one deliberate virtual clock** β€” the allowlisted exception in - // `probes::probe_schedules_read_the_wall_clock`. The fixture age below is an age *on the clock - // the effect animates on* β€” the same clock this system freezes at save time, one field down β€” - // not a schedule. Everything schedule-shaped in the harness reads [`probes::ProbeClock`] - // instead; decision 0789 says why, and what learning it cost. + // The harness's one virtual-clock read, allowlisted in + // `probes::probe_schedules_read_the_wall_clock`: the fixture's age is measured on the clock the + // effect animates on. Everything schedule-shaped reads [`probes::ProbeClock`]. game_time: Res