diff --git a/crates/benilla-world/src/art_scope.rs b/crates/benilla-world/src/art_scope.rs index 19c8de6b..101f22f1 100644 --- a/crates/benilla-world/src/art_scope.rs +++ b/crates/benilla-world/src/art_scope.rs @@ -1,43 +1,9 @@ -//! **Within-map art residency** — the distance half of decision 0729's map-scope teardown. +//! Within-map art residency: the world-art dedup caches drop what was last wanted more than a +//! radius from the view focus; a cross-map transition still clears them outright. //! -//! 0729 gave the world-art dedup caches a *reset point*: a cross-map transition clears them, and the -//! loading screen pays for the rebuild. B131 is the case that argument does not reach — a long flight -//! **inside** one map never fires that transition, so nothing ever evicts and `mats`/`images` ratchet -//! for as long as you stay on a continent (decision 0785's open half: 26.8 k materials / 2.8 k images -//! after ten minutes here; 3.2 → 4.9 GiB of VRAM on the reporter's tour). -//! -//! This module turns clear-on-*transition* into clear-on-*distance*: every cache entry remembers the -//! view focus it was last used from, and a sweep drops what was last wanted more than -//! [`DEFAULT_RADIUS_YD`] away. So residency is bounded by *where you are*, not by how long the process -//! has run, and the map-change clear stays exactly as it was — the hard reset on top of the soft one. -//! -//! **Distance decides what expires; a dwell floor decides how soon.** [`MIN_DWELL_SECS`] exists -//! because the radius alone is sized for gameplay speed — the detached free-fly camera crosses it in -//! five seconds and would lose a city's dedup only to rebuild it on the way back (found on the -//! director's first real run; see the constant). -//! -//! **Distance, not a TTL**, and the difference is safety rather than taste: a time-expired entry can -//! be one the streamer is about to ask for again from ten yards away, and dropping it costs a -//! duplicate; a distance-expired entry is one whose tiles unloaded long ago. The radius is therefore -//! floored at the streamer's own reach ([`radius_floor`]) so eviction can never outrun the thing that -//! placed the art. -//! -//! **Two properties worth knowing before reading the code**, both of which buy the design its -//! smallness: -//! -//! 1. **Stamping needs no plumbing.** A *use* (a [`SpatialCache::fetch`] hit or an insert) clears the -//! stamp to `None` — "used since the last sweep" — and the *sweep* is what writes today's focus -//! onto every `None`. So no call site has to know where the camera is, and a cache nothing sweeps -//! (the `Local`s in the glue booth and the portrait bake) simply never expires. -//! 2. **Nothing needs ordering.** The stamp is at worst one sweep interval stale (≈25 yd at flight -//! speed, against a 2.6 km radius) and `due` being read a frame before it is set only moves the -//! sweep one frame. There are no `.before`/`.after` constraints anywhere in this module. -//! -//! The failure mode if the radius were ever too small: evicting the dedup for art that is still -//! drawn. That is not a visual bug and not a leak — the drawn entity holds its own handles, and the -//! next spawn of that art re-creates one duplicate material which becomes the new cache entry. It -//! costs a lost batch, and it self-heals. That asymmetry (too-eager = mild and transient, too-lazy = -//! the bug we are fixing) is why the floor is a clamp and not a warning. +//! A use (a [`SpatialCache::fetch`] hit or an insert) clears an entry's stamp to `None` and each +//! sweep stamps every `None` with the current focus, so no call site needs the camera and a cache +//! nothing sweeps never expires. Eviction only drops the dedup: a drawn entity keeps its handles. use std::hash::Hash; @@ -48,36 +14,20 @@ use bevy::time::Real; use benilla_assets::SpatialCache; use benilla_formats::TILE_SIZE; -/// Wall seconds between sweeps ([`Real`], not the virtual clock — this is a housekeeping cadence, -/// and decision 0789's reading applies: "what time is it" is not "how much world time to advance"). -/// One `retain` over ~30 k entries a second is not measurable; the interval exists so it is not run -/// per frame, and it doubles as the stamp's resolution. +/// Real-clock seconds between sweeps (housekeeping, not world time); also the stamp's resolution. const SWEEP_SECS: f32 = 1.0; -/// Default eviction radius in yards — five ADT tiles (2667 yd). Chosen as the smallest round -/// multiple of the tile grid comfortably clear of [`radius_floor`] (2560 yd): far enough that art -/// still on screen is never evicted, close enough that leaving one city for another recovers it -/// (Stormwind → Booty Bay is ~5500 yd, Ironforge → Stormwind ~4250). +/// Five ADT tiles (2667 yd), the smallest round tile multiple clear of [`radius_floor`]. const DEFAULT_RADIUS_YD: f32 = 5.0 * TILE_SIZE; -/// The smallest radius that cannot evict art the streamer is still holding: the far **corner** of -/// the widest tile block the residency window can keep, plus a tile of margin. -/// -/// A stamp records where *the viewer* was when the art was last wanted, not where the art is — so an -/// entry can be up to a streaming reach "behind" the art it belongs to. Flooring the radius at the -/// block's circumscribed radius means you have to leave the art's neighbourhood entirely before its -/// dedup expires. The window follows the live `farclip` (1513), so the floor is taken at the clamp's -/// **max** — the widest the window can ever be — rather than tracking the slider: a floor that -/// shrank with the view distance would buy nothing (the default clears it either way) and would -/// have to re-resolve on every drag. +/// The far corner of the widest tile block the residency window keeps (at the `farclip` clamp's +/// maximum), plus a tile: a stamp is where the viewer stood, so no smaller radius is safe. fn radius_floor() -> f32 { crate::terrain_stream::window::max_resident_reach_yd(*crate::view::FARCLIP_RANGE.end()) } -/// Resolve `$WOW_ART_RADIUS` (yards; `0` or negative ⇒ eviction off, the A/B leg that reproduces the -/// unbounded behaviour on a fixed build) against the streamer's reach. An explicitly-requested radius -/// below [`radius_floor`] is raised **and** warned about, rather than silently obeyed: a knob that -/// quietly means something else than it says is how decision 0789 happened. +/// `$WOW_ART_RADIUS` in yards (`0` or less turns eviction off); a value under [`radius_floor`] is +/// raised to it with a warning. fn radius_from_env() -> f32 { let floor = radius_floor(); match std::env::var("WOW_ART_RADIUS") @@ -97,27 +47,25 @@ fn radius_from_env() -> f32 { } } -/// Which cache a census row is about. Fixed set, in journal-column order — the CSV's columns only -/// ever grow at the end, so this enum's order is part of the file format. +/// Which cache a census row is about, in journal-column order: the CSV only grows columns at the +/// end, so this order is part of the file format. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ArtSlot { - /// `Placements::materials` — streamed doodad/WMO submesh materials. + /// `Placements::materials`: streamed doodad and WMO submesh materials. PlaceMats, - /// `ModelMaterials` — every authored-batch material: creatures, players, GameObjects, the - /// character composites, the booth scenes. + /// `ModelMaterials`: every authored-batch material, units, GameObjects and booth scenes alike. ModelMats, - /// `SkinComposites` — composited character body atlases (decision 0044). + /// `SkinComposites`: composited character body atlases. Skins, - /// `WorldAssets::model_materials` — the ground-clutter material dedup. + /// `WorldAssets::model_materials`: the ground-clutter material dedup. ClutterMats, - /// `WorldAssets::textures` — decoded world BLPs by (path, wrap). + /// `WorldAssets::textures`: decoded world BLPs by (path, wrap). Textures, - /// `ClutterGeometry` — CPU submesh copies of every detail M2 decoded. + /// `ClutterGeometry`: CPU submesh copies of every decoded detail M2. ClutterGeo, } impl ArtSlot { - /// Every slot, in journal-column order. pub const ALL: [ArtSlot; 6] = [ ArtSlot::PlaceMats, ArtSlot::ModelMats, @@ -127,7 +75,6 @@ impl ArtSlot { ArtSlot::ClutterGeo, ]; - /// The journal's column name for this slot. pub(crate) fn column(self) -> &'static str { match self { ArtSlot::PlaceMats => "pmat", @@ -151,9 +98,8 @@ impl ArtSlot { } } -/// Per-cache residency, refreshed by every [`ArtScope::apply`] call — the instrument that localizes -/// a residency ratchet to **one cache** in a single journal row, instead of the run-length probe that -/// found B131. `Assets::len()` says "materials are growing"; this says which map is holding them. +/// Per-cache live and dropped counts, refreshed by every [`ArtScope::apply`], so one journal row +/// shows which cache is growing. #[derive(Resource, Default)] pub struct ArtCensus { live: [usize; ArtSlot::ALL.len()], @@ -161,7 +107,7 @@ pub struct ArtCensus { } impl ArtCensus { - /// Live entries in one cache as of its last sweep. + /// Live entries in one cache as of its last [`ArtScope::apply`]. pub fn live(&self, slot: ArtSlot) -> usize { self.live[slot.idx()] } @@ -175,25 +121,20 @@ impl ArtCensus { /// The sweep's shared state: the radius, this tick's focus, and whether this frame is a sweep frame. #[derive(Resource, Default)] pub struct ArtScopeState { - /// Eviction radius in yards; `0` ⇒ never evict. Zero until [`configure_art_scope`] runs, so - /// nothing can be dropped before the streamer's reach is known. + /// Eviction radius in yards, `0` for none; zero until [`configure_art_scope`] runs. radius: f32, - /// The view focus, wow coords (`crate::terrain_stream::view_focus` — the same ladder the - /// streamer and the WDL ring use). `None` when there is no focus at all (no avatar, no camera), - /// which suspends both stamping and sweeping. + /// The view focus in WoW coords; `None` (no avatar, no camera) suspends stamping and sweeping. focus: Option<[f32; 3]>, /// Set on the one frame per [`SWEEP_SECS`] that sweeps. due: bool, last_sweep: f32, - /// Wall seconds since app start, this frame — the clock the dwell floor is measured on. + /// Real seconds since app start this frame; the cache's dwell floor is measured on it. now: f32, } impl ArtScopeState { - /// The view focus this frame, wow coords — what the sweep measures from. Exposed for the FPS - /// journal: its `x,y,z` are the *avatar*, which stands still through a whole detached free-fly, so - /// on that leg the position columns say nothing about where the art was being asked for. This is - /// the column that does. + /// This frame's view focus in WoW coords, which the sweep measures from. The FPS journal logs + /// it because its avatar columns stand still through a detached free-fly. pub fn focus(&self) -> Option<[f32; 3]> { self.focus } @@ -207,8 +148,7 @@ pub struct ArtScope<'w> { } impl ArtScope<'_> { - /// Sweep one cache (on sweep frames) and record its residency. Safe to call for a cache that is - /// empty, unconfigured, or in a run with eviction switched off — it degrades to a census read. + /// Sweeps one cache on sweep frames and records its residency. pub fn apply(&mut self, cache: &mut SpatialCache, slot: ArtSlot) { let dropped = match self.state.focus { Some(focus) if self.state.due && self.state.radius > 0.0 => { @@ -230,7 +170,6 @@ impl ArtScope<'_> { } } -/// Resolve the radius once at startup (the floor is a constant of the view-distance clamp). fn configure_art_scope(mut state: ResMut) { state.radius = radius_from_env(); if state.radius > 0.0 { @@ -243,8 +182,8 @@ fn configure_art_scope(mut state: ResMut) { } } -/// Publish this frame's focus and decide whether it is a sweep frame. Deliberately unordered against -/// every consumer — see the module docs. +/// Publish this frame's focus and whether it sweeps. Unordered against every consumer: a stamp is +/// at most one sweep stale, and reading `due` a frame early only moves the sweep a frame. fn track_art_scope( mut state: ResMut, time: Res>, @@ -261,8 +200,7 @@ fn track_art_scope( } } -/// Owns the sweep policy + the census. The caches themselves stay private to their modules; each -/// registers its own three-line system that hands its caches to [`ArtScope::apply`]. +/// Owns the sweep policy and the census; each cache's owner calls [`ArtScope::apply`]. pub(crate) struct ArtScopePlugin; impl Plugin for ArtScopePlugin { @@ -278,12 +216,6 @@ impl Plugin for ArtScopePlugin { mod tests { use super::*; - // The cache's own behaviour (stamping, the dwell floor, altitude, handle drop) is tested where - // the cache now lives — `benilla_assets::spatial_cache`. What is left here is the half that - // stayed: how wide the sweep reaches, and that an unconfigured run still bounds itself. - - /// Eviction must never outrun the streamer: the default radius clears the far corner of the - /// widest block the window keeps at the clamp's max view distance. #[test] fn the_radius_clears_the_streamers_own_reach() { assert!( @@ -291,12 +223,11 @@ mod tests { "default {DEFAULT_RADIUS_YD} must clear the floor {}", radius_floor() ); - // (26 + 1 + 16) chunks per axis at 777, the corner of that, plus a tile: ~2560 yd. + // (26 + 1 + 16) chunks each way at farclip 777, to the corner, plus a tile: ~2560 yd. assert!((radius_floor() - 2560.0).abs() < 1.0, "{}", radius_floor()); } - /// With `$WOW_ART_RADIUS` unset (every ordinary run, and the test binary) the radius is the - /// default, floored — never zero, so a shipped build always bounds itself. + /// `$WOW_ART_RADIUS` is unset in the test binary, as in every ordinary run. #[test] fn an_unconfigured_run_still_bounds_itself() { assert_eq!(radius_from_env(), DEFAULT_RADIUS_YD.max(radius_floor())); diff --git a/crates/benilla-world/src/assets/mod.rs b/crates/benilla-world/src/assets/mod.rs index 4d580afb..0af2c64e 100644 --- a/crates/benilla-world/src/assets/mod.rs +++ b/crates/benilla-world/src/assets/mod.rs @@ -1,10 +1,5 @@ -//! The asset foundation's **plugin shell** — the three systems that drive -//! [`benilla_assets::WorldAssets`] from inside the client. -//! -//! The store itself went down to `benilla-assets` (decision 1164); what could not follow it is -//! exactly this: opening the chain needs the shared light buffer (`lighting`), evicting the world -//! art needs the cross-map message (`world_map`), and the residency sweep needs the art-scope -//! instrument. Three upward reaches, all of them here, none of them in the data core. +//! Drives [`benilla_assets::WorldAssets`] from the client: each system needs a client piece (the +//! light buffer, `MapChange`, the art scope), so it lives here rather than in `benilla-assets`. use bevy::prelude::*; use bevy::render::renderer::RenderDevice; @@ -13,8 +8,7 @@ use crate::art_scope::{ArtScope, ArtSlot}; use benilla_assets::{AssetSet, RenderConfig, WorldAssets}; use benilla_formats::open_chain; -/// The asset foundation plugin: opens the **one** patch chain at startup and inserts the shared -/// [`WorldAssets`] + [`RenderConfig`] that the other subsystems build on. +/// Opens the patch chain at startup and inserts the shared [`WorldAssets`] and [`RenderConfig`]. pub(crate) struct AssetPlugin; impl Plugin for AssetPlugin { @@ -24,11 +18,8 @@ impl Plugin for AssetPlugin { } } -/// Drop the world-art dedup on a cross-map transition (`world_map::MapChange` — see its doc for -/// why a clear is always safe): `textures` + `model_materials` pin every map's world art forever -/// otherwise (the teleport leak). The UI sprite caches (`sprites`/`tiled_sprites`/ -/// `portraits`/`masks`) stay — they are game-global UI scope, and their negative entries exist -/// precisely to stop per-frame re-walks of the chain. +/// Clears `textures` and `model_materials` on a map change, or they pin every map's art forever. +/// The UI sprite caches stay: they are global, and their negative entries stop per-frame re-walks. fn evict_world_art( mut changes: MessageReader, assets: Option>, @@ -43,11 +34,8 @@ fn evict_world_art( } } -/// Expire the world-art dedup by **distance** (decision 0793) — the within-map half of the eviction -/// above. `textures` is the one that matters for VRAM: a decoded BLP is pinned by the material that -/// samples it, and a material by this cache, so nothing here dropping is why `images` never fell on a -/// same-map traverse. The UI sprite caches stay unswept for the same reason they survive a map change -/// (game-global scope, and their negative entries exist to stop per-frame chain re-walks). +/// Expires the world-art caches by distance within a map: a cached material pins the decoded BLP it +/// samples, so `textures` is what frees VRAM. The UI sprite caches stay, as on a map change. fn scope_world_art(mut scope: ArtScope, assets: Option>) { if let Some(mut a) = assets { scope.apply(&mut a.model_materials, ArtSlot::ClutterMats); @@ -55,20 +43,11 @@ fn scope_world_art(mut scope: ArtScope, assets: Option>) { } } -/// Open the vanilla patch chain from wherever the install is ([`benilla_formats::wow_data`] — -/// `$WOW_DATA`, the project folder on a dev build, else beside the binary; decision 1175) and -/// insert the shared [`WorldAssets`] (chain + dedup caches) + [`RenderConfig`]. If the client data -/// can't be found or opened, `WorldAssets` is simply absent and downstream startup falls back to -/// an empty free-fly scene. +/// Opens the patch chain found by [`benilla_formats::wow_data`] and inserts [`WorldAssets`] and +/// [`RenderConfig`]; with no install, `WorldAssets` is absent and startup falls back to free-fly. fn open_world_assets(mut commands: Commands, device: Res) { - // The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists - // before any material is built. Inserted FIRST — ahead of the install lookup, so no early return - // below can skip it: it is cloned into `WorldAssets` (for model materials) and read as the - // `SharedLightBuffer` resource by the terrain streamer, the model/particle lanes and the - // render-world upload. Always present — even with no client data — so the render upload has a - // target; harmless if unused. It used to be created *after* the lookup, so a client that found no - // install had no buffer at all and `particles::model::update_model_particles` — a hard - // `Res` — could not validate (decision 1451). + // Inserted before the install lookup so no early return skips it: even with no install, + // `particles::model::update_model_particles` takes it as a hard `Res`. let shared_light = crate::lighting::new_shared_light_buffer(&device); let light_buf = shared_light.0.clone(); commands.insert_resource(shared_light); @@ -79,11 +58,7 @@ fn open_world_assets(mut commands: Commands, device: Res) { ); return; }; - // How much terrain is resident is NOT a knob here: the streamer derives its window from the - // live `farclip` (`view::ViewDistance`, the player's Terrain Distance setting) the way the - // reference does — `terrain_stream::window`, decision 1513. `$WOW_TILE_RADIUS` is retired. - // See the field doc: the tile-unload budget (B181). Default 1 — even the fastest focus - // (boosted free-fly, ~1 stale row/s) produces stale tiles far slower than 60/s drains them. + // Stale tiles released per frame: 1 outpaces even boosted free-fly's stale row a second. let unload_budget = std::env::var("WOW_TILE_UNLOAD") .ok() .and_then(|s| s.parse().ok()) diff --git a/crates/benilla-world/src/bgwin.rs b/crates/benilla-world/src/bgwin.rs index 278c928d..809dd892 100644 --- a/crates/benilla-world/src/bgwin.rs +++ b/crates/benilla-world/src/bgwin.rs @@ -1,49 +1,15 @@ -//! Background instrumented runs: probe/capture/regression windows that open *behind* the -//! director's work — and stay reachable once they're there. +//! Background instrumented runs: a probe or capture window opens behind the user's work without +//! taking focus, then becomes an ordinary window. //! -//! Agent-driven runs (captures, live probes, the rig, FPS journals) open a real window for -//! seconds to minutes on the same desktop the director is working on. Left alone, each launch -//! stole their focus twice over: -//! -//! - **Window-side** — the primary window opened focused (`makeKeyAndOrderFront`), swallowing -//! whatever they were typing (on 2026-07-19 a login-shot run typed their keystrokes into the -//! account box). `Window::focused = false` fixed that for capture/login-shot runs only; every -//! live probe still opened key. -//! - **App-side (macOS)** — winit's AppKit backend unconditionally calls -//! `activateIgnoringOtherApps` at `applicationDidFinishLaunching` and sets the Regular -//! activation policy, so even an unfocused run activated the app: the menu bar switched and -//! the window ordered in **front** of every other app's. bevy_winit 0.18 exposes no -//! `EventLoopBuilderExtMacOS` hook to turn either off, so it can't be prevented — only undone, -//! immediately after launch (decision 0703). -//! -//! Stealing the screen is a **launch-time event**, and only an event's worth of correction is -//! owed. 0703 paid for it with two *permanent* states, and the bill came due: with -//! `WindowLevel::AlwaysOnBottom` (`kCGNormalWindowLevel - 1`) the window could never be raised -//! above a normal one however hard you clicked it, and the Accessory activation policy struck it -//! from Cmd-Tab and the Dock. Every instrumented window was unreachable for its whole life, not -//! just polite at birth. Decision 0709 keeps both mechanisms — they are the right ones, and -//! Accessory in particular is doing more work than it looks (see [`macos::hand_back_activation`]) -//! — but scopes them to the launch instead of the run: -//! -//! [`background_run`] detects an instrumented run from the env that drives it (no recipe or -//! script changes anywhere). `main` opens the window unfocused and below the normal level, so it -//! cannot flash over the director's work on the way up. [`BgWinPlugin`] then holds the launch: -//! Accessory policy, activation handed straight back to whatever they were using, and our window -//! ordered to the **back of the stack** — on screen, so surface rendering, readback and -//! screenshots are untouched, but behind their work. Once the launch goes quiet (half a second -//! with no activation landing on us; hard-capped at three seconds) it lets go in sequence — -//! window level back to Normal, policy back to Regular, one last order-to-back — and latches -//! shut. What's left is an ordinary window that Cmd-Tabs, clicks and raises like any other, for -//! as long as the run lasts. +//! winit's AppKit backend activates the app at launch and bevy_winit 0.18 has no hook to stop it, +//! so `main` opens the window unfocused below the normal level and [`BgWinPlugin`] undoes the +//! launch, then restores the Normal level and Regular policy: left on, `AlwaysOnBottom` keeps the +//! window under every other and Accessory hides it from Cmd-Tab and the Dock. use bevy::prelude::*; -/// Env prefixes that mark a run as agent-driven — the capture harness, shots/smokes, and the -/// live-probe fleet. Deliberately only *run-driving* switches: a plain modifier the director -/// might set on an attended run (`WOW_GM`, `WOW_FARCLIP`, traces…) must NOT push their own -/// window to the bottom of the stack. A new probe env usually starts with `WOW_PROBE`/ -/// `WOW_CAPTURE` and is covered for free; one that doesn't shows up as "the new probe window -/// opened focused" and gets its prefix added here. +/// Env prefixes that mark an instrumented run: only run-driving switches, never a modifier someone +/// sets on an attended run (`WOW_GM`, `WOW_FARCLIP`). A probe env none covers opens focused. const BG_ENV_PREFIXES: &[&str] = &[ "WOW_AUDIT_", "WOW_CAPTURE", @@ -69,20 +35,13 @@ const BG_ENV_PREFIXES: &[&str] = &[ "WOW_PROBE", "WOW_RIG", "WOW_SCHED_CENSUS", - // The one that names the condition directly: `WOW_UNATTENDED` *is* "the chair is empty" - // (1769), and docs/METHOD.md tells every ad-hoc probe to set it. It was missing here for as long - // as this list has existed, so a probe launched exactly the way the method prescribes — but - // under none of the named prefixes — opened focused and borderless-fullscreen over the - // director's work. Reported 2026-09-01 while probing B346. This is the case the list's own - // doc predicts ("one that doesn't shows up as the new probe window opened focused"); it is - // also the strongest signal available, since nothing else asserts nobody is there. + // Set by every unattended run (docs/METHOD.md): the one env that says nobody is at the desk. "WOW_UNATTENDED", "WOW_WORLDVIEW_", ]; -/// Is this an instrumented background run? `WOW_BG=1` forces yes on any run, `WOW_BG=0` forces -/// no (the escape hatch when the director wants to *watch* a probe live); otherwise, auto-detect -/// from the run-driving env ([`BG_ENV_PREFIXES`]). +/// Whether this is an instrumented background run: `WOW_BG=0` forces no (to watch a probe live), +/// any other `WOW_BG` forces yes, and otherwise any [`BG_ENV_PREFIXES`] env counts. pub fn background_run() -> bool { match std::env::var("WOW_BG").as_deref() { Ok("0") => return false, @@ -95,23 +54,12 @@ pub fn background_run() -> bool { }) } -/// Background-run envs whose run produces **no image** — nothing reads the framebuffer, so the -/// window is pure plumbing. Prefix-matched, and the rule in [`no_pixel_run`] is **all-of**: a run -/// counts as no-pixel only when *every* background env it sets is in this list, so a -/// `WOW_PROBE_CHAT=… WOW_LIVE_SHOT=…` pairing (drive there, then shoot) keeps its full-size window. -/// -/// Erring here is cheap in one direction only: a missing entry just means a big window, which is -/// the old behaviour. A *wrong* entry shrinks a window someone is photographing — so a new -/// `WOW_PROBE…`-named env that captures pixels has to be excluded by name here, since the bare -/// `WOW_PROBE` prefix would otherwise sweep it in. +/// Background envs whose run reads no pixels, so its window may be small; [`no_pixel_run`] needs +/// every background env a run sets to be here. A wrong entry shrinks a window being photographed, +/// and the bare `WOW_PROBE` prefix also matches any new `WOW_PROBE…` env that reads pixels. const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ - // Paired with its entry above, and it has to be BOTH or neither: `cine.sh` and - // `summon-live.sh` all set `WOW_UNATTENDED` beside a `WOW_PROBE*`/`WOW_RIG` that IS no-pixel, - // so listing it only as a background env would make it the one non-no-pixel member of every - // such run and the all-of rule would grow all three back to the full 1600x900 that decision - // 1148 shrank. A run whose ONLY background env is this one reads no pixels by construction — - // anything that photographs the frame names itself (`WOW_CAPTURE`, `WOW_LIVE_SHOT`, - // `WOW_*_SHOT`), and none of those are here, so every such pairing keeps its full size. + // In both lists: unattended runs pair it with a no-pixel `WOW_PROBE*` or `WOW_RIG`, which the + // all-of rule would otherwise grow back to full size. A run that photographs names itself. "WOW_UNATTENDED", "WOW_FEED_GATE_CHECK", "WOW_FEED_GATE_TRACE", @@ -121,27 +69,13 @@ const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ "WOW_PROBE", "WOW_RIG", "WOW_SCHED_CENSUS", - // The engine boot check reads no pixels — it reads the ERROR LOG. Its sibling - // `WOW_WORLDVIEW_SHOT` is deliberately absent: that one photographs the frame, and the - // all-of rule below keeps a CHECK+SHOT pairing at full size. + // The engine boot check reads its log; `WOW_WORLDVIEW_SHOT` photographs and stays off. "WOW_WORLDVIEW_CHECK", ]; -/// Does this run read no pixels — i.e. may its window be small and parked out of the way? -/// -/// The point is the director's screen. A probe window is asserted `AlwaysOnTop` for its whole life -/// ([`crate::capture::ProbeFocusPlugin`], decision 0906 — an occluded macOS window throttles to -/// ~1 fps and a wall-clock probe schedule then fires the wrong steps), which silently defeats -/// everything this module does to keep instrumented runs behind the director's work: the level is -/// re-asserted every frame, over the `AlwaysOnBottom` birth cage AND over the `Normal` this module -/// hands back at release. That assertion is correct and stays. What was wrong is pairing it with -/// the **full 1600×900 default**, so every agent probe planted a screen-filling window over -/// whatever they were doing — six of them in one session, which is how it got reported -/// (decision 1148). Un-occludable and unobtrusive were never in conflict: a small window in a -/// corner is both. -/// -/// `WOW_BG=1` (forced background, no run-driving env) is deliberately NOT no-pixel — that is the -/// hand-driven case, and shrinking a window the director asked for is the opposite of the point. +/// Whether this run reads no pixels, so its window may be small and parked. The app pins a probe +/// window `AlwaysOnTop` (an occluded macOS window throttles to about 1 fps), overriding this +/// module's ordering. `WOW_BG=1` with no run-driving env is not a no-pixel run. pub fn no_pixel_run() -> bool { if !background_run() { return false; @@ -160,9 +94,7 @@ pub fn no_pixel_run() -> bool { saw_one } -/// On a [`background_run`], undo winit's launch-time app activation and window ordering (macOS; -/// no-op elsewhere) for the length of the launch window, then get out of the way. The -/// `focused: false` half lives where the window is built, in `main`. +/// On a [`background_run`], undoes winit's launch-time activation and raise (macOS only). pub struct BgWinPlugin; impl Plugin for BgWinPlugin { @@ -192,48 +124,34 @@ mod macos { use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; use objc2_foundation::MainThreadMarker; - /// How quiet the launch has to go before [`hold_background`] calls it settled and lets go: - /// this long with the app continuously inactive. winit's `activateIgnoringOtherApps` is a - /// WindowServer round-trip that can be *granted* well after `PreStartup`'s - /// [`hand_back_activation`] already ran — and a granted activation re-raises our window — so - /// the hold can't be a fixed count; every activation it sees restarts the clock. + /// How long the app must stay inactive before [`hold_background`] lets go; winit's activation + /// is a WindowServer round-trip that can land late, so each one seen restarts this clock. const SETTLE: Duration = Duration::from_millis(500); - /// Hard ceiling on the hold, however unsettled things look. Past this the window is the - /// director's, and an unrecognised activation source is a bug to find, not to fight forever. + /// Hard ceiling on the hold, however unsettled the launch looks. const CEILING: Duration = Duration::from_secs(3); - /// How long to keep watching after the promotion to Regular. Raising the activation policy - /// is the one step that could itself pull the app forward, so the release is not trusted - /// blind — it is checked for a moment, then latched shut. + /// How long to watch after the promotion to Regular, which can itself activate the app. const TAIL: Duration = Duration::from_millis(300); - /// Where the launch correction has got to. The whole plugin is this one-way sequence. + /// Where the launch correction has got to, a one-way sequence. #[derive(Default)] pub(super) enum Phase { /// Undoing winit's activation and raise until the launch goes quiet. #[default] Holding, - /// Quiet reached: the window level has been retargeted at Normal and bevy applies the - /// `setLevel` this frame — which re-orders us to the front of the level we just joined. + /// Quiet: the level is retargeted at Normal, and bevy's `setLevel` this frame puts the + /// window at the front of that level. Promoting, - /// Regular policy restored (Dock icon, Cmd-Tab entry). Watching for a moment in case that - /// promotion pulled the app forward with it. + /// Regular policy restored (Dock icon, Cmd-Tab); watching in case that activated the app. Tail(Duration), /// Hands off, permanently. Done, } - /// Give activation back to the app the director was using. The `NonSendMarker` param pins - /// the system to the main thread (AppKit's requirement). - /// - /// Demoting to the Accessory policy for the launch is load-bearing, and not merely to hide - /// the Dock icon: `deactivate()` on its own does **not** hold a Regular-policy app in the - /// background on macOS 26 — measured, the app took frontmost within 160 ms of launch and kept - /// it for the whole run, which is the 2026-07-19 keystroke-stealing bug exactly. Accessory is - /// what actually keeps us out of the foreground. Its cost — no Cmd-Tab entry — is why it is - /// wrong to *leave* on (that is what stranded the window), so it is scoped to the launch and - /// promoted back to Regular the moment things settle (decision 0709). + /// Demotes the app to the Accessory policy and hands activation back, on the main thread + /// (`NonSendMarker`, AppKit's requirement). Accessory is what holds the app back: on macOS 26 + /// `deactivate()` alone lets a Regular app take frontmost within 160 ms and keep it. pub(super) fn hand_back_activation(_main_thread: NonSendMarker) { let Some(mtm) = MainThreadMarker::new() else { return; @@ -243,18 +161,9 @@ mod macos { let _ = hand_back(&app); } - /// The launch window: keep handing activation back and keep our window at the back of the - /// stack until the launch settles, then let go for good. A no-op thereafter — from that point - /// the window is ordinary, and raising it is between the director and the WindowServer. - /// - /// Deliberately *not* keyed on window focus, which reads like the obvious "the director - /// reached for it" signal and isn't: winit's `window_activation_hack` - /// (`app_state.rs`, `applicationDidFinishLaunching`) calls `makeKeyAndOrderFront` on every - /// visible window regardless of the `focused: false` we asked for, so the launch itself - /// delivers a focus gain on ~frame 2 — and an activation granted later delivers another, as - /// the key window resigns and re-takes key across the app deactivate/activate. Focus cannot - /// tell us apart from the WindowServer, so the hold is timed against quiet instead, and kept - /// short enough that it can't meaningfully fight a director who clicks during it. + /// Hands activation back and orders the window back until the launch settles, then lets go. + /// Timed against quiet, not window focus: winit's `window_activation_hack` makes every visible + /// window key at launch whatever `focused` says, so focus gains come from the WindowServer too. pub(super) fn hold_background( _main_thread: NonSendMarker, time: Res>, @@ -278,17 +187,14 @@ mod macos { && now.saturating_sub(*started) < CEILING { if hand_back(&app) { - // An activation landed (winit's, granted late). Restart the settle clock: - // whatever raised us once can raise us again while the round-trip is - // still in flight. + // A late-granted activation landed: restart the settle clock. *last_active = now; debug!("bgwin: took activation back {now:?} in"); } order_back(&app); return; } - // Quiet. Start letting go — the window level first, retargeted through the - // component so bevy's own cache stays honest about where the window is. + // Quiet: release the level through the component, so bevy's cache stays true. let Ok(mut window) = windows.single_mut() else { return; }; @@ -296,17 +202,14 @@ mod macos { *phase = Phase::Promoting; } Phase::Promoting => { - // bevy applied the `setLevel` at the end of last frame, which put us at the front - // of the normal level; drop straight back to the bottom of it before anything is - // presented on top of the director's work. Then hand the Dock icon and the - // Cmd-Tab entry back. + // bevy's `setLevel` last frame put us at the front of the normal level: order + // back before restoring the Dock icon and Cmd-Tab entry. order_back(&app); app.setActivationPolicy(NSApplicationActivationPolicy::Regular); *phase = Phase::Tail(now); } Phase::Tail(since) => { if hand_back(&app) { - // The promotion pulled us forward after all — put it back. debug!("bgwin: policy promotion activated the app; handed it back"); } order_back(&app); @@ -321,10 +224,9 @@ mod macos { } } - /// Give activation back to whoever had it before we launched. Reports whether there was any - /// to give back — i.e. whether an activation had landed on us since the last look. + /// Deactivates the app if it is active; `true` when an activation had landed on us. fn hand_back(app: &NSApplication) -> bool { - // SAFETY: main thread — every caller takes `NonSendMarker` and checks `MainThreadMarker`. + // SAFETY: main thread; every caller takes `NonSendMarker` and checks `MainThreadMarker`. unsafe { if !app.isActive() { return false; @@ -334,20 +236,11 @@ mod macos { } } - /// Order our windows to the back of the normal window level: on screen (surface rendering, - /// readback and screenshots untouched) but behind the director's work. - /// - /// Plain stacking, not a window level — that is the whole point. `AlwaysOnBottom` pins a - /// window below every normal one *forever*; a window ordered to the back is merely last in - /// line, and comes forward the instant anything raises it. - /// - /// Unconditional for the length of the hold, and deliberately not gated on "the app is - /// active": winit raises the window when it creates it and again in its - /// `window_activation_hack`, both of which can land while we are already inactive, so a gate - /// on activation would leave that raise standing and the window parked on top for the whole - /// run. Repeating it while already at the back is an idempotent no-op. + /// Orders our windows to the back of the normal level, still on screen, where anything can + /// raise them. Not gated on the app being active: winit raises the window at creation and in + /// its activation hack, and either can land while we are inactive. fn order_back(app: &NSApplication) { - // SAFETY: main thread — every caller takes `NonSendMarker` and checks `MainThreadMarker`. + // SAFETY: main thread; every caller takes `NonSendMarker` and checks `MainThreadMarker`. unsafe { for window in app.windows().iter() { window.orderBack(None); diff --git a/crates/benilla-world/src/billboard.rs b/crates/benilla-world/src/billboard.rs index 2924c511..24ab1341 100644 --- a/crates/benilla-world/src/billboard.rs +++ b/crates/benilla-world/src/billboard.rs @@ -1,25 +1,14 @@ -//! M2 billboard cards — submeshes that ride a billboard bone (glow cards, chains, the questgiver -//! markers). The real 1.12 client re-orients the bone to the camera every frame; benilla otherwise -//! renders M2 geometry in its static bind pose, single-sided. +//! M2 billboard cards: submeshes riding a billboard bone (glow cards, chains, the quest markers), +//! turned to the camera every frame as the 1.12 client does. //! -//! **The re-orientation law is byte-pinned**: the M2 bone palette is computed in **VIEW space**, -//! and a billboard bone's matrix rows are -//! REPLACED with the camera basis — spherical (`0x08`) takes the whole fixed basis (bone X toward -//! the viewer, Y screen-right, Z screen-up: the identity rows `{(0,0,−1),(1,0,0),(0,1,0)}` at -//! `0x714463`); the lock arms (`0x10`/`0x20`/`0x40` = keep X/Y/Z) keep their authored axis and -//! rebuild the other two from the camera (`0x40` lock-Z — the `?` marker's `0x240` — keeps model -//! up, rebuild the in-plane pair). Crucially this is the **view-matrix basis, one shared -//! orientation for every billboard** — NOT a per-pivot aim, and NOT the geometry's facet normal -//! (the old card aimed its first-triangle normal at the camera: arbitrary for 3-D geometry like -//! the 353-vert `?`, which is exactly why its proportions read wrong). The lock-Z in-plane sign -//! is `Y = Fwd × Z` — the 0168 handedness residual, settled by the director's A/B (the recorded -//! `Z × Fwd` order turned the model 180°: a mirrored `?`); it makes lock-Z agree with the -//! spherical arm's toward-the-viewer X at a level camera. +//! The reference computes the bone palette in view space and replaces a billboard bone's rows with +//! the camera basis. Spherical (`0x08`) takes the whole basis, bone X toward the viewer, Y +//! screen-right, Z screen-up (the rows `{(0,0,−1),(1,0,0),(0,1,0)}` at `0x714463`); lock-X/Y/Z +//! (`0x10`/`0x20`/`0x40`) keeps its authored axis and rebuilds the other two from the camera. It +//! is one shared orientation for every billboard, never a per-pivot aim or a facet normal. //! -//! The submesh mesh is built **centred at its bone pivot** (`benilla_assets::build_submesh_mesh`) -//! in the model-local Bevy frame — where the WoW bone axes land as X→−Z, Y→−X, Z→+Y (coords.rs) — -//! so we place the entity at the pivot's world position and write the rebuilt basis as its -//! rotation each frame; the geometry itself is never touched. +//! A card's mesh is centred at its bone pivot in model-local Bevy axes (WoW X→−Z, Y→−X, Z→+Y), so +//! the entity sits at the pivot with the rebuilt basis as its rotation. use benilla_assets::BillboardInfo; use benilla_formats::{BillboardKind, BoneScaleAnim}; @@ -29,73 +18,41 @@ use bevy::prelude::*; use crate::view::WorldCamera; -/// A spawned billboard card: where its pivot sits in the world, its per-axis scale, how -/// it tracks the camera (the bone-flag arm), and its optional global-sequence scale pulse. The -/// per-frame system rewrites the entity transform from these — including `Visibility` (the -/// hidden-owner mirror), so a card requires it rather than trusting every spawn site's `Mesh3d` -/// to bring it along. -/// -/// It requires a [`MeshTag`] for the same reason: a card is a world ROOT, so every per-model alpha -/// that reaches an ordinary submesh by descending the model's tree has to reach a card through its -/// own tag instead (`player::apply_self_model_fade` — the zoom-to-first-person feather). Only the -/// alpha-animated spawn sites used to bring one, which left the channel *incidentally* present; -/// the default `MeshTag(0)` is the shader's untagged-⇒-opaque sentinel, so requiring it changes -/// nothing about how a card draws. +/// A spawned billboard card: world pivot, per-axis scale, bone-flag arm and scale pulse. It needs +/// a [`MeshTag`] as a world root: a per-model alpha (the self-model fade) reaches it through its +/// own tag, and the default `MeshTag(0)` is the shader's untagged, opaque sentinel. #[derive(Component)] #[require(Transform, Visibility, MeshTag)] pub struct BillboardCard { world_pivot: Vec3, - /// The card's scale in its OWN (pre-billboard) frame — the joint's/placement's per-axis scale, - /// applied before the camera basis, exactly as [`billboard_joint_palette`] applies it to a - /// billboard JOINT (`Transform { rotation: camera_basis, scale, .. }` = `T·R_cam·S`). It is a - /// `Vec3` and not a scalar because real content animates a billboard bone **non-uniformly**: - /// the Lightwell's shaft (`World\Goober\G_HolyLightWell.m2`, bone 0, lock-Z) holds - /// `(8.808, 8.808, 37.560)` through its whole Stand loop, a 0.12 yd card stretched into a - /// 4.5 yd column of light. Collapsing that to `scale.x` and splatting it — which every lane - /// did until 2026-08-25 — rendered the shaft 1.06 yd tall instead: a squat, blown-out card - /// sitting on the well's own bowl and drowning it (bug B169, "Lightwell renders very buggy"). + /// Per-axis scale in the card's own frame, applied before the camera basis (`T·R_cam·S`) as + /// the joint palette applies it. Not a scalar: the Lightwell's shaft + /// (`World\Goober\G_HolyLightWell.m2` bone 0) holds `(8.808, 8.808, 37.560)`. scale: Vec3, kind: BillboardKind, - /// The billboard bone's looping scale animation (the lamppost glow "breathe"), sampled each frame - /// and multiplied into [`Self::scale`]. `None` for a static card (no global-sequence scale track). + /// The bone's global-sequence scale loop (a lamppost glow's breathe), times [`Self::scale`]. scale_anim: Option, - /// The armed-sequence cursor offset (ms, negated so a wrapping ADD subtracts): sampling the - /// [`Self::seq_translation`] loop runs on `elapsed − arm_ms` — the reference's per-play - /// phase for SEQUENCE tracks (`cursor = clock − startOffset`, re-baked at every arm). `0` - /// until [`Self::arm_seq_translation`] arms a loop. + /// The arm time, negated so a wrapping add subtracts: [`Self::seq_translation`] samples at + /// `elapsed − arm_ms`, re-armed per play like the reference's sequence tracks. arm_neg_ms: u32, - /// The gseq [`Self::scale_anim`]'s ATTACH anchor (ms): `None` until the first placement pass - /// stamps it — the reference snapshots the scene clock once per model instance at attach - /// (`CM2Model+0x68`; decision 0856), so a row of lampposts streamed - /// in on different frames breathes at per-instance phases, while same-frame spawns share - /// one. Distinct from [`Self::arm_neg_ms`]: the gseq anchor is stamped once per instance, - /// the sequence cursor re-arms per play. (An earlier position-hash de-sync here emulated - /// the per-instance spread with an invented mechanism; 0855 briefly removed phase entirely — - /// both superseded by the byte law.) + /// The global-sequence clock's attach anchor (ms), stamped on the first placement pass and + /// never re-armed: the reference snapshots the scene clock once per instance (`CM2Model+0x68`) + /// and samples at `sceneNow − attach`, so cards attached on different frames breathe apart. gseq_attach_ms: Option, - /// The bone's armed first-sequence **translation** loop (the questgiver `?` marker's bob, keys in - /// Bevy axes) — sampled each frame on the same clock/phase and added at the pivot, rotated by - /// [`Self::placement_rot`]. `None` (every doodad card today) = the static pivot; only the marker - /// spawn site arms it via [`Self::with_seq_translation`] — the doodad half of that ride belongs - /// to the 0130 phase-4 bone-follow work. + /// The bone's armed first-sequence translation loop (a quest marker's bob, Bevy axes), added + /// at the pivot and turned by [`Self::placement_rot`]. Only the marker spawn site arms one. seq_translation: Option, /// The placement's rotation, so the bob offset (model-local) points where the instance points. placement_rot: Quat, - /// The entity this card FOLLOWS (a unit/GameObject anchor or held-item root): the facing system - /// re-seats the card from its live `GlobalTransform` every frame and despawns the card when it - /// goes — the ONE mechanism for every non-doodad spawn path (braziers, held torches, missiles), - /// so a glow card can never again render at the model origin because a spawn site forgot the - /// pivot (the recurring "glow on the ground" family — decision 0153). `None` = fixed placement - /// (terrain doodads, whose transform never moves). + /// The entity this card follows, re-seated from it every frame and despawned with it (a unit + /// or GameObject anchor, a held-item root); `None` for a fixed placement. follow: Option, /// The pivot in the model's local Bevy frame (re-applied each frame when `follow` is set). local_pivot: Vec3, } impl BillboardCard { - /// Build a card from a submesh's [`BillboardInfo`] and its instance `placement`. The pivot is placed - /// in the world; the card's orientation ignores the placement rotation (a billboard faces the camera - /// regardless of how the prop is turned). + /// A card for a submesh's [`BillboardInfo`] at a fixed instance `placement`. pub fn new(info: &BillboardInfo, placement: Transform) -> Self { let world_pivot = placement.transform_point(info.pivot); Self { @@ -112,27 +69,16 @@ impl BillboardCard { } } - /// Build a card that FOLLOWS `owner` — the entity-path form (creatures, GameObjects, held - /// items, missiles, spell effects): world pivot/scale/rotation are re-derived from the owner's - /// live `GlobalTransform` every frame, and the card despawns when the owner goes. + /// A card following `owner` (a creature, GameObject, held item, missile or spell effect). pub fn following(info: &BillboardInfo, owner: Entity) -> Self { let mut card = Self::new(info, Transform::IDENTITY); card.follow = Some(owner); card } - /// Build a card riding a live JOINT — an animated host's billboard bone (the swinging lamp, - /// the mount's lights). The joint's frame already bakes the bone pivot (the 0130 rig identity - /// `joint = root · M_bone · T(pivot)`), so the card's local pivot is the joint origin. - /// - /// The joint also already carries the bone's global-sequence scale — every joint/anchor lane - /// runs a [`crate::rig_anim::GlobalSeqDrive`] over the same bone list, and `re_place` - /// reads the composed result back as [`Self::scale`] — so the card must NOT sample its own - /// copy of the track. Keeping it multiplied the twinkle in twice, at two different clocks - /// (the drive's spawn clock × the card's position-hash phase): squared peaks at random - /// alignment — Arcane Intellect's sometimes-2.5-yd lens flare (decision 0851). The sampler - /// stays on the rigless lanes ([`Self::new`]/[`Self::following`]), where the card is the only - /// thing animating. + /// A card on an animated host's billboard joint (a swinging lamp, a mount's lights). The joint + /// already bakes the pivot and the bone's global-sequence scale + /// ([`crate::rig_anim::GlobalSeqDrive`]), so the card samples none: that would twinkle twice. pub fn following_joint(info: &BillboardInfo, joint: Entity) -> Self { let mut card = Self::following(info, joint); card.local_pivot = Vec3::ZERO; @@ -140,19 +86,9 @@ impl BillboardCard { card } - /// A card with **no geometry** — a pure billboard *frame*: an entity whose live transform is a - /// billboard bone's replaced palette matrix (pivot in the world, camera basis as the rotation), - /// for the other consumers of that matrix to ride. Today's one caller is the equipped-item - /// emitter lane (`entities::equipment::spawn`): an item model spawns no rig, so nothing else - /// would apply the replacement to a particle emitter hanging under a billboard bone — and the - /// reference folds the emitter's record position through exactly this matrix - /// (`0x7190a9`–`0x71910c`). - /// - /// It is the same mechanism as a card and deliberately not a second one (decision 0153's rule): - /// same basis, same pivot law, same follow/despawn contract, one system. A card without a - /// `Mesh3d` simply draws nothing while its transform is maintained. - /// - /// `pivot` is model-local **Bevy** axes (the frame `owner`'s children live in). + /// A card with no mesh: a billboard bone's replaced palette matrix as a live transform, for an + /// item's particle emitter to ride (an item spawns no rig); the reference folds the emitter's + /// position through this matrix (`0x7190a9`-`0x71910c`). `pivot` is model-local Bevy axes. pub fn frame_following(kind: BillboardKind, pivot: Vec3, owner: Entity) -> Self { Self { world_pivot: Vec3::ZERO, // re-seated from the owner before the first facing write @@ -168,17 +104,15 @@ impl BillboardCard { } } - /// Arm the card's first-sequence translation loop (the questgiver `?` bob) with the client's - /// arm-time cursor: sampling runs on `elapsed − arm_ms` (the loop starts at its first key the - /// moment the marker attaches, like the real arm at status receive). + /// Arms the first-sequence translation loop (a quest marker's bob) at `arm_ms`, so it opens on + /// its first key, as the reference arms it when the quest status arrives. pub fn with_seq_translation(mut self, anim: Option, arm_ms: u32) -> Self { self.arm_seq_translation(anim, arm_ms); self } - /// Re-arm the translation loop on a LIVE card — the marker swapping between its low (anim 0) - /// and raised (anim 190) bob when the unit's overhead name toggles: fresh cursor, same law as - /// [`Self::with_seq_translation`]. + /// Re-arms the loop on a live card: the marker swaps between its low (anim 0) and raised + /// (anim 190) bob when the unit's overhead name toggles. pub fn arm_seq_translation(&mut self, anim: Option, arm_ms: u32) { if anim.is_some() { self.arm_neg_ms = arm_ms.wrapping_neg(); @@ -186,23 +120,14 @@ impl BillboardCard { self.seq_translation = anim; } - /// The entity this card follows, if any — the anchor/joint that decides both where it sits and - /// which model it BELONGS to. A card is a world root, so a system that walks a model's tree - /// (the self-avatar fade; the light node's shade push in `entity_shade`) can only recognise the - /// model's own cards by testing this against the entities it walked. `None` = a fixed terrain - /// doodad's card, whose shade rides its material selector instead. - /// - /// It is also the entity whose `InheritedVisibility` decides whether an entity-lane card draws - /// at all — a world root inherits nothing, so its model's hide has to be *read*, by the one - /// `Visibility` authority (`model_render::visibility`, decision 1409). + /// The entity this card follows. A card is a world root, so a walk over its model's tree (the + /// self fade, the shade push) recognises it through this, and the visibility authority + /// (`model_render::visibility`) draws it only while this entity is visible. pub fn follows(&self) -> Option { self.follow } - /// Re-seat a card that FOLLOWS something (the questgiver `!`/`?` markers over a unit that can - /// move) — recompute the world pivot/scale/rotation from a fresh `placement`, keeping the card's - /// orientation kind, rest normal, and animation phase. Doodad cards never need this (their - /// placement is fixed at spawn). + /// Re-seats the card from a fresh `placement` (a quest marker over a moving unit). pub fn re_place(&mut self, placement: Transform, local_pivot: Vec3) { self.world_pivot = placement.transform_point(local_pivot); self.scale = placement.scale; @@ -210,25 +135,12 @@ impl BillboardCard { } } -/// A bone's rewritten **effective parent matrix** — the `flags & 0x7` arm the reference takes at -/// `0x71496d`–`0x714d0c`, before the billboard selector and before the bone's own TRS -/// composes onto it. This is not an -/// escape hatch from the billboard: it changes the input the billboard law is applied to, and the -/// `&0x78` switch runs afterwards exactly as before. -/// -/// - `parent` — the bone's ANIMATED parent world matrix (`palette[parent_bone]` before the rewrite). -/// - `root` — the model's own root frame, `world_from_model` (`[model+0xfc]`): for a rigged host -/// that is its `joints_root`, and for a mounted rider that is its seat anchor, which is exactly -/// what the reference composes at `0x714389`. -/// - `pivot` — the bone's BIND local translation (`pivot_i − pivot_parent`). Our joint chain is -/// pivot-relative, so this plays the role of the byte law's model-space `piv` in the -/// pivot-preserving tail `T' = pivotWorld − piv·newBasis`: the bone keeps the POSITION its -/// animated parent carried it to and loses only the orientation. That is the whole reason a -/// galloping mount still carries its rider up and down while never rocking them. -/// -/// The reference is row-major with row vectors, so its "row K" is our column K — both name the -/// image of model basis vector K, and the WoW→Bevy bake is a signed permutation of those axes, so -/// the per-axis legs below pair the same axes the bytes do. +/// A bone's rewritten parent matrix, the `flags & 0x7` arm (`0x71496d`-`0x714d0c`), taken before +/// its own TRS and the `& 0x78` billboard switch, which still runs. `root` is the model's frame +/// `[model+0xfc]` (composed at `0x714389`; a rider's seat anchor), `pivot` the bone's bind local +/// translation. The tail `T' = pivotWorld − piv·newBasis` keeps where the animated parent carried +/// the bone and drops only its orientation, so a mount carries its rider without rocking them. +/// The reference's row K is our column K, both the image of basis vector K. pub(crate) fn parent_arm_matrix( arm: benilla_formats::ParentArm, parent: Affine3A, @@ -236,15 +148,15 @@ pub(crate) fn parent_arm_matrix( pivot: Vec3, ) -> Affine3A { use benilla_formats::ParentBasis; - // `0x714bdb`'s unit guard: a degenerate axis is left alone rather than exploding. + // `0x714bdb`'s unit guard: a degenerate axis is left as it is. const UNIT_EPS: f32 = 1.0 / (1 << 22) as f32; - // `0x80c5c8`, the ratio leg's own constant — deliberately NOT the unit eps above. + // `0x80c5c8`, the ratio leg's own constant, not the unit eps above. const RATIO_EPS: f32 = 1e-5; let (p, r) = (parent.matrix3, root.matrix3); let per_axis = |f: &dyn Fn(usize) -> Vec3A| Mat3A::from_cols(f(0), f(1), f(2)); let matrix3 = match arm.basis { ParentBasis::Keep => p, - // `flags & 6 == 2` — ignore parent scale: unit-length axes, directions kept. + // `flags & 6 == 2`, ignore parent scale: unit-length axes, directions kept. ParentBasis::UnitNormalize => per_axis(&|k| { let len = p.col(k).length(); if len > UNIT_EPS { @@ -253,8 +165,7 @@ pub(crate) fn parent_arm_matrix( p.col(k) } }), - // `flags & 6 == 4` — ignore parent rotation: the ROOT's direction at the PARENT's - // magnitude, per axis. + // `flags & 6 == 4`, ignore parent rotation: the root's direction at the parent's length. ParentBasis::RootDirection => per_axis(&|k| { let rl2 = r.col(k).length_squared(); let ratio = if rl2 <= RATIO_EPS { @@ -264,7 +175,7 @@ pub(crate) fn parent_arm_matrix( }; r.col(k) * ratio }), - // `flags & 6 == 6` — ignore parent rotation AND scale: the root basis outright. + // `flags & 6 == 6`, ignore parent rotation and scale: the root basis outright. ParentBasis::RootBasis => r, }; Affine3A { @@ -279,12 +190,9 @@ pub(crate) fn parent_arm_matrix( } } -/// The rebuilt orientation for a billboard of `kind` — the byte law (module doc), one function for -/// both consumers: the CARD path (`kept_rot` = the placement/owner rotation) and the JOINT palette -/// pass below (`kept_rot` = the joint's fully-composed pre-billboard world rotation). `bx/by/bz` -/// are the bone's WoW-frame X/Y/Z axes as world directions after the replacement; the returned quat -/// maps the mesh's model-local Bevy frame onto them (WoW axes sit in that frame as X→−Z, Y→−X, Z→+Y -/// — coords.rs — so local X→−by, Y→bz, Z→−bx). +/// The rebuilt orientation for a billboard of `kind`, for cards and joints alike; `kept_rot` is +/// the pre-billboard rotation a lock arm keeps its axis from. `bx/by/bz` are the bone's WoW axes +/// after the replacement, and Bevy local X/Y/Z maps onto `−by`/`bz`/`−bx`. pub fn billboard_basis( kind: BillboardKind, kept_rot: Quat, @@ -293,27 +201,20 @@ pub fn billboard_basis( up: Vec3, ) -> Quat { let (bx, by, bz) = match kind { - // Spherical (`0x08`): the whole fixed basis — X toward the viewer, Y screen-right, - // Z screen-up (the view-space identity rows). + // Spherical (`0x08`): X toward the viewer, Y screen-right, Z screen-up. BillboardKind::Spherical => (-fwd, right, up), - // Lock-Z (`0x40` — the `?` marker, the frost-armor sheets): keep the authored bone Z - // (model up, pointed by `kept_rot`), rebuild the in-plane pair from the camera. The - // in-plane sign is `Y = Fwd × Z` — the 0168 residual, settled by the director's A/B - // (the other order showed the model's back: a mirrored `?`); this order also agrees - // with the spherical arm at a level camera (X toward the viewer, Y screen-right), the - // coherence the flipped version lacked. A camera looking straight along the kept axis - // degenerates the cross — hold screen-right then. + // Lock-Z (`0x40`, the `?` marker): keep the authored Z, rebuild the in-plane pair. + // `Y = Fwd × Z`, as the other order mirrors the model, and it agrees with the spherical + // arm at a level camera. Looking along Z degenerates the cross: Y holds screen-right. BillboardKind::LockZ => { let bz = (kept_rot * Vec3::Y).normalize_or(Vec3::Y); let by = fwd.cross(bz).try_normalize().unwrap_or(right); let bx = by.cross(bz); (bx, by, bz) } - // Lock-X/-Y: the same verified structure generalized per kept axis — the cyclically - // PREVIOUS axis takes `Fwd × kept` (that assignment is what reproduces the settled - // lock-Z arm), the third completes the right-handed WoW triple. No shipped content has - // A/B'd these two arms yet; if a chain/rope ever reads mirrored, the sign here is the - // one knob (0168's pattern). + // Lock-X/Y: lock-Z's structure per kept axis, the cyclically previous axis taking + // `Fwd × kept` and the third completing the right-handed WoW triple. Their in-plane sign + // follows lock-Z's by analogy, not from the reference. BillboardKind::LockX => { let bx = (kept_rot * -Vec3::Z).normalize_or(-fwd); let bz = fwd.cross(bx).try_normalize().unwrap_or(up); @@ -330,42 +231,30 @@ pub fn billboard_basis( Quat::from_mat3(&Mat3::from_cols(-by, bz, -bx)) } -/// A rigged host whose skeleton authors billboard bones (component beside the rig's -/// `AnimationPlayer`): the joint entities in bone order, each bone's parent, and which joints -/// billboard. [`billboard_joint_palette`] rewrites those joints' propagated world rotations to -/// the camera basis every frame — the byte law operates on the BONE PALETTE, where children -/// multiply onto the replaced parent matrix (`0x7151ba`), so geometry -/// skinned to a billboard bone's CHILDREN inherits the facing. The per-batch card split can -/// never catch that case: the frost-armor sheets skin every vertex to the scale-in CHILD of the -/// lock-Z bone, which is exactly why they rendered glued to the character. +/// A rigged host's billboard and parent-arm bones, beside its `AnimationPlayer`. The reference +/// billboards the bone palette, where children multiply onto the replaced matrix (`0x7151ba`), so +/// geometry skinned to a billboard bone's child (the frost-armor sheets) inherits the facing. #[derive(Component)] pub struct BillboardJointRig { - /// The host root entity — the model's own root frame `[model+0xfc]`, which the `flags & 0x7` - /// arm rebuilds a bone's parent matrix out of. + /// The host root, the model's own frame `[model+0xfc]` that the `flags & 0x7` arm builds from. root: Entity, joints: Vec, parents: Vec, kinds: Vec>, - /// Bone flags `0x1/0x2/0x4` per joint ([`parent_arm_matrix`]) — the HandArrow/Bullet attach - /// helpers (the nocked arrow lies flat along the facing instead of twisting with the draw - /// hand) and every vanilla mount's rider seat. + /// Bone flags `0x1/0x2/0x4` per joint: the HandArrow/Bullet helpers, every mount's rider seat. arms: Vec>, - /// Each bone's BIND local translation — the pivot the arm preserves. `locals` carry the - /// ANIMATED translation, which the byte law rotates by the *new* basis, so the two are not - /// interchangeable here. + /// Each bone's bind local translation, the pivot the arm preserves; the animated local is not + /// interchangeable, as the reference rotates it by the new basis. binds: Vec, } impl BillboardJointRig { - /// The host root — the collapsed-rig world pass's do-not-enter set reads it (a nested rig - /// with its own billboard output owns its interior, whichever lane the outer rig is on). + /// The host root: the collapsed-rig world pass leaves a nested rig's interior to it. pub(crate) fn root(&self) -> Entity { self.root } - /// Build for a spawned rig — `None` when the skeleton authors neither a billboard bone nor a - /// `flags & 0x7` bone (the common case: ordinary rigs cost nothing). `root` is the host entity - /// the joints hang under (the model-space frame). + /// For a rig under host `root`; `None` when no bone billboards or has a `flags & 0x7` arm. pub fn new( skeleton: &benilla_assets::ModelSkeleton, joints: &[Entity], @@ -393,34 +282,17 @@ impl BillboardJointRig { } } -/// The palette half of the billboard law: for each rigged host, replace every billboard joint's -/// world rotation with the camera basis (scale and pivot translation preserved — the reference's -/// default tail `0x715868`, which in our rig identity is simply "keep the joint's global -/// scale/translation"), then re-compose every descendant joint from its local TRS so skinned -/// geometry — and emitters/ribbons riding those joints — inherit the facing. Runs after propagation -/// and writes `GlobalTransform` directly (the same exactness argument as [`face_billboards`], which -/// must run after this so following-joint cards read the replaced frames). **Every palette consumer -/// must read AFTER this system, same frame**: avian's physics sync re-propagates the hierarchy from -/// locals inside the fixed loop, so an Update-time read gets the UN-billboarded pose — the Demon -/// Skin flames followed the character's yaw instead of the camera until the particle/ribbon sims -/// moved behind this pass. Bone order is parent-sorted in every real M2 (the format guarantees -/// parent < child); a malformed child whose parent follows it just keeps its propagated pose. -/// -/// A rigged model can hang under ANOTHER rig's joint — a spell-effect instance on a unit's -/// attach-helper bone, a rigged held item in a hand. One ownership law keeps the passes from -/// fighting over those frames: the child-recompose walk **never enters a nested rig's subtree** -/// — not even its root, whose propagated global (the live ANIMATED attach-bone frame) is what -/// its emitters' attach frame must read. Without it, the boar's flag-0x04 attach helper -/// re-composed the Eviscerate impact model's frames from raw locals, erasing its camera-born -/// billboard basis or its animated attach rotation depending on per-launch query order — the -/// burst rendered as a body-locked pillar on some launches and correctly on others. With no rig -/// ever writing into another rig's subtree, the passes are order-independent again. +/// The palette half of the billboard law: each billboard joint's world rotation becomes the camera +/// basis, keeping scale and translation (the reference's tail `0x715868`), and its descendants +/// re-compose on it, so skinned geometry and riding emitters inherit the facing. Every palette +/// consumer must read after this pass: avian's physics sync re-propagates from locals in the +/// fixed loop, so an Update read sees the un-billboarded pose. The walk never enters a nested rig, +/// not even its root (a spell effect on an attach bone), so no rig overwrites another's frames. +/// M2 bones are parent-sorted; a malformed child whose parent follows it keeps its propagated pose. pub fn billboard_joint_palette( cam: Query<&GlobalTransform, With>, hosts: Query<&BillboardJointRig>, - // A parked unit's pose is frozen off-frustum (decision 0448) — camera-facing its glow joints - // would re-dirty the subtree for a rig no one sees. A parked host still sits in the - // do-not-enter set (its propagated frames are real); it just isn't re-faced. + // A parked host (frozen off-frustum) is not re-faced but stays in the do-not-enter set. parked: Query>, mut joints: Query<(&Transform, &mut GlobalTransform), Without>, children: Query<&Children>, @@ -429,15 +301,14 @@ pub fn billboard_joint_palette( return; }; let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up()); - // Every rig root — the walk's do-not-enter set. + // Every rig root: the walk's do-not-enter set. let rig_roots: bevy::platform::collections::HashSet = hosts.iter().map(|r| r.root).collect(); for rig in hosts .iter() .filter(|r| !parked.get(r.root).unwrap_or(false)) { - // The model's own root frame `[model+0xfc]` — what the `flags & 0x7` arm rebuilds a - // parent matrix out of. Read before the joint loop (the root is never a joint). + // The model's own frame `[model+0xfc]`, which the `flags & 0x7` arm builds from. let root_affine = joints .get(rig.root) .map(|(_, g)| g.affine()) @@ -449,10 +320,9 @@ pub fn billboard_joint_palette( let parent_new = pidx.and_then(|p| replaced[p]); let arm = rig.arms[i]; if parent_new.is_none() && rig.kinds[i].is_none() && arm.is_none() { - continue; // untouched subtree — the propagated pose stands + continue; // untouched: the propagated pose stands } - // The arm needs the PARENT's matrix in hand; propagation only left us the child's, so - // an armed bone whose parent was untouched reads the parent's propagated frame here. + // An armed bone under an untouched parent reads the parent's propagated frame. let parent_world = if arm.is_some() { parent_new.or_else(|| { let e = pidx.map_or(rig.root, |p| rig.joints[p]); @@ -465,8 +335,7 @@ pub fn billboard_joint_palette( continue; }; let mut g = match (arm, parent_world) { - // `flags & 0x7`: rewrite the parent matrix, then compose this bone's own TRS onto - // it — and fall through to the billboard switch, which still runs. + // `flags & 0x7` rewrites the parent; the billboard switch below still runs. (Some(a), Some(pw)) => GlobalTransform::from(parent_arm_matrix( a, pw.affine(), @@ -488,10 +357,8 @@ pub fn billboard_joint_palette( replaced[i] = Some(g); *global = g; } - // Rigid children hanging under a rewritten joint (a held item, the nocked arrow) got - // their globals from ordinary propagation — BEFORE this rewrite. Re-compose those - // subtrees from the replaced frames; sibling JOINTS are excluded (the replaced-chain - // above owns them). Skinned geometry never needs this — it reads the joint frames. + // Rigid children under a rewritten joint (held items, the nocked arrow) were propagated + // before the rewrite: re-compose them, skipping joints, which the loop above owns. let joint_set: bevy::platform::collections::HashSet = rig.joints.iter().copied().collect(); let mut stack: Vec<(Entity, GlobalTransform)> = Vec::new(); @@ -522,27 +389,15 @@ pub fn billboard_joint_palette( } } -/// The billboard placement pass (PostUpdate, after `TransformSystems::Propagate`, before -/// visibility) — [`billboard_joint_palette`] then [`face_billboards`] run here, and upstream -/// card re-seaters (the quest markers) order `.before` it. Placement reads the SAME-frame -/// propagated pose: running in Update read last-frame joint/owner globals, so a card over a -/// moving unit trailed a frame behind and snapped forward on stop (the nameplate lag's sibling). +/// The billboard placement pass: PostUpdate, after transform propagation and before visibility, +/// so placement reads this frame's pose. Card re-seaters (the quest markers) order before it. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct BillboardPlace; -/// Per-frame: face each billboard card to the camera (around its pivot) and apply its scale pulse. -/// A FOLLOWING card (entity-path) is first re-seated from its owner's live global transform — and -/// despawned when the owner is gone (streamed out, unequipped, died). Runs in [`BillboardPlace`] -/// (post-propagation), so it writes `GlobalTransform` directly alongside `Transform` — cards -/// write ABSOLUTE world transforms and live at the root/identity, so the direct write is exact. -/// -/// It does NOT own a card's `Visibility` — [`crate::model_render::visibility`] does, for every -/// card as for every other `ModelPart` (decision 0025). This system used to mirror a hidden owner -/// here as well, and that write was **dead**: [`BillboardPlace`] is only ordered -/// `before(CheckVisibility)`, not before `VisibilityPropagate`, so the `Hidden` it wrote landed -/// after the propagation that would have consumed it and was overwritten in the next frame's -/// `Update` by the authority — for a year, a GameObject's glow card went on burning over its own -/// culled model (decision 1409). +/// Faces each card to the camera about its pivot and applies its scale pulse, first re-seating a +/// following card from its owner, or despawning it with the owner. Cards are world roots, so the +/// direct `GlobalTransform` write is exact. A `Visibility` write here would be lost: this set is +/// not ordered before visibility propagation, and [`crate::model_render::visibility`] owns it. #[allow(clippy::type_complexity)] // the card tuple, commented inline pub(crate) fn face_billboards( mut commands: Commands, @@ -564,22 +419,13 @@ pub(crate) fn face_billboards( let Ok((cam_tf, cam_local)) = cam.single() else { return; }; - // The camera basis — the VIEW-MATRIX axes the byte law substitutes (one shared orientation - // for every billboard; never a per-pivot aim). - // The propagated frame OR the seat's own write: a teleport frame moves the local before - // propagation runs (see doodad_anim's gate), and the old read carried a pre-snap placement - // across the snap. + // A teleport frame moves the seat's local before propagation, so either change counts. let cam_moved = cam_tf.is_changed() || cam_local.as_ref().is_some_and(|l| l.is_changed()); let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up()); let elapsed_ms = time.elapsed().as_millis() as u32; for (entity, mut card, mut tf, mut global, tag) in &mut cards { - // A card with no track is a pure function of the camera basis and its owner's frame: - // when neither moved this frame, the placement below would come out bit-identical - // (the write guards at the end already knew that; the recompute did not — 1979's - // floor, every resident card re-placed on a still frame). - // …and a card that has never been placed (the first-pass stamp is what says so): it - // spawned unrotated at scale one, and a still frame must not leave it that way - // (review of 2026-09-04 — a tile bursting in under a parked camera). + // A track-less card is a pure function of the camera and its owner, so it is skipped + // when neither moved, unless it has never been placed (no attach stamp yet). let tracked = card.scale_anim.is_some() || card.seq_translation.is_some() || card.gseq_attach_ms.is_none(); @@ -591,16 +437,9 @@ pub(crate) fn face_billboards( } let pivot = card.local_pivot; card.re_place(gt.compute_transform(), pivot); - // The card-provenance trace (`WOW_MOVE_TRACE`, ~2 Hz): where each following - // card sits, how big it renders, and at what alpha. An invisible spell-fx - // card has exactly three possible causes — placement, scale, alpha — and a - // screenshot cannot tell them apart; this line does (it split Arcane - // Intellect's "missing" stars into scale ✓ / place ✓ / alpha ×0.3 — the - // faithful stealth-aura compose, not a defect). - // `enabled_for`, not `enabled`: this is by far the busiest tag in the file - // (thousands of lines a second in a populated scene, each an unbuffered write - // under the shared mutex), so it is the one most worth dropping cheaply when the - // run is asking a movement question — `WOW_MOVE_TRACE_TAGS`, decision 0880. + // The `card` trace (`WOW_MOVE_TRACE`, ~2 Hz): each following card's place, + // scale and alpha, the three causes of an invisible card. `enabled_for`, so + // `WOW_MOVE_TRACE_TAGS` drops this busiest tag cheaply. if benilla_assets::trace::enabled_for("card") && elapsed_ms % 512 < 20 { benilla_assets::trace::line( "card", @@ -621,21 +460,15 @@ pub(crate) fn face_billboards( } else if !tracked && !cam_moved { continue; } - // The gseq attach anchor: stamped on the card's first placement pass — the reference's - // once-per-instance scene-clock snapshot (decision 0856). + // The gseq attach anchor, stamped on the first placement pass (`CM2Model+0x68`). let attach_ms = *card.gseq_attach_ms.get_or_insert(elapsed_ms); let card = &*card; let rotation = billboard_basis(card.kind, card.placement_rot, fwd, right, up); - // The bone's global-sequence scale pulse (the lamppost glow "breathe"), on the instance's - // anchored cursor (`sceneNow − attach`): instances stamped on different frames pulse at - // per-instance phases, same-frame spawns in phase — the reference's anchor law. - // `Vec3::ONE` (no-op) when the card has no scale track. + // The global-sequence scale pulse on the instance's anchored cursor, `sceneNow − attach`. let pulse = card.scale_anim.as_ref().map_or(Vec3::ONE, |a| { Vec3::from_array(a.sample(elapsed_ms.wrapping_sub(attach_ms))) }); - // The armed first-sequence translation loop (the questgiver `?` bob): a model-local offset - // at the pivot, pointed by the placement rotation and sized by its scale — on the ARM - // cursor (`elapsed − arm_ms`), the sequence-track half of the clock law. + // The armed bob on the arm cursor `elapsed − arm_ms`, turned and sized by the placement. let bob = card.seq_translation.as_ref().map_or(Vec3::ZERO, |a| { card.placement_rot * (Vec3::from_array(a.sample(elapsed_ms.wrapping_add(card.arm_neg_ms))) @@ -646,15 +479,11 @@ pub(crate) fn face_billboards( rotation, scale: card.scale * pulse, }; - // A parked camera over a still, track-less card recomputes bit-identical values, so the - // write only lands on real movement: unconditional, it marked every card - // `Changed`+`Changed` every frame and the downstream - // change-detection consumers (`classify_water_side`'s moved sweep, the shade dirty walk) - // never went quiet. + // Only a real change is written, so change-detection consumers go quiet on a still frame. if *tf != placed { *tf = placed; } - // Propagation already ran this frame — the direct global write is what renders. + // Propagation already ran this frame: the direct global write is what renders. let placed_global = GlobalTransform::from(placed); if *global != placed_global { *global = placed_global; @@ -662,14 +491,12 @@ pub(crate) fn face_billboards( } } -/// Registers the billboard placement pass ([`BillboardPlace`], PostUpdate post-propagation). -/// Cards are spawned by the model spawn sites (in Update — mesh churn stays there). +/// Registers the [`BillboardPlace`] pass; the model spawn sites spawn cards, in Update. pub struct BillboardPlugin; impl Plugin for BillboardPlugin { fn build(&self, app: &mut App) { - // The set carries the schedule constraints so every member — including the - // particle/ribbon sims other plugins add — lands post-propagation, pre-visibility. + // The set holds the ordering, so the particle and ribbon sims other plugins add get it too. app.configure_sets( PostUpdate, BillboardPlace @@ -680,9 +507,8 @@ impl Plugin for BillboardPlugin { PostUpdate, ( billboard_joint_palette, - // The collapsed-rig world pass (decision 0724): palette rows + replaced-subtree - // anchor re-seats, between the entity lane's joint rewrite and the card facing - // (cards following a unit's billboard-bone anchor read the replaced frame). + // The collapsed-rig world pass, between the joint rewrite and the card facing: + // cards on a unit's billboard-bone anchor read the replaced frame. crate::rig_anim::finalize_rig_worlds, face_billboards, ) @@ -697,24 +523,11 @@ mod tests { use super::*; use benilla_assets::BillboardInfo; - /// **Geometry lying along a bone's own axis does not sweep** — the fact decision 0847 got wrong - /// and 0853 restored, pinned here in our own basis rather than trusted to algebra. - /// - /// The R14 pauldron's spikes run along WoW **−Z** (`seamswing`: mean |z| 0.284/0.291 against - /// ≤0.06 on x and y, worst vertex 12° off axis), which is Bevy local **−Y**. A spherical - /// billboard maps WoW Z to the camera's up, so that direction must come out as **−up — screen - /// DOWN — from every camera orientation**, never tracing an arc. 0847 read the spikes' 0.29 yd - /// length as a 0.29 yd arc through the plate and withdrew a correct change on it; if that arc - /// were real, this test is where it would show up as a direction that moves with the camera. - /// - /// The `kept_rot` argument is swept too: the spherical arm discards the pre-billboard rotation - /// outright (`0x7152f8`), so the wearer's shoulder yaw must not reach - /// the result — which is *also* why the spike stops following the shoulder, the real visible - /// difference the arm makes. + /// The R14 pauldron's spikes run along WoW −Z (`seamswing`), Bevy −Y: a spherical billboard + /// points them screen-down from every camera and discards the pre-billboard rotation + /// (`0x7152f8`), so the wearer's shoulder yaw never reaches them. #[test] fn a_spike_along_its_bone_axis_points_screen_down_from_every_angle() { - // Bevy local −Y is the pauldron spike's run axis (WoW −Z through coords.rs' X→−Z, Y→−X, - // Z→+Y). let spike_local = Vec3::NEG_Y; for (yaw, pitch) in [ (0.0, 0.0), @@ -747,10 +560,6 @@ mod tests { } } - /// A FOLLOWING card (decision 0153 — the entity-path glow cards) re-seats from its owner's - /// live global transform each frame and despawns with it: the brazier glow burns at the bowl - /// (owner translation + authored pivot), never the model origin — and dies when the owner - /// streams out / unequips. #[test] fn following_card_rides_its_owner_and_dies_with_it() { let mut app = App::new(); @@ -782,10 +591,6 @@ mod tests { Vec3::new(5.0, 1.7, 0.0), "owner translation + authored pivot — not the model origin" ); - // The hidden-owner half of this test used to live here, asserting a `Visibility` write - // this system made and the real schedule then threw away — a green test pinning a dead - // write (decision 1409). The law is unchanged and now lives with the one authority that - // can enforce it: `model_render::visibility::a_card_follows_its_owners_verdict`. app.world_mut().entity_mut(owner).despawn(); app.update(); assert!( @@ -794,12 +599,7 @@ mod tests { ); } - /// A JOINT-lane card takes the global-sequence twinkle from the joint ALONE (decision 0851): - /// every joint/anchor lane runs a `GlobalSeqDrive` that writes the bone's scale track onto - /// the joint, and `re_place` reads the composed result back as the card's scale — a card that - /// also sampled its own copy multiplied the twinkle in twice, at two clocks offset by the - /// position-hash phase (Arcane Intellect's sometimes-2.5-yd lens flare). The rigless lanes - /// keep the sampler: there the card is the only thing animating. + /// The joint's `GlobalSeqDrive` carries the twinkle; only a rigless card samples its own. #[test] fn joint_lane_takes_the_twinkle_from_the_joint_alone() { let mut app = App::new(); @@ -809,7 +609,6 @@ mod tests { crate::view::WorldCamera, GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)), )); - // The twinkle track, a flat ×2 — visible wherever it is applied. let info = BillboardInfo { bone: 0, pivot: Vec3::ZERO, @@ -821,8 +620,8 @@ mod tests { }), seq_translations: vec![], }; - // The joint arrives already composed by the rig: drive-written twinkle ×2 × parent - // flare ×3 = 6. Sampling the track again on top would render ×12. + // The joint as the rig composes it: twinkle ×2 × parent flare ×3 = 6; sampling the + // track again would render ×12. let joint = app .world_mut() .spawn(GlobalTransform::from(Transform::from_scale(Vec3::splat( @@ -836,8 +635,6 @@ mod tests { Transform::IDENTITY, )) .id(); - // The rigless lane: a plain anchor at scale 1 — the card's own sampler is the only - // twinkle writer. let anchor = app.world_mut().spawn(GlobalTransform::IDENTITY).id(); let rigless_card = app .world_mut() @@ -857,25 +654,12 @@ mod tests { ); } - /// **A card keeps its bone's NON-UNIFORM scale** — bug B169, the Lightwell (2026-08-25). - /// - /// `World\Goober\G_HolyLightWell.m2` bone 0 is a lock-Z billboard carrying the well's light - /// shaft (batch 0, `LIGHTWELLRAY.BLP`, 12 verts all weight-1 on that bone, so the 0028 split - /// makes it a card). Its scale is a **sequence** track, not a global-sequence one, so nothing - /// in `BillboardInfo::scale_anim` carries it: the value reaches the card only through the - /// joint, via `re_place`. Authored (WoW axes, `m2anim`): the Spawn clamp ramps - /// `(1,1,1) → (8.808, 8.808, 37.560)` in 0.5 s and the 23.3 s Stand loop holds it there, - /// pulsing Z between 35.170 and 37.560 — a 0.18 × 0.12 yd card stretched into a - /// **1.59 × 4.51 yd** column of light standing out of the bowl. - /// - /// `re_place` used to keep `placement.scale.x` and splat it, so the shaft rendered - /// 1.59 × **1.06** yd: a squat additive card sitting exactly on the 1.09 yd bowl at the same - /// height, blowing it out. Every test above this one used a uniform `Vec3::splat`, which is - /// why the collapse survived — the numbers here are the asset's own. + /// `World\Goober\G_HolyLightWell.m2` bone 0 is a lock-Z card whose sequence scale track + /// (`m2anim`, WoW axes) holds `(8.808, 8.808, 37.560)` through its Stand loop; it reaches the + /// card only through the joint, and stands the 0.12 yd card 4.51 yd tall. #[test] fn a_card_keeps_its_bones_non_uniform_scale() { - // The authored WoW-axis scale, permuted to Bevy axes exactly as the clip builder does - // (`benilla_assets`: `Vec3::new(s[1], s[2], s[0])` — Bevy Y is WoW Z, the shaft's long axis). + // WoW axes permuted to Bevy as the clip builder does: Bevy Y is WoW Z, the long axis. const WOW: [f32; 3] = [8.808, 8.808, 37.560]; let bevy_scale = Vec3::new(WOW[1], WOW[2], WOW[0]); // The card's authored span (`m2batch`): 0.18 yd across, 0.12 yd tall, in the YZ plane. @@ -892,11 +676,9 @@ mod tests { bone: 0, pivot: Vec3::ZERO, kind: BillboardKind::LockZ, - scale_anim: None, // a SEQUENCE track — the joint is the only carrier + scale_anim: None, // a sequence track: the joint is its only carrier seq_translations: vec![], }; - // The joint as the rig composes it: the billboard palette preserves the full scale, so - // this is what `re_place` reads back. let joint = app .world_mut() .spawn(GlobalTransform::from(Transform::from_scale(bevy_scale))) @@ -922,22 +704,6 @@ mod tests { ); } - /// **The billboard FRAME an equipped item's emitter rides** (decision 0813), with the real - /// numbers of the R14 PVP shoulder (`LShoulder_Mail_PVPAlliance_C_01`: billboard bone 1 pivot - /// `(-0.012, 0.162, -0.060)`, sparkle emitter position `(-0.252, 0.178, -0.046)`, both raw WoW - /// model space — pinned in `benilla_formats`' `real_pvp_shoulder_emitters_ride_a_billboard_bone`). - /// - /// The law: the emitter's live origin is `pivot + camBasis·(position − pivot)`, so the sparkle - /// sits a **fixed 0.24 yd along the VIEW axis** from the pauldron's billboard pivot — behind it - /// (the chain offset's WoW +X is toward the viewer and this offset is negative), which is what - /// puts most of the 0.7 yd quad behind the pauldron's own depth. A rest-pose placement instead - /// nails it to a fixed model-space point, so what the pad occludes changes with every camera - /// move — the reported "way too strong and off position". Both halves are asserted: the offset's - /// magnitude/direction at one camera, and that it FOLLOWS the camera to the next. - /// A card that spawns under a parked camera must still be placed on its first pass: the - /// still-frame skip (1979) has no view of "never placed", and a tile bursting in under a - /// parked camera left every glow card unrotated at scale one until the mouse moved - /// (review 2026-09-04). #[test] fn a_card_born_on_a_still_frame_is_placed() { use benilla_assets::coords::wow_to_bevy; @@ -972,6 +738,9 @@ mod tests { ); } + /// The R14 PVP shoulder (`LShoulder_Mail_PVPAlliance_C_01`): billboard bone 1's pivot and its + /// sparkle emitter, raw WoW axes. The emitter rides `pivot + camBasis·(position − pivot)`, so + /// the sparkle sits 0.24 yd behind the pivot along the view axis from any camera. #[test] fn an_item_emitters_billboard_frame_puts_it_behind_the_pivot() { use benilla_assets::coords::wow_to_bevy; @@ -991,7 +760,6 @@ mod tests { .world_mut() .spawn((crate::view::WorldCamera, GlobalTransform::IDENTITY)) .id(); - // The item root — the shoulder attach point, wherever the wearer stands. let root = app .world_mut() .spawn(GlobalTransform::from_translation(Vec3::new(3.0, 1.5, 0.0))) @@ -1004,8 +772,7 @@ mod tests { )) .id(); - // Camera 1: the Bevy default (at the origin, looking down −Z) — so "away from the viewer" - // is −Z. + // Camera 1: the Bevy default, looking down −Z, so away from the viewer is −Z. app.update(); let tf = *app.world().entity(frame).get::().unwrap(); let pivot_world = Vec3::new(3.0, 1.5, 0.0) + wow_to_bevy(PIVOT); @@ -1023,8 +790,7 @@ mod tests { "…and all but ~2 cm of the offset is in that one axis: {sparkle:?}" ); - // Camera 2: a quarter turn — the offset must follow the camera, not the model. This is the - // whole difference from the rest pose, which would return the same vector both times. + // Camera 2: a quarter turn. The offset follows the camera; a rest pose would not move. app.world_mut() .entity_mut(cam) .insert(GlobalTransform::from(Transform::from_rotation( @@ -1044,14 +810,7 @@ mod tests { ); } - /// A card the **exterior-scene cull** owns must not have its `Visibility` written here - /// (decision 0784). One component, one authority (0025): a world-placement card is tagged - /// with the rest of its model, both systems run in the same unordered post-propagation - /// window, and this mirror silently undid the cull's `Hidden` — a lamp glow drawing through - /// a sealed room's wall while every other submesh of the same lamp was correctly gone. - /// - /// The card must still be *placed* (its transform is this system's job either way), and an - /// untagged card must still mirror — that half is the test above. + /// A card tagged `ExteriorScene` keeps the cull's `Hidden`, and is still placed. #[test] fn the_exterior_cull_owns_a_tagged_cards_visibility() { let mut app = App::new(); @@ -1061,7 +820,7 @@ mod tests { crate::view::WorldCamera, GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)), )); - // A VISIBLE owner — the mirror's "show it" arm, which is the one that did the damage. + // A visible owner, whose verdict must not reach a card the cull has hidden. let owner = app .world_mut() .spawn(( @@ -1081,7 +840,7 @@ mod tests { .spawn(( BillboardCard::following(&info, owner), Transform::IDENTITY, - // …and the cull has already hidden it: no window admits this model. + // The cull has already hidden it: no window admits this model. Visibility::Hidden, crate::exterior_cull::ExteriorScene, )) @@ -1103,31 +862,24 @@ mod tests { ); } - /// The palette pass: a lock-Z billboard JOINT gets its propagated world rotation replaced by - /// the camera basis (translation/scale kept — the pivot stays put, the grow-in scale - /// survives), and its CHILD joint is re-composed from the replaced parent — so geometry - /// skinned to the child inherits the facing (the frost-armor case). The host's own yaw must - /// not leak into the result: two hosts facing opposite ways produce the SAME billboarded - /// orientation for an upright lock-Z bone. + /// A lock-Z joint takes the camera basis, keeping its pivot and scale, and its child joint + /// re-composes on it; the host's yaw does not reach the result. #[test] fn palette_pass_faces_joints_and_recomposes_children() { let mut app = App::new(); app.add_systems(Update, billboard_joint_palette); app.world_mut().spawn(( crate::view::WorldCamera, - // Looking along −Z from +Z, world-up Y — the identity camera frame. GlobalTransform::from(Transform::from_translation(Vec3::new(0.0, 0.0, 10.0))), )); let mut spawn_host = |yaw: f32| { let host_rot = Quat::from_rotation_y(yaw); - // Joint 0: lock-Z billboard at the host's frame, world pivot (5, 1, 0), scale 2. let j0_global = GlobalTransform::from(Transform { translation: Vec3::new(5.0, 1.0, 0.0), rotation: host_rot, scale: Vec3::splat(2.0), }); let j0 = app.world_mut().spawn((Transform::IDENTITY, j0_global)).id(); - // Joint 1: the scale-in child, one unit up its parent's Y, half scale. let j1_local = Transform::from_translation(Vec3::Y).with_scale(Vec3::splat(0.5)); let j1 = app .world_mut() @@ -1167,11 +919,8 @@ mod tests { let (s0, r0, t0) = g0.to_scale_rotation_translation(); assert_eq!(t0, Vec3::new(5.0, 1.0, 0.0), "the pivot stays put"); assert!((s0 - Vec3::splat(2.0)).length() < 1e-5, "scale preserved"); - // Lock-Z at this camera: kept axis = world up; the replaced frame is exactly the - // camera-agreeing basis — local +Y stays up, local −Z faces the viewer. assert!((r0 * Vec3::Y).dot(Vec3::Y) > 0.999, "kept axis upright"); assert!((r0 * -Vec3::Z).dot(Vec3::Z) > 0.999, "faces the camera"); - // The opposite-facing host lands on the SAME orientation — char yaw does not leak. let (_, rb, _) = app .world() .entity(b0) @@ -1182,8 +931,8 @@ mod tests { rb.angle_between(r0) < 1e-4, "host yaw must not change the facing" ); - // The child re-composed onto the replaced parent: parent's new Y is world Y, so the - // child sits one PARENT-scaled unit above the pivot, with composed scale 2·0.5 = 1. + // The child re-composed on the replaced parent: one parent-scaled unit above the pivot, + // composed scale 2·0.5 = 1. let (s1, _, t1) = app .world() .entity(a1) @@ -1200,14 +949,9 @@ mod tests { ); } - /// The four `flags & 0x6` legs of [`parent_arm_matrix`], each against its byte definition - /// (`0x71496d`–`0x714d0c`), plus the pivot-preserving tail that is the whole - /// reason a galloping mount carries its rider without rocking them. - /// - /// The parent here is rotated 90° about X and scaled non-uniformly; the root is a plain 90° - /// yaw at scale 3. Each leg is asserted on the quantity it is defined by — axis LENGTHS for - /// the scale legs, axis DIRECTIONS for the rotation legs — so a leg that accidentally did the - /// other one's job cannot pass. + /// Each `flags & 0x6` leg (`0x71496d`-`0x714d0c`) is asserted on what defines it, axis lengths + /// for scale and axis directions for rotation, so a leg doing another's job fails; then the + /// pivot-preserving tail. #[test] fn the_parent_arm_legs_match_their_byte_definitions() { use benilla_formats::{ParentArm, ParentBasis}; @@ -1229,11 +973,9 @@ mod tests { let axis_len = |m: Affine3A, k: usize| m.matrix3.col(k).length(); let axis_dir = |m: Affine3A, k: usize| Vec3::from(m.matrix3.col(k).normalize()); - // `flags & 6 == 0` — the basis is the parent's, untouched. let keep = parent_arm_matrix(arm(ParentBasis::Keep), parent, root, pivot); assert!(keep.matrix3.abs_diff_eq(parent.matrix3, 1e-5)); - // `flags & 6 == 2` — unit axes, parent's directions. let unit = parent_arm_matrix(arm(ParentBasis::UnitNormalize), parent, root, pivot); for k in 0..3 { assert!((axis_len(unit, k) - 1.0).abs() < 1e-5, "axis {k} unit"); @@ -1243,7 +985,6 @@ mod tests { ); } - // `flags & 6 == 4` — root's direction, parent's magnitude, per axis. let ratio = parent_arm_matrix(arm(ParentBasis::RootDirection), parent, root, pivot); for k in 0..3 { assert!( @@ -1256,12 +997,10 @@ mod tests { ); } - // `flags & 6 == 6` — the root basis outright, scale included. let full = parent_arm_matrix(arm(ParentBasis::RootBasis), parent, root, pivot); assert!(full.matrix3.abs_diff_eq(root.matrix3, 1e-5)); - // The pivot-preserving tail: whichever leg ran, composing the bone's own bind translation - // onto the rewritten matrix lands it exactly where the ANIMATED parent carried it. + // The tail: every leg lands the bind translation where the animated parent carried it. let want = parent.transform_point3a(pivot.into()); for m in [keep, unit, ratio, full] { let landed = m.transform_point3a(pivot.into()); @@ -1281,10 +1020,8 @@ mod tests { assert!((Vec3::from(moved.translation) - Vec3::new(0.0, 5.0, 0.0)).length() < 1e-5); } - /// The ignore-parent-rotation joint (bone flag 0x04 — the HandArrow/Bullet attach helpers): - /// its pivot rides the parent's full matrix, its - /// ROTATION resets to the host root's frame — and a rigid child (the nocked arrow) hanging - /// under it re-composes onto the replaced frame instead of keeping the twisted propagated one. + /// Bone flag 0x04 (the HandArrow/Bullet helpers): the pivot rides the parent, the rotation + /// resets to the host root's, and a rigid child (the nocked arrow) re-composes onto it. #[test] fn ignore_parent_rotation_joint_keeps_the_model_frame() { let mut app = App::new(); @@ -1293,7 +1030,7 @@ mod tests { crate::view::WorldCamera, GlobalTransform::from(Transform::from_translation(Vec3::new(0.0, 0.0, 10.0))), )); - // The host root: yawed 90° — the model frame every flag-0x04 joint must land on. + // The host root, yawed 90°: the frame every flag-0x04 joint lands on. let host_rot = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); let host = app .world_mut() @@ -1302,8 +1039,7 @@ mod tests { GlobalTransform::from(Transform::from_rotation(host_rot)), )) .id(); - // Joint 0: the animated hand — twisted a further 90° about X (the draw-hand roll the - // arrow must NOT inherit), pivot at (1, 2, 3). + // Joint 0: the animated hand, with a roll about X the arrow must not inherit. let hand_rot = host_rot * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); let j0_global = GlobalTransform::from(Transform { translation: Vec3::new(1.0, 2.0, 3.0), @@ -1311,14 +1047,13 @@ mod tests { scale: Vec3::ONE, }); let j0 = app.world_mut().spawn((Transform::IDENTITY, j0_global)).id(); - // Joint 1: the flag-0x04 attach helper, one local unit up the HAND's frame. + // Joint 1: the flag-0x04 attach helper, one local unit up the hand's frame. let j1_local = Transform::from_translation(Vec3::Y); let j1 = app .world_mut() .spawn((j1_local, j0_global.mul_transform(j1_local))) .id(); - // The rigid arrow child under the helper, at a local offset — propagated PRE-pass with - // the twisted frame (what the bug rendered). + // The rigid arrow under the helper, propagated before the pass with the twisted frame. let arrow_local = Transform::from_translation(Vec3::X); let arrow = app .world_mut() @@ -1354,8 +1089,6 @@ mod tests { app.world_mut().spawn(rig); app.update(); - // The helper joint: pivot carried by the HAND's frame (hand rot · Y above the hand), - // rotation snapped back to the HOST's. let (_, r1, t1) = app .world() .entity(j1) @@ -1371,7 +1104,6 @@ mod tests { r1.angle_between(host_rot) < 1e-3, "the rotation resets to the model root's frame" ); - // The arrow child re-composed onto the replaced frame: host-frame X off the pivot. let (_, ra, ta) = app .world() .entity(arrow) @@ -1388,13 +1120,8 @@ mod tests { ); } - /// A rigged model nested under another rig's rewritten joint (the Eviscerate impact instance - /// on the boar's flag-0x04 attach helper): the outer rig's child walk must not enter the - /// nested rig's subtree AT ALL — the root keeps its propagated global (the live animated - /// attach-bone frame its emitters' attach rotation reads), and the interior belongs to the - /// nested rig's own pass. Both spawn orders must land on the identical result; pre-fix, - /// whichever rig iterated last won, so the effect's camera-born billboard frame (and its - /// animated attach frame) survived or died per launch. + /// A rig nested under another's rewritten joint (a spell impact on an attach helper) keeps its + /// root's propagated frame and its own billboard, in either spawn order. #[test] fn nested_rig_interior_is_owned_by_its_own_pass() { for nested_first in [false, true] { @@ -1404,8 +1131,8 @@ mod tests { crate::view::WorldCamera, GlobalTransform::from(Transform::from_translation(Vec3::new(0.0, 0.0, 10.0))), )); - // The outer host (a boar): yawed 90°, one flag-0x04 attach-helper joint whose - // propagated global carries an animated twist the reset must erase. + // The outer host: yawed 90°, one flag-0x04 attach helper whose propagated global + // carries an animated twist the reset erases. let host_rot = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); let host = app .world_mut() @@ -1433,9 +1160,8 @@ mod tests { spine_bone: None, head_bone: None, }; - // The nested effect instance: its root hangs one local X under the helper, and its - // single joint is a lock-Z billboard whose propagated global still carries the - // (wrong) host twist — its own pass must replace it, and keep it replaced. + // The nested effect: its root one local X under the helper, its one joint a spherical + // billboard whose propagated global still carries the host twist. let fx_local = Transform::from_translation(Vec3::X); let fx_root = app .world_mut() @@ -1468,8 +1194,6 @@ mod tests { } app.update(); - // The nested root keeps its PROPAGATED global — the animated attach-bone frame - // (with the hand twist): the walk never entered the nested subtree. let expected = j0_global.mul_transform(fx_local); let (_, rr, rt) = app .world() @@ -1486,18 +1210,14 @@ mod tests { rr.angle_between(er) < 1e-3, "nested root keeps the animated attach rotation (nested_first={nested_first})" ); - // The nested rig's own billboard frame SURVIVES, in either spawn order: the outer - // walk stopped at the nested root instead of re-composing fj0 from its raw local. let (_, rj, _) = app .world() .entity(fj0) .get::() .unwrap() .to_scale_rotation_translation(); - // The spherical basis at this camera, through the WoW→Bevy axis fold - // (`billboard_basis`'s `from_cols(-by, bz, -bx)`): Bevy-local −Z toward the viewer - // (WoW X), Bevy-local +Y screen-up (WoW Z) — the (π,0) ray ring's camera-born plane - // (`0x71547c`). + // The spherical basis at this camera: Bevy-local −Z (WoW X) toward the viewer, +Y + // (WoW Z) screen-up, the camera-born plane at `0x71547c`. assert!( (rj * -Vec3::Z).dot(Vec3::Z) > 0.999, "the nested billboard faces the camera (nested_first={nested_first})" @@ -1509,17 +1229,13 @@ mod tests { } } - /// An armed first-sequence translation loop (the questgiver `?` bob) moves the card off its - /// pivot by the sampled offset, on the arm-time cursor: armed at t=0, sampled at the loop's - /// midpoint, the card sits at the middle key's offset. No `Time` plugin — the clock is set by - /// hand so the sample point is exact. + /// Armed at t=0 and sampled at the loop's midpoint, the card sits at the middle key's offset. #[test] fn armed_seq_translation_bobs_the_card() { let mut app = App::new(); app.init_resource::