From fdfd8d39ec719e2d0b26fead272f18df16dd3dae Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:43:40 +0700 Subject: [PATCH] Comments in benilla-world: one line of fact each, no history, no record numbers (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #41: the comments of `benilla-world`, Rust and WGSL, are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 24,226 comment lines become 8,863. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x6b62e0` is the WMO group's liquid renderer, not a doodad drawer. - The reference creates a WMO's props once, when a group enters its streaming window (`0x698720` β†’ `0x695aa0`). It then admits them each frame the portal walk visits the group (`0x685d70` β†’ `0x6838f0`). The old text had them created per visible group. - The liquid query's comments now state the reference's order: the map-object leg first, then the ADT (`0x69b6d0`). They no longer call the Outdoors/Inside split a match. - The interior probe runs from the character, not the camera. - The reference lights ground clutter. The old comment said it draws unlit. - The model-particle "pool order" deviation was wrong. Each instance is its own mesh, depth-sorted every frame. - The reference's vertical field of view follows the aspect ratio (44.1Β° at 16:9). The comment now gives it beside our fixed 45Β°. - The effect shader's far wall is the live `farclip`, 177 to 777 yd, not a fixed 777 yd. - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Five second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference. Defects the rewrite surfaced outside the comments are filed as their own issues (#106, #114, #115, #116). The comments here state what the code does. String literals are untouched; they are #92. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-world/src/art_scope.rs | 135 +-- crates/benilla-world/src/assets/mod.rs | 49 +- crates/benilla-world/src/bgwin.rs | 197 +--- crates/benilla-world/src/billboard.rs | 584 +++--------- crates/benilla-world/src/boot.rs | 82 +- crates/benilla-world/src/build_id.rs | 47 +- crates/benilla-world/src/clouds/kernel.rs | 260 ++---- crates/benilla-world/src/clouds/layer.rs | 95 +- crates/benilla-world/src/clouds/mod.rs | 72 +- crates/benilla-world/src/clouds/tables.rs | 21 +- crates/benilla-world/src/clutter.rs | 329 ++----- crates/benilla-world/src/collision.rs | 414 ++------ .../benilla-world/src/collision/one_sided.rs | 207 ++-- crates/benilla-world/src/decal.rs | 107 +-- crates/benilla-world/src/dev_state.rs | 110 +-- crates/benilla-world/src/doodad_anim/lazy.rs | 156 +--- .../benilla-world/src/doodad_anim/mat_anim.rs | 701 ++++---------- crates/benilla-world/src/doodad_anim/mod.rs | 547 +++-------- crates/benilla-world/src/entity_shade.rs | 362 ++----- crates/benilla-world/src/exterior_cull.rs | 540 +++-------- crates/benilla-world/src/ffx_glow.rs | 710 ++++---------- crates/benilla-world/src/final_pass.rs | 58 +- crates/benilla-world/src/frame_pace.rs | 163 +--- crates/benilla-world/src/ground_fx.rs | 130 +-- crates/benilla-world/src/instance_tint.rs | 138 +-- crates/benilla-world/src/interact/mod.rs | 125 +-- crates/benilla-world/src/interact/pick.rs | 342 ++----- crates/benilla-world/src/interior.rs | 673 ++++--------- crates/benilla-world/src/lib.rs | 50 +- crates/benilla-world/src/lighting/blob.rs | 107 +-- crates/benilla-world/src/lighting/daynight.rs | 326 +++---- .../src/lighting/global_light.rs | 369 ++------ crates/benilla-world/src/lighting/mod.rs | 339 ++----- .../benilla-world/src/lighting/prop_probes.rs | 132 +-- crates/benilla-world/src/lighting/resolve.rs | 305 ++---- crates/benilla-world/src/lighting/sh.rs | 62 +- crates/benilla-world/src/liquid/drift.rs | 327 ++----- crates/benilla-world/src/liquid/mod.rs | 184 +--- crates/benilla-world/src/liquid/query.rs | 745 ++++----------- crates/benilla-world/src/liquid/real_data.rs | 133 +-- crates/benilla-world/src/liquid/spatial.rs | 53 +- crates/benilla-world/src/liquid/surface.rs | 468 +++------- crates/benilla-world/src/log_ring.rs | 37 +- crates/benilla-world/src/mac_quit.rs | 54 +- crates/benilla-world/src/map_proj.rs | 147 +-- crates/benilla-world/src/mat_anim_table.rs | 111 +-- crates/benilla-world/src/mesh_tag.rs | 434 +++------ crates/benilla-world/src/model_fade.rs | 826 ++++------------ crates/benilla-world/src/model_forms.rs | 121 +-- crates/benilla-world/src/model_render.rs | 794 ++++------------ .../benilla-world/src/model_render/batch.rs | 268 ++---- crates/benilla-world/src/model_render/lazy.rs | 185 +--- crates/benilla-world/src/model_render/park.rs | 62 +- .../src/model_render/visibility.rs | 318 ++----- crates/benilla-world/src/modkeys.rs | 124 +-- crates/benilla-world/src/particles.rs | 814 ++++------------ crates/benilla-world/src/particles/buffer.rs | 371 ++------ .../benilla-world/src/particles/depthdump.rs | 70 +- crates/benilla-world/src/particles/dumps.rs | 11 +- crates/benilla-world/src/particles/emit.rs | 123 +-- .../benilla-world/src/particles/emitdump.rs | 51 +- crates/benilla-world/src/particles/model.rs | 97 +- crates/benilla-world/src/particles/quads.rs | 184 +--- crates/benilla-world/src/particles/render.rs | 314 ++----- crates/benilla-world/src/particles/sim.rs | 826 ++++------------ crates/benilla-world/src/ribbons.rs | 454 +++------ crates/benilla-world/src/ride_frame.rs | 124 +-- crates/benilla-world/src/rig_anim.rs | 33 +- crates/benilla-world/src/rig_anim/compose.rs | 231 +---- .../benilla-world/src/rig_anim/global_seq.rs | 110 +-- crates/benilla-world/src/rig_anim/pose.rs | 238 ++--- crates/benilla-world/src/rig_palette.rs | 496 +++------- crates/benilla-world/src/rig_rider.rs | 180 +--- crates/benilla-world/src/schedule.rs | 125 +-- crates/benilla-world/src/shaders.rs | 49 +- .../benilla-world/src/shaders/celestial.wgsl | 33 +- crates/benilla-world/src/shaders/cloud.wgsl | 12 +- .../benilla-world/src/shaders/ffx_glow.wgsl | 56 +- crates/benilla-world/src/shaders/sky.wgsl | 17 +- .../benilla-world/src/shaders/sky_vertex.wgsl | 14 +- crates/benilla-world/src/shaders/star.wgsl | 5 +- .../benilla-world/src/shaders/static_gx.wgsl | 22 +- .../benilla-world/src/shaders/wow_effect.wgsl | 43 +- crates/benilla-world/src/sky.rs | 136 +-- crates/benilla-world/src/sky_order.rs | 500 +++------- crates/benilla-world/src/skybox.rs | 360 ++----- crates/benilla-world/src/static_gx/bake.rs | 183 +--- crates/benilla-world/src/static_gx/cull.rs | 182 +--- crates/benilla-world/src/static_gx/mod.rs | 591 +++--------- crates/benilla-world/src/static_gx/pick.rs | 49 +- crates/benilla-world/src/static_gx/pool.rs | 57 +- crates/benilla-world/src/static_gx/render.rs | 279 ++---- crates/benilla-world/src/static_merge.rs | 66 +- crates/benilla-world/src/straddle.rs | 226 ++--- crates/benilla-world/src/sun/follow.rs | 372 ++------ crates/benilla-world/src/sun/materials.rs | 62 +- crates/benilla-world/src/sun/mesh.rs | 25 +- crates/benilla-world/src/sun/mod.rs | 136 +-- crates/benilla-world/src/sun/setup.rs | 98 +- crates/benilla-world/src/surface.rs | 69 +- crates/benilla-world/src/terrain_stream.rs | 822 +++++----------- .../src/terrain_stream/collider.rs | 251 ++--- .../src/terrain_stream/furnish.rs | 71 +- .../benilla-world/src/terrain_stream/merge.rs | 292 ++---- .../src/terrain_stream/queries.rs | 134 +-- .../src/terrain_stream/spawn/assemble.rs | 514 +++------- .../src/terrain_stream/spawn/fx.rs | 140 +-- .../src/terrain_stream/spawn/mod.rs | 448 +++------ .../src/terrain_stream/spawn/prop_light.rs | 118 +-- .../benilla-world/src/terrain_stream/weld.rs | 100 +- .../src/terrain_stream/window.rs | 78 +- crates/benilla-world/src/thread_qos.rs | 67 +- crates/benilla-world/src/view.rs | 506 +++------- crates/benilla-world/src/vis_chain.rs | 40 +- crates/benilla-world/src/water_fx/mod.rs | 247 ++--- crates/benilla-world/src/water_fx/params.rs | 79 +- crates/benilla-world/src/wdl.rs | 112 +-- crates/benilla-world/src/weather/mod.rs | 189 ++-- .../src/weather/precip/census.rs | 107 +-- .../benilla-world/src/weather/precip/mist.rs | 216 ++--- .../benilla-world/src/weather/precip/mod.rs | 422 ++------- .../benilla-world/src/weather/precip/pool.rs | 289 ++---- .../src/weather/precip/render.rs | 132 +-- .../benilla-world/src/weather/precip/wind.rs | 126 +-- .../src/wmo_portal/audit/light_probe.rs | 102 +- .../benilla-world/src/wmo_portal/audit/mod.rs | 258 ++--- .../benilla-world/src/wmo_portal/audit/pin.rs | 187 ++-- crates/benilla-world/src/wmo_portal/fog.rs | 78 +- .../benilla-world/src/wmo_portal/interior.rs | 450 +++------ crates/benilla-world/src/wmo_portal/mod.rs | 881 ++++++------------ crates/benilla-world/src/wmo_portal/probe.rs | 39 +- .../benilla-world/src/wmo_portal/room_vis.rs | 35 +- crates/benilla-world/src/wmo_portal/seed.rs | 389 +++----- crates/benilla-world/src/world_census.rs | 171 +--- crates/benilla-world/src/world_map.rs | 62 +- crates/benilla-world/src/world_plugins.rs | 313 ++----- crates/benilla-world/src/world_point.rs | 190 ++-- crates/benilla-world/src/world_unit.rs | 100 +- crates/benilla-world/src/worldview.rs | 162 +--- crates/benilla-world/src/zfill.rs | 91 +- 140 files changed, 8285 insertions(+), 23652 deletions(-) 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::