diff --git a/crates/benilla-app/src/go_templates.rs b/crates/benilla-app/src/go_templates.rs index 16656e9d..9a3c3b3d 100644 --- a/crates/benilla-app/src/go_templates.rs +++ b/crates/benilla-app/src/go_templates.rs @@ -45,6 +45,22 @@ pub(crate) struct GoTemplate { /// (`0x5f6d80` — byte-for-byte the same shape, a different slot). `false` for every other type, /// which never consults it. pub(crate) highlight_column: bool, + /// GENERIC(5) only: the template's **`data[0]`**, the sole input of that type's `[vtbl+0x5c]` + /// (`0x5f8630`) — the fork in the mouseover publisher that decides whether this object's + /// tooltip FOLLOWS THE CURSOR (`0x492a01` → `0x52ffe0(owner, 6, 0, 0)`, the SetOwner core at + /// anchor state 6) or takes the default corner seat like a creature's (`0x492a42`, which + /// writes no owner and fires `OnTooltipSetDefaultAnchor` instead). + /// + /// The slot is named by `0x621b00(type, semantic 0x13)`, a key that resolves for exactly one + /// of the 31 GO types — so no other type can take the cursor arm, and 0766's INTERIM "GENERIC + /// vs not" proxy is replaced by the real key (decision 2259). **Distinct from + /// [`Self::highlight_column`]**, which is `data[1]` via semantic `0x12` and gates *eligibility* + /// rather than *placement*: an object can be hoverable and still be corner-seated. + /// + /// `false` for every other type. (The *name* is inferred from vmangos's field order; the index + /// and the fork are byte-verified, and the data is cross-checked against the reference client's + /// own `gameobjectcache.wdb` — 1890 entries, zero disagreements with the server's.) + pub(crate) floating_tooltip: bool, /// MEETINGSTONE (type 23) only: the template's `data[2]` = **areaID**, the sole input of that /// type's own `highlightable` slot (`0x5f6990` — decision 1106). `None` for every other type. pub(crate) meeting_stone_area: Option, @@ -124,6 +140,10 @@ impl GameObjectTemplates { 29 => data[19] != 0, _ => false, }; + // GENERIC(5): data[0] = the tooltip-placement fork's input (decision 2259). Same shape as + // the highlight column above and deliberately beside it — they are adjacent slots that + // answer different questions, and reading them as one is the mistake 0766 made. + let floating_tooltip = type_id == 5 && data[0] != 0; // MEETINGSTONE (23): data[2] = areaID (vmangos `gameobject_template`), the one input of // that type's own highlightable slot. let meeting_stone_area = (type_id == 23).then(|| data[2].max(0) as u32); @@ -150,6 +170,7 @@ impl GameObjectTemplates { lock_id, name, highlight_column, + floating_tooltip, meeting_stone_area, mo_transport, text_page, @@ -269,4 +290,61 @@ mod tests { ); assert_eq!(t.get(guid).map(|g| g.lock_id), Some(38)); } + + /// **`data[0]` and `data[1]` are different questions, and a GENERIC object can answer them + /// differently** — the pin that replaces 0766's INTERIM proxy (decision 2259). + /// + /// `data[1]` (semantic `0x12`, `0x5f4830`) is mouseover ELIGIBILITY: is this hoverable at all. + /// `data[0]` (semantic `0x13`, `0x5f8630`) is PLACEMENT: does its plate follow the cursor or + /// take the corner seat. Reading one as the other is exactly the mistake 0766 named as a proxy. + /// + /// The two rows are real, taken from the reference client's own `gameobjectcache.wdb` + /// (build 5875): `Brill` (entry 1630) is hoverable *and* cursor-seated, while + /// `Doodad_WoodSignPointerNice10` (175656) — the same family of wooden pointer sign — is + /// hoverable and CORNER-seated. Same model family, opposite answers, which is why the type + /// alone could never have been the key. + #[test] + fn a_generic_can_be_hoverable_and_still_corner_seated() { + let mut t = GameObjectTemplates::default(); + let mut brill = [0i32; 24]; + (brill[0], brill[1]) = (1, 1); + let mut pointer = [0i32; 24]; + (pointer[0], pointer[1]) = (0, 1); + t.insert(1630, 5, "Brill".into(), &brill); + t.insert(175656, 5, "Doodad_WoodSignPointerNice10".into(), &pointer); + + let brill = t.templates.get(&1630).expect("Brill cached"); + assert!(brill.highlight_column, "Brill is hoverable"); + assert!(brill.floating_tooltip, "Brill's plate follows the cursor"); + + let pointer = t.templates.get(&175656).expect("the pointer sign cached"); + assert!( + pointer.highlight_column, + "the pointer sign is hoverable too" + ); + assert!( + !pointer.floating_tooltip, + "but its plate takes the corner seat — eligibility did not decide placement" + ); + } + + /// The fork's key exists for GENERIC(5) and for no other type: `0x621b00` answers semantic + /// `0x13` for exactly one of the 31 types, so `data[0]` is never read as a placement flag + /// anywhere else — a CHEST's `data[0]` is its lockId, and must not move its tooltip. + #[test] + fn no_other_type_reads_data0_as_the_placement_fork() { + let mut t = GameObjectTemplates::default(); + let mut data = [0i32; 24]; + data[0] = 1; + for type_id in [0u32, 1, 2, 3, 6, 7, 8, 9, 10, 16, 22, 23, 25, 29] { + t.insert(9000 + type_id, type_id, format!("type {type_id}"), &data); + assert!( + !t.templates + .get(&(9000 + type_id)) + .expect("cached") + .floating_tooltip, + "type {type_id} must not take the cursor arm — it carries no semantic 0x13" + ); + } + } } diff --git a/crates/benilla-app/src/minimap/composite.rs b/crates/benilla-app/src/minimap/composite.rs index 24b9581c..616d8b8b 100644 --- a/crates/benilla-app/src/minimap/composite.rs +++ b/crates/benilla-app/src/minimap/composite.rs @@ -118,8 +118,9 @@ pub(super) fn setup_composite( let image = images.add(image); // A 1×1 quad centred on the origin: every tile is this mesh under its own Transform, so a - // panning composite never rewrites a vertex buffer (1463). - let quad = meshes.add(Rectangle::new(1.0, 1.0)); + // panning composite never rewrites a vertex buffer (1463). Its layout is a pipeline key axis + // and pipe_warm warms the same mesh, so the builder has one author (2262). + let quad = meshes.add(crate::ui_pass::tile_quad_mesh()); let camera = commands .spawn(( diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index fcb06177..8666599b 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -129,7 +129,7 @@ fn texel_stretch(extent_yd: f32) -> f32 { /// above `224/255` (wow-re `system/minimap/scratch/wmo-interior-minimap-composite.md`, VERIFIED). /// Written as the exact f32 the client computes rather than the ratio, because that ULP is the /// value fragments are compared against. -const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2); +pub(crate) const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2); /// The corpse blip's edge as a fraction of the widget side (the POIIcons cell is authored 16px on /// a 140px minimap ≈ 0.11; INTERIM eyeball beside [`ARROW_FRACTION`]'s). diff --git a/crates/benilla-app/src/name_persist.rs b/crates/benilla-app/src/name_persist.rs index 3dedd158..8f395f90 100644 --- a/crates/benilla-app/src/name_persist.rs +++ b/crates/benilla-app/src/name_persist.rs @@ -104,12 +104,16 @@ fn load_name_cache( match NameCache::from_tsv(&text, &realm) { Some(loaded) => { let n = loaded.len(); - // Replace rather than merge: this is the *start* of a realm's session, so there is - // nothing of this realm's in memory to preserve, and a merge would silently keep the - // previous realm's records alive under their own keys. (What it replaces is creature - // templates — the file carries nothing else since 2223 — and world entry clears the - // guid-keyed stores a moment later regardless.) - *names = loaded; + // **The file's own records go in; nothing else is touched** (decision 2260). This used + // to be `*names = loaded`, on the premise stated here that "world entry clears the + // guid-keyed stores a moment later regardless" — and the ordering is the other way + // round. This loader fires the first frame `identity` answers, which is the frame the + // *pick* is in flight; `connected` does its world-session clear and then seeds our own + // name in the same neighbourhood. Whichever ran second won, and when it was this one + // the player's own name went with it — `UnitName("player")` nil for a wire round-trip, + // once per process, on the first login of a fresh start. See + // [`NameCache::install_persisted`]. + names.install_persisted(loaded); file.saved_generation = names.generation(); debug!( "names: loaded {n} cached records for {realm} from {}", @@ -165,3 +169,134 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) { Err(e) => warn!("names: cannot write {}: {e}", path.display()), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::local_state::test_env::{EnvGuard, ENV_LOCK}; + + /// A roster with a pick in flight and no realm — [`crate::ui_macro::identity`] then answers + /// `("Realm", )`, which is the identity the loader keys the file by. + fn roster_with_pick(name: &str, guid: u64) -> Roster { + Roster::with_pending_pick( + vec![benilla_protocol::Character { + guid, + name: name.into(), + race: 1, + class: 1, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + level: 1, + zone: 0, + map: 0, + position: benilla_protocol::wire::Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + flags: 0, + equipment: [benilla_protocol::CharEnumItem::default(); 19], + pet_display_id: 0, + pet_level: 0, + pet_family: 0, + }], + guid, + ) + } + + /// **The director's report, at the seam that produced it** (decision 2260). + /// + /// The realm cache is read off disk the first frame the pick's identity is known, and the + /// login's seed of our OWN name lands in the same neighbourhood — so the two race, and the + /// loader used to win by overwriting the whole cache (`*names = loaded`). What that cost was + /// `UnitName("player")` answering **nil** for one wire round-trip on the first login of a + /// fresh start, because the feed's player snapshot carries the cache's answer and a `None` + /// there replaces the roster seat the UI loaded under. KLHThreatMeter reads it on its first + /// `OnUpdate` and indexes a table with it: `KTM_Tables.lua:198: table index is nil`. + /// + /// The load runs here *after* the seed on purpose: that is the losing order, and it must now + /// be survivable rather than merely unlikely. + #[test] + fn the_disk_load_installs_templates_without_touching_the_live_session() { + use benilla_protocol::guid; + /// `counter | (entry << 24) | (high << 48)` — the server's own composition. + fn compose(high: u16, entry: u32, counter: u32) -> u64 { + u64::from(counter) | (u64::from(entry) << 24) | (u64::from(high) << 48) + } + const CREATURE_ENTRY: u32 = 1234; + const PET_NUMBER: u32 = 7; + let me = compose(guid::HIGH_PLAYER, 0, 0x2A); + let pet = compose(guid::HIGH_PET, PET_NUMBER, 9); + let guard = compose(guid::HIGH_UNIT, CREATURE_ENTRY, 1); + + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = std::env::temp_dir().join(format!("benilla-namecache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let _capture = EnvGuard::unset("WOW_CAPTURE"); + let _home = EnvGuard::set("BENILLA_HOME", tmp.to_str().expect("utf-8 temp path")); + + // A realm file with one creature template in it — written the way the saver writes it, so + // the header the loader gates on is this build's by construction. + let mut on_disk = NameCache::default(); + on_disk.insert_creature( + CREATURE_ENTRY, + Some(crate::names::CreatureRecord { + name: "Stormwind Guard".into(), + subname: None, + creature_type: 7, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + let path = crate::local_state::name_cache_path("Realm").expect("a state folder"); + std::fs::create_dir_all(path.parent().expect("cache dir")).expect("cache dir"); + std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache"); + + // The live session: the login has already seeded our own name (and a pet's), exactly as + // `net::apply::session::connected` does a moment after the pick goes out. + let mut names = NameCache::default(); + names.insert_player(me, "Nelprifour".into(), None); + names.insert_pet(PET_NUMBER, "Fluffy".into()); + let before = names.generation(); + + let mut app = App::new(); + app.insert_resource(roster_with_pick("Nelprifour", me)) + .insert_resource(names) + .init_resource::() + .add_systems(Update, load_name_cache); + app.update(); + + let names = app.world().resource::(); + assert_eq!( + names.peek(me), + Some("Nelprifour"), + "the login's seed of our own name must survive the disk load — losing it is the bug" + ); + assert_eq!( + names.peek(pet), + Some("Fluffy"), + "…and so must a pet name: the file carries neither, so neither is its to replace" + ); + assert_eq!( + names.peek(guard), + Some("Stormwind Guard"), + "the file's own records are what the load is for" + ); + assert!( + names.generation() > before, + "a landed record moves the counter the gated feeds watch (1439)" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/crates/benilla-app/src/names.rs b/crates/benilla-app/src/names.rs index b0a8c7ef..edbb4bf8 100644 --- a/crates/benilla-app/src/names.rs +++ b/crates/benilla-app/src/names.rs @@ -312,6 +312,36 @@ impl NameCache { self.generation = self.generation.wrapping_add(1); } + /// **Install a realm cache read off disk — its CREATURE templates, and nothing else** + /// (decision 2260). + /// + /// The persisted file has carried creature templates alone since 2223, so this is the whole of + /// what a load can legitimately say. It used to be spelled `*names = loaded` in + /// [`crate::name_persist::load_name_cache`], which is the same thing only when the loader is + /// the first writer of the session — and it is not. The loader fires the first frame the realm + /// identity is known, which is the frame the *pick* is in flight; the login's + /// `SMSG_..._VERIFY_WORLD` seed of our OWN name ([`Self::insert_player`], from + /// `net::apply::session::connected`) lands in the same neighbourhood, and whichever arrives + /// second wins. When the load won, it discarded the player's own name — so `UnitName("player")` + /// answered nil for one wire round-trip while the feed re-asked for a guid it had just been + /// told about, and every addon reading it in that window saw nil (the KLHThreatMeter + /// `table index is nil` report; reproduced live 3 runs in 4). + /// + /// Once per process, because `NameCacheFile::realm` only goes `None` → `Some` once — which is + /// exactly the "first login of a fresh game start, and never again" shape the report named. + /// + /// So the rule is not an ordering to get right, it is a *scope*: this touches what the file + /// carries. Dropping the guid-keyed stores across a realm change is + /// [`Self::clear_world_session`]'s, and it runs at every world entry regardless. + /// + /// Replace rather than merge — the realm-change case, 2223's own reasoning: the previous + /// realm's templates are not this realm's. The generation ticks because a landed record is + /// exactly what the gated feeds watch for (1439). + pub(crate) fn install_persisted(&mut self, loaded: NameCache) { + self.creatures = loaded.creatures; + self.generation = self.generation.wrapping_add(1); + } + /// The landed-answer counter — see the [`Self::generation`] field. pub(crate) fn generation(&self) -> u64 { self.generation diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 4a6a6c8b..16558f69 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -60,6 +60,9 @@ pub(super) struct WarmLanes<'w> { /// The image store (0958): the twin booth's render target and the effect-lane warm's /// stand-in texture are created here for the life of the pass. pub(super) images: ResMut<'w, Assets>, + /// The UI quad store (2262): the minimap interior composite's tile material is built here + /// through its own production builder, for the tile-quad warm rig below. + ui_quads: ResMut<'w, Assets>, } /// One portrait/paperdoll booth camera + its layer ([`crate::portrait`], 0938): the booths run @@ -95,6 +98,7 @@ pub(super) fn spawn_menagerie( cam: Entity, booth: Option<(Entity, &bevy::camera::visibility::RenderLayers)>, warm_booth: &(Entity, bevy::camera::visibility::RenderLayers), + warm_ortho: &(Entity, bevy::camera::visibility::RenderLayers), meshes: &mut Assets, materials: &mut Assets, lanes: &mut WarmLanes, @@ -383,6 +387,14 @@ pub(super) fn spawn_menagerie( // (the class real bakes install) — that otherwise compiles live on the first in-world // portrait. A unit's gear (any family, any layout, far-swapped included when submerged) // can reach a booth pane. Shard rows can't (particle instances never ride booth layers). + // + // …and onto the ORTHOGRAPHIC twin (2262), the third projection class: the UI model tile + // atlas's camera. Its rigs are built by `ui_models` through `portrait::material_variant`, + // which clones the world material and swaps only the light buffer — the same pipeline key, + // under a class nothing warmed. **Minus the far twins**, which the booths do take: a far + // twin is `model_render::classify_water_side` swapping a material for an eye-and-model + // straddle of a water plane, and a tile is a `` widget's own file on its own stage — + // there is no water plane in a tile and no classify pass runs over one. for (mesh, aabb, skinned) in &layouts { for mat in mats.iter().chain(far_mats.iter()) { spawn_model_rig(commands, cam, None, mesh, aabb, *skinned, mat); @@ -410,6 +422,18 @@ pub(super) fn spawn_menagerie( ); count += 1; } + for mat in &mats { + spawn_model_rig( + commands, + warm_ortho.0, + Some(warm_ortho.1.clone()), + mesh, + aabb, + *skinned, + mat, + ); + count += 1; + } if !*skinned { for mat in shard_mats.iter().chain(far_shard_mats.iter()) { spawn_model_rig(commands, cam, None, mesh, aabb, false, mat); @@ -464,7 +488,8 @@ pub(super) fn spawn_menagerie( let (plain_mesh, plain_aabb, _) = layouts[0].clone(); let (colours_mesh, colours_aabb, _) = layouts[2].clone(); let posuv = meshes.add(warm_pos_uv_mesh()); - let liquid_mesh = meshes.add(warm_liquid_mesh()); + let liquid_mesh = meshes.add(warm_liquid_mesh(false)); + let liquid_color_mesh = meshes.add(warm_liquid_mesh(true)); // Celestial discs + glares (`sun::setup` quads: position+normal+UV). for mat in lane_handles(&mut lanes.celestial) { spawn_lane_rig( @@ -514,14 +539,40 @@ pub(super) fn spawn_menagerie( &mut count, ); } - // Liquid: every kind × fog-block material `setup_liquid` built, on the liquid grid layout. + // Liquid: every kind × fog-block material `setup_liquid` built, on BOTH of the liquid grid's + // layouts. An **interior** WMO pool bakes its `MOMT.diffColor` into a fifth attribute — + // `ATTRIBUTE_COLOR`, `liquid::surface::liquid_bevy_mesh` — which `liquid.wgsl:275` reads behind + // `#ifdef VERTEX_COLORS`: a different shader-def set AND a different vertex buffer, so a + // different pipeline, exactly the way the minimap composite's quad was (2262). The ADT and + // WMO-exterior lanes pass `None` and take the shader's `#else` white. Warming only the + // four-attribute form left the first indoor water pool — a cave, a dungeon, an inn's basin — + // compiling live on the frame it came into view. for mat in lane_handles(&mut lanes.liquid) { - spawn_lane_rig(commands, cam, None, &liquid_mesh, None, mat, &mut count); + spawn_lane_rig( + commands, + cam, + None, + &liquid_mesh, + None, + mat.clone(), + &mut count, + ); + spawn_lane_rig( + commands, + cam, + None, + &liquid_color_mesh, + None, + mat, + &mut count, + ); } // The plain-`StandardMaterial` lanes (0938 — the director's evening log). The fallback cube // (`entities::CubeAssets`, drawn while any entity's model streams) uses the production mesh // + materials, on the world camera AND a booth layer (a cube-bodied target can reach a - // portrait pane). The nameplate and raid-mark materials are built on first need, so the lane + // portrait pane) — but NOT the orthographic twin (2262): `ui_models` has no cube fallback at + // all, a tile's root simply stays bare until its M2 is resident, so no cube ever draws + // through the tile camera. The nameplate and raid-mark materials are built on first need, so the lane // warms REPRESENTATIVES with the builders' exact key fields (`nameplates::spawn_nameplates`, // `raid_marks::place_marks` — texture presence is not a key axis); the plate/mark quads // share the static-plain attribute set. @@ -590,6 +641,43 @@ pub(super) fn spawn_menagerie( mark, &mut count, ); + + // The **minimap interior composite's tile quad** (1466, warmed by 2262). A `Material2d` + // pipeline is keyed on `(view key, MESH LAYOUT)`, and `UiQuadMaterial` therefore has TWO + // pipelines, not the one `warm_ui_quad_lane`'s doc claimed: the HUD's batch mesh is + // POSITION+UV_0+COLOR, the composite's tile is `Rectangle` — POSITION+NORMAL+UV_0. The + // composite's own camera cannot be warmed through (it is `is_active: false` until the player + // is inside a baked WMO interior, and an inactive camera is not extracted at all, so there is + // no view to specialise against) — but it does not need to be: the two cameras' VIEW keys are + // byte-identical (both `Msaa::Off`, neither HDR, same tonemap defs — confirmed by diffing the + // two variants in a `WOW_PIPE_TRACE` inventory, which differ in nothing but the vertex defs + // and buffer layout). So drawing the composite's mesh + its production material through the + // player-UI camera mints exactly the composite's pipeline, under the cover. + // + // The texture is a real 1×1 stand-in rather than a default handle, so the material's + // `AsBindGroup` cannot land on the retry path: a `#[texture]` binding whose `Some(handle)` + // does not resolve returns `RetryNextUpdate`, and a warm rig that never prepares is a warm + // rig that draws nothing and warms nothing, silently. `warm_effect_lane` binds a stand-in + // for the same reason. + let tile_tex = lanes.images.add(Image::default()); + commands.spawn(( + Mesh2d(meshes.add(crate::ui_pass::tile_quad_mesh())), + MeshMaterial2d( + lanes + .ui_quads + .add(crate::ui_pass::UiQuadMaterial::interior_tile( + tile_tex, + crate::minimap::INTERIOR_TILE_ALPHA_REF, + )), + ), + // Visible — unlike the model rigs, which the pacing reveals a slice at a time. It is one + // pipeline, drawn once at a scale that covers no pixel worth speaking of. + Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(0.001)), + crate::ui_pass::ui_render_layers(), + WarmRig, + )); + count += 1; + count } @@ -705,9 +793,16 @@ fn warm_pos_uv_mesh() -> Mesh { m } -/// A tiny quad in the liquid grid's layout — POSITION + NORMAL + UV_0 + UV_1 -/// (`liquid::surface` inserts exactly these four attributes). -fn warm_liquid_mesh() -> Mesh { +/// A tiny quad in the liquid grid's layout — POSITION + NORMAL + UV_0 + UV_1, plus +/// `ATTRIBUTE_COLOR` when `body_color` is set. +/// +/// **Both forms are real** (2262's lesson, found by the sweep after it): `liquid_bevy_mesh` takes +/// an `Option<[f32; 3]>` body colour and inserts the fifth attribute only for an INTERIOR WMO +/// pool that is not fullbright — the ADT and WMO-exterior lanes pass `None`. `liquid.wgsl:275` +/// reads it behind `#ifdef VERTEX_COLORS`, so the two are separate pipelines. This doc used to +/// claim `liquid::surface` "inserts exactly these four attributes", which is what kept the second +/// one out of the menagerie. +fn warm_liquid_mesh(body_color: bool) -> Mesh { let mut m = Mesh::new( PrimitiveTopology::TriangleList, RenderAssetUsages::default(), @@ -725,6 +820,10 @@ fn warm_liquid_mesh() -> Mesh { m.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]); m.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs.clone()); m.insert_attribute(Mesh::ATTRIBUTE_UV_1, uvs); + if body_color { + // The production insert's shape exactly (`liquid_bevy_mesh`): one RGBA per vertex, alpha 1. + m.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[1.0, 1.0, 1.0, 1.0]; 4]); + } m.insert_indices(Indices::U32(vec![0, 1, 2, 0, 2, 3])); m } @@ -806,13 +905,20 @@ mod tests { // (Its world-lane twin, benilla-world's `FfxCombinePipeline`, is outside this scan // and compiles under the same cover: the player-UI camera's backdrop pair is keyed on // that camera's own main texture and specialised on its first frame, pre-world, and a - // bake's pair on the bake image's fixed format.) + // bake's pair on the bake image's fixed format. That list was one short — a world view + // also has its OWN pair, for the frames nothing claims it, and 2262 found it compiling + // live at app exit. `prepare_textures` now specialises that pair on every frame rather + // than only on the frame it first needs it.) let exempt = ["UiGammaPipeline"]; - let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap() - + &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap(); + let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap() + + &std::fs::read_to_string(own_src.join("pipe_warm/menagerie.rs")).unwrap(); let mut missing = Vec::new(); - for (path, text) in walk_rs(&src_root) { + let files: Vec<_> = workspace_src_roots() + .iter() + .flat_map(|r| walk_rs(r)) + .collect(); + for (path, text) in files { for needle in [ "impl SpecializedRenderPipeline for ", "impl SpecializedMeshPipeline for ", @@ -838,6 +944,41 @@ mod tests { ); } + /// **The scan root: every crate in the workspace, not just this one.** + /// + /// Both lane-coverage scans below used to walk `CARGO_MANIFEST_DIR/src` — which is + /// `crates/benilla-app/src`, and every lane either scan exists to catch lives in + /// **`benilla-world`**: all eight 3-D `MaterialPlugin` registrations, and both real hand-rolled + /// pipelines (`FfxCombinePipeline`, `EffectPipeline`). The only `Specialized*Pipeline` impl in + /// benilla-app is `UiGammaPipeline`, which the test then exempts — so the custom-lane assertion + /// could not fail, ever, and the material assertion could only ever see the two 2-D/UI + /// families. Both tests were green because they were looking at an empty room; the exempt list + /// even named `TerrainMaterial`/`WdlMaterial`, types the walk could never reach. Found by the + /// sweep after 2262, which is the third time in this file's history that a lane shipped + /// unwarmed because the instrument could not see it. + fn workspace_src_roots() -> Vec { + // `crates/benilla-app` → `crates`, then every crate's `src` under it. + let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("CARGO_MANIFEST_DIR always has a parent") + .to_path_buf(); + let mut roots: Vec = std::fs::read_dir(&crates) + .expect("the crates/ directory is always readable from a test") + .filter_map(|e| { + let p = e.ok()?.path().join("src"); + p.is_dir().then_some(p) + }) + .collect(); + roots.sort(); + assert!( + roots.len() > 1, + "the lane scans must walk the whole workspace — a single root is the bug this \ + function exists to prevent (only {roots:?} found under {})", + crates.display(), + ); + roots + } + /// Every `.rs` file under `src`, read — shared by both lane-coverage scans. fn walk_rs(src_root: &std::path::Path) -> Vec<(std::path::PathBuf, String)> { let mut out = Vec::new(); @@ -882,12 +1023,16 @@ mod tests { ("Material2dPlugin::<", &[]), ("UiMaterialPlugin::<", &["AddUiMaterial"]), ]; - let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); // "Named in this file" = anywhere in the pipe_warm module folder. - let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap() - + &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap(); + let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap() + + &std::fs::read_to_string(own_src.join("pipe_warm/menagerie.rs")).unwrap(); let mut missing = Vec::new(); - for (path, text) in walk_rs(&src_root) { + let files: Vec<_> = workspace_src_roots() + .iter() + .flat_map(|r| walk_rs(r)) + .collect(); + for (path, text) in files { for (needle, exempt) in families { for (i, _) in text.match_indices(needle) { // A preceding ident char means this match is really a longer family's name diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs index 1af7e9ab..5359762f 100644 --- a/crates/benilla-app/src/pipe_warm/mod.rs +++ b/crates/benilla-app/src/pipe_warm/mod.rs @@ -110,7 +110,15 @@ pub(crate) fn plugin(app: &mut App) { }); app.insert_resource(PipeWatch(shared.clone())); app.init_resource::(); - app.add_systems(Last, (publish_cover, publish_compile_burst)); + app.add_systems( + Last, + ( + publish_cover, + publish_compile_burst, + record_warmed_views, + census_view_classes, + ), + ); // Before the Present stage so the loading screen reads this frame's gate, not last frame's. app.add_systems( Update, @@ -321,6 +329,15 @@ pub(crate) struct WarmPass { /// LIVE" warn is exactly the instrument for it — which is why this can be a latch and not a /// guess. A timeout does NOT latch it: something was still pending. warmed_once: bool, + /// **The cameras the menagerie parents rigs to** — the world camera, one real portrait booth, + /// the twin booth and the orthographic twin. Recorded as entities at spawn so + /// [`record_warmed_views`] can read their LIVE view key each frame instead of restating what + /// the spawn code meant (2264). + anchors: Vec, + /// **The view keys those anchors actually carried while the pass ran** — the census's only + /// notion of "warm". Never a rule, never an inference: if a rig did not render through it, + /// it is not in here. + warmed_views: Vec, /// Pacing state (1116): rigs warmed so far, when the last slice went out, how many frames /// the reveal spanned, and the slice currently on screen (hidden again next frame). revealed: usize, @@ -461,13 +478,23 @@ fn run_warm_pass( commands .entity(warm_booth.0) .insert((WarmRig, WarmBoothCam)); + // The orthographic twin (2262): the THIRD projection class, the one the UI model tile + // atlas draws through. Same deal — a WarmRig, so the despawn paths take it too. + let warm_ortho = crate::ui_models::spawn_warm_tile_cam(&mut commands, &mut lanes.images); + commands.entity(warm_ortho.0).insert(WarmRig); // The effect lane's stand-in texture — held for the life of the pass. warm.effect_tex = Some(lanes.images.add(Image::default())); + // The anchors the census measures "warm" from (2264): every camera the menagerie actually + // hangs rigs on. Recorded as entities, not as remembered shapes. + warm.anchors = vec![cam, warm_booth.0, warm_ortho.0]; + warm.anchors.extend(booth.iter().next().map(|(e, _)| e)); + warm.warmed_views.clear(); let count = spawn_menagerie( &mut commands, cam, booth.iter().next(), &warm_booth, + &warm_ortho, &mut meshes, &mut materials, &mut lanes, @@ -549,6 +576,149 @@ fn run_warm_pass( } } +/// **The view-class census** (2262, rebuilt by 2264) — 0958's claim, turned from a sentence into +/// an instrument that can actually fire. +/// +/// 0958 verified that "the whole 3-D view space is `(samples, projection class)`, and both classes +/// of both sample counts are now warm". True the day it was written; false a month later, when +/// 2013 gave the UI model tile atlas an orthographic camera — a third class, warmed by nothing, +/// whose first tile compiled its whole batch set live. Neither lane-coverage gate test can see a +/// *camera*, so nothing caught it. +/// +/// **2262's first attempt at this census could not fire.** It decided what was warm from a rule it +/// held in its own head — "samples=1 is warm in all three classes, and the world camera's own pair +/// is warm" — and then skipped every camera matching it. But `gxMultisample` registers `"1"` +/// (`cvars.rs`), `MsaaSetting::default()` is 1, and **only the world camera ever reads it** +/// (`player/setup.rs:99`, `:118`); every other 3-D camera hard-codes `Msaa::Off` through +/// `booth_view_shape()`. So `samples == 1` covered the entire population and the loop body was +/// unreachable — including for the very camera 2013 added. An instrument that restates what the +/// author believed is not an instrument. +/// +/// So it no longer believes anything. [`WarmPass::warmed_views`] records the view key of each +/// camera the menagerie **actually parented rigs to**, read off those cameras live while the pass +/// runs; the census compares every `Camera3d` against that recorded set and nothing else. It also +/// runs for the whole session rather than once at drain, warning once per distinct unwarmed key, +/// because the class a booth is warm in is one it installs *at runtime* on its first bake — which +/// a single read at drain is too early to see. +fn census_view_classes( + warm: Res, + mut reported: Local>, + cams: ViewCamQuery, +) { + if !warm.warmed_once { + return; + } + for (name, projection, msaa, hdr) in &cams { + let class = view_class(projection, msaa, hdr); + if warm.warmed_views.contains(&class) || reported.contains(&class) { + continue; + } + reported.push(class); + warn!( + "pipeline warm: camera {} draws through {} — a view key the menagerie never rendered \ + a rig through, so its whole model-pipeline space compiles LIVE on first sight. Give \ + it a warm arm (decisions 0958/2262/2264). Warm keys this session: {}.", + name.map_or("", Name::as_str), + class.describe(), + warm.warmed_views + .iter() + .map(ViewClass::describe) + .collect::>() + .join(", "), + ); + } +} + +/// Every 3-D camera in the world, for [`census_view_classes`]: the name it reports itself by, and +/// the three components that decide its mesh-pipeline view key. +type ViewCamQuery<'w, 's> = Query< + 'w, + 's, + ( + Option<&'static Name>, + Option<&'static Projection>, + Option<&'static Msaa>, + Has, + ), + With, +>; + +/// The key a 3-D view contributes to `MeshPipelineKey`, as [`census_view_classes`] compares them. +/// +/// `hdr` is here because it is a key bit in its own right AND the gate on two more: bevy admits +/// `TONEMAP_IN_SHADER` and `DEBAND_DITHER` into the key only when the view is **not** HDR +/// (`bevy_pbr-0.18.1` `render/mesh.rs:418`), and `Camera3d`'s required components hand a camera +/// both by default. 0958's "every 3-D camera is HDR so tonemap/dither are dead axes" is true of +/// every camera we spawn today and is exactly the kind of sentence this census exists to stop +/// trusting: a new 3-D camera that forgets `Hdr` flips three key bits at once, and 2262's version +/// did not look at it. +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub(crate) struct ViewClass { + projection: &'static str, + samples: u32, + hdr: bool, +} + +impl ViewClass { + fn describe(&self) -> String { + format!( + "{}/samples={}/{}", + self.projection, + self.samples, + if self.hdr { "hdr" } else { "no-hdr" } + ) + } +} + +fn view_class(projection: Option<&Projection>, msaa: Option<&Msaa>, hdr: bool) -> ViewClass { + ViewClass { + // bevy_pbr folds exactly these three into `MeshPipelineKey` (`bevy_pbr-0.18.1` + // `render/mesh.rs:397`); the match is exhaustive so a fourth variant upstream stops the + // build here rather than opening a silent hole. A view with no `Projection` at all sets + // no bits, which is the NONSTANDARD pattern — the same class as Custom. + projection: match projection { + Some(Projection::Perspective(_)) => "Perspective", + Some(Projection::Orthographic(_)) => "Orthographic", + Some(Projection::Custom(_)) | None => "Custom", + }, + samples: msaa.copied().unwrap_or_default().samples(), + hdr, + } +} + +/// The menagerie's anchor cameras, read for their LIVE view key by [`record_warmed_views`] — +/// entity first, so the recorder can match against [`WarmPass::anchors`]. +type AnchorViewQuery<'w, 's> = Query< + 'w, + 's, + ( + Entity, + Option<&'static Projection>, + Option<&'static Msaa>, + Has, + ), + With, +>; + +/// Record the view key of every camera the menagerie actually parents rigs to, while the pass is +/// running. Read off the live cameras rather than restated from what the spawn code intended — +/// that restatement is what made 2262's census unable to fire. +fn record_warmed_views(mut warm: ResMut, cams: AnchorViewQuery) { + if warm.spawned_at.is_none() || warm.done { + return; + } + let anchors = warm.anchors.clone(); + for (e, projection, msaa, hdr) in &cams { + if !anchors.contains(&e) { + continue; + } + let class = view_class(projection, msaa, hdr); + if !warm.warmed_views.contains(&class) { + warm.warmed_views.push(class); + } + } +} + /// Tear the pass down by despawning only its ROOT entities. `despawn` is recursive, and the twin /// booth's rigs are *children* of the twin booth camera — itself a `WarmRig` — so despawning /// every query row queues the children twice (once explicitly, once via the parent's recursion): @@ -576,11 +746,16 @@ fn despawn_rigs(commands: &mut Commands, rigs: &Query<(Entity, Option<&ChildOf>) /// half exercised too. Per-frame like the gizmo line: the stream clears every frame. /// /// [`EffectPipelineKey`]: benilla_world::particles::render::EffectPipelineKey -/// The HUD-substrate warm (0958's sweep, residual): `UiQuadMaterial` is exactly ONE pipeline, -/// and on a normal entry it compiles covered because the HUD's first quad batch lands under the -/// cover — but nothing structural holds that timing (a slow Interface load would land it after -/// the lift). One invisible overlay quad per warm frame pins the compile inside the cover -/// window; the append lane clears itself every frame, so nothing lingers. +/// The HUD-substrate warm (0958's sweep, residual): on a normal entry the HUD's first quad batch +/// lands under the cover — but nothing structural holds that timing (a slow Interface load would +/// land it after the lift). One invisible overlay quad per warm frame pins the compile inside the +/// cover window; the append lane clears itself every frame, so nothing lingers. +/// +/// This warms the HUD's **batch mesh** layout (POSITION + UV_0 + COLOR) only. 0958 read +/// `UiQuadMaterial` as "exactly ONE pipeline"; it is two, because a `Material2d` pipeline is +/// keyed on the mesh layout as well as the view, and the minimap interior composite draws the +/// same material on a `Rectangle` (POSITION + NORMAL + UV_0). That second one is warmed as a rig +/// in [`menagerie`], not here — this lane's stream only ever builds the batch layout (2262). fn warm_ui_quad_lane(warm: Res, mut quads: ResMut) { if warm.spawned_at.is_none() || warm.done { return; @@ -700,3 +875,77 @@ fn warm_effect_lane( ); } } + +#[cfg(test)] +mod census_tests { + use super::{view_class, ViewClass}; + use bevy::camera::{OrthographicProjection, PerspectiveProjection, Projection}; + use bevy::render::view::Msaa; + + fn perspective() -> Projection { + Projection::Perspective(PerspectiveProjection::default()) + } + + fn orthographic() -> Projection { + Projection::Orthographic(OrthographicProjection::default_3d()) + } + + /// The warm set as it stood the day 2013 landed: the world camera's Perspective and the two + /// `Msaa::Off` booth classes. No orthographic arm — because there was no orthographic camera + /// when 0958 wrote the census down. + fn warm_set_before_2262() -> Vec { + vec![ + view_class(Some(&perspective()), Some(&Msaa::Off), true), + view_class(Some(&perspective()), Some(&Msaa::Sample4), true), + view_class(None, Some(&Msaa::Off), true), + ] + } + + /// **The census must fire for decision 2013's tile camera.** This is the case 2262's first + /// version could not report: that camera is `Msaa::Off` like every other booth, and 2262 + /// skipped `samples == 1` outright, so the loop body was unreachable for it — and for every + /// other camera the client spawns, since `gxMultisample` defaults to 1 and only the world + /// camera ever reads it. The census now compares against the keys rigs actually rendered + /// through, so a class nothing warmed is a class it names. + #[test] + fn the_census_fires_for_an_unwarmed_orthographic_camera() { + let warm = warm_set_before_2262(); + let tile_cam = view_class(Some(&orthographic()), Some(&Msaa::Off), true); + assert!( + !warm.contains(&tile_cam), + "the ui_models tile camera's view key must read as unwarmed against a warm set that \ + has no orthographic arm — this is the report 2262 was written to produce" + ); + // And the rule 2262 actually shipped would have swallowed it. + assert_eq!( + tile_cam.samples, 1, + "the tile camera is Msaa::Off, like every booth" + ); + } + + /// The other half: a class the menagerie DID render through is silent, so the census cannot + /// cry wolf on the cameras it is meant to bless. + #[test] + fn the_census_is_silent_for_every_warmed_class() { + let warm = warm_set_before_2262(); + for class in &warm { + assert!(warm.contains(class)); + } + let booth_after_first_bake = view_class(None, Some(&Msaa::Off), true); + assert!( + warm.contains(&booth_after_first_bake), + "a booth's runtime-installed custom projection is the NONSTANDARD class, warmed by \ + the twin booth (0958)" + ); + } + + /// `Hdr` is a key bit and the gate on two more (tonemap, deband — `bevy_pbr` `mesh.rs:418`). + /// 2262's census did not read it, so a 3-D camera that forgot `Hdr` was invisible to it. + #[test] + fn dropping_hdr_is_a_different_view_key() { + let with = view_class(Some(&perspective()), Some(&Msaa::Off), true); + let without = view_class(Some(&perspective()), Some(&Msaa::Off), false); + assert_ne!(with, without); + assert!(!warm_set_before_2262().contains(&without)); + } +} diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 1edf2e3a..b9453887 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -224,12 +224,19 @@ pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1; /// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs /// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer. pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1; +/// pipe_warm's **orthographic twin camera**'s layer ([`crate::ui_models::spawn_warm_tile_cam`], +/// decision 2262) — the next one past the twin booth's, same ladder rule. bevy_pbr keys a mesh +/// pipeline on the view's projection CLASS, and the tile atlas's one camera is +/// `Projection::Orthographic`: a THIRD class beside the world camera's Perspective and the twin +/// booth's custom `WowPortraitProjection`. Like [`WARM_BOOTH_LAYER`] this camera exists only while +/// the warm pass runs, and nothing but menagerie rigs ever rides its layer. +pub(crate) const WARM_ORTHO_LAYER: usize = WARM_BOOTH_LAYER + 1; /// The **minimap interior composite**'s render layer (decision 1466) — the next one past the warm /// booth's. Not a portrait booth, but it is an offscreen camera with its own layer, and 0775's rule /// is that EVERY such layer is computed in this one ladder: the two booths that each worked out /// "the next layer past the paper doll's" in their own file landed on the same number, and the /// clash was silent in both rendering and the emitter→camera match. -pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1; +pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_ORTHO_LAYER + 1; /// The UI model tiles' layer (`crate::ui_models`, decision 2008): every `` widget's M2 /// renders into one atlas through one camera on this layer. pub(crate) const UI_MODELS_LAYER: usize = MINIMAP_COMPOSITE_LAYER + 1; @@ -262,7 +269,8 @@ const _: () = assert!( && INSPECT_LAYER != GLUE_LAYER && DRESSUP_LAYER > GLUE_LAYER && WARM_BOOTH_LAYER > DRESSUP_LAYER - && MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER + && WARM_ORTHO_LAYER > WARM_BOOTH_LAYER + && MINIMAP_COMPOSITE_LAYER > WARM_ORTHO_LAYER && UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER && UI_MODEL_CAM_LAYER_BASE > UI_MODELS_LAYER, "booth render layers must be distinct — see GLUE_LAYER" diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 7ebe974d..14c0c085 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -762,7 +762,18 @@ pub(super) fn play_kit_ext( return Ok(false); } - let mixer = out.mixer.as_mut().context("no audio device")?; + // **No device is a no-op, not an error.** Running silent (`WOW_NOSOUND=1`, CI, an unattended + // probe — or a player whose device is genuinely gone) is a startup fact `sound::plugin` + // already warns about ONCE. Returning `Err` here turned that one fact into a warn PER EVENT + // at every one of this module's ~25 call sites: a 75 s lava probe logged 3975 identical + // `no audio device` lines, 89% of the file, which is why `liquid_loop` grew a guard of its + // own — and a probe sweep still footstepped one warn every half-second. The fix belongs here, + // at the one place that knows, rather than as a guard repeated at each caller. `false` is the + // signal this function already uses for "the gates dropped it", which is exactly what + // happened. + let Some(mixer) = out.mixer.as_mut() else { + return Ok(false); + }; let (track, handle) = match pos { Some(p) => { // `EAXDef 0` = no `SoundSamplePreferences` row = the reference's NULL-slot skip at @@ -984,7 +995,10 @@ pub(crate) fn play_file( if !claim_voice(out, amp) { return Ok(()); } - let mixer = out.mixer.as_mut().context("no audio device")?; + // No device is a no-op here too — see the note in [`play_kit_ext`]. + let Some(mixer) = out.mixer.as_mut() else { + return Ok(()); + }; let handle = mixer.play_2d(data)?; if let Some(probe) = out.probe.as_ref() { probe.note_play(0, path, "sfx", "2d"); diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index 59a47174..c2238f90 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -755,6 +755,63 @@ fn setup_tiles( let _ = light_buf; } +/// pipe_warm's **orthographic twin camera** (decision 2262) — the ortho leg's view key space, the +/// way [`crate::portrait::spawn_warm_booth`] is the custom-projection one (0958). +/// +/// bevy_pbr folds the view's projection **class** into `MeshPipelineKey` (`bevy_pbr-0.18.1` +/// `render/mesh.rs:397` — `Perspective | Orthographic | Custom`, emitted as the +/// `VIEW_PROJECTION_*` shader def at `:2549`), so one material is a *different pipeline* per +/// class. 0958's census closed with "the whole 3-D view space is `(samples, projection class)`, +/// and both classes of both sample counts are now warm" — true on 2026-08-04, when the only +/// classes were the world camera's Perspective and the booths' custom `WowPortraitProjection`. +/// Decision 2013 added [`setup_tiles`]' orthographic camera a month later and nothing widened the +/// warm pass, so the first UI model tile of a session — a cooldown pie, a minimap ping, an +/// item-push card — compiled its batches live, uncovered, on the render thread. +/// +/// This camera is that missing class in the tile camera's exact shape: [`booth_view_shape`] +/// (`Msaa::Off`, HDR, no tonemap) and `FfxGlow::UI_PANE`, spawned right beside the real one above +/// so the two cannot drift apart. The REAL tile camera is deliberately not borrowed for warming — +/// it is `is_active: false` until a pane packs a cell, and switching it on would draw the whole +/// menagerie into the live atlas. Only the projection's CLASS keys the pipeline, never its +/// numbers; they mirror the real camera's regardless, for the same anti-drift reason. +pub(crate) fn spawn_warm_tile_cam( + commands: &mut Commands, + images: &mut Assets, +) -> (Entity, RenderLayers) { + let layer = RenderLayers::layer(crate::portrait::WARM_ORTHO_LAYER); + // The atlas's own minimum size, the way `spawn_warm_booth` takes the real booths'. A render + // target's SIZE reaches no pipeline key (the combine pair is keyed on format; mesh pipelines + // specialise at queue time, before anything rasterises), so this could be tiny — and a 64² + // arm was measured against this one: 4.59 s vs 4.61 s of warm drain, i.e. nothing. The pass's + // extra cost is the wider cross it reveals, not the pixels this camera fills, so the size + // stays the one that matches the camera being warmed. + let image = images.add(new_target_image_sized(ATLAS_MIN, ATLAS_MIN)); + let cam = commands + .spawn(( + Name::new("pipe_warm orthographic twin camera"), + booth_view_shape(), + Camera { + order: TILE_CAMERA_ORDER - 1, + clear_color: ClearColorConfig::Custom(Color::NONE), + ..default() + }, + RenderTarget::Image(image.into()), + benilla_world::ffx_glow::FfxGlow::UI_PANE, + Projection::Orthographic(OrthographicProjection { + near: 0.1, + far: 2000.0, + scaling_mode: ScalingMode::Fixed { + width: ATLAS_MIN as f32, + height: ATLAS_MIN as f32, + }, + ..OrthographicProjection::default_3d() + }), + layer.clone(), + )) + .id(); + (cam, layer) +} + /// The bevy-space → tile-camera-space rotation: WoW `+X` (bevy `−Z`) to the right, WoW `+Y` /// (bevy `−X`) up, WoW `+Z` (bevy `+Y`) toward the viewer — the ortho leg's axes (§2). A proper /// rotation (determinant +1), so winding survives. diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 1cacca5d..bef5e7f9 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -353,7 +353,7 @@ fn clear_ui_overlays(mut quads: ResMut) { /// doesn't need a mesh-visible layer at all). const UI_RENDER_LAYER: usize = 1; -fn ui_render_layers() -> RenderLayers { +pub(crate) fn ui_render_layers() -> RenderLayers { RenderLayers::layer(UI_RENDER_LAYER) } @@ -480,6 +480,21 @@ impl UiQuadMaterial { } } +/// The mesh every `UiQuadMaterial` quad that is **not** part of the HUD batch stream draws with: +/// a 1×1 rectangle at the origin, scaled and placed by its own Transform (decision 1466 — a +/// panning composite never rewrites a vertex buffer). +/// +/// It has ONE author because a `Material2d` pipeline is keyed on `(view key, MESH LAYOUT)`, and +/// these two quad families have different layouts: `Rectangle` gives POSITION + NORMAL + UV_0 +/// (`bevy_mesh` `primitives/dim2.rs`), the HUD's batch mesh POSITION + UV_0 + COLOR +/// ([`rebuild_ui_mesh`]). So `UiQuadMaterial` is **two** pipelines, not one — the claim pipe_warm +/// carried until decision 2262, which is why the composite's compiled live on the first step into +/// a WMO interior. `pipe_warm`'s menagerie warms this exact mesh; drifting it here would take the +/// warm rig with it. +pub(crate) fn tile_quad_mesh() -> Mesh { + Rectangle::new(1.0, 1.0).into() +} + impl Material2d for UiQuadMaterial { /// Our own vertex stage, in the same file: bevy's carries no per-instance data past the /// transform, and the run's tint rides the instance tag (see [`tint_tag`]). diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index d87a6238..161498cb 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -534,7 +534,11 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { fn the_roster_seat_names_the_character_the_addons_will_meet() { let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player"); assert_eq!(seat.name.as_deref(), Some("Harness")); - assert_eq!(seat.level, 60); + // **0, not 60 — the reference's answer, byte-verified** (decision 2263). `UnitLevel 0x517fc0` + // carries no `"player"` fast path at all: it resolves the token, misses (no object, no roster + // record for a zero GUID) and reaches `0x51813e push 0; push 0` — the NUMBER 0, one return. + // The roster's real level arrives with the descriptor, within the second. + assert_eq!(seat.level, 0); assert_eq!(seat.race_file.as_deref(), Some("Human")); assert_eq!(seat.class_file.as_deref(), Some("WARRIOR")); assert_eq!(seat.sex, 2, "the wire's 0 is UnitSex's 2"); @@ -543,7 +547,17 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() { Some("Alliance"), "nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation" ); - assert!(seat.exists && seat.is_player); + // **`exists` is false, and that is the correction 2263 made** (it was `true` from 1230 until + // then). `UnitExists 0x515fb0` has no fast path either; its resolver reads the GUID out of the + // OBJECT (`0x515994`), so with none it holds `0:0`, and the roster fallback `0x491900` bails + // on a zero GUID at `0x4e80aa je` BEFORE fetching the active player — so the `0 == 0` that + // would answer "that's me" never runs, and `0x516001` pushes nil. + // + // Safe to correct only *because* of the record above: 1230 seated a whole unit in order to + // deliver a name at addon file scope, and the name no longer needs a unit to exist. The rest + // of the seat is untouched and still load-bearing — the faction side below above all. + assert!(!seat.exists); + assert!(seat.is_player); // Deliberately NOT invented — the descriptor says these, within the second. assert_eq!((seat.health, seat.max_health), (0, 0)); diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index aca510e3..adbf603e 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -502,6 +502,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { script.set_realm_name(&realm); // …and so does the PLAYER, for the same reason and with more riding on it — see // [`seat_from_roster`], which is where the why lives. + // + // **The player RECORD first, and separately** (decisions 2261/2263). The reference keeps the + // local player's name, race, class and gender somewhere no cache, feed or object can reach — a + // copy of the char-enum row written at the Enter World commit (`0x5abd9e`) and never cleared — + // and `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only that, unconditionally, even after + // the descriptor exists. The snapshot below is the descriptor's stand-in until the real one + // streams in and is replaced when it does; the record outlives every push. Seeded here because + // our VM is rebuilt per login (1290) while the reference's record simply persists, so each new + // VM has to be told once — before the addon walk, like the realm. + if let Some(record) = world + .get_resource::() + .and_then(record_from_roster) + { + script.set_player_record(record); + } if let Some(seat) = world .get_resource::() .and_then(seat_from_roster) @@ -660,6 +675,43 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { } } +/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s own 2/3 scale — the one mapping, so +/// the record and the snapshot can never disagree about which is which. +fn roster_sex(gender: u8) -> u8 { + match gender { + 0 => 2, + 1 => 3, + _ => 0, + } +} + +/// **The local player record the UI loads under** — our copy of the char-enum row the reference +/// copies at the Enter World commit (decision 2263; the bytes are on +/// [`benilla_ui::script::PlayerRecord`]). +/// +/// The same roster row [`seat_from_roster`] builds the `"player"` *snapshot* from, and +/// deliberately the same `race_names`/`class_names` lookups: the reference resolves the record's +/// race and class bytes through `ChrRaces`/`ChrClasses` at call time, so resolving them once here +/// is the same answer, and sharing the lookup is what stops the record and the snapshot drifting +/// into two opinions about what race 4 is. +/// +/// **The level is not here, and that is byte-verified, not an omission** — the reference's record +/// carries a level at `+0x108` and ships an accessor for it (`0x5abe00`) that *nothing calls*, so +/// `UnitLevel("player")` reads the descriptor. +pub(crate) fn record_from_roster( + roster: &crate::char_select::Roster, +) -> Option { + let row = roster.pending_row()?; + let race = crate::ui_unit::race_names(row.race); + let class = crate::ui_unit::class_names(row.class); + Some(benilla_ui::script::PlayerRecord { + name: row.name.clone(), + race: race.map(|(n, f)| (n.to_string(), f.to_string())), + class: class.map(|(n, f)| (n.to_string(), f.to_string())), + sex: roster_sex(row.gender), + }) +} + /// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in /// flight — `None` when there is no pick (a capture, a scenario, a test world). /// @@ -693,20 +745,32 @@ pub(crate) fn seat_from_roster( let row = roster.pending_row()?; let race = crate::ui_unit::race_names(row.race); let class = crate::ui_unit::class_names(row.class); + let sex = roster_sex(row.gender); Some(benilla_ui::script::UnitState { - exists: true, + // **`exists` is FALSE, and the level is 0 — the reference's answers, byte-verified** + // (decision 2263). `UnitExists("player")` `0x515fb0` has no fast path: it resolves the + // token, and the resolver reads the GUID out of the OBJECT (`0x515994`), not out of + // `[mgr+0xc0]` — so with no object it holds `0:0`. The roster fallback `0x491900` then + // bails on a zero GUID at `0x4e80aa je` *before* it fetches the active player, so the + // `0 == 0` that would otherwise answer "yes, that's me" is never reached: `0x516001 + // lua_pushnil`. `UnitLevel 0x517fc0` likewise carries no `"player"` compare at all and + // reaches its total-miss arm `0x51813e push 0; push 0` — the **number 0**, not nil. The + // two misses are deliberately asymmetric and a client that modelled "no data" uniformly + // would be wrong on one of them (wow-re `ui/scratch/unit-verbs-before-player-object.md` + // §2-§3; it also corrected that repo's own `main`, which had published the opposite). + // + // This seat is no longer what makes the player *answerable* — the record above is — so it + // no longer has to claim a unit exists in order to deliver a name. What it still carries + // is the handful of fields the corpus reads at file scope that are neither the record's + // nor the descriptor's to say yet, the faction side below chief among them. + exists: false, name: Some(row.name.clone()), - level: u32::from(row.level), race: race.map(|(n, _)| n.to_string()), race_file: race.map(|(_, f)| f.to_string()), class: class.map(|(n, _)| n.to_string()), class_file: class.map(|(_, f)| f.to_string()), // The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping. - sex: match row.gender { - 0 => 2, - 1 => 3, - _ => 0, - }, + sex, is_player: true, player_controlled: true, // Nil here is not "no faction", it is a state a player character cannot be in, and diff --git a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs index 4fa81f39..ad13fc37 100644 --- a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs @@ -565,3 +565,44 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() { s.errors() ); } + +/// **The CORNER arm must end up owned too — the other half of the existence gate** (decision 2259). +/// +/// The reference's corner arm (`0x492a42`) writes no owner itself; the owner is restored purely by +/// the `+0x444` handler, `OnTooltipSetDefaultAnchor` → `GameTooltip_SetDefaultAnchor(this, +/// UIParent)` → `SetOwner(UIParent, "ANCHOR_NONE")`. And the owner really is 0 on the way in: +/// `Tooltip::Hide 0x530a60` *is* `SetOwner(NULL, 0, 0, 0)`, so every hover starts un-owned. +/// +/// That makes this test the precondition for narrowing the placement fork at all. Moving an object +/// from the cursor arm to the corner arm is only safe while the corner arm produces an OWNED, +/// SHOWN plate — otherwise those objects would build their lines and then hide, which is precisely +/// the failure 2255 had just fixed on the cursor arm. +#[test] +fn a_corner_seated_gameobject_plate_is_owned_and_shown() { + let mut s = harness(&[]); + assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None)); + let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap(); + assert!(owned, "the corner plate is owned; errors: {:?}", s.errors()); + let shown: bool = s + .eval("return GameTooltip:IsShown() and true or false") + .unwrap(); + assert!(shown, "and it is on screen; errors: {:?}", s.errors()); +} + +/// And it survives the same addon hook the cursor arm had to: `!Questie`'s `OnShow` handler ends in +/// `GameTooltip:Show()`, and `:Show()` is the existence gate `0x530a80`. +#[test] +fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() { + let mut s = harness(&[]); + s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#) + .unwrap(); + assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None)); + let shown: bool = s + .eval("return GameTooltip:IsShown() and true or false") + .unwrap(); + assert!( + shown, + "the corner plate is still up; errors: {:?}", + s.errors() + ); +} diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index 9ce2209d..c783efe3 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -1657,3 +1657,68 @@ ScreenProbeHeight = GetScreenHeight() drop(world); let _ = std::fs::remove_dir_all(&tmp); } + +/// **The entry load seeds the player record, not just the snapshot** (decisions 2261/2263). +/// +/// [`super::seat_from_roster`]'s `"player"` push is the descriptor's stand-in and is *replaced* +/// the moment the real one streams in — which is how decision 2260's nameless push reached +/// `UnitName("player")`. The buffer is seeded beside it, from the same roster row, and the verb +/// reads only that; so the token can be replaced by a nameless snapshot or removed outright and +/// the name still answers, exactly as the reference's never-cleared `0xc27d88` does. +#[test] +fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _capture, _home) = hermetic_probe("nameseed"); + let mut world = booted_world(); + log_in_as(&mut world, "Nelprifour", 0x2A); + + assert_eq!( + probe_saw(&world).as_deref(), + Some("Nelprifour"), + "addon file scope reads the live character, as it always has (1230)" + ); + + let mut script = world + .get_non_send_resource_mut::() + .expect("a VM"); + // The feed's 2260 push: the descriptor landed, the name cache missed for our own guid. + script.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + has_object: true, + name: None, + ..Default::default() + }), + ); + assert_eq!( + script + .eval::>(r#"return UnitName("player")"#) + .unwrap() + .as_deref(), + Some("Nelprifour"), + "the record answers, so a nameless snapshot is invisible to the verb" + ); + assert_eq!( + script + .eval::>(r#"local _, t = UnitClass("player"); return t"#) + .unwrap() + .as_deref(), + Some("WARRIOR"), + "…and the same for the other three fields the reference reads off that record (2263)" + ); + + // …and so does the logout despawn, which removes the token altogether. + script.set_unit("player", None); + assert_eq!( + script + .eval::>(r#"return UnitName("player")"#) + .unwrap() + .as_deref(), + Some("Nelprifour") + ); + + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 37952b12..2e7ce490 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -845,22 +845,30 @@ fn drive_mouseover_tooltip( // distinction the director's two reference observations agree on — a **GENERIC(5)** // signpost follows the cursor, an interactable GameObject sits in the corner. // - // Not merely a guess-shaped proxy: after 0762 the only objects that are eligible for a - // tooltip *and* never highlightable are GENERIC ones, so "GENERIC" and "not interactable" - // pick out the same set here. They diverge only for the three always-eligible types - // (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25), which is exactly where a pin would - // settle it. Flagged INTERIM in 0766 rather than presented as verified. + // 0766 keyed this on "is it GENERIC(5)" and said plainly that it was a proxy: the real + // client asks the object's own `[vtbl+0x5c]`, and what selects it was not then pinned. + // **It is pinned now, and it is narrower** (decision 2259): `[vtbl+0x5c]` is `0x5f8630`, + // whose body is `template.data[0x621b00(type, semantic 0x13)] != 0`, and key `0x13` + // resolves for exactly ONE of the 31 GO types — GENERIC(5), at `data[0]`. Every other type + // gets `-1` back and `0x5f8150`'s unsigned bound turns that into FALSE. // - // **THE PIN HAS ARRIVED, AND IT IS NARROWER THAN THIS LINE** (2255): `[vtbl+0x5c]` is - // `0x5f8630` = `template.data[0x621b00(type, 0x13)] != 0`, and semantic key `0x13` exists - // on exactly ONE of the 31 types — GENERIC(5), at `data[0]`. So the cursor arm is "GENERIC - // **with `data[0]` set**", and a GENERIC with it clear is corner-seated; the three - // always-eligible types above are corner-seated too, so 0766's "GENERIC" reading wins over - // its "not interactable" one. Applying it moves 190 of the 1387 hoverable type-5 templates - // from the cursor to the corner — a visible change, so it is its own slice and not a - // passenger on a bug fix (2255 carries the counts and the reasoning). - let cursor_seated = - stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC); + // So the cursor arm is **GENERIC with `data[0]` set**, and a GENERIC with it clear is + // corner-seated like everything else. That also settles 0766's named divergence: the three + // always-eligible types (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25) carry no key + // `0x13`, so all three are corner-seated — 0766's "GENERIC" reading wins over its "not + // interactable" one. + // + // **`data[0]` is not `data[1]`.** The neighbouring slot is the mouseover-ELIGIBILITY column + // (0762, semantic `0x12`, `0x5f4830`), and the two answer different questions: `data[1]` + // says whether the object is hoverable at all, `data[0]` only says *where its plate sits*. + // 342 of the 447 type-5 entries in the reference's own `gameobjectcache.wdb` carry both; + // the objects that differ are hoverable and corner-seated, not silent. + let cursor_seated = stores.get(entity).map(|s| s.0.gameobject_type_id()) + == Ok(GO_TYPE_GENERIC) + && go_inputs + .templates + .get(guid) + .is_some_and(|t| t.floating_tooltip); // Window px → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) — // the anchor this point seats is resolved in UI units, so a raw-px point lands the // plate (s−1)× the cursor's distance from the bottom-left corner away from it. diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index 7312f2b4..bf31c492 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -267,6 +267,31 @@ impl super::UiScript { /// /// Set at world entry from the realm the session actually connected to. Idempotent, and the /// empty string is a legitimate value (no realm yet), not a "clear". + /// Seed the **local player record** — the reference's `0xc27d80`, copied from the char-enum + /// row at the character-select Enter World commit (decisions 2261/2263, and see + /// [`super::PlayerRecord`] for the bytes and the four verbs that read it). + /// + /// Called from the world-entry UI load beside [`Self::set_realm_name`], and for the same + /// reason 1195 put the realm there: the values have to be in the VM before a single addon file + /// runs, because `local currentPlayer = UnitName("player")` at file scope is the corpus idiom. + /// The reference has them a whole login earlier still. + /// + /// **A record with no name is refused, not stored.** The only unset state is "no Enter World + /// has been committed in this process"; once the record holds a character, nothing in the + /// reference's image ever empties it again, so a caller with nothing to say must leave the + /// last answer standing rather than blank it. The write is whole-record because the + /// reference's is one `rep movsd`: these four fields describe one character and can never + /// legitimately be seeded from two. + pub fn set_player_record(&mut self, record: super::PlayerRecord) { + if record.name.is_empty() { + return; + } + let mut model = self.model_mut(); + if model.player_record != record { + model.player_record = record; + } + } + pub fn set_realm_name(&mut self, realm: &str) { { let mut model = self.model_mut(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index ba84ceec..d9812526 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -279,7 +279,8 @@ pub use types::{ }; pub(crate) use types::{FontExplicit, MeasuredText, RegionData}; pub use unit::{ - grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState, + grey_band, level_reads_unknown, power_token, unit_is_grey, PlayerRecord, SelectionRequest, + UnitState, }; pub use weapon_enchant::WeaponEnchant; pub use who_sort::{WhoSortChain, WhoSortKey}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 23932f11..1559a5ab 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1726,6 +1726,26 @@ pub(crate) struct Model { /// payload, so two calls in a frame are two captures. pub(crate) screenshot_asks: u32, pub(crate) realm_name: String, + /// **The local player record — `0xc27d80`, ours** (decisions 2261 and 2263). + /// + /// The bytes, the four verbs that read it and why `UnitLevel` is not among them are on + /// [`super::PlayerRecord`] itself; what belongs here is the *lifetime*, because that is what + /// this field's position in the `Model` decides. + /// + /// **Two writers, and the asymmetry between them is the mechanism.** The world-entry UI load + /// seeds it from the roster row of the pick in flight — our copy of the same char-enum record + /// — beside [`Self::realm_name`] and before the addon walk (1195's slot, 1230's source); and + /// [`super::UiScript::set_unit`] keeps it in step for any `"player"` push that **carries** the + /// field. A push that does not carry it leaves it standing. So the record can be corrected but + /// never blanked, which is the reference's never-cleared property expressed where it can be + /// relied on — and decision 2260 is why that matters: a name-cache miss for our own guid + /// reached Lua as a nameless player snapshot and took KLHThreatMeter down with it. + /// + /// Our VM is rebuilt per login (1290) where the reference's record simply persists, so each new + /// VM is told once. That is the whole of the difference, and it is unobservable: the four verbs + /// do not exist between logins either — they live only in the in-game table `0x850438`, which + /// `UI_Init` installs and the teardown nils, and `GlueScript_RegisterBindings 0x46abb0` skips. + pub(crate) player_record: super::PlayerRecord, /// The hearthstone bind location's NAME, behind `GetBindLocation()` — the app resolves the /// `SMSG_BINDPOINTUPDATE` area id through the same AreaTable catalog the hearthstone's `$z` /// token already uses, and pushes the resolved string here. @@ -2224,6 +2244,7 @@ impl Model { played_time_asks: 0, screenshot_asks: 0, realm_name: String::new(), + player_record: super::PlayerRecord::default(), bind_location: String::new(), gm_ticket_categories: Vec::new(), gm_ticket_intents: Vec::new(), diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index e0d80769..a8795fd7 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -21,9 +21,36 @@ use super::super::binding_abi::flag; use super::super::Model; use super::{ check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token, - unit_predicate, unknownobject, with_unit, SelectionRequest, + unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest, }; +/// **The `"player"` fast path, shared by the four verbs that take it** (decision 2263). +/// +/// `Some(x)` = this token is `"player"` and `x` is the record's answer; `None` = it is any other +/// token and the caller falls through to the unit resolver. That is the reference's own shape — +/// a full-string, case-insensitive compare of the token against `0x847894` (`b"player\0"`) whose +/// **match is the fall-through** — and it is one function rather than four copies for the reason +/// [`super::super::names::gated_rank`](crate)'s sibling comment gives about rank: four readers of +/// one record, each testing the token itself, is how they drift apart. `UnitName`, `UnitRace`, +/// `UnitClass` and `UnitSex` are the whole set; `UnitLevel` is **not** one of them. +/// +/// The compare is a full string, not a prefix: `"playerfoo"` is a recognised-but-unresolvable +/// token ([`super::token_recognised`]) and goes to the resolver, not here. +fn player_record_arm( + lua: &Lua, + token: &Option, + f: impl FnOnce(&PlayerRecord) -> T, +) -> Option { + if !token + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("player")) + { + return None; + } + let model = lua.app_data_ref::().expect("model app_data"); + Some(f(&model.player_record)) +} + /// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares /// the class byte `[[player+0x110]+0x79]` against. That byte is `UNIT_FIELD_BYTES_0` byte 1, the /// same value [`PlayerReqState::class_id`](super::super::PlayerReqState) carries; `UnitClass`'s own @@ -180,8 +207,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // // Value 1 — and the ONLY two nils a recognised token can produce (`0x517020`, wow-re // `ui/scratch/binding-shape-arity-law.md` §2.1): the `"player"` fast path reads the - // local name buffer and pushes nil when it is empty (`0x517083` → `0x5abdc0`), and a - // token that resolves to GUID 0 pushes nil (`0x5170c0`). EVERY other path ends in a + // local name buffer and pushes nil when it is empty (`0x51708c` → `0x5abdc0`; the + // `0x517083` this used to cite is the token's string *compare*, `call 0x64a4c0` — + // corrected 2261), and a token that resolves to GUID 0 pushes nil (`0x5170c0`). + // The empty-buffer nil is not an explicit push either: `0x517095` is + // `lua_pushstring`, which falls through on NULL at `0x6f3895` into `lua_pushnil`. EVERY other path ends in a // string — the cached name, or `FrameScript_GetText("UNKNOWNOBJECT")`: `0x517220` for // a GUID with no object and no cache row, `0x609324` inside `CGUnit_C::GetUnitName` // for a unit whose name cache has not answered or is stale (a pet's is @@ -195,17 +225,30 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // named gap), so a not-selectable unit reads its name there with `UnitExists` nil; the // name resolver's own nil is GUID 0 and nothing else, and a feed that seats a token // has resolved it. Decision 2002. + // + // **The `"player"` arm is the BUFFER, not the snapshot** (decision 2261). `0x51708c` + // reads `0x5abdc0` — the local name buffer at `0xc27d88` — and returns; it never + // reaches the resolver, so no object, no descriptor and no name-query answer is + // involved on this path at all. Modelling it off the snapshot (as this did until + // 2261) put the one name the client always knows behind the one cache that can miss: + // decision 2260's realm-cache load dropped our own guid, the feed pushed a nameless + // player over the roster seat, and `UnitName("player")` read nil in the world. + // + // The buffer's emptiness is still the reference's own nil — it just means something + // a snapshot cannot say, and something a client that is in the world never is: + // "no Enter World has been committed in this process". + if let Some(seeded) = player_record_arm(lua, &token, |r| r.name.clone()) { + let name = if seeded.is_empty() { + Value::Nil + } else { + Value::String(lua.create_string(&seeded)?) + }; + return Ok((name, Value::Nil)); + } let name = with_unit(lua, &token, None, |u| Some(u.name.clone()))?; let name = match name { None => Value::Nil, Some(Some(n)) => Value::String(lua.create_string(&n)?), - Some(None) - if token - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("player")) => - { - Value::Nil - } Some(None) => Value::String(unknownobject(lua)?), }; Ok((name, Value::Nil)) @@ -862,6 +905,18 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { token, r#"Usage: UnitRace("unit")"#, )?); + // The `"player"` arm is the RECORD, not the snapshot (decision 2263): `0x518269` + // reads `0x5abdd0` — `0xc27e80`, the char-enum row's race byte — and resolves it + // through `ChrRaces`, never reaching the unit resolver. See [`PlayerRecord`]. + if let Some(pair) = player_record_arm(lua, &token, |r| r.race.clone()) { + return match pair { + Some((loc, file)) => Ok(( + Value::String(lua.create_string(&loc)?), + Value::String(lua.create_string(&file)?), + )), + None => Ok((Value::Nil, Value::Nil)), + }; + } let pair = with_unit(lua, &token, None, |u| { u.race.clone().zip(u.race_file.clone()) })?; @@ -882,6 +937,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { token, r#"Usage: UnitClass("unit")"#, )?); + // The `"player"` arm is the RECORD (decision 2263): `0x5183b9` → `0x5abde0`, + // `0xc27e81` through `ChrClasses`. Second return is the UPPERCASE token. + if let Some(pair) = player_record_arm(lua, &token, |r| r.class.clone()) { + return match pair { + Some((loc, file)) => Ok(( + Value::String(lua.create_string(&loc)?), + Value::String(lua.create_string(&file)?), + )), + None => Ok((Value::Nil, Value::Nil)), + }; + } let pair = with_unit(lua, &token, None, |u| { u.class.clone().zip(u.class_file.clone()) })?; @@ -945,7 +1011,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { token, r#"Usage: UnitSex("unit")"#, )?); - let sex = with_unit(lua, &token, 0u8, |u| u.sex)?; + // The `"player"` arm is the RECORD (decision 2263): `0x517ef9` → `0x5abdf0`, + // `0xc27e82`. **No bounds check anywhere on that path** — the byte indexes + // `fild [4*eax+0x808be4]` over `{2,3,1,6}` directly — so an unset record answers `2` + // ("male") in the reference too, which is what our `0` maps to below. + let sex = match player_record_arm(lua, &token, |r| r.sex) { + Some(sex) => sex, + None => with_unit(lua, &token, 0u8, |u| u.sex)?, + }; Ok(Value::Integer(i64::from(if sex == 0 { 2 } else { sex }))) })?, )?; diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 22796bfe..322ee4fd 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -53,6 +53,59 @@ pub enum SelectionRequest { LastEnemy, } +/// **The local player record — `0xc27d80`, ours** (decision 2263). +/// +/// A copy of ONE `SMSG_CHAR_ENUM` `CHARACTER_INFO` row, and the source the reference answers four +/// `"player"` verbs from. Each opens with the identical full-string, case-insensitive compare of +/// the token against `0x847894` (`b"player\0"`) whose **match is the fall-through**, and each is +/// unconditional — there is no arm that re-routes `"player"` to the unit resolver, so the +/// descriptor never supersedes this record even once the object exists: +/// +/// | verb | fast path | accessor | byte | +/// |---|---|---|---| +/// | `UnitName` `0x517020` | `0x51708c` | `0x5abdc0` | `+0x08` name | +/// | `UnitRace` `0x518200` | `0x518269` | `0x5abdd0` | `+0x100` → `ChrRaces` | +/// | `UnitClass` `0x518350` | `0x5183b9` | `0x5abde0` | `+0x101` → `ChrClasses` | +/// | `UnitSex` `0x517e90` | `0x517ef9` | `0x5abdf0` | `+0x102` gender | +/// +/// One writer image-wide — `0x5abd9e` (`rep movsd`, 0x44 dwords) inside `CGlueMgr::EnterWorld`, +/// at the character-select commit — and **no instruction anywhere clears it**. It is filled before +/// FrameXML loads and before `CMSG_PLAYER_LOGIN` is sent, which is why the reference can run addon +/// `OnUpdate` for many frames with no local player object and still answer all four (wow-re +/// `ui/scratch/unitname-player-seed-window.md` §6). +/// +/// **`UnitLevel` is deliberately NOT here.** The record carries the level at `+0x108` and the +/// client ships an accessor for it, `0x5abe00`, that **nothing calls** — so `UnitLevel("player")` +/// reads the descriptor and answers `0` in exactly the window the four answer in. +/// +/// **Resolved strings, not the raw bytes**, because this crate has no DBC: the reference resolves +/// `+0x100`/`+0x101` through `ChrRaces`/`ChrClasses` at call time, and the app does that same +/// resolution once when it seeds the record (it is the identical lookup +/// `ui_unit::race_names`/`class_names` already does for the unit snapshot, so the two cannot +/// disagree about what a race byte means). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PlayerRecord { + /// `+0x08` — the NUL-terminated name buffer at `0xc27d88`. Empty is the record's only unset + /// state, and the reference's only `UnitName("player")` nil: `lua_pushstring(NULL)` at + /// `0x517095` tail-jumps into `lua_pushnil`. Unreachable from Lua in the real client, because + /// the verb is not registered until `UI_Init` and only an Enter World commit gets there. + pub name: String, + /// `+0x100` resolved — `UnitRace`'s `(localized, file token)` pair, e.g. + /// `("Night Elf", "NightElf")`. `None` is the all-zero record: race 0 has no `ChrRaces` row, + /// and the reference's bound/NULL-row arms push `nil, nil` rather than reaching the resolver. + pub race: Option<(String, String)>, + /// `+0x101` resolved — `UnitClass`'s `(localized, UPPERCASE token)` pair, e.g. + /// `("Warrior", "WARRIOR")`. `None` for the all-zero record, same shape as [`Self::race`]. + pub class: Option<(String, String)>, + /// `+0x102` on `UnitSex`'s scale (2 male, 3 female) — **not** the wire's 0/1. + /// + /// `0` is our unset marker and reads as `2`, which is not a fudge: `0x517ef9`'s accessor feeds + /// `fild [4*eax+0x808be4]` over `{2,3,1,6}` with **no bounds check at all**, so an all-zero + /// record answers `2` ("male") in the reference too. It is the one of the four with no + /// validity guard. + pub sex: u8, +} + /// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*` /// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3 /// draws between the app's net/ECS feed and the Lua API. @@ -539,6 +592,33 @@ impl super::UiScript { // canonical lowercase token, so a feed that ever pushed `"Target"` could not create // a second, shadowing entry. Some(s) => { + // **A `"player"` push keeps the record in step, field by field** (2261, + // widened by 2263). The reference writes `0xc27d80` once per Enter World + // commit and never clears it; our VM is rebuilt per login, so the entry load + // seeds it — and this keeps it true for any later push that KNOWS a field, + // without ever letting one that doesn't blank it. + // + // That asymmetry is the whole mechanism: 2260's nameless player snapshot is + // exactly the push that must not be able to reach these four verbs. It is also + // what keeps every fixture in the workspace correct without touching them — a + // test that seats a full player is, by this rule, also seating the record. + if token.eq_ignore_ascii_case("player") { + let rec = &mut model.player_record; + if let Some(name) = s.name.as_deref().filter(|n| !n.is_empty()) { + if rec.name != name { + rec.name = name.to_string(); + } + } + if let Some(race) = s.race.clone().zip(s.race_file.clone()) { + rec.race = Some(race); + } + if let Some(class) = s.class.clone().zip(s.class_file.clone()) { + rec.class = Some(class); + } + if s.sex != 0 { + rec.sex = s.sex; + } + } model.units_by_lower.insert(token.to_ascii_lowercase(), s); } None => { diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 6eb985d0..740d4803 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -1,6 +1,6 @@ //! The `Unit*` binding tests (the parent module is the unit under test). -use crate::script::{PartyState, UiScript, UnitState}; +use crate::script::{PartyState, PlayerRecord, UiScript, UnitState}; fn player() -> UnitState { UnitState { @@ -1995,3 +1995,170 @@ fn unit_on_taxi_is_one_or_nil_and_raises_the_reference_usage() { "the gate's own message, not mlua's type error: {err}" ); } + +/// **The four `"player"` verbs read the seeded record, and nothing can blank it** (2261/2263). +/// +/// The reference's `"player"` arm never reaches the resolver: `0x51708c` reads `0xc27d88`, a copy +/// of the char-enum row written once at the Enter World commit and never cleared anywhere in the +/// image. So the name survives everything that can happen to the *snapshot* — which is the whole +/// of decision 2260's bug: a name-cache miss for our own guid produced a nameless player push, it +/// replaced the roster seat, and the verb went nil in the world. That push is modelled here +/// exactly, and it must now be invisible to this verb. +#[test] +fn the_player_record_outlives_every_snapshot() { + let mut s = UiScript::new().unwrap(); + + // Before any Enter World commit — the buffer's one empty state, and the reference's nil. + assert!(s + .eval::(r#"return UnitName("player") == nil"#) + .unwrap()); + + // The world-entry load seeds it from the roster row, before a single addon file runs. + s.set_player_record(PlayerRecord { + name: "Nelprifour".into(), + race: Some(("Night Elf".into(), "NightElf".into())), + class: Some(("Priest".into(), "PRIEST".into())), + sex: 2, + }); + assert_eq!( + s.eval::(r#"return UnitName("player")"#).unwrap(), + "Nelprifour", + "the buffer answers with no player snapshot at all — the reference runs addon OnUpdate \ + for many frames with no local player object and this verb still answers" + ); + + // 2260's push, verbatim: the descriptor landed, the name cache missed for our own guid. + let nameless = UnitState { + exists: true, + has_object: true, + name: None, + level: 5, + ..Default::default() + }; + s.set_unit("player", Some(nameless)); + assert_eq!( + s.eval::(r#"return UnitName("player")"#).unwrap(), + "Nelprifour", + "a nameless snapshot must not reach this verb — that push is the reported bug" + ); + assert_eq!( + s.eval::(r#"return UnitRace("player")"#).unwrap(), + "Night Elf", + "the same holds for the other three fields of the record (2263)" + ); + assert_eq!( + s.eval::(r#"local _, t = UnitClass("player"); return t"#) + .unwrap(), + "PRIEST", + "UnitClass's second return is the UPPERCASE token" + ); + assert_eq!(s.eval::(r#"return UnitSex("player")"#).unwrap(), 2); + // …and the snapshot is still the snapshot for everything else. `UnitLevel` is the boundary: + // the record carries a level byte and the client's accessor for it (`0x5abe00`) is DEAD, so + // this one reads the descriptor even for `"player"`. + assert_eq!(s.eval::(r#"return UnitLevel("player")"#).unwrap(), 5); + + // The token going away entirely (the logout despawn frames) does not empty the buffer either. + s.set_unit("player", None); + assert_eq!( + s.eval::(r#"return UnitName("player")"#).unwrap(), + "Nelprifour" + ); + assert!( + s.eval::(r#"return UnitExists("player") == nil"#) + .unwrap(), + "the name and the unit are different questions — which is exactly what the buffer buys" + ); + + // An empty seed is refused, not stored: nothing in the reference ever empties this record. + s.set_player_record(PlayerRecord::default()); + assert_eq!( + s.eval::(r#"return UnitName("player")"#).unwrap(), + "Nelprifour" + ); + assert_eq!( + s.eval::(r#"return UnitRace("player")"#).unwrap(), + "Night Elf" + ); + + // A NAMED push does keep it in step — a second login as somebody else (1290's per-login VM + // is the usual route, but a push must never leave the two disagreeing). + let mut other = player(); + other.name = Some("Onewarrior".into()); + s.set_unit("player", Some(other)); + assert_eq!( + s.eval::(r#"return UnitName("player")"#).unwrap(), + "Onewarrior" + ); + assert_eq!( + s.eval::(r#"local _, t = UnitClass("player"); return t"#) + .unwrap(), + "WARRIOR", + "a push that carries the field updates it; one that does not leaves it standing" + ); + assert_eq!( + s.eval::(r#"return UnitSex("player")"#).unwrap(), + 3, + "player() is female — the record follows the push" + ); + + // Still the family's two returns, the second nil (1840). + assert_eq!( + s.eval::(r#"local t = {UnitName("player")}; return table.getn(t)"#) + .unwrap(), + 1 + ); + // …and the case fold on the token holds on the fast path too. + assert_eq!( + s.eval::(r#"return UnitName("PLAYER")"#).unwrap(), + "Onewarrior" + ); +} + +/// **The unset record's four answers, which are not uniform** (decision 2263). +/// +/// The state is "no Enter World has been committed in this process" — unreachable from Lua in the +/// reference, because the four verbs only exist inside `UI_Init`'s table and only an Enter World +/// commit gets there. It is reachable *here* (a bare VM, a capture, a test world with no pick), so +/// it is pinned to what the bytes do rather than left to whatever falls out: +/// +/// - `UnitName` → nil, via `lua_pushstring(NULL)` at `0x517095` tail-jumping into `lua_pushnil`; +/// - `UnitRace`/`UnitClass` → `nil, nil`, via the DBC bound and NULL-row arms — race 0 and class 0 +/// have no row, and those arms reach `pushnil` **without** falling back to the unit resolver; +/// - `UnitSex` → the number **2**, because `0x517ef9`'s accessor feeds `fild [4*eax+0x808be4]` +/// over `{2,3,1,6}` with **no bounds check at all**. It is the one of the four with no validity +/// guard, and "male" is what an all-zero record means to it. +#[test] +fn an_unseeded_player_record_answers_the_references_four_ways() { + let s = UiScript::new().unwrap(); + assert!(s + .eval::(r#"return UnitName("player") == nil"#) + .unwrap()); + assert!(s + .eval::(r#"return UnitRace("player") == nil"#) + .unwrap()); + assert!(s + .eval::(r#"local _, t = UnitRace("player"); return t == nil"#) + .unwrap()); + assert!(s + .eval::(r#"return UnitClass("player") == nil"#) + .unwrap()); + assert!(s + .eval::(r#"local _, t = UnitClass("player"); return t == nil"#) + .unwrap()); + assert_eq!( + s.eval::(r#"return UnitSex("player")"#).unwrap(), + 2, + "the one of the four with no bounds check — an all-zero record reads male, not nil" + ); + // And the fast path is the TOKEN, not a prefix: `"playerfoo"` is recognised but resolves to + // nothing, so it goes to the resolver and answers the resolver's nil. + assert!(s + .eval::(r#"return UnitName("playerfoo") == nil"#) + .unwrap()); + assert_eq!( + s.eval::(r#"return UnitSex("playerfoo")"#).unwrap(), + 2, + "…which for UnitSex is the same 2, by the binding's own unresolved-token default" + ); +} diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs index d49b1c8d..96ddf445 100644 --- a/crates/benilla-world/src/clutter.rs +++ b/crates/benilla-world/src/clutter.rs @@ -640,17 +640,29 @@ pub(crate) fn stream_chunk_clutter( ); cc.built = built; } + // Two cases, and only one is a defect — the message always said so, but BOTH were warnings, so + // the benign one cried wolf. A login/teleport burst has a BACKLOG: the per-frame cap is why the + // near chunks are late, and the whole burst runs behind the loading cover. The director's + // 2026-09-15 log has three of these at t+1.7 s under a cover that did not lift until t+4.3 s — + // including one announcing grass at 4.4 yd that nobody could possibly have seen. With NO + // backlog the cap is not the cause: the distance gate let a near chunk through late on an + // ordinary frame, the player can see that one, and that one still warns. if late > 0 { - warn!( - "clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest {late_nearest:.1} yd) \ - — grass appeared where it could already be seen; {}", - cfg.fade_far, - if backlog > 0 { - format!("{backlog} more still queued behind the {CLUTTER_BUILDS_PER_FRAME}/frame cap (expected in a login/teleport burst)") - } else { - "no build backlog, so the DISTANCE GATE let it through late".to_string() - } - ); + if backlog > 0 { + debug!( + "clutter: {late} chunk(s) built inside the {:.0} yd horizon (nearest \ + {late_nearest:.1} yd) — {backlog} more queued behind the \ + {CLUTTER_BUILDS_PER_FRAME}/frame cap (the expected login/teleport burst)", + cfg.fade_far, + ); + } else { + warn!( + "clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest \ + {late_nearest:.1} yd) — grass appeared where it could already be seen, and with \ + no build backlog, so the DISTANCE GATE let it through late", + cfg.fade_far, + ); + } } } diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index eda089f3..d0f37e99 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -838,11 +838,22 @@ fn prepare_textures( let Some(vp) = camera.physical_viewport_size else { continue; }; - // A claimed view (some [`FfxBackdrop`] names it) has no combine of its own — the UI - // camera's ground pass is it, keyed on THAT camera's target — so it carries none rather - // than a pair keyed on a target nothing writes. - let combine_format = (!claims.0.contains(&entity)) - .then(|| FinalPassTarget::format(&camera.output_mode, target)); + // The view's OWN combine pair, specialised **whether or not this view is claimed** + // (decision 2262). A claimed view (some [`FfxBackdrop`] names it) has no combine of its + // own — the UI camera's ground pass is it, keyed on THAT camera's target — so it carries + // none rather than a pair keyed on a target nothing writes. But the claim is not a + // property of the world, it is a property of *this frame*: it drops the moment the UI + // camera loses its `ViewTarget`, which is what `prepare_view_targets` does as soon as the + // window's surface goes away. At app exit that is harmless (bevy runs one to three more + // updates after the last presented frame — see `benilla_app::shutdown`) and it is exactly + // what the director's 2026-09-15 log caught: two `pipeline compiled LIVE` lines in the + // same millisecond as "No windows are open, exiting". A minimize to zero size or a surface + // reconfigure reaches the same branch **while the player is looking at the frame**, and + // there the pair would be two synchronous Metal compiles on the render thread. Specialising + // is a cached lookup on a four-field key, so holding the pair warm from the first covered + // frame costs that lookup and nothing else. + let own = specializer.pair(FinalPassTarget::format(&camera.output_mode, target)); + let combine = (!claims.0.contains(&entity)).then_some(own); // The reference's RT-dim chain: ½ and ¼, floored (clamp ≥8 — `ffx_compute_rt_dims`). let mut tex = |label: &'static str, w: u32, h: u32| { texture_cache.get( @@ -871,11 +882,10 @@ fn prepare_textures( if existing.is_some_and(|t| { t.quarter_a.texture.id() == quarter_a.texture.id() && t.quarter_b.texture.id() == quarter_b.texture.id() - && t.combine.as_ref().map(|c| c.format) == combine_format + && t.combine.as_ref().map(|c| c.format) == combine.as_ref().map(|c| c.format) }) { continue; } - let combine = combine_format.map(|format| specializer.pair(format)); let pipelines = &specializer.pipelines; let layout_filter = specializer .pipeline_cache diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs index 5a723b45..d18fef84 100644 --- a/crates/benilla-world/src/rig_palette.rs +++ b/crates/benilla-world/src/rig_palette.rs @@ -146,6 +146,10 @@ pub struct RigPalettes { /// Allocations refused since the last census line (`WOW_RIG_CENSUS`) — the exhaustion /// diagnosis's live denial rate (decision 0863). Read-and-reset by [`census_rig_palettes`]. denied: u32, + /// Has the exhaustion warn already been logged this session? A latch, not a counter, and + /// deliberately never reset: [`Self::denied`] is `mem::take`n by the census, so gating the + /// warn on it would re-fire once per census window instead of once per session (2264). + denied_warned: bool, /// `WOW_RIG_COST` meters (decision 0736 premise check): whole-vec deep copies this frame /// (`Arc::make_mut` clones when the extract still holds last publish's reference), the µs /// they took, and the rows (bones) written. Printed + reset by [`publish_rig_palettes`]. @@ -174,6 +178,7 @@ impl Default for RigPalettes { peak_bones: 0, live_bones: 0, denied: 0, + denied_warned: false, cost_copies: 0, cost_copy_us: 0.0, cost_rows: 0, @@ -697,7 +702,8 @@ impl RigSkin { /// Allocate a palette rig over live joint entities (the effect/booth/equipment lane — the /// change sweep computes its rows; doodads moved to [`Self::allocate_bones`], decision 1365). - /// `None` (with one loud warn per session) when the table is full — the caller renders the + /// `None` (with one loud warn per session — a promise this doc made for a long time before + /// the code kept it, 2264) when the table is full — the caller renders the /// static bind-pose mesh instead. pub fn allocate( palettes: &mut RigPalettes, @@ -724,11 +730,22 @@ impl RigSkin { }), None => { palettes.denied += 1; - let (s, b, ps, pb) = palettes.occupancy(); - warn!( - "rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \ - peak {ps}/{pb}) — rig renders at bind pose" - ); + // **Once per session, as this function's doc has always promised.** It did not + // keep that promise: the denial path warned on every call, and the lazy-doodad + // caller retries *every frame while the host is drawn* + // (`doodad_anim::lazy` — "table full — the warn fired; the gate retries while + // drawn"), so a crowded scene turned one fact into a per-frame flood that buries + // every other warning in the log. The repeats were never the signal anyway: + // `denied` is already counted here and already reported by the palette census. + if !palettes.denied_warned { + palettes.denied_warned = true; + let (s, b, ps, pb) = palettes.occupancy(); + warn!( + "rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \ + peak {ps}/{pb}) — rig renders at bind pose. Further denials are counted \ + in `denied`, not logged; run with WOW_RIG_CENSUS for the running tally." + ); + } None } }