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