From 74c3b47a7f89486f3bdf08ea49fcf7bac1db32a0 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 19:01:11 +0700 Subject: [PATCH] event-driven UI feeds, and the character window's cost lets go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the eight heaviest UI feeds — spellbook, party, character, bags, talents, units, pet, player — rebuild only when an input moved instead of every frame: 216 → 12 µs a frame parked - the character window's cost settles once it closes: enchant countdowns feed by the second instead of firing an inventory event into all 20 slots every frame, the paper-doll booth parks with its camera, and a window hidden under the cursor fires OnLeave so its tooltip goes with it — the reference's hover-hide law - the walk stops paying for finished work: the loading-screen tally's per-placement walk latches off once the world is ready, the settled placement spawner is one integer compare, and billboards stop dirtying every card's transform - never-rendering hierarchy nodes — rig anchors, joints, doodad hosts — keep the hide chain but leave the per-camera visibility sweep: 25,198 → 18,026 swept entities at Goldshire Co-Authored-By: Claude Fable 5 --- .../src/capture/probes/world_census.rs | 18 +- crates/benilla-app/src/cooldowns.rs | 73 +++++++- .../src/entities/equipment/spawn.rs | 4 + .../benilla-app/src/entities/spell_fx/mod.rs | 6 + crates/benilla-app/src/entities/wmo_props.rs | 4 +- crates/benilla-app/src/items.rs | 130 ++++++++++++- crates/benilla-app/src/names.rs | 50 +++++ crates/benilla-app/src/net/apply/objects.rs | 5 + crates/benilla-app/src/pending_item_ops.rs | 7 + crates/benilla-app/src/portrait/booth.rs | 14 +- crates/benilla-app/src/portrait/dressup.rs | 9 + crates/benilla-app/src/portrait/glue_booth.rs | 13 +- crates/benilla-app/src/portrait/mod.rs | 93 ++++++++- crates/benilla-app/src/transport.rs | 7 +- crates/benilla-app/src/ui_char.rs | 91 +++++++-- crates/benilla-app/src/ui_guild/mod.rs | 10 + crates/benilla-app/src/ui_items/feed.rs | 159 ++++++++++++++-- crates/benilla-app/src/ui_party/feed.rs | 81 +++++++- crates/benilla-app/src/ui_pet/unit.rs | 45 ++++- .../benilla-app/src/ui_script/extract/mod.rs | 8 + crates/benilla-app/src/ui_script/gate.rs | 176 ++++++++++++++++++ crates/benilla-app/src/ui_script/mod.rs | 5 + .../src/ui_script/multibar_stance_tests.rs | 11 +- crates/benilla-app/src/ui_script/session.rs | 37 +++- crates/benilla-app/src/ui_spellbook.rs | 64 ++++++- crates/benilla-app/src/ui_talent.rs | 31 ++- crates/benilla-app/src/ui_unit.rs | 92 ++++++++- crates/benilla-app/tests/world_api_wall.rs | 11 +- crates/benilla-ui/src/script/event.rs | 33 ++++ crates/benilla-ui/src/script/model.rs | 10 + crates/benilla-ui/src/script/tests/input.rs | 95 ++++++++++ crates/benilla-ui/src/script/tick.rs | 16 ++ crates/benilla-world/src/bgwin.rs | 4 + crates/benilla-world/src/billboard.rs | 15 +- crates/benilla-world/src/doodad_anim/mod.rs | 8 +- crates/benilla-world/src/lib.rs | 1 + crates/benilla-world/src/model_render.rs | 10 +- .../src/model_render/visibility.rs | 7 +- crates/benilla-world/src/particles/sim.rs | 26 ++- .../benilla-world/src/rig_anim/global_seq.rs | 7 +- crates/benilla-world/src/rig_anim/pose.rs | 6 + crates/benilla-world/src/rig_palette.rs | 5 +- crates/benilla-world/src/static_gx/bake.rs | 6 +- crates/benilla-world/src/static_gx/mod.rs | 2 +- crates/benilla-world/src/terrain_stream.rs | 155 ++++++++++----- .../src/terrain_stream/furnish.rs | 9 +- .../benilla-world/src/terrain_stream/merge.rs | 4 +- .../src/terrain_stream/spawn/mod.rs | 12 ++ crates/benilla-world/src/vis_chain.rs | 68 +++++++ 49 files changed, 1611 insertions(+), 142 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/gate.rs create mode 100644 crates/benilla-world/src/vis_chain.rs diff --git a/crates/benilla-app/src/capture/probes/world_census.rs b/crates/benilla-app/src/capture/probes/world_census.rs index bafffe91..1f67a910 100644 --- a/crates/benilla-app/src/capture/probes/world_census.rs +++ b/crates/benilla-app/src/capture/probes/world_census.rs @@ -166,7 +166,7 @@ fn fire_entity_census(world: &mut World) { ); } let components = world.components(); - let mut rows: Vec<(usize, String)> = world + let mut rows: Vec<(usize, bool, String)> = world .archetypes() .iter() .filter(|a| !a.is_empty()) @@ -177,6 +177,10 @@ fn fire_entity_census(world: &mut World) { .filter_map(|id| components.get_info(*id)) .map(|c| c.name().shortname().to_string()) .collect(); + // Whether this archetype sits in the render-visibility population: bevy's + // `check_visibility` sweeps every `ViewVisibility` row once PER ACTIVE CAMERA, + // so `vis=y` rows are the ones a second camera (booth) re-bills the frame for. + let in_vis_population = full.iter().any(|n| n == "ViewVisibility"); let signal: Vec = full .iter() .filter(|n| { @@ -204,21 +208,25 @@ fn fire_entity_census(world: &mut World) { if more > 0 { comps.push_str(&format!(" +{more}")); } - (a.len() as usize, comps) + (a.len() as usize, in_vis_population, comps) }) .collect(); rows.sort_by_key(|r| std::cmp::Reverse(r.0)); let (total_arch, total_n) = (rows.len(), rows.iter().map(|r| r.0).sum::()); + let vis_n = rows.iter().filter(|r| r.1).map(|r| r.0).sum::(); let other_n = rows .iter() .skip(ENTITY_CENSUS_ROWS) .map(|r| r.0) .sum::(); - for (n, comps) in rows.iter().take(ENTITY_CENSUS_ROWS) { - println!("ENTITY_CENSUS_ARCH n={n} comps=[{comps}]"); + for (n, vis, comps) in rows.iter().take(ENTITY_CENSUS_ROWS) { + println!( + "ENTITY_CENSUS_ARCH n={n} vis={} comps=[{comps}]", + if *vis { "y" } else { "n" } + ); } println!( - "ENTITY_CENSUS total={total_n} archetypes={total_arch} \ + "ENTITY_CENSUS total={total_n} vis_n={vis_n} archetypes={total_arch} \ rows={} other_n={other_n}", rows.len().min(ENTITY_CENSUS_ROWS), ); diff --git a/crates/benilla-app/src/cooldowns.rs b/crates/benilla-app/src/cooldowns.rs index 2d4ec198..baa9027e 100644 --- a/crates/benilla-app/src/cooldowns.rs +++ b/crates/benilla-app/src/cooldowns.rs @@ -131,6 +131,12 @@ pub(crate) struct Cooldowns { records: Vec, /// Bumped on every mutation — the UI feed's `ACTIONBAR_UPDATE_COOLDOWN` edge. pub(crate) generation: u64, + /// Bumped when [`Self::prune`] actually removes records — the **natural-expiry** edge, kept + /// apart from [`Self::generation`] on purpose (decision 1439): a pruned record flips a + /// pushed `ui_triple` to `None`, so a feed gated on this store must reopen at that instant — + /// but the action feed's `ACTIONBAR_UPDATE_COOLDOWN` must NOT fire there, because the + /// reference sweeps elapsed records on events and never announces a natural expiry. + expiry_epoch: u64, } impl Cooldowns { @@ -138,6 +144,27 @@ impl Cooldowns { self.generation = self.generation.wrapping_add(1); } + /// The single counter a gated feed watches (decision 1439): moves exactly when a snapshot + /// built over this store could differ — on any mutation, or on a natural expiry the prune + /// noticed. (Both terms only ever increment, so the sum moves whenever either does.) + pub(crate) fn feed_epoch(&self) -> u64 { + self.generation.wrapping_add(self.expiry_epoch) + } + + /// True while an elapsed record still awaits its prune — the one window where a snapshot + /// built over this store changes with NO counter having moved yet: the feeds run before + /// `feed_action_state`'s per-frame prune, so on the exact frame a timer crosses zero the + /// pushed triple flips to `None` a frame ahead of [`Self::feed_epoch`] (decision 1439). + /// The predicate is prune's own removal test; cold and parked it costs an empty iteration. + pub(crate) fn sweep_pending(&self, now: Instant) -> bool { + self.records.iter().any(|r| { + !r.on_hold + && r.recovery.remaining(now).is_zero() + && r.category_recovery.remaining(now).is_zero() + && r.gcd.remaining(now).is_zero() + }) + } + /// The insert primitive (`AddCooldown 0x6e12c0`): nothing to track → no-op; else **append a /// new record**. The client never matches-by-id here — its "reuse" scan is free-list node /// recycling, an allocator detail (no `[node+8]==spellId` compare anywhere in `0x6e12c0`'s @@ -186,14 +213,19 @@ impl Cooldowns { /// Prune records with nothing left to say: every timer elapsed and not on hold. Behaviorally /// invisible (an elapsed record contributes zero remaining) — bounds the list without the - /// client's event-driven sweep sites. + /// client's event-driven sweep sites. A removal bumps [`Self::expiry_epoch`], not + /// [`Self::generation`]: gated feeds must see the expiry, the event edge must not. pub(crate) fn prune(&mut self, now: Instant) { + let before = self.records.len(); self.records.retain(|r| { r.on_hold || !r.recovery.remaining(now).is_zero() || !r.category_recovery.remaining(now).is_zero() || !r.gcd.remaining(now).is_zero() }); + if self.records.len() != before { + self.expiry_epoch = self.expiry_epoch.wrapping_add(1); + } } /// Start a spell's own cooldown (`StartCooldown 0x6e2c60`, spell-only path): recovery / @@ -838,6 +870,45 @@ mod tests { assert!(cds.records.is_empty()); } + /// The gated feeds' counter (1439): `feed_epoch` moves on a mutation AND on a natural + /// expiry the prune noticed — but a prune that removes nothing moves nothing, or the + /// per-frame prune call would hold every cooldown-watching gate open forever. + #[test] + fn the_feed_epoch_moves_on_expiry_but_an_empty_prune_is_silent() { + let t0 = Instant::now(); + let mut cds = Cooldowns::default(); + let e0 = cds.feed_epoch(); + cds.start_gcd(133, &fireball(), t0); + let armed = cds.feed_epoch(); + assert_ne!(e0, armed, "a mutation moves the epoch (generation)"); + + cds.prune(t0 + Duration::from_millis(100)); + assert_eq!( + cds.feed_epoch(), + armed, + "nothing elapsed — the prune is silent" + ); + + cds.prune(t0 + Duration::from_secs(5)); + let expired = cds.feed_epoch(); + assert_ne!( + armed, expired, + "the expiry IS a feed edge (the triple flips to None)" + ); + assert_eq!( + cds.generation, + { + let mut probe = Cooldowns::default(); + probe.start_gcd(133, &fireball(), t0); + probe.generation + }, + "…but the event-edge generation never saw it (the reference is silent on expiry)" + ); + + cds.prune(t0 + Duration::from_secs(6)); + assert_eq!(cds.feed_epoch(), expired, "an empty prune stays silent"); + } + /// The vanished-GCD-pie regression (spam-press: fail-clear + re-arm inside one inter-feed /// gap): the UI triple must be (a) frame-stable for one running cooldown — re-reading the /// same arm later yields the same start — and (b) distinct across two arms, so the seam can diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index 19a15612..d4acdd9d 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -12,6 +12,7 @@ use benilla_world::model_fade::{ join_unit_appear_fade, FadeSet, JoinedFade, PartFade, UnitAppearFade, }; use benilla_world::model_render::{ModelKind, ModelPart}; +use benilla_world::vis_chain::VisChainOnly; use super::super::{item_glow::ItemGlow, spawn_carried_lights}; use super::{ @@ -293,6 +294,9 @@ fn spawn_slot( // instances included (decision 0833). benilla_world::model_fade::ParentModel(entity), )) + // Chain-only visibility (benilla_world::vis_chain): the wrapper renders nothing — + // the item's parts and glows are the children. + .vis_chain_only() .id(); commands.entity(joint).add_child(root); // The item/enchant glow (decision 0805): its instances hang off the ITEM's own diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index 7bfe0783..d24769e2 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -58,6 +58,7 @@ use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; use benilla_world::model_render::{ModelKind, ModelPart}; use benilla_world::particles; +use benilla_world::vis_chain::VisChainOnly; use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle}; pub(super) use lifecycle::advance_fx_anim; @@ -877,6 +878,9 @@ pub(super) fn attach_spell_fx( follow, }, )) + // Chain-only visibility (benilla_world::vis_chain) — the wrapper + // renders nothing; the effect's parts are the children. + .vis_chain_only() .id(); (root, true) } else { @@ -896,6 +900,8 @@ pub(super) fn attach_spell_fx( let (parent, offset) = point.map_or((unit, Vec3::ZERO), |(_, j, o)| (j, o)); let root = commands .spawn((Transform::from_translation(offset), Visibility::default())) + // Chain-only, as above. + .vis_chain_only() .id(); commands.entity(parent).add_child(root); (root, ground_anchor) diff --git a/crates/benilla-app/src/entities/wmo_props.rs b/crates/benilla-app/src/entities/wmo_props.rs index 6f31c533..5f328b64 100644 --- a/crates/benilla-app/src/entities/wmo_props.rs +++ b/crates/benilla-app/src/entities/wmo_props.rs @@ -307,10 +307,12 @@ pub(super) fn spawn_wmo_gameobject_props( // `Static` body — same mechanism, no motion. if let Some((verts, tris)) = placement_collider_data(m.collision.as_ref(), &prop.local) { + // No visibility components at all: a hull renders nothing and hosts nothing, + // and B0004 only checks the child→parent direction — a bare child under a + // visible parent is fine (benilla_world::vis_chain has the sweep-tax law). let hull = commands .spawn(( Transform::IDENTITY, - Visibility::default(), PendingCollider::new(build_collider_task(verts, tris), None, false), )) .id(); diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index 95d7a5ae..93b2f9df 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -136,6 +136,12 @@ pub(crate) struct Items { /// itself is what ISSUES the ask-once query, so the first resolve of a cold entry ALWAYS /// misses (decision 0660). template_epoch: u64, + /// [`Self::template_epoch`]'s twin for the INSTANCE side (decision 1439): bumped by every + /// object create/merge/destroy and every enchant-deadline write — everything wire-driven + /// that can change what a bag/equipment view reads. The pair exists because `is_changed` on + /// this resource says nothing: the feeds' own lazy `template()` resolves take `&mut self` + /// every frame, so the resource reads as changed even when nothing landed. + object_epoch: u64, /// The **temporary-enchant deadlines**, keyed `(item guid, enchant slot)` — the reference's /// per-item `[obj + slot*4 + 0x324]` array (wow-re `tooltip-content-law.md` §E3), whose only /// feed is `SMSG_ITEM_ENCHANT_TIME_UPDATE`. NOT derived from `ITEM_FIELD_ENCHANTMENT`'s @@ -154,6 +160,7 @@ impl Items { e.insert(fields); } } + self.object_epoch = self.object_epoch.wrapping_add(1); } /// Merge a `Values` delta into a tracked item; `false` when the guid isn't ours to track (a @@ -161,6 +168,7 @@ impl Items { pub(crate) fn merge_object(&mut self, guid: u64, fields: ObjectFields) -> bool { if let Some(store) = self.objects.get_mut(&guid) { store.merge(fields); + self.object_epoch = self.object_epoch.wrapping_add(1); true } else { false @@ -169,21 +177,31 @@ impl Items { /// The item ceased to exist (`SMSG_DESTROY_OBJECT` — consumed, sold, destroyed). pub(crate) fn remove_object(&mut self, guid: u64) { - self.objects.remove(&guid); + if self.objects.remove(&guid).is_some() { + self.object_epoch = self.object_epoch.wrapping_add(1); + } self.enchant_deadlines.retain(|&(g, _), _| g != guid); } + /// The instance-side broadcast counter — see the [`Self::object_epoch`] field. + pub(crate) fn object_epoch(&self) -> u64 { + self.object_epoch + } + /// `SMSG_ITEM_ENCHANT_TIME_UPDATE` landed: park the absolute deadline for that item's enchant /// slot. `seconds == 0` is the reference's own "no timer" store (`0x5d9cc0`'s `<= 0` arm), so /// it REMOVES the entry rather than parking an already-expired one. pub(crate) fn set_enchant_deadline(&mut self, guid: u64, slot: u32, seconds: u32) { match seconds { 0 => { - self.enchant_deadlines.remove(&(guid, slot)); + if self.enchant_deadlines.remove(&(guid, slot)).is_some() { + self.object_epoch = self.object_epoch.wrapping_add(1); + } } n => { let at = std::time::Instant::now() + std::time::Duration::from_secs(u64::from(n)); self.enchant_deadlines.insert((guid, slot), at); + self.object_epoch = self.object_epoch.wrapping_add(1); debug!("enchant timer: item {guid:#x} slot {slot} → {n}s"); } } @@ -198,6 +216,42 @@ impl Items { (!left.is_zero()).then_some(left.as_millis() as u64) } + /// [`Self::enchant_remaining_ms`] at **display granularity**: floored to the whole second. + /// + /// The snapshot feeds read this one. The tooltip's bucket ladder is ceil at day/hour/min and + /// truncate at seconds, so every value inside one second renders the same line — but a raw + /// per-ms read makes the *snapshot* differ every frame, which held the 1439 gates open and + /// fired `UNIT_INVENTORY_CHANGED` at frame rate for as long as a poison ticked (director + /// report, 2026-08-19: the char window's cost, and its refusal to settle after closing). + /// Floored, the snapshot moves once a second — exactly as often as its rendering can. + /// The live per-ms reader stays for `GetWeaponEnchantInfo` ([`Self::enchant_deadline_ms`]), + /// whose per-frame push is the reference's own recompute-per-call (`0x5d9d00`). + pub(crate) fn enchant_remaining_display_ms(&self, guid: u64, slot: u32) -> Option { + self.enchant_remaining_ms(guid, slot) + .map(|ms| ms - ms % 1000) + } + + /// The counter a gated feed watches instead of holding its gate open per-frame: moves exactly + /// when some [`Self::enchant_remaining_display_ms`] can move. Each live deadline contributes + /// `floor(seconds left) + 1` — the `+1` makes the final `Some(0) → None` collapse (the tooltip + /// reverting to the plain enchant name) its own step, one frame after the deadline elapses. + /// Landings and removals move [`Self::object_epoch`] on their own. Empty — the overwhelmingly + /// common case — costs an empty iteration. + pub(crate) fn enchant_display_epoch(&self) -> u64 { + let now = std::time::Instant::now(); + self.enchant_deadlines + .values() + .map(|&at| { + let left = at.saturating_duration_since(now); + if left.is_zero() { + 0 + } else { + left.as_secs() + 1 + } + }) + .sum() + } + /// The same deadline read **without** the tooltip's expired-is-absent collapse: `Some(0)` for a /// timer that has run out, `None` only when the slot never had one. /// @@ -304,6 +358,7 @@ impl Items { self.objects.clear(); self.enchant_deadlines.clear(); self.pending.clear(); + self.object_epoch = self.object_epoch.wrapping_add(1); } } @@ -446,6 +501,77 @@ mod tests { ); } + /// The gated feeds' instance-side counter (1439): every object mutation moves it, a + /// template landing moves ONLY the template epoch, and a lazy `template()` miss — the + /// per-frame read that poisons `is_changed` — moves neither. + #[test] + fn the_object_epoch_counts_instances_and_asks_count_nothing() { + let (cmds, _rx) = commands(); + let mut items = Items::default(); + let e0 = items.object_epoch(); + + assert!(items.template(117, 0x42, &cmds).is_none()); + assert_eq!(items.object_epoch(), e0, "an ask-once miss is not a change"); + let t0 = items.template_epoch(); + items.insert_template(117, Some(info("Tough Jerky"))); + assert_eq!( + items.object_epoch(), + e0, + "a landed template is the OTHER epoch's edge" + ); + assert_ne!(items.template_epoch(), t0); + + items.insert_object(0x42, ObjectFields::from_pairs(&[(3, 117)])); + let created = items.object_epoch(); + assert_ne!(e0, created, "a create moves it"); + items.merge_object(0x42, ObjectFields::from_pairs(&[(14, 5)])); + let merged = items.object_epoch(); + assert_ne!(created, merged, "a delta moves it"); + items.merge_object(0x99, ObjectFields::from_pairs(&[(14, 5)])); + assert_eq!( + items.object_epoch(), + merged, + "an untracked guid's delta is dropped, not counted" + ); + items.remove_object(0x42); + assert_ne!(items.object_epoch(), merged, "a destroy moves it"); + let removed = items.object_epoch(); + items.remove_object(0x42); + assert_eq!( + items.object_epoch(), + removed, + "removing the already-removed is silent" + ); + } + + /// The display epoch/quantize pair: a parked deadline contributes `floor(secs)+1` (bounded + /// here, not exact — the test can't pin the sub-second phase), the display read is floored + /// to the whole second, and clearing the deadline zeroes the epoch (the `Some(0) → None` + /// collapse is the term's own last step). + #[test] + fn the_enchant_display_epoch_steps_by_displayable_seconds() { + let mut items = Items::default(); + assert_eq!(items.enchant_display_epoch(), 0, "no deadlines, no epoch"); + + items.set_enchant_deadline(0x42, 1, 90); + let epoch = items.enchant_display_epoch(); + assert!( + (90..=91).contains(&epoch), + "a 90 s deadline contributes floor(secs)+1, got {epoch}" + ); + let shown = items.enchant_remaining_display_ms(0x42, 1).unwrap(); + assert_eq!(shown % 1000, 0, "the display read is second-floored"); + assert!(shown <= 90_000); + + items.set_enchant_deadline(0x42, 1, 0); + assert_eq!( + items.enchant_display_epoch(), + 0, + "cleared deadline, term gone" + ); + assert_eq!(items.enchant_remaining_display_ms(0x42, 1), None); + } + /// The push half of the tooltip store: a landed template is marked fresh exactly once (the /// drain empties), and a cached negative never is (there's nothing to push). #[test] diff --git a/crates/benilla-app/src/names.rs b/crates/benilla-app/src/names.rs index 41bd1fab..4c45a2dc 100644 --- a/crates/benilla-app/src/names.rs +++ b/crates/benilla-app/src/names.rs @@ -46,6 +46,12 @@ pub(crate) struct NameCache { pending_players: HashSet, pending_creatures: HashSet, pending_pets: HashSet, + /// Bumped by every **landed** answer (and the pet-rename eviction) — never by an ask. The + /// gated feeds' watch counter (decision 1439): a feed that resolved a miss re-runs when the + /// answer lands, and only then. `is_changed` cannot say this — the resolves themselves take + /// `&mut self` every frame (the ask-once marker), so the resource reads as changed whenever + /// anything is still pending. + generation: u64, } /// One cached creature template head (see [`NameCache::creatures`]). @@ -204,6 +210,12 @@ impl NameCache { } self.players .insert(guid, (!name.is_empty()).then_some(name)); + self.generation = self.generation.wrapping_add(1); + } + + /// The landed-answer counter — see the [`Self::generation`] field. + pub(crate) fn generation(&self) -> u64 { + self.generation } /// `(race, class, gender)` for a player guid the name query has answered for — the macro @@ -216,6 +228,7 @@ impl NameCache { pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) { self.pending_pets.remove(&pet_number); self.pets.insert(pet_number, name); + self.generation = self.generation.wrapping_add(1); } /// Forget a pet's cached name so the next [`Self::resolve`] asks for it again (decision 1066). @@ -230,12 +243,14 @@ impl NameCache { pub(crate) fn forget_pet(&mut self, pet_number: u32) { self.pending_pets.remove(&pet_number); self.pets.remove(&pet_number); + self.generation = self.generation.wrapping_add(1); } /// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry. pub(crate) fn insert_creature(&mut self, entry: u32, record: Option) { self.pending_creatures.remove(&entry); self.creatures.insert(entry, record); + self.generation = self.generation.wrapping_add(1); } /// The cached subname (the overhead/tooltip title line) for a creature entry — read-only: the @@ -291,6 +306,41 @@ mod tests { (NetCommands(tx), rx) } + /// The gated feeds' counter (1439): a landed answer (and the rename eviction) moves it; an + /// ask-once miss — the read gated feeds run every frame — never does, or the outstanding + /// query would hold every name-watching gate open until it answered. + #[test] + fn the_generation_counts_landings_never_asks() { + let (cmds, _rx) = commands(); + let mut cache = NameCache::default(); + let g0 = cache.generation(); + + let player = compose(0, 0, 7); + assert_eq!(cache.resolve(player, &cmds), None); + assert_eq!( + cache.generation(), + g0, + "the miss asked, and asking is not a landing" + ); + + cache.insert_player(player, "Benilla".into(), None); + let landed = cache.generation(); + assert_ne!(g0, landed, "the answer landing is the edge"); + + assert_eq!(cache.resolve(player, &cmds), Some("Benilla")); + assert_eq!(cache.generation(), landed, "a cache hit moves nothing"); + + cache.insert_pet(137, "Voidwalker".into()); + let pet = cache.generation(); + assert_ne!(landed, pet); + cache.forget_pet(137); + assert_ne!( + pet, + cache.generation(), + "the rename eviction is a landing-shaped edge" + ); + } + /// A summoned pet resolves through `CMSG_PET_NAME_QUERY`, keyed by the pet number in its guid — /// NOT through the creature query, which would ask for a template entry that does not exist and /// leave the pet nameless (the reported bug: an NPC-summoned voidwalker with no name). diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 34999199..63ac3a75 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -13,6 +13,7 @@ use crate::go_templates::GameObjectTemplates; use crate::items::Items; use crate::names::NameCache; use benilla_world::model_fade::DespawnFade; +use benilla_world::vis_chain::VisChainOnly; use super::super::motion::{ create_spline, monster_move_spline, pose_transform, resolve_facing, trace_create_spline, @@ -209,6 +210,10 @@ pub(super) fn object_create( pose_transform(position, orientation), visibility, )); + // Chain-only visibility (benilla_world::vis_chain): the net root renders nothing — + // its model parts and joints are the children, and the cull authorities flip this + // root's `Visibility` through `Mut` writes, which don't re-add the sweep row. + entity.vis_chain_only(); if let Some(s) = speeds { entity.insert(UnitSpeeds(s)); } diff --git a/crates/benilla-app/src/pending_item_ops.rs b/crates/benilla-app/src/pending_item_ops.rs index 0e5d292c..1122bc2c 100644 --- a/crates/benilla-app/src/pending_item_ops.rs +++ b/crates/benilla-app/src/pending_item_ops.rs @@ -47,6 +47,13 @@ impl PendingItemOps { ); } + /// No outstanding ops at all — the container feed's gate reads this (decision 1439): while + /// anything is in flight the resolving walk must run every frame, and once nothing is, the + /// last resolve's own change tick already covered the final unlock. + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + /// Whether `(bag, slot)` is covered by any outstanding op — the container feed reads this /// building each pushed `ContainerSlot::locked`. pub(crate) fn contains(&self, bag: i64, slot: u32) -> bool { diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs index c625a031..ae0df66b 100644 --- a/crates/benilla-app/src/portrait/booth.rs +++ b/crates/benilla-app/src/portrait/booth.rs @@ -454,13 +454,18 @@ pub(super) fn spawn_booth_model( /// joint pose is read a frame stale (its global is last propagate's), invisible on the near-static /// Stand loop the booth runs — the same latency budget the paper-doll/portrait stills already accept. pub(super) fn face_booth_billboards( - cams: Query<(&GlobalTransform, &RenderLayers), With>, + cams: Query<(&GlobalTransform, &RenderLayers, &Camera), With>, joints: Query<&GlobalTransform>, mut cards: Query<(&BoothBillboard, &ChildOf, &RenderLayers, &mut Transform)>, ) { for (card, child_of, layers, mut tf) in &mut cards { - let Some((cam, _)) = cams.iter().find(|(_, l)| l.intersects(layers)) else { - continue; // the card's booth camera isn't up (booth torn down) — leave it be + let Some((cam, _, _)) = cams + .iter() + // A sleeping camera renders nothing — leave its cards be (the booth park); they + // re-face on the wake window's first frame, before anything samples the target. + .find(|(_, l, c)| c.is_active && l.intersects(layers)) + else { + continue; // …or the card's booth camera isn't up at all (booth torn down) }; let Ok(joint) = joints.get(child_of.parent()) else { continue; @@ -595,6 +600,9 @@ mod tests { .world_mut() .spawn(( crate::portrait::BoothCam("glue".to_string()), + // `Camera` because the facer skips a sleeping camera (the booth park); + // the default is active — the state a rendering booth is in. + Camera::default(), GlobalTransform::from(cam_tf), layer.clone(), )) diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs index eca63dd3..b1932333 100644 --- a/crates/benilla-app/src/portrait/dressup.rs +++ b/crates/benilla-app/src/portrait/dressup.rs @@ -158,7 +158,10 @@ pub(super) fn spawn_dressup_booth( wake: 0, live: false, pending: Vec::new(), + pending_since: None, aspect: 1.0, + joints: Vec::new(), + parked: false, }, ); } @@ -219,6 +222,9 @@ pub(super) fn sync_dressup_booth( booth.wake = BOOTH_SETTLE_FRAMES; booth.live = false; booth.pending.clear(); + // The despawn above took the park set with it. + booth.joints.clear(); + booth.parked = false; *staged = false; } *last = Some((bake.revision, preview.yaw)); @@ -311,6 +317,9 @@ pub(super) fn sync_dressup_booth( // The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame // the window is drawing this pane, and none once it closes. booth.live = true; + // A fresh bake is animated by construction; the park set is the new skeleton's. + booth.joints = joints; + booth.parked = false; *staged = true; aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors, aspect)); // `WOW_BOOTH_LOG=1` — one line per committed bake, the same instrument the mirrored booths diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 0584810f..7e314629 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -420,7 +420,10 @@ pub(super) fn spawn_glue_booth( // flag: the glue booth is live from the moment a screen shows, before any bake. live: false, pending: Vec::new(), + pending_since: None, aspect: 1.0, + joints: Vec::new(), + parked: false, }, ); // The background scene's own root (the character root above yaws; the scene never does). @@ -744,7 +747,7 @@ pub(super) fn sync_glue_booth( mut commands: Commands, preview: Res, bake: Res, - booths: Res, + mut booths: ResMut, mut scene: Option>, mut booth_light: ResMut, mut materials: ResMut>, @@ -754,7 +757,7 @@ pub(super) fn sync_glue_booth( mut palettes: ResMut, mut last: Local>, ) { - let Some(booth) = booths.0.get(GLUE_SLOT) else { + let Some(booth) = booths.0.get_mut(GLUE_SLOT) else { return; }; // Where the character stands (the scene's stage spot while a scene is up, else the origin) — @@ -777,6 +780,9 @@ pub(super) fn sync_glue_booth( // Empty parts (nothing selected, or the model failed) → clear the booth. if bake.parts.is_empty() { commands.entity(booth.root).despawn_related::(); + // The despawn above took the park set with it. + booth.joints.clear(); + booth.parked = false; *last = Some((bake.revision, preview.yaw, scene_rev)); apply_yaw(&mut commands, booth.root, spot, preview.yaw); return; @@ -895,6 +901,9 @@ pub(super) fn sync_glue_booth( bake.effects.len() ); } + // A fresh bake is animated by construction; the park set is the new skeleton's. + booth.joints = joints; + booth.parked = false; // The character-only fallback framing (no scene up — art missing / still loading): the // body-frame transform with an aspect-aware projection, because the booth target is // window-sized now (the square-true `WowPortraitProjection` would stretch on it). While a diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 9b971ffd..1eff33d1 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -421,16 +421,41 @@ struct Booth { /// until each lands (an `mpq://` image arriving after the bake would otherwise be frozen /// OUT of the still forever), then renders one final resident frame. pending: Vec>, + /// When the current `pending` hold began (wall secs) — `None` while it is empty. The hold + /// was designed for a texture in flight, not one that will NEVER land (a failed load leaves + /// no `Assets` entry): unbounded, that pinned the camera rendering forever behind a + /// closed window. [`gate_booth_cameras`] releases the hold at [`PENDING_LANDING_SECS`] + /// with a warn — the still keeps whatever did land. + pending_since: Option, /// The **destination pane's** aspect this booth's camera is currently framed for /// ([`framing::WowPortraitProjection::aspect`], decision 1069) — 1.0 until the UI has drawn the /// pane once, then sticky: a hidden window must not re-frame the bake back to square. aspect: f32, + /// The bake's joint entities ([`booth::spawn_booth_model`]'s return) — the park set. Every + /// joint carries `AnimationTargetId` + `AnimatedBy`, which is a seat in bevy's per-frame + /// `animate_targets` par sweep whether or not anything renders the result (a *paused* + /// animation still evaluates its held pose every frame). Replaced wholesale by each bake; + /// cleared with the emptied booth. + joints: Vec, + /// The booth scene is parked: its camera is asleep, so [`gate_booth_cameras`] stripped the + /// joints' `AnimatedBy` (the pose HOLDS — transforms are state), paused the root's + /// [`benilla_world::rig_anim::GlobalSeqDrive`], and the booth-lane emitters freeze under the + /// draw-set law (`particles::sim` — the reference only ticks an emitter its frame draws). + /// A camera wake reverses all three before the next render. Costs nothing while a window + /// is open; retires the whole idle lane the rest of the session. + parked: bool, } /// How many frames a content edge keeps a booth camera rendering ([`Booth::wake`]): covers the /// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes. const BOOTH_SETTLE_FRAMES: u32 = 4; +/// How long a [`Booth::pending`] texture hold may keep the camera awake (wall secs) before it is +/// declared never-landing and released ([`Booth::pending_since`]). Generous against a real load — +/// an MPQ image lands in well under a second — because a premature release only costs a stale +/// still, while the old unbounded hold cost a forever-rendering camera. +const PENDING_LANDING_SECS: f64 = 10.0; + /// `WOW_BOOTH_LOG=1` — is the booth instrument armed? (Read once.) fn booth_log() -> bool { static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -531,6 +556,8 @@ fn wake_booth<'a>( .filter_map(|h| mats.get(h)) .filter_map(|m| m.base.base_color_texture.clone()) .collect(); + // A fresh hold gets a fresh clock ([`Booth::pending_since`]) — the gate stamps it. + booth.pending_since = None; } #[derive(Resource, Default)] @@ -866,7 +893,10 @@ fn setup_booths( wake: 0, live: false, pending: Vec::new(), + pending_since: None, aspect: 1.0, + joints: Vec::new(), + parked: false, }, ); } @@ -936,7 +966,10 @@ fn setup_booths( wake: 0, live: false, pending: Vec::new(), + pending_since: None, aspect: 1.0, + joints: Vec::new(), + parked: false, }, ); } @@ -1098,6 +1131,9 @@ fn sync_portraits( // backdrop, not the departed unit's face, before the camera sleeps. booth.wake = BOOTH_SETTLE_FRAMES; booth.pending.clear(); + // The despawn above took the park set with it. + booth.joints.clear(); + booth.parked = false; } let live = PortraitSource::Live(booth.target.clone()); if portraits.0.get(token) != Some(&live) { @@ -1189,7 +1225,7 @@ fn sync_portraits( continue; } commands.entity(booth.root).despawn_related::(); - spawn_booth_model( + let joints = spawn_booth_model( &mut commands, &mut palettes, booth.root, @@ -1206,6 +1242,11 @@ fn sync_portraits( [false, false], // a still portrait sheaths its weapons — no in-hand grip &booth_billboards, ); + // Even a Frozen still is a sweep seat per joint (a paused clip re-evaluates its held + // pose every frame) — the park set matters MOST here, since a portrait's camera + // sleeps for the whole session outside its bake window. + booth.joints = joints; + booth.parked = false; // **No emitters here, and that is the reference's own answer** (decision 0822): the // round portrait is a ONE-SHOT bake — a fresh M2 scene + instance, one `0x707680` draw, // the texture cached by GUID/displayId and returned with *no re-render* on a hit, nothing @@ -1437,6 +1478,9 @@ fn sync_body_booth( booth.wake = BOOTH_SETTLE_FRAMES; booth.live = false; booth.pending.clear(); + // The despawn above took the park set with it. + booth.joints.clear(); + booth.parked = false; } return; } @@ -1551,6 +1595,9 @@ fn sync_body_booth( // So the whole bake is live, emitters or not — `wake` can't gate a looping animation. // `gate_booth_cameras` renders it every frame its pane is on screen, and none when it isn't. booth.live = true; + // A fresh bake is animated by construction; the park set is the new skeleton's. + booth.joints = joints; + booth.parked = false; // Body framing from the display's bounds — the full standing figure, feet-to-crown, at the // destination pane's aspect. Resolved before the teardown above; see the portrait site for // why it cannot be faked. @@ -1607,6 +1654,7 @@ fn sync_body_booth( /// only works if the booth cameras render during the warm window. #[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource/query fn gate_booth_cameras( + mut commands: Commands, mut booths: ResMut, preview: Res, panes: Res, @@ -1614,6 +1662,7 @@ fn gate_booth_cameras( warm: Res, time: Res>, mut cams: Query<(&BoothCam, &mut Camera)>, + mut seq_drives: Query<&mut benilla_world::rig_anim::GlobalSeqDrive>, mut env_cache: Local>, ) { let test = test_mode(&mut env_cache); @@ -1630,6 +1679,25 @@ fn gate_booth_cameras( if had_pending && booth.pending.is_empty() { booth.wake = booth.wake.max(1); } + // Bound the hold (the `pending_since` field's doc): a texture that will never land must + // not keep this camera rendering for the rest of the session. + if booth.pending.is_empty() { + booth.pending_since = None; + } else { + let now = time.elapsed_secs_f64(); + let since = *booth.pending_since.get_or_insert(now); + if now - since > PENDING_LANDING_SECS { + warn!( + "booth {}: {} texture(s) never landed after {PENDING_LANDING_SECS:.0}s — \ + releasing the wake hold with the still as-is", + token.as_str(), + booth.pending.len(), + ); + booth.pending.clear(); + booth.pending_since = None; + booth.wake = booth.wake.max(1); + } + } let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some(); // A live bake renders every frame — but only while the UI is actually drawing its pane. // (The glue screens sample their booth outside the FrameXML extract, so they publish no @@ -1659,6 +1727,29 @@ fn gate_booth_cameras( if cam.is_active != active { cam.is_active = active; } + // Park/unpark the standing scene with its camera (director report 2026-08-19: the doll + // bake animated at full cost from the LOGIN bake to quit, window opened or not). + // Parking strips each joint's `AnimatedBy` — its seat in bevy's per-frame + // `animate_targets` par sweep; the pose HOLDS, transforms are state — and pauses the + // root's `GlobalSeqDrive` (cursor = shared clock − anchor, so resume needs no re-seek). + // Booth-lane emitters freeze on the camera bit itself (`particles::sim`). This system + // runs before the PostUpdate animation lane, so an unpark's re-inserted `AnimatedBy` is + // evaluated the same frame — the wake window's first render already animates. + if booth.parked == active && !booth.joints.is_empty() { + for &j in &booth.joints { + if active { + commands + .entity(j) + .insert(bevy::animation::AnimatedBy(booth.root)); + } else { + commands.entity(j).remove::(); + } + } + if let Ok(mut drive) = seq_drives.get_mut(booth.root) { + drive.set_paused(!active); + } + booth.parked = !active; + } if active { booth.wake = booth.wake.saturating_sub(1); } diff --git a/crates/benilla-app/src/transport.rs b/crates/benilla-app/src/transport.rs index ff890d69..3b90aa00 100644 --- a/crates/benilla-app/src/transport.rs +++ b/crates/benilla-app/src/transport.rs @@ -36,6 +36,7 @@ use bevy::prelude::*; use crate::go_templates::GameObjectTemplates; use crate::net::Guid; use benilla_assets::{LockRecover, WorldAssets}; +use benilla_world::vis_chain::VisChainOnly; use benilla_world::world_map::CurrentMap; pub(crate) struct TransportPlugin; @@ -257,7 +258,11 @@ fn arm_transports( commands .entity(entity) .remove::<(ElevatorSeed, TransportAnchor)>() - .insert(Visibility::default()); + .insert(Visibility::default()) + // The one insert-based visibility flip: `Visibility`'s require chain just + // re-added the sweep row a net root sheds at spawn — strip it again + // (benilla_world::vis_chain). + .vis_chain_only(); continue; }; let Some(current_map) = current_map.as_ref() else { diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index bd9c9c49..ac38e46a 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -52,7 +52,7 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use crate::pending_item_ops::PendingItemOps; use crate::portrait::PaperDollBooth; use crate::ui_items::{find_equip_slot, item_link}; -use crate::ui_script::UiInput; +use crate::ui_script::{gate, UiInput}; /// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand, /// off hand, and ranged slots the weapon-skill / offhand / wand resolutions read. @@ -87,6 +87,14 @@ struct CharFeedState { struct CharFeedMemo { last_stats: Option, last_inv: Option, + /// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed` + /// for this feed (the equipment templates' ask-once, the enchant-name creator lookups). + items_objects: gate::Watch, + items_templates: gate::Watch, + names_generation: gate::Watch, + /// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot + /// views read second-floored countdowns), including the last elapse's collapsing push. + enchant_deadlines: gate::Watch, } /// Adds the per-frame character-window feed. The bindings live in `benilla-ui`'s `char_stats`; @@ -548,7 +556,7 @@ fn slot_view( // The temp-enchant countdowns, read before the object borrow (both live on `Items`) — the bag // feed's twin. let enchant_ms: [Option; 7] = - std::array::from_fn(|s| items.enchant_remaining_ms(guid, s as u32)); + std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32)); let obj = items.object(guid)?; let entry = obj.object_entry()?; let count = obj.item_stack_count().unwrap_or(1).max(1); @@ -851,6 +859,7 @@ pub(crate) fn fire_stat_transitions( fn feed_char( script: Option>, self_q: Query<&ObjectStore, With>, + changed_self: Query<(), (With, Changed)>, mut items: ResMut, icons: Option>, // `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915). @@ -865,11 +874,15 @@ fn feed_char( return; }; // The pane's Model:SetRotation transcription owns the yaw; the booth mirrors it (0208 §5). - booth.yaw = script.paperdoll_yaw(); + // Write-if-different: an unconditional write would mark the booth changed every frame. + let yaw = script.paperdoll_yaw(); + if booth.yaw != yaw { + booth.yaw = yaw; + } // Resolved against THIS VM (1290/1291): a `/reload`'s replacement reads a fresh memo, so both // snapshots below re-push and their events re-fire for it. - let memo = feed.vm.get(&script); + let (memo, vm_reset) = feed.vm.get_reset(&script); let Some(store) = self_q.iter().next() else { // The self store's absence is NO DATA, never "the player has nothing equipped". The one @@ -886,8 +899,68 @@ fn feed_char( return; }; + // `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8 + // corpus addons). Pushed EVERY frame and change-gated by nothing — including the 1439 gate + // below: the remaining time is a live countdown, so riding a snapshot diff would fire + // `UNIT_INVENTORY_CHANGED` on every tick. The reference recomputes it per call for exactly + // the same reason (`0x5d9d00`). Hoisted above the gate (it used to sit last): the push is + // order-free of the two snapshots, and a handler they fire reads the fresher value this way. + script.set_weapon_enchants( + weapon_enchant(store, &items, EQUIPMENT_SLOT_MAINHAND), + weapon_enchant(store, &items, EQUIPMENT_SLOT_OFFHAND), + ); + + // The gate (1439), around the two snapshot builds only: the self descriptor (equipment + // slots, every stat field), the item stores behind the equipped templates and enchant + // names, the two catalogs, and the pending-op locks (held open while in flight; the + // resolving frame's own field update moves the object epoch). + let objects_moved = memo.items_objects.moved(items.object_epoch()); + let templates_moved = memo.items_templates.moved(items.template_epoch()); + let names_moved = memo.names_generation.moved(names.generation()); + // The DISPLAY epoch, not a per-frame hold-open: the snapshot reads second-floored countdowns + // (`enchant_remaining_display_ms`), so it can only differ when this counter steps — once a + // second per ticking enchant, plus one final step at the elapse. Holding the gate open on + // `live_enchant_deadlines() > 0` (the first 1439 shape) rebuilt both snapshots and fired + // `UNIT_INVENTORY_CHANGED` at frame rate for the whole life of a poison. + let deadlines_moved = memo.enchant_deadlines.moved(items.enchant_display_epoch()); + let self_changed = !changed_self.is_empty(); + // `is_added` for the icon catalog: the feeds read only its load-once icon column; + // its model-cache half churns every frame (the containers gate's own note). + let icons_added = icons.as_ref().is_some_and(|r| r.is_added()); + let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed()); + let pending_held = !pending.is_empty(); + gate::trace( + "feed_char", + &[ + ("vm_reset", vm_reset), + ("objects", objects_moved), + ("templates", templates_moved), + ("names", names_moved), + ("deadlines", deadlines_moved), + ("self", self_changed), + ("icons", icons_added), + ("enchants", enchants_changed), + ("pending", pending_held), + ], + ); + let gate = gate::Gate::new( + vm_reset + || objects_moved + || templates_moved + || names_moved + || deadlines_moved + || self_changed + || icons_added + || enchants_changed + || pending_held, + ); + if gate.skip() { + return; + } + let stats = combat_stats(store, &mut items, &commands); if memo.last_stats.as_ref() != Some(&stats) { + gate.audit("feed_char", "the combat-stats snapshot"); // PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot // must already be in the VM when they repaint (the ui_unit rule — a fire-first ordering // paints the OLD values and, being transition-gated, never corrects itself). @@ -907,6 +980,7 @@ fn feed_char( &mut names, ); if memo.last_inv.as_ref() != Some(&inv) { + gate.audit("feed_char", "the inventory snapshot"); script.set_inventory_slots(inv.clone()); script.fire_event( "UNIT_INVENTORY_CHANGED", @@ -914,15 +988,6 @@ fn feed_char( ); memo.last_inv = Some(inv); } - - // `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8 - // corpus addons). Pushed EVERY frame and change-gated by nothing: the remaining time is a live - // countdown, so riding the snapshot above would fire `UNIT_INVENTORY_CHANGED` on every tick. - // The reference recomputes it per call for exactly the same reason (`0x5d9d00`). - script.set_weapon_enchants( - weapon_enchant(store, &items, EQUIPMENT_SLOT_MAINHAND), - weapon_enchant(store, &items, EQUIPMENT_SLOT_OFFHAND), - ); } /// The client's own 0-based `EQUIPMENT_SLOT_*` ids for the two weapon slots — the numbers diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs index 0ca61864..9906331a 100644 --- a/crates/benilla-app/src/ui_guild/mod.rs +++ b/crates/benilla-app/src/ui_guild/mod.rs @@ -141,6 +141,10 @@ pub(crate) struct GuildState { rank_index: u32, /// Guild id → identity, from `SMSG_GUILD_QUERY_RESPONSE`. Holds negatives (module doc). identities: HashMap, + /// Bumped by every landed identity ([`Self::apply_query_response`]) — never by an ask. The + /// gated unit feeds' watch counter (decision 1439): `unit_guild`'s miss resolves later, and + /// `is_changed` cannot flag the landing because the miss itself takes `&mut self` per frame. + identity_generation: u64, /// Guild ids with a `CMSG_GUILD_QUERY` in flight — the ask-once gate. queried: HashSet, /// The message of the day. Kept beside the roster rather than inside it because `GE_MOTD` @@ -250,6 +254,11 @@ impl GuildState { self.identity(guild_id) } + /// The landed-identity counter — see the [`Self::identity_generation`] field. + pub(crate) fn identity_generation(&self) -> u64 { + self.identity_generation + } + /// `SMSG_GUILD_QUERY_RESPONSE` — fill (or negatively fill) the identity cache. fn apply_query_response(&mut self, response: GuildQueryResponse) { self.queried.remove(&response.guild_id); @@ -261,6 +270,7 @@ impl GuildState { rank_names: response.rank_names, }, ); + self.identity_generation = self.identity_generation.wrapping_add(1); self.dirty = true; if ours { // Our own rank names just landed under the roster rows that display them, so the pane diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 455a56c8..e982ba4f 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -13,6 +13,7 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, SelfPlayer}; use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps}; +use crate::ui_script::gate; use super::equip_error::equip_error_key; use super::{ @@ -30,6 +31,15 @@ pub(crate) struct FeedMemory { /// every frame. It is the gate the entire keyring UI hangs off, and "the keyring never /// appeared" is otherwise indistinguishable from "the button is mis-anchored". had_key: bool, + /// The gate's counter memories (1439) — the four stores whose lazy `&mut` resolves poison + /// `is_changed` for this feed, watched by their explicit counters instead. + items_objects: gate::Watch, + items_templates: gate::Watch, + cooldown_epoch: gate::Watch, + names_generation: gate::Watch, + /// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot + /// views read second-floored countdowns), including the last elapse's collapsing push. + enchant_deadlines: gate::Watch, } /// A spell's tooltip text: the $-substituted description (the real "Use: Restores 392 to 653 @@ -375,6 +385,7 @@ pub(super) fn feed_item_stats( pub(super) fn feed_player_req( script: Option>, self_q: Query<&ObjectStore, With>, + changed_self: Query<(), (With, Changed)>, proficiencies: Res, reputations: Res, factions: Option>, @@ -385,7 +396,40 @@ pub(super) fn feed_player_req( let Some(mut script) = script else { return; }; - let last = last.get(&script); + let (last, vm_reset) = last.get_reset(&script); + // The gate (1439): the state below is a pure function of these six — the self descriptor + // (skills/level/race/class/honor; its absence returns pushless), the proficiency and + // reputation mirrors, the faction catalog, and the known-spell set with its catalog. + let self_changed = !changed_self.is_empty(); + let prof_changed = proficiencies.is_changed(); + let reps_changed = reputations.is_changed(); + let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); + let actions_changed = actions.is_changed(); + let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed()); + gate::trace( + "feed_player_req", + &[ + ("vm_reset", vm_reset), + ("self", self_changed), + ("proficiencies", prof_changed), + ("reputations", reps_changed), + ("factions", factions_changed), + ("actions", actions_changed), + ("spells", spells_changed), + ], + ); + let gate = gate::Gate::new( + vm_reset + || self_changed + || prof_changed + || reps_changed + || factions_changed + || actions_changed + || spells_changed, + ); + if gate.skip() { + return; + } let Some(store) = self_q.iter().next() else { return; }; @@ -440,6 +484,7 @@ pub(super) fn feed_player_req( honor_rank: store.0.player_honor_rank().unwrap_or(0), }; if last.as_ref() != Some(&state) { + gate.audit("feed_player_req", "the player-requirement state"); *last = Some(state.clone()); script.set_player_req_state(state); } @@ -469,7 +514,7 @@ fn resolve_slot( // The temporary-enchant countdowns, read off the deadline store BEFORE the object borrow below // (both live on `Items`): one `Option` per enchant slot. let enchant_ms: [Option; 7] = - std::array::from_fn(|s| items.enchant_remaining_ms(guid, s as u32)); + std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32)); let (entry, count, durability, readable, creator, flags, enchant_lines) = match items.object(guid) { Some(fields) => ( @@ -601,14 +646,19 @@ fn bag_family_name( families.name(family).map(str::to_string) } -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] // the param list IS the input set pub(crate) fn feed_containers( script: Option>, mut items: ResMut, icons: Option>, // `SpellItemEnchantment`'s name column — the tooltip's enchant lines (decision 0915). enchants: Option>, - self_q: Query<&ObjectStore, With>, + // The two self-descriptor legs in one param (the 16-SystemParam ceiling): the store the + // slot arrays read, and its change tick — the gate's cheapest input (1439). + self_q: ( + Query<&ObjectStore, With>, + Query<(), (With, Changed)>, + ), commands: Res, cooldowns: Res, spells: Option>, @@ -625,10 +675,82 @@ pub(crate) fn feed_containers( let Some(mut script) = script else { return; }; - let memory = memory.get(&script); - // `WOW_FEED_COST=1` — the premise counter for gating this feed (the 2026-08-15 ledger's #8): - // the whole snapshot+diff below runs per frame; before an epoch gate is built, this says what - // that actually costs on a real bag population. Printed once a second. + let (memory, vm_reset) = memory.get_reset(&script); + // The gate (1439): the snapshot below is a function of the self descriptor's slot arrays, + // the item stores (both epochs — `is_changed` on `Items`/`NameCache`/`Cooldowns` is + // poisoned by per-frame lazy resolves, so the counters carry the truth), the four + // catalogs, and the drained side-channels (each held open while non-empty; their + // *arrival* frames are covered because a drain writer marks them non-empty). `UiClock` + // is deliberately NOT an input: the pushed cooldown triple carries the ABSOLUTE start + // (frame-stable, the memory struct's own doc), and natural expiry moves the store's + // `feed_epoch` through the prune. + let objects_moved = memory.items_objects.moved(items.object_epoch()); + let templates_moved = memory.items_templates.moved(items.template_epoch()); + let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch()); + let names_moved = memory.names_generation.moved(names.generation()); + // The DISPLAY epoch (see `feed_char`'s twin comment): the snapshot's countdowns are + // second-floored, so one watch step per displayable change replaces the per-frame hold-open + // that rebuilt every bag snapshot for the whole life of a ticking enchant. + let deadlines_moved = memory + .enchant_deadlines + .moved(items.enchant_display_epoch()); + // Bound as `let`s (not a bare OR-chain) so the gate trace below can name each input. + let sweep = cooldowns.sweep_pending(clock.anchor); + let self_changed = !self_q.1.is_empty(); + // `is_added`, NOT `is_changed`: the feeds read only the load-once icon CATALOG off this + // resource (its struct doc says so verbatim); the world's held-model cache half is + // get-or-insert every frame, so `is_changed` reads true forever — 1439's gate-trace found + // the containers gate held open by exactly this. + let icons_changed = icons.as_ref().is_some_and(|r| r.is_added()); + let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed()); + let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed()); + let families_changed = bag_families.as_ref().is_some_and(|r| r.is_changed()); + let errors_held = !equip_errors.0.is_empty() || !error_lines.0.is_empty(); + let locks_held = !lock_cleared.0.is_empty() || !pending.is_empty(); + gate::trace( + "feed_containers", + &[ + ("vm_reset", vm_reset), + ("objects", objects_moved), + ("templates", templates_moved), + ("cooldowns", cooldowns_moved), + ("sweep", sweep), + ("names", names_moved), + ("deadlines", deadlines_moved), + ("self", self_changed), + ("icons", icons_changed), + ("enchants", enchants_changed), + ("spells", spells_changed), + ("families", families_changed), + ("errors", errors_held), + ("locks", locks_held), + ], + ); + let gate = gate::Gate::new( + vm_reset + || objects_moved + || templates_moved + || cooldowns_moved + // The frame a timer crosses zero, BEFORE the per-frame prune has moved the epoch + // (`sweep_pending`'s own doc) — the slot triple flips to None right then. + || sweep + || names_moved + || deadlines_moved + || self_changed + || icons_changed + || enchants_changed + || spells_changed + || families_changed + || errors_held + || locks_held, + ); + if gate.skip() { + return; + } + // `WOW_FEED_COST=1` — the counter that PRICED gating this feed (the 2026-08-15 ledger's #8, + // built when the snapshot+diff below still ran every frame; the gate above is 1439's + // answer). Kept as the gate's wiring instrument: it accumulates only when the body runs, so + // a parked gated run prints (almost) nothing where the ungated binary printed once a second. let cost_t0 = std::env::var_os("WOW_FEED_COST") .is_some() .then(std::time::Instant::now); @@ -650,7 +772,7 @@ pub(crate) fn feed_containers( // `ui_action::cast_fail` already runs. No hex debug line on the player's screen: a code we // failed to map can't reach here (the table is total), and a key we typo'd is caught by // `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime. - let player = self_q.iter().next(); + let player = self_q.0.iter().next(); for e in equip_errors.0.drain(..) { // Reason 16's substitution — the ONE reason whose text is chosen by the app rather than // by the table, because the choice needs the named bag (decision 0916). The reference's @@ -919,6 +1041,7 @@ pub(crate) fn feed_containers( // same slot arrays, and it must be fresh on exactly the frames a BAG_UPDATE fires. let key = has_key(&store.0, &mut items, &commands); if key != memory.had_key { + gate.audit("feed_containers", "the HasKey() flip"); debug!( "ui_items: HasKey() -> {key} (keyring {})", if key { "shown" } else { "hidden" } @@ -943,12 +1066,14 @@ pub(crate) fn feed_containers( ); } } - apply_container_source( + if apply_container_source( &mut script, memory, player.is_some().then_some(fresh), transitioned, - ); + ) { + gate.audit("feed_containers", "a bag diff or a lock event"); + } } /// The feed's outward half: diff `source` against what was last pushed, push each changed bag into @@ -974,22 +1099,26 @@ pub(crate) fn apply_container_source( memory: &mut FeedMemory, source: Option>, transitioned: Vec<(i64, u32)>, -) { +) -> bool { // Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A // pending-lock transition always flips a slot's `.locked` (part of `ContainerSlot`'s equality), // so it always shows up here too — but `transitioned`'s ITEM_LOCK_CHANGED fires unconditionally // below rather than leaning on that invariant. + let mut pushed = false; if let Some(fresh) = source { - diff_and_push(script, memory, fresh); + pushed |= diff_and_push(script, memory, fresh); } // The lock-transition event (decision 0218: the bag windows' own repaint trigger) — after the // container push above, so a listener's repaint reads the corrected `.locked` state. + pushed |= !transitioned.is_empty(); for (bag, slot) in transitioned { script.fire_event( "ITEM_LOCK_CHANGED", vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))], ); } + // Whether anything went into the VM — the caller's gate audit reads it (1439). + pushed } /// The present-source half of [`apply_container_source`]: push every changed bag, announce each. @@ -997,7 +1126,7 @@ fn diff_and_push( script: &mut UiScript, memory: &mut FeedMemory, fresh: HashMap, -) { +) -> bool { let changed: Vec = fresh .keys() .chain(memory.pushed.keys()) @@ -1044,7 +1173,9 @@ fn diff_and_push( } memory.pushed = fresh; script.fire_event("BAG_UPDATE_DELAYED", vec![]); + return true; } + false } #[cfg(test)] diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 0280246c..541b07a8 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -14,6 +14,7 @@ use bevy::prelude::*; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; use crate::target::Selection; +use crate::ui_script::gate; use super::GroupState; @@ -27,6 +28,14 @@ pub(super) struct FedParty { loot: Option, invite: Option, units: [Option; 4], + /// The whole roster-level snapshot last pushed — `set_party`'s own diff (1439; it used to + /// re-push, allocations and all, every frame). + pushed_party: Option, + /// The gate's counter memories (1439): the name cache by its landed counter (the resolves + /// other feeds run per frame poison `is_changed` on it), and the leaf area under us (our + /// own raid row's zone — a plain value watched as a counter). + names_generation: gate::Watch, + area: gate::Watch, } const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"]; @@ -49,6 +58,8 @@ pub(super) fn feed_party( group: Res, index: Res, stores: Query<&ObjectStore>, + changed_stores: Query<(), Changed>, + mut removed_stores: RemovedComponents, self_q: Query<(&Guid, &ObjectStore), With>, factions: Option>, names: Res, @@ -63,7 +74,48 @@ pub(super) fn feed_party( let Some(mut script) = script else { return; }; - let fed = fed.get(&script); + let (fed, vm_reset) = fed.get_reset(&script); + // The gate (1439): the group state, any member/self descriptor change or DESPAWN (a removed + // store is invisible to `Changed`), the streamed-guid index the merged view resolves + // through, the two catalogs, the name cache by its landed counter, and our own leaf area + // (the raid row's zone). The gate closing needs the party quiet — solo, it almost always is. + let names_moved = fed.names_generation.moved(names.generation()); + let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from)); + let group_changed = group.is_changed(); + let index_changed = index.is_changed(); + let stores_changed = !changed_stores.is_empty(); + let stores_removed = !removed_stores.is_empty(); + let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); + let areas_changed = areas.as_ref().is_some_and(|r| r.is_changed()); + gate::trace( + "feed_party", + &[ + ("vm_reset", vm_reset), + ("names", names_moved), + ("area", area_moved), + ("group", group_changed), + ("index", index_changed), + ("stores", stores_changed), + ("removed", stores_removed), + ("factions", factions_changed), + ("areas", areas_changed), + ], + ); + let gate = gate::Gate::new( + vm_reset + || names_moved + || area_moved + || group_changed + || index_changed + || stores_changed + || stores_removed + || factions_changed + || areas_changed, + ); + removed_stores.clear(); + if gate.skip() { + return; + } let self_pair = self_q.iter().next(); let self_guid = self_pair.map(|(g, _)| g.0); // The party's PvP faction group (decision 0646 §1): our own. A 1.12 party is always one @@ -139,16 +191,22 @@ pub(super) fn feed_party( }; let raid = raid_roster(&group, me.as_ref(), &names, &zone_name); - script.set_party(PartyState { + let fresh = PartyState { members, leader_index, raid, loot_method, master_looter, loot_threshold, - }); + }; + if fed.pushed_party.as_ref() != Some(&fresh) { + gate.audit("feed_party", "the roster snapshot"); + script.set_party(fresh.clone()); + fed.pushed_party = Some(fresh); + } - // party1..party4 unit snapshots + their per-field UNIT_* transitions. + // party1..party4 unit snapshots + their per-field UNIT_* transitions — pushed on diff (1439; + // an identical snapshot re-pushed is invisible to the VM, so only a change pays the clone). for (i, token) in PARTY_TOKENS.iter().enumerate() { let snap = slots.get(i).map(|m| { member_unit_state( @@ -160,28 +218,35 @@ pub(super) fn feed_party( own_group.clone(), ) }); - script.set_unit(token, snap.clone()); - if let Some(cur) = &snap { - crate::ui_unit::fire_transitions(&mut script, token, fed.units[i].as_ref(), cur); + if fed.units[i] != snap { + gate.audit("feed_party", "a party-token snapshot"); + script.set_unit(token, snap.clone()); + if let Some(cur) = &snap { + crate::ui_unit::fire_transitions(&mut script, token, fed.units[i].as_ref(), cur); + } + fed.units[i] = snap; } - fed.units[i] = snap; } // The party events, on edges. let roster: Vec = group.members.iter().map(|m| m.guid).collect(); if roster != fed.roster { + gate.audit("feed_party", "the roster edge"); script.fire_event("PARTY_MEMBERS_CHANGED", vec![]); fed.roster = roster; } if group.leader != fed.leader { + gate.audit("feed_party", "the leader edge"); script.fire_event("PARTY_LEADER_CHANGED", vec![]); fed.leader = group.leader; } if group.loot != fed.loot { + gate.audit("feed_party", "the loot-method edge"); script.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]); fed.loot = group.loot; } if group.pending_invite != fed.invite { + gate.audit("feed_party", "the invite edge"); match &group.pending_invite { Some(inviter) => script.fire_event( "PARTY_INVITE_REQUEST", diff --git a/crates/benilla-app/src/ui_pet/unit.rs b/crates/benilla-app/src/ui_pet/unit.rs index 03062e61..6267f76d 100644 --- a/crates/benilla-app/src/ui_pet/unit.rs +++ b/crates/benilla-app/src/ui_pet/unit.rs @@ -11,6 +11,7 @@ use benilla_ui::script::{ScriptValue, UiScript, UnitState}; use crate::names::NameCache; use crate::net::{NetCommands, ObjectStore}; +use crate::ui_script::gate; use crate::ui_unit::{fire_transitions, snapshot}; use super::{PetBar, PetUnit}; @@ -31,6 +32,9 @@ pub(super) struct PetUnitMemory { /// (decision 1066). The guid rides along because the timestamp alone would read a *different* /// pet's stamp as a rename of this one. name_stamp: Option<(u64, Option)>, + /// The gate's counter memory (1439): the name cache by its landed counter — the token's own + /// `resolve` miss lands frames later, and `is_changed` cannot flag it. + names_generation: gate::Watch, } /// `UNIT_FIELD_FLAGS` bit `0x800` — the **pet-in-combat** flag, and the whole trigger for @@ -55,10 +59,13 @@ pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800; /// The reaction argument is `0`: the pet frame reads no reaction (only `"target"` resolves one — /// [`crate::ui_unit::feed_units`]' own note), and the party feed passes the same for the same /// reason. +#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set pub(super) fn feed_pet_unit( script: Option>, bar: Res, pet: PetUnit, + changed_stores: Query<(), Changed>, + mut removed_stores: RemovedComponents, mut names: ResMut, commands: Res, mut memory: Local>, @@ -66,7 +73,30 @@ pub(super) fn feed_pet_unit( let Some(mut script) = script else { return; }; - let memory = memory.get(&script); + let (memory, vm_reset) = memory.get_reset(&script); + // The gate (1439): the bar (the token's identity), any descriptor change or DESPAWN (the + // snapshot, the combat flag, and the rename timestamp all live on the pet's store), and the + // name cache by its landed counter. + let names_moved = memory.names_generation.moved(names.generation()); + let bar_changed = bar.is_changed(); + let stores_changed = !changed_stores.is_empty(); + let stores_removed = !removed_stores.is_empty(); + gate::trace( + "feed_pet_unit", + &[ + ("vm_reset", vm_reset), + ("names", names_moved), + ("bar", bar_changed), + ("stores", stores_changed), + ("removed", stores_removed), + ], + ); + let gate = + gate::Gate::new(vm_reset || names_moved || bar_changed || stores_changed || stores_removed); + removed_stores.clear(); + if gate.skip() { + return; + } let pet_guid = bar.spells.pet_guid; watch_name_timestamp(pet_guid, &mut script, &pet, &mut names, memory); // A bar whose unit has not streamed yet (or has left) pushes nothing: `UnitExists("pet")` then @@ -82,7 +112,16 @@ pub(super) fn feed_pet_unit( s }); - script.set_unit("pet", fresh.clone()); + // Pushed on diff (1439): an identical snapshot re-pushed is invisible to the VM. + let dirty = match (&fresh, &memory.pushed) { + (Some(cur), Some(prev)) => cur != prev, + (None, None) => false, + _ => true, + }; + if dirty { + gate.audit("feed_pet_unit", "the pet snapshot"); + script.set_unit("pet", fresh.clone()); + } match &fresh { Some(cur) => { if memory.pushed.as_ref() != Some(cur) { @@ -113,6 +152,7 @@ pub(super) fn feed_pet_unit( // the same pet (a learned spell, a mode change) is not one, which is why this diffs the guid // rather than riding `feed_pet_bar`'s whole-state diff. if memory.guid != Some(pet_guid) { + gate.audit("feed_pet_unit", "the UNIT_PET edge"); memory.guid = Some(pet_guid); debug!("ui_pet: UNIT_PET — pet is now {pet_guid:#x}"); script.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]); @@ -143,6 +183,7 @@ pub(super) fn feed_pet_unit( .and_then(|_| pet_combat_flag(pet.store(pet_guid)?, pet.self_guid.0)); if let Some(now) = in_combat { if memory.in_combat != Some(now) { + gate.audit("feed_pet_unit", "the pet combat-flag edge"); memory.in_combat = Some(now); debug!("ui_pet: pet in-combat flag → {now}"); script.fire_event( diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 38411aa0..dfbde120 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -213,10 +213,18 @@ pub(super) fn drive_script( ) { let (capture, ui_cost_wanted) = run; let Some(mut script) = script else { + // No VM ⇒ no UI is sampling any booth pane. The panes map must not outlive its writer: + // every OTHER return in this system provably keeps pane presence unchanged (the settled + // gate compares the whole extracted list; the splice refuses `portrait_unit` entries), + // but a VM dying mid-world with the character window up would strand its pane here and + // the paper-doll camera would render behind a dead UI until the next full conversion. + booths.panes.0.clear(); return; }; let prev = prev.get(&script); let Ok(window) = window.single() else { + // Same law as the no-VM arm: no window, no sampling — a pane map with no writer lies. + booths.panes.0.clear(); return; }; let (w, h) = (window.width(), window.height()); diff --git a/crates/benilla-app/src/ui_script/gate.rs b/crates/benilla-app/src/ui_script/gate.rs new file mode 100644 index 00000000..6e696d77 --- /dev/null +++ b/crates/benilla-app/src/ui_script/gate.rs @@ -0,0 +1,176 @@ +//! The **feed gate** (decision 1439): the input-side early-out for a per-frame UI feed. +//! +//! Every VM feed is a rebuild-to-diff: build the fresh snapshot, compare against what was last +//! pushed, push only the difference. The diff half was always cheap; the REBUILD half runs every +//! frame whether or not any input moved, and on a parked frame none do — that waste is what 1435 +//! priced and 1436 deferred to its own slice. The gate is the honest fix, not a cache bolted on: +//! the reference client pushes on *events* (its handlers run when a field-watch or a packet says +//! so), and an input-change gate is that same shape written in change detection. +//! +//! **The gate lives in the feed's body, never in a `run_if`.** A run condition is a second +//! parameter list that must mirror the system's own, and nothing ties them together — every +//! param added to the body without its condition twin is a silent stale-window bug. In the body, +//! the gate reads the very params it guards, beside their declarations, and the audit mode below +//! can check it. +//! +//! Three rules for writing one: +//! +//! 1. **Every input the body reads is in the gate.** `Res::is_changed()` for plain resources; +//! a [`Watch`] over the counter for the generation-counted stores (`Cooldowns`, `Items`, +//! `NameCache`, `GuildState` — counted precisely because their lazy `&mut` resolves mark the +//! resource changed every frame, poisoning `is_changed` for everyone); `Changed`/ +//! `RemovedComponents` queries for component inputs; an emptiness check for drained +//! side-channels. +//! 2. **The VM reset is an input.** [`super::VmMemo::get_reset`]'s flag ORs in first: a fresh VM +//! re-pushes with every other input unchanged (session.rs's whole failure class). +//! 3. **Evaluate every [`Watch`] unconditionally** (bind `let`s, then OR the bools): a `moved` +//! short-circuited past doesn't observe its counter and would re-open the gate one frame +//! late — harmless, but a cascade of them re-runs the feed for nothing, N frames deep. +//! +//! **The audit (`WOW_FEED_GATE_CHECK=1`)** is the validator that makes the pattern safe to +//! extend: a closed gate still runs its body, and any push the diff then finds is a missed +//! input — [`audit_push`] panics on the spot, naming the feed. A gate bug's natural form is a +//! window that goes stale until something else pokes it, which no capture and no test reliably +//! sees; the audit converts it into a crash in any probe leg that drives the state. + +/// A remembered counter observation: [`Watch::moved`] answers whether the counter changed since +/// the last call, and observes the new value. Default is "never observed", so the first call +/// always reports movement — which is the safe direction, and exactly right inside a +/// [`super::VmMemo`]-wrapped memory: the VM reset defaults the memo, every watch in it re-opens, +/// and the fresh VM gets its full push with no extra wiring. +/// +/// Booleans watch too: `moved(u64::from(present))` turns a presence flip into an edge. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Watch(Option); + +impl Watch { + /// True when `now` differs from the last observed value (or nothing was ever observed); + /// either way `now` is the observation the next call compares against. + pub(crate) fn moved(&mut self, now: u64) -> bool { + self.0.replace(now) != Some(now) + } +} + +/// `WOW_FEED_GATE_CHECK=1` — audit mode: gated feeds run their bodies even on a closed gate and +/// [`audit_push`] panics if the diff then pushes anything. Read once; a probe leg opts in. +pub(crate) fn auditing() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_FEED_GATE_CHECK").is_some_and(|v| v != "0")) +} + +/// `WOW_FEED_GATE_TRACE=1` — once per second per feed, print WHICH gate inputs read open this +/// frame (silent on fully-closed frames). The first question every gate slice asks when a +/// battery reads flat is "which input is holding the gate open?", and it cannot be answered +/// from outside the body — 1439's own wiring anomaly was run to ground with exactly this. +pub(crate) fn trace(feed: &'static str, inputs: &[(&str, bool)]) { + use std::sync::Mutex; + use std::time::Instant; + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + if !*ON.get_or_init(|| std::env::var_os("WOW_FEED_GATE_TRACE").is_some_and(|v| v != "0")) { + return; + } + if !inputs.iter().any(|&(_, open)| open) { + return; + } + static LAST: Mutex>> = Mutex::new(None); + let mut last = LAST.lock().unwrap(); + let map = last.get_or_insert_with(Default::default); + let now = Instant::now(); + if map + .get(feed) + .is_some_and(|t| now.duration_since(*t).as_secs_f32() < 1.0) + { + return; + } + map.insert(feed, now); + let open: Vec<&str> = inputs + .iter() + .filter_map(|&(name, open)| open.then_some(name)) + .collect(); + eprintln!("[gate-trace] {feed}: open by {}", open.join("+")); +} + +/// The audit assertion: called at every push/fire site of a gated feed, with the feed's +/// gate-closed flag. A push reached while the gate is closed means an input is missing from the +/// gate — the silent-stale-window bug, converted to a crash that names itself. +#[track_caller] +pub(crate) fn audit_push(gate_closed: bool, feed: &str, what: &str) { + assert!( + !gate_closed, + "feed gate audit (1439): {feed} pushed {what} on a CLOSED gate — \ + an input is missing from its gate" + ); +} + +/// One system's gate verdict, binding the two flags every gated body threads: `open` (run the +/// rebuild) and `closed_audit` (the gate said skip, but audit mode runs the body to check it). +/// [`Gate::skip`] is the early-return test; [`Gate::audit`] forwards to [`audit_push`]. +pub(crate) struct Gate { + open: bool, + closed_audit: bool, +} + +impl Gate { + /// Judge a gate from its OR-of-inputs verdict. + pub(crate) fn new(open: bool) -> Self { + Self { + open, + closed_audit: !open && auditing(), + } + } + + /// True when the body should not run at all this frame. + pub(crate) fn skip(&self) -> bool { + !self.open && !self.closed_audit + } + + /// Assert (in audit mode) that a push site was not reached under a closed gate. + #[track_caller] + pub(crate) fn audit(&self, feed: &str, what: &str) { + audit_push(self.closed_audit, feed, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_watch_opens_first_and_on_every_move_only() { + let mut w = Watch::default(); + assert!(w.moved(7), "never observed → moved"); + assert!(!w.moved(7), "steady → quiet"); + assert!(w.moved(8), "moved → open"); + assert!(!w.moved(8)); + assert!(w.moved(7), "any change counts, direction-free"); + } + + #[test] + fn a_defaulted_watch_reopens_like_a_fresh_vm() { + // The property the VmMemo embedding rests on: reset-to-default re-arms the watch. + let mut w = Watch::default(); + assert!(w.moved(3)); + assert!(!w.moved(3)); + w = Watch::default(); + assert!(w.moved(3), "the reset memo's watch reports movement again"); + } + + #[test] + fn a_gate_skips_exactly_when_closed_and_not_auditing() { + // `auditing()` is off in the test env (no WOW_FEED_GATE_CHECK); Gate::new(false) skips. + let open = Gate::new(true); + assert!(!open.skip()); + let closed = Gate::new(false); + assert!( + closed.skip() || auditing(), + "closed and not auditing → skip" + ); + open.audit("test_feed", "anything"); // an open gate never panics + } + + #[test] + #[should_panic(expected = "an input is missing from its gate")] + fn the_audit_names_the_missed_input_class() { + audit_push(true, "feed_test", "the snapshot"); + } +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index c24e3e85..1cbec852 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -41,6 +41,11 @@ mod reference_ui; /// or a change-memo outliving the VM it was written against (decision 1290). mod session; +/// The feed gate (decision 1439): the input-side early-out for a per-frame UI feed, its +/// [`gate::Watch`] counter memory, and the `WOW_FEED_GATE_CHECK=1` audit that catches a gate +/// missing an input. +pub(crate) mod gate; + pub(crate) use session::VmMemo; // The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`. diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index 713678b3..37882283 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -23,10 +23,19 @@ fn load_xml(s: &UiScript, file: &str) { /// Load the shipped `UIParent.xml` (UIParent_ManageFramePositions — the stance bar's /// OnShow/OnHide calls it, decision 0272; the runtime loads it before every bar) + +/// `Fonts.xml`/`GameTooltip.xml` (the buttons' OnEnter/OnLeave reach GameTooltip, and since the +/// hover-hide law a bar hiding under the cursor fires the hovered button's OnLeave — a harness +/// without the tooltip turns that faithful fire into a nil-index error) + /// `Cooldown.xml` (CooldownFrame_SetTimer — same before-every-consumer posture) + /// `ActionBar.xml` (the anchor target + shared globals both new bars need). fn load_action_bar(s: &UiScript) { - for file in ["UIParent.xml", "Cooldown.xml", "ActionBar.xml"] { + for file in [ + "Fonts.xml", + "UIParent.xml", + "GameTooltip.xml", + "Cooldown.xml", + "ActionBar.xml", + ] { load_xml(s, file); } } diff --git a/crates/benilla-app/src/ui_script/session.rs b/crates/benilla-app/src/ui_script/session.rs index 57ab76c7..c5dbdfbb 100644 --- a/crates/benilla-app/src/ui_script/session.rs +++ b/crates/benilla-app/src/ui_script/session.rs @@ -66,12 +66,26 @@ impl VmMemo { /// way into it and once on the way out, and holds in between. Reaching for a scratch default /// each frame instead would quietly turn the memo off for the whole glue phase. pub(crate) fn get_for(&mut self, script: Option<&UiScript>) -> &mut T { + self.get_reset_for(script).0 + } + + /// [`VmMemo::get`], also reporting whether the memory RESET on this read — i.e. this is the + /// first read against a new VM. A gated feed (decision 1439) keys its "must run" on exactly + /// this: with every input unchanged, a fresh VM still needs the full re-push, and the reset is + /// the only signal that says so. The flag is true at most once per session per memo, so a gate + /// that ORs it in costs nothing on the steady frames it exists to skip. + pub(crate) fn get_reset(&mut self, script: &UiScript) -> (&mut T, bool) { + self.get_reset_for(Some(script)) + } + + fn get_reset_for(&mut self, script: Option<&UiScript>) -> (&mut T, bool) { let now = script.map_or(0, UiScript::session); - if self.session != now { + let reset = self.session != now; + if reset { self.session = now; self.inner = T::default(); } - &mut self.inner + (&mut self.inner, reset) } } @@ -118,6 +132,25 @@ mod tests { ); } + /// The gate half (1439): `get_reset` reports the reset exactly once per session flip — the + /// one frame a gated feed must run with every other input unchanged. + #[test] + fn get_reset_reports_each_session_flip_once() { + let first = UiScript::new().expect("VM"); + let second = UiScript::new().expect("VM"); + let mut memo: VmMemo = VmMemo::default(); + + let (m, reset) = memo.get_reset(&first); + assert!(reset, "the first read of a session is the reset"); + *m = 7; + let (m, reset) = memo.get_reset(&first); + assert!(!reset, "steady frames read quietly"); + assert_eq!(*m, 7); + let (m, reset) = memo.get_reset(&second); + assert!(reset, "a new VM resets again"); + assert_eq!(*m, 0, "…and the memory is gone with the old one"); + } + #[test] fn a_seed_is_claimed_once_per_vm() { let first = UiScript::new().expect("VM"); diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index 08096de3..0c7771f8 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -45,7 +45,7 @@ use crate::ui_action::{ cast_target, melee_auto_attack_icon, ranged_weapon_icon, CastCommit, CastLadder, PlayerActions, Spells, }; -use crate::ui_script::UiInput; +use crate::ui_script::{gate, UiInput}; use crate::ui_unit::UnitFeed; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; @@ -117,6 +117,13 @@ fn load_skill_lines(mut commands: Commands, assets: Option>) { #[derive(Default)] struct FeedMemory { pushed: SpellBookState, + /// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed` + /// (the weapon-icon template asks, the cooldown store's per-frame prune), plus the self + /// store's PRESENCE: the book's weapon icons flip on a despawn no `Changed` filter sees. + items_objects: gate::Watch, + items_templates: gate::Watch, + cooldown_epoch: gate::Watch, + self_present: gate::Watch, } #[allow(clippy::too_many_arguments)] // a Bevy system's full input set @@ -126,6 +133,7 @@ fn feed_spellbook( spells: Option>, skill_lines: Option>, self_q: Query<&ObjectStore, With>, + changed_self: Query<(), (With, Changed)>, mut items: ResMut, icons: Option>, commands: Res, @@ -136,7 +144,58 @@ fn feed_spellbook( let Some(mut script) = script else { return; }; - let memory = memory.get(&script); + let (memory, vm_reset) = memory.get_reset(&script); + // The gate (1439): the book is a function of the known-spell set, the two catalogs, the + // self descriptor (race/class, form byte, the two weapon slots — presence watched too, + // see the memory struct), the item stores behind the weapon icons, and the cooldown + // store. `UiClock` is deliberately NOT an input: `ui_triple` is frame-stable per arm and + // natural expiry rides the store's `feed_epoch`. + let objects_moved = memory.items_objects.moved(items.object_epoch()); + let templates_moved = memory.items_templates.moved(items.template_epoch()); + let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch()); + let presence_moved = memory.self_present.moved(u64::from(!self_q.is_empty())); + // The frame a timer crosses zero, BEFORE the per-frame prune has moved the epoch + // (`sweep_pending`'s own doc) — the triple flips to None right then. + let sweep = cooldowns.sweep_pending(clock.anchor); + let self_changed = !changed_self.is_empty(); + let actions_changed = actions.is_changed(); + let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed()); + let lines_changed = skill_lines.as_ref().is_some_and(|r| r.is_changed()); + // `is_added` for the icon catalog: the feeds read only its load-once icon column; + // its model-cache half churns every frame (the containers gate's own note). + let icons_added = icons.as_ref().is_some_and(|r| r.is_added()); + gate::trace( + "feed_spellbook", + &[ + ("vm_reset", vm_reset), + ("objects", objects_moved), + ("templates", templates_moved), + ("cooldowns", cooldowns_moved), + ("sweep", sweep), + ("presence", presence_moved), + ("self", self_changed), + ("actions", actions_changed), + ("spells", spells_changed), + ("skill_lines", lines_changed), + ("icons", icons_added), + ], + ); + let gate = gate::Gate::new( + vm_reset + || objects_moved + || templates_moved + || cooldowns_moved + || sweep + || presence_moved + || self_changed + || actions_changed + || spells_changed + || lines_changed + || icons_added, + ); + if gate.skip() { + return; + } // Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands. let Some(spells) = spells.as_deref() else { return; @@ -198,6 +257,7 @@ fn feed_spellbook( .ui_triple(anchor, ui_now); } if fresh != memory.pushed { + gate.audit("feed_spellbook", "the spellbook snapshot"); // Two event edges off one diff: the ref fires CURRENT_SPELL_CAST_CHANGED for a checked- // ring move and SPELLS_CHANGED for the book itself; a same-frame both fires both. let ring_moved = fresh.slots.len() != memory.pushed.slots.len() diff --git a/crates/benilla-app/src/ui_talent.rs b/crates/benilla-app/src/ui_talent.rs index ea155588..e1377a23 100644 --- a/crates/benilla-app/src/ui_talent.rs +++ b/crates/benilla-app/src/ui_talent.rs @@ -38,7 +38,7 @@ use benilla_ui::script::{TalentPrereqView, TalentTabView, TalentUiState, TalentV use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{PlayerActions, Spells}; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; -use crate::ui_script::UiInput; +use crate::ui_script::{gate, UiInput}; use crate::ui_unit::UnitFeed; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; @@ -90,19 +90,44 @@ struct FeedMemory { points: Option<(u32, u32)>, } +#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set fn feed_talents( script: Option>, talents: Option>, actions: Res, spells: Option>, self_q: Query<&ObjectStore, With>, + changed_self: Query<(), (With, Changed)>, mut chat: ResMut, mut memory: Local>, ) { let Some(mut script) = script else { return; }; - let memory = memory.get(&script); + let (memory, vm_reset) = memory.get_reset(&script); + // The gate (1439): every input the build below reads — the known-spell set, the two + // catalogs (`Added ⊆ Changed` covers their one-time landing), and the self descriptor + // (points, race/class; its ABSENCE needs no reopen — the body returns pushless without it). + let self_changed = !changed_self.is_empty(); + let actions_changed = actions.is_changed(); + let talents_changed = talents.as_ref().is_some_and(|r| r.is_changed()); + let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed()); + gate::trace( + "feed_talents", + &[ + ("vm_reset", vm_reset), + ("self", self_changed), + ("actions", actions_changed), + ("talents", talents_changed), + ("spells", spells_changed), + ], + ); + let gate = gate::Gate::new( + vm_reset || self_changed || actions_changed || talents_changed || spells_changed, + ); + if gate.skip() { + return; + } let (Some(talents), Some(spells)) = (talents.as_deref(), spells.as_deref()) else { return; }; @@ -124,6 +149,7 @@ fn feed_talents( points, ); if fresh != memory.pushed { + gate.audit("feed_talents", "the talent pages"); debug!( "ui_talent: fed {} tab(s), {} points", fresh.tabs.len(), @@ -146,6 +172,7 @@ fn feed_talents( } } if memory.points != Some(points) { + gate.audit("feed_talents", "the points memo"); memory.points = Some(points); } } diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 62a81aed..7b52c658 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -30,7 +30,7 @@ use crate::names::NameCache; use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer}; use crate::target::{ring_reaction, Factions, Selection}; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; -use crate::ui_script::UiInput; +use crate::ui_script::{gate, UiInput}; /// The feed pass — runs **after [`benilla_world::schedule::WorldStage::Net`]** (the feeds snapshot state /// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a @@ -108,6 +108,10 @@ struct UnitFeedState { /// The per-VM half of [`UnitFeedState`] — the event-trigger diffs. #[derive(Default)] struct UnitFeedMemo { + /// The gate's counter memories (1439) — the two lazy caches this feed resolves through + /// (their per-frame `&mut` misses poison `is_changed`, the counters carry the landings). + names_generation: gate::Watch, + guild_generation: gate::Watch, /// Whether `PLAYER_ENTERING_WORLD` has been fired (once per world entry, once per VM). entered_world: bool, /// Per token, the last snapshot we pushed — the per-field event triggers diff against it. @@ -775,6 +779,8 @@ fn feed_units( self_q: Query<(&ObjectStore, &Guid), With>, selection: Res, stores: Query<&ObjectStore>, + changed_stores: Query<(), Changed>, + mut removed_stores: RemovedComponents, mut feed: ResMut, mut names: ResMut, commands: Res, @@ -790,6 +796,51 @@ fn feed_units( let Some(mut script) = script else { return; }; + // One reborrow so the memo (`feed.vm`) and `feed.warned_sideless` can be borrowed as the + // disjoint fields they are — through the `ResMut` deref they would alias. + let feed = &mut *feed; + let (memo, vm_reset) = feed.vm.get_reset(&script); + + // The gate (1439): every input the two snapshots and the edge diffs below read — any + // descriptor change or DESPAWN (a removed store is invisible to `Changed`), the selection, + // the group/reputation/faction state, and the two lazy caches by their landed counters. + let names_moved = memo.names_generation.moved(names.generation()); + let guild_moved = memo.guild_generation.moved(guild.identity_generation()); + let selection_changed = selection.is_changed(); + let stores_changed = !changed_stores.is_empty(); + let stores_removed = !removed_stores.is_empty(); + let group_changed = group.is_changed(); + let reps_changed = reputations.is_changed(); + let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); + gate::trace( + "feed_units", + &[ + ("vm_reset", vm_reset), + ("names", names_moved), + ("guild", guild_moved), + ("selection", selection_changed), + ("stores", stores_changed), + ("removed", stores_removed), + ("group", group_changed), + ("reputations", reps_changed), + ("factions", factions_changed), + ], + ); + let gate = gate::Gate::new( + vm_reset + || names_moved + || guild_moved + || selection_changed + || stores_changed + || stores_removed + || group_changed + || reps_changed + || factions_changed, + ); + removed_stores.clear(); + if gate.skip() { + return; + } // "player" = our own avatar's descriptor; "target" = the selected entity's. Absent → None, which // set_unit clears (UnitExists false), exactly as the real client reports a missing unit. Names @@ -828,10 +879,8 @@ fn feed_units( } feed.warned_sideless = sideless; } - // The VM half — resolved against THIS VM, so a `/reload`'s fresh state reads a fresh memo and - // every event below re-fires for it (1291). Taken after the `warned_sideless` writes above: - // that field is server memory and must NOT expire with the VM. - let memo = feed.vm.get(&script); + // (The VM-half memo was taken at the top — the gate needs its reset flag. `warned_sideless` + // stays server memory outside it, which the disjoint field borrows above preserve.) let target = selection.target.zip(selection.guid).and_then(|(e, guid)| { let store = stores.get(e).ok()?; let name = names.resolve(guid, &commands).map(str::to_string); @@ -880,10 +929,23 @@ fn feed_units( // valid through its shutdown; a fresh VM starts with no `"player"` anyway, so nothing needs // the clear). `"target"` keeps the unconditional push: a selection's absence IS data — the // deselect/despawn transition the real client also reports. - if player.is_some() { - script.set_unit("player", player.clone()); + // Both pushes diff against the SAME memo the event loop below uses (1439): an identical + // snapshot re-pushed is invisible to the VM, so only a real change pays the clone. + if let Some(cur) = &player { + if memo.last.get("player") != Some(cur) { + gate.audit("feed_units", "the player snapshot"); + script.set_unit("player", player.clone()); + } + } + let target_dirty = match (&target, memo.last.get("target")) { + (Some(cur), Some(prev)) => cur != prev, + (None, None) => false, + _ => true, + }; + if target_dirty { + gate.audit("feed_units", "the target snapshot"); + script.set_unit("target", target.clone()); } - script.set_unit("target", target.clone()); // The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only // ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's @@ -894,6 +956,7 @@ fn feed_units( let xp = store.0.player_xp().unwrap_or(0); let next = store.0.player_next_level_xp().unwrap_or(0); if memo.last_xp != Some((xp, next)) { + gate.audit("feed_units", "the XP pair"); memo.last_xp = Some((xp, next)); script.set_player_xp(xp, next); script.fire_event("PLAYER_XP_UPDATE", vec![]); @@ -917,6 +980,7 @@ fn feed_units( store.0.player_flags(), ); if memo.last_rest != Some(rest) { + gate.audit("feed_units", "the rest snapshot"); let prev = memo.last_rest; memo.last_rest = Some(rest); script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0); @@ -957,6 +1021,7 @@ fn feed_units( if let Some(level) = store.0.unit_level() { let prev = memo.last_level.replace(level); if prev.is_some_and(|p| level != p) { + gate.audit("feed_units", "the level edge"); script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]); } } @@ -981,10 +1046,12 @@ fn feed_units( // repro), and a normal→rested char switch would misfire "You feel rested." at login. if self_pair.is_some() { if !memo.entered_world { + gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm"); script.fire_event("PLAYER_ENTERING_WORLD", vec![]); memo.entered_world = true; } } else if memo.entered_world { + gate.audit("feed_units", "the world-exit disarm"); memo.entered_world = false; memo.last_xp = None; memo.last_rest = None; @@ -999,6 +1066,7 @@ fn feed_units( Some(cur) => { let prev = memo.last.get(token); if prev != Some(cur) { + gate.audit("feed_units", "a unit-token transition"); fire_transitions(&mut script, token, prev, cur); memo.last.insert(token.to_string(), cur.clone()); } @@ -1006,13 +1074,16 @@ fn feed_units( None => { // Clearing a token isn't a UNIT_* event; the target frame reacts to // PLAYER_TARGET_CHANGED below. - memo.last.remove(token); + if memo.last.remove(token).is_some() { + gate.audit("feed_units", "a unit-token clear"); + } } } } // PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes. if selection.guid != memo.target_guid { + gate.audit("feed_units", "the PLAYER_TARGET_CHANGED edge"); memo.target_guid = selection.guid; script.fire_event("PLAYER_TARGET_CHANGED", vec![]); } @@ -1024,6 +1095,7 @@ fn feed_units( if let Some((store, _)) = self_pair { let in_combat = store.0.unit_flags() & 0x0008_0000 != 0; if memo.in_combat != Some(in_combat) { + gate.audit("feed_units", "the combat-flag edge"); let first_sight = memo.in_combat.is_none(); memo.in_combat = Some(in_combat); if !first_sight || in_combat { @@ -1049,6 +1121,7 @@ fn feed_units( if let Some((store, _)) = self_pair { let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0; if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) { + gate.audit("feed_units", "the PvP-desired edge"); script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]); chat.push_event(ChatEvent::text_only( ChatEventKind::System, @@ -1078,6 +1151,7 @@ fn feed_units( store.0.player_combo_target(), ); if let Some(fire) = combo_edge(memo.last_combo, banked) { + gate.audit("feed_units", "the combo-point edge"); memo.last_combo = Some(banked); script.set_combo_points(banked.0, banked.1); if fire { diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 8965fb48..ab4fb92c 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -209,6 +209,15 @@ fn is_instrument_consumer(rel: &str) -> bool { /// count. The items were always crossing; the measurement improved. That is the only kind of raise /// that is not a retreat — a raise for a NEW leak is the failure this number exists to catch. /// +/// And 158 → 159: `vis_chain::VisChainOnly`, a PUBLISH by 1164's test (decision 1441). The +/// chain-only visibility idiom — keep `Visibility`+`InheritedVisibility` (hide-propagation), +/// remove `ViewVisibility` (the per-camera sweep row) — is an ENGINE law about bevy's visibility +/// pipeline, but half the never-rendering hierarchy nodes it applies to are spawned game-side +/// (net-object roots, held-item and spell-fx wrappers, transports). Keeping it engine-private +/// would mean every game spawner hand-rolling `.remove::()` with its own copy of +/// the why — the exact drift a named idiom exists to prevent; the trait is the smallest honest +/// carrier of the law. +/// /// And 157 → 158: `collision::ColliderEpoch`, a PUBLISH by 1164's test (decision 1384). It is the /// stamp on the world's collider set — "the geometry you last asked has changed" — and the whole /// point of it is that a *cached* collision answer must not outlive the world it described. The @@ -247,7 +256,7 @@ fn is_instrument_consumer(rel: &str) -> bool { /// input, which is game-side; a one-field resource is the smallest honest expression of that. The /// alternative was ordering a game system against an engine system, which crosses this same line /// *and* publishes a schedule point to do it. -const CEILING: usize = 158; +const CEILING: usize = 159; /// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be /// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index 9181ee05..90d3b8e5 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -150,6 +150,39 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) { }) .collect() }; + // **The hover-hide law** (wow-re `ui/scratch/hover-hide-and-tooltip-owner-law.md`, §5- + // arbitrated): hiding the hovered frame — directly or through an ancestor's cascade — fires + // its `OnLeave` SYNCHRONOUSLY, inside the hide and **before that frame's `OnHide`** + // (`0x764ba0`'s kind-2 tail runs mid-`effective_visible_hide`, the leave at `0x764cce`; the + // only silent case is destruction). It clears the hover cache and the drag-arm on that frame + // (`+0x100/+0x104`, the arg-1 flavor), and schedules the re-pick — the pump re-hovers + // whatever is now topmost at the unchanged cursor next tick ([`Model::hover_repick`]). + // A SHOW arms the re-pick too (`0x764b8d`: any mouse-bucket insert): a window opening under + // a stationary cursor gets hovered without a mouse move. This is what makes a slot button's + // FrameXML `OnLeave → GameTooltip:Hide()` actually run when its window closes under the + // cursor — the reference has NO engine-side owner-visibility fallback for the tooltip + // (byte-censused negative), so the leave firing here is the whole mechanism. + let left: Option<(FrameHandle, u32)> = { + let mut model = lua.app_data_mut::().expect("model"); + let hidden_hover = model + .mouseover + .filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis)); + if let Some(m) = hidden_hover { + model.mouseover = None; + if model.drag.as_ref().is_some_and(|d| d.source == m) { + model.drag = None; + } + } + model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis); + hidden_hover.map(|m| (m, model.frame_id(m))) + }; + if let Some((_, oid)) = left { + if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) { + lua.app_data_mut::() + .expect("model") + .record_script_error(e.to_string()); + } + } for (h, id, visible) in items { if visible { let mut model = lua.app_data_mut::().expect("model"); diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 1b6f1122..0c4d1536 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -275,6 +275,15 @@ pub(crate) struct Model { /// The frame currently under the cursor (the last [`UiScript::mouse_move`] capture), so the next /// move knows whether to fire `OnLeave`/`OnEnter`. `None` = cursor over no mouse-enabled frame. pub(crate) mouseover: Option, + /// The hover **re-pick** is due: the world under a stationary cursor changed — the hovered + /// frame hid (its `OnLeave` already fired at hide time), or a frame was shown that may now + /// be the topmost under the cursor. The reference keeps exactly this flag on the frame + /// manager (`[root+0x1100]`) and its per-tick pump re-runs the hover walk at the **saved** + /// cursor position, explicitly bypassing the didn't-move coalesce — so the newly exposed + /// frame gets `OnEnter` with no physical mouse move (wow-re + /// `ui/scratch/hover-hide-and-tooltip-owner-law.md`: writers `0x764cbb`/`0x764b8d`, pump + /// tail `0x7657a1` → `0x7660d0` self-alias). [`UiScript::tick`] drains it the same way. + pub(crate) hover_repick: bool, /// Per-button, the frame a mouse-down last captured (`button name → frame`), for the `OnClick` /// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton` /// press is not cleared by a `RightButton` release. @@ -1220,6 +1229,7 @@ impl Model { frame_events: HashMap::new(), focused_editbox: None, mouseover: None, + hover_repick: false, mouse_down_on: HashMap::new(), last_click: HashMap::new(), pending_size_changed: Vec::new(), diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 0f087a4b..df0b8a5f 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -623,3 +623,98 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() { ); } } + +/// The hover-hide law (wow-re `ui/scratch/hover-hide-and-tooltip-owner-law.md`, §5-arbitrated): +/// hiding the hovered frame fires its `OnLeave` synchronously, BEFORE its own `OnHide` +/// (`0x764ba0`'s kind-2 tail runs mid-hide, the leave at `0x764cce`; OnHide fires at the hide +/// body's tail) — and an ancestor's `Hide()` reaches the hovered descendant through the cascade. +#[test] +fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + local win = CreateFrame("Frame", "Win") + win:SetPoint("BOTTOMLEFT", 0, 0); win:SetSize(800, 600) + local slot = CreateFrame("Button", "Slot", win) + slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetSize(100, 100); slot:EnableMouse(true) + slot:SetScript("OnLeave", function() table.insert(log, "leave") end) + slot:SetScript("OnHide", function() table.insert(log, "hide") end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(150.0, 150.0); // hover the slot + s.run("Win:Hide()").unwrap(); // the ANCESTOR hides — the cascade reaches the slot + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "leave,hide", + "OnLeave fires synchronously inside the hide, before the frame's own OnHide" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The negative: hiding a frame the cursor is NOT over fires no OnLeave (the reference's removal +/// tail acts only when the removed frame IS the cached hover). +#[test] +fn hiding_an_unhovered_frame_fires_no_onleave() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + leaves = 0 + local a = CreateFrame("Button", "Away") + a:SetPoint("BOTTOMLEFT", 500, 500); a:SetSize(50, 50); a:EnableMouse(true) + a:SetScript("OnLeave", function() leaves = leaves + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(10.0, 10.0); // nowhere near it + s.run("Away:Hide()").unwrap(); + assert_eq!(s.eval::("return leaves").unwrap(), 0.0); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The re-pick (`[root+0x1100]` + the pump tail `0x7660d0` on the SAVED event): after the hovered +/// frame hides, the next tick re-hovers whatever is now topmost at the unchanged cursor — OnEnter +/// with no physical mouse move. And the symmetric insert law: a frame SHOWN under a stationary +/// cursor gets hovered on the next tick too (`0x764b8d`). +#[test] +fn the_repick_re_hovers_without_a_mouse_move() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + entered = {} + local under = CreateFrame("Button", "Under") + under:SetPoint("BOTTOMLEFT", 100, 100); under:SetSize(100, 100); under:EnableMouse(true) + under:SetScript("OnEnter", function() table.insert(entered, "under") end) + local over = CreateFrame("Button", "Over") + over:SetPoint("BOTTOMLEFT", 100, 100); over:SetSize(100, 100); over:EnableMouse(true) + over:SetScript("OnEnter", function() table.insert(entered, "over") end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(150.0, 150.0); // hovers Over (topmost) + s.run("Over:Hide()").unwrap(); + s.tick(0.016); // the pump's re-pick — cursor has not moved + assert_eq!( + s.eval::("return table.concat(entered, ',')") + .unwrap(), + "over,under", + "the frame beneath is entered on the next tick with no mouse move" + ); + // The insert half: re-showing the top frame under the stationary cursor re-hovers it. + s.run("Over:Show()").unwrap(); + s.tick(0.016); + assert_eq!( + s.eval::("return table.concat(entered, ',')") + .unwrap(), + "over,under,over", + "a frame shown under the cursor is entered on the next tick" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 6afb16ec..a2e0e769 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -170,6 +170,22 @@ impl UiScript { // Advance fading tooltips (FadeOut's ramp + end-of-ramp hide) — engine behavior like the // message fade above, decision 0274. tooltip::tick_fades(&self.lua); + // The hover RE-PICK ([`Model::hover_repick`]): the world under a stationary cursor + // changed this tick (the hovered frame hid, or a frame was shown over the cursor), so + // re-run the hover walk at the SAVED cursor position — the reference's own pump tail + // (`0x765650` → `0x7660d0` with the saved event, didn't-move coalesce bypassed). + // `mouse_move` at the unchanged point is exactly that walk: the hidden frame's OnLeave + // already fired at hide time and `mouseover` is clear, so only the new winner's + // boundary fires — no second OnLeave, `OnEnter` with no physical mouse move. + let repick = { + let mut model = self.model_mut(); + let due = model.hover_repick; + model.hover_repick = false; + due.then_some(model.cursor_pos) + }; + if let Some((x, y)) = repick { + self.mouse_move(x, y); + } // `WOW_UI_HANDLERS=` — who spent the frame (decision 1395). Last, so a report covers // everything this tick fired; a no-op unless the instrument is armed. self.report_handler_profile(elapsed); diff --git a/crates/benilla-world/src/bgwin.rs b/crates/benilla-world/src/bgwin.rs index 1de0b020..965b5800 100644 --- a/crates/benilla-world/src/bgwin.rs +++ b/crates/benilla-world/src/bgwin.rs @@ -51,6 +51,8 @@ const BG_ENV_PREFIXES: &[&str] = &[ "WOW_CHARSELECT_SHOT", "WOW_CREATE_TEST", "WOW_DEPTH", + "WOW_FEED_GATE_CHECK", + "WOW_FEED_GATE_TRACE", "WOW_FPS_", "WOW_GLUE_ROUNDTRIP", "WOW_LIVE_", @@ -95,6 +97,8 @@ pub fn background_run() -> bool { /// `WOW_PROBE…`-named env that captures pixels has to be excluded by name here, since the bare /// `WOW_PROBE` prefix would otherwise sweep it in. const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ + "WOW_FEED_GATE_CHECK", + "WOW_FEED_GATE_TRACE", "WOW_FPS_", "WOW_LIVE_FPS", "WOW_PARTICLE_CENSUS", diff --git a/crates/benilla-world/src/billboard.rs b/crates/benilla-world/src/billboard.rs index 181d52a6..e772ab65 100644 --- a/crates/benilla-world/src/billboard.rs +++ b/crates/benilla-world/src/billboard.rs @@ -614,13 +614,24 @@ pub(crate) fn face_billboards( * (Vec3::from_array(a.sample(elapsed_ms.wrapping_add(card.arm_neg_ms))) * card.scale) }); - *tf = Transform { + let placed = Transform { translation: card.world_pivot + bob, rotation, scale: Vec3::splat(card.scale) * pulse, }; + // A parked camera over a still, track-less card recomputes bit-identical values, so the + // write only lands on real movement: unconditional, it marked every card + // `Changed`+`Changed` every frame and the downstream + // change-detection consumers (`classify_water_side`'s moved sweep, the shade dirty walk) + // never went quiet. + if *tf != placed { + *tf = placed; + } // Propagation already ran this frame — the direct global write is what renders. - *global = GlobalTransform::from(*tf); + let placed_global = GlobalTransform::from(placed); + if *global != placed_global { + *global = placed_global; + } } } diff --git a/crates/benilla-world/src/doodad_anim/mod.rs b/crates/benilla-world/src/doodad_anim/mod.rs index 92537e86..703b229d 100644 --- a/crates/benilla-world/src/doodad_anim/mod.rs +++ b/crates/benilla-world/src/doodad_anim/mod.rs @@ -34,6 +34,7 @@ use bevy::prelude::*; use benilla_assets::{AnimClip, M2Model, ModelAnimations, ModelSkeleton}; use crate::rig_anim::{AnimParked, GlobalSeqDrive, RigPose}; +use crate::vis_chain::VisChainOnly; mod lazy; mod mat_anim; @@ -214,7 +215,12 @@ pub fn spawn_anim_host( return None; } let anims = m.animations.as_ref().expect("animated tier ⇒ animations"); - let root = commands.spawn((transform, Visibility::default())).id(); + // Chain-only visibility (`crate::vis_chain`): the host root renders nothing — its parts + // draw as world roots — and ~1.4k hosts sat in the per-camera sweep at the Goldshire pin. + let root = commands + .spawn((transform, Visibility::default())) + .vis_chain_only() + .id(); // The collapsed pose buffer (decision 1365 — 0724 replayed for this lane): no joint // entities, no `AnimatedBy` targets, no `BillboardJointRig`. The 0712 evaluator poses // `locals` off the player, the model pass folds + seats the anchors, and the world pass diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 677c76e3..5c71c45a 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -168,6 +168,7 @@ pub mod surface; pub mod terrain_stream; pub mod thread_qos; pub mod view; +pub mod vis_chain; pub mod water_fx; pub mod wdl; pub mod weather; diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index d7aafc4a..eea759ef 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -42,6 +42,13 @@ pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3; /// 899 tie here — see the cap rationale at the one application site. pub(crate) const BATCH_ORDER_SORT_CAP: f32 = 0.9; +/// `WOW_NO_ALPHATEST=1` — every cutout batch draws opaque (the A/B lever described at the +/// [`model_material`] use site; the static-gx bake keys its cutout runs on the same read). +pub(crate) fn alphatest_disabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_NO_ALPHATEST").is_some()) +} + /// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material. #[derive(PartialEq, Eq, Hash)] pub struct MatKey { @@ -274,8 +281,7 @@ pub fn model_material( // from the surface losing a depth test or never being submitted. (B38: the flip // survives it unchanged, so the cutout is not what removes the awning.) It suppresses the // fade twin's cutout marker too, so the A/B answers the same question mid-fade. - let source_cutout = - blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err(); + let source_cutout = blend == ModelBlend::AlphaTest && !alphatest_disabled(); let alpha_mode = if is_additive || fade_variant { // A fade twin blends whatever its source blend is — the reference's promotion is // transparent-pass membership (`m2-blend-promotion-zfill.md` §1); the source blend the diff --git a/crates/benilla-world/src/model_render/visibility.rs b/crates/benilla-world/src/model_render/visibility.rs index d4b2e8a6..1fb59df5 100644 --- a/crates/benilla-world/src/model_render/visibility.rs +++ b/crates/benilla-world/src/model_render/visibility.rs @@ -371,7 +371,9 @@ type TracedModels<'w, 's> = Query< &'static GlobalTransform, &'static Visibility, &'static InheritedVisibility, - &'static ViewVisibility, + // `Option`: a chain-only node (anim host root — `crate::vis_chain`) has no sweep row; + // it must still appear in the trace rather than silently vanish from the instrument. + Option<&'static ViewVisibility>, Option<&'static Aabb>, // Which lane the row is: a world-root billboard CARD inherits nothing, so `inh=true` on it // means only "no parent", never "my model is drawn" — the one row where the second field @@ -421,7 +423,8 @@ pub(super) fn trace_model_visibility( cam=[{px:.1},{py:.1},{pz:.1}]", label = object.label, inh = inherited.get(), - view = view.get(), + // "-" = chain-only node, no sweep row (never drawn, so no per-view verdict exists). + view = view.map_or("-".into(), |v| v.get().to_string()), ox = xf.translation().x, oy = xf.translation().y, oz = xf.translation().z, diff --git a/crates/benilla-world/src/particles/sim.rs b/crates/benilla-world/src/particles/sim.rs index fd749485..2ec3dc27 100644 --- a/crates/benilla-world/src/particles/sim.rs +++ b/crates/benilla-world/src/particles/sim.rs @@ -452,7 +452,7 @@ pub(super) fn simulate_particles( // rule. `Without`/`Without` keep the read provably disjoint from // the `&mut GlobalTransform` writes above. booth_cams: Query< - (Entity, &GlobalTransform, &RenderLayers), + (Entity, &GlobalTransform, &RenderLayers, &Camera), ( With, Without, @@ -504,10 +504,30 @@ pub(super) fn simulate_particles( // camera. let booth = layers .filter(|l| !l.intersects(&RenderLayers::default())) - .and_then(|l| booth_cams.iter().find(|(_, _, cl)| cl.intersects(l))); + .and_then(|l| booth_cams.iter().find(|(_, _, cl, _)| cl.intersects(l))); let is_booth = booth.is_some(); + // A sleeping booth camera (`gate_booth_cameras` — the pane's window closed) draws none + // of this lane, so its emitters freeze with it: pool + age held, the draw-set law's own + // shape (the reference ticks an emitter only inside a draw the frame performs). Draining + // pools still run out — freezing one strands it (the world arm's rule below). + if let Some((_, _, _, booth_cam)) = booth { + if !booth_cam.is_active && !emitter.draining { + if !emitter.gated { + emitter.gated = true; + for slot in &emitter.model_instances { + for (e, _) in &slot.meshes { + if let Ok((_, _, mut cv)) = child_draws.get_mut(*e) { + *cv = Visibility::Hidden; + } + } + } + } + continue; + } + emitter.gated = false; + } let (draw_cam, e_cam_pos, e_right, e_up) = match booth { - Some((cam_entity, tf, _)) => { + Some((cam_entity, tf, _, _)) => { let (_, rot, _) = tf.to_scale_rotation_translation(); (cam_entity, tf.translation(), rot * Vec3::X, rot * Vec3::Y) } diff --git a/crates/benilla-world/src/rig_anim/global_seq.rs b/crates/benilla-world/src/rig_anim/global_seq.rs index 82f972bc..92435967 100644 --- a/crates/benilla-world/src/rig_anim/global_seq.rs +++ b/crates/benilla-world/src/rig_anim/global_seq.rs @@ -86,9 +86,10 @@ impl GlobalSeqDrive { }) } - /// Pause/resume the joint writes (the doodad draw gate). While paused the joints hold their - /// last pose; resume lands on the anchored cursor with nothing to catch up. - pub(crate) fn set_paused(&mut self, paused: bool) { + /// Pause/resume the joint writes (the doodad draw gate, and the booth park — a sleeping + /// booth camera renders nothing, so its scene's channels hold). While paused the joints + /// hold their last pose; resume lands on the anchored cursor with nothing to catch up. + pub fn set_paused(&mut self, paused: bool) { self.paused = paused; } } diff --git a/crates/benilla-world/src/rig_anim/pose.rs b/crates/benilla-world/src/rig_anim/pose.rs index e09b8a4d..e31a0fc4 100644 --- a/crates/benilla-world/src/rig_anim/pose.rs +++ b/crates/benilla-world/src/rig_anim/pose.rs @@ -26,6 +26,7 @@ use bevy::prelude::*; use bevy::transform::TransformSystems; use super::AnimParked; +use crate::vis_chain::VisChainOnly; /// The collapsed rig's pose buffer (decision 0724), on the entity carrying the `AnimationPlayer`: /// one local `Transform` per bone in skeleton order — no joint entities at all (decision 0712's @@ -164,6 +165,10 @@ impl RigPose { } let m = self.model.get(bone as usize)?; let (scale, rotation, translation) = m.to_scale_rotation_translation(); + // Visibility so an attached subtree (a held item under a hand anchor) inherits the + // owner's hide — chain only: an anchor renders nothing, and at the Goldshire pin the + // anchor population was the single largest never-rendering block in the per-camera + // visibility sweep (4.8k rows, decision 1441). let anchor = commands .spawn(( Transform { @@ -174,6 +179,7 @@ impl RigPose { Visibility::default(), RigAnchor { rig, bone }, )) + .vis_chain_only() .id(); commands.entity(self.joints_root).add_child(anchor); self.anchors.push((bone, anchor)); diff --git a/crates/benilla-world/src/rig_palette.rs b/crates/benilla-world/src/rig_palette.rs index 5a2bf72a..21b7421f 100644 --- a/crates/benilla-world/src/rig_palette.rs +++ b/crates/benilla-world/src/rig_palette.rs @@ -57,6 +57,7 @@ use bevy::render::renderer::RenderQueue; use bevy::render::{Render, RenderApp, RenderSystems}; use crate::mesh_tag::MAX_RIG_SLOTS; +use crate::vis_chain::VisChainOnly; /// Bone capacity of the palette region — concurrently-resident skinned bones across every live /// rig (units, animated doodads, spell effects, booths). The LBRS census (0718) holds ~59 k unit @@ -942,12 +943,14 @@ pub fn spawn_joints( // Visibility too, not just Transform: held items and spell effects hang their // visible roots under joints, and a gap in the chain both trips Bevy's B0004 and // orphans those subtrees from the unit root's visibility (a hidden unit would - // keep its weapon on screen). + // keep its weapon on screen). Chain only — a joint renders nothing + // (`crate::vis_chain`). .spawn(( Transform::from_translation(j.local_translation), Visibility::default(), RigJoint(holder), )) + .vis_chain_only() .id() }) .collect(); diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs index 8bfe5478..5214f99f 100644 --- a/crates/benilla-world/src/static_gx/bake.rs +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -58,7 +58,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut, + /// Latched by the publisher once its own terms first read fully ready ([`Self::scene_ready`] + /// and [`Self::is_ready`]): the focus neighbourhood's per-placement walk is skipped from + /// then on — the counts stay at their converged values — until the focus lands on + /// unfurnished ground again (a worldport's map swap, a far teleport), which re-arms the full + /// accounting. [`Self::focus_tile`] and [`Self::focus_resident`] stay live every frame + /// regardless: the loading screen's backstop and the settle release key on them. + pub complete: bool, } impl WorldLoadProgress { @@ -722,7 +736,7 @@ fn stream_terrain( // lane when measuring a streaming cost. Residency grows for the life of the run; dev-only. let unload_budget = cfg.as_ref().map(|c| c.unload_budget).unwrap_or(1); let keep = radius + 1; - let mut stale: Vec<(i32, i32)> = if std::env::var("WOW_NO_TILE_DROP").is_ok() { + let mut stale: Vec<(i32, i32)> = if tile_drop_disabled() { Vec::new() } else { state @@ -848,7 +862,10 @@ fn stream_terrain( // render world their entire extract/prepare/upload as one 80–105 ms (process-CPU) frame — // the B181 first-contact spike. `furnish_tile_cells` (chained right after this system) // builds them from `AdtTile::chunks` + `shading` a few per frame while live. + // Chain-only visibility (`crate::vis_chain`): the tile root renders nothing itself — + // its cell meshes are the drawn children, and they need the inheritance chain intact. let mut tile_ent = commands.spawn((Transform::IDENTITY, Visibility::default())); + tile_ent.vis_chain_only(); if let Some((verts, tris)) = collider_data { // `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`). // `PickOccluder`: terrain clamps the mouse pick (the reference's world trace). @@ -928,13 +945,6 @@ fn stream_terrain( // focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting // for ground that isn't there. if let Some(p) = load_progress.as_mut() { - // "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the - // cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a - // root whose ground is still landing (`furnished` lags root spawn by one frame here, - // since the furnisher runs after this system in the same chain). - let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.furnished); - p.total = desired.len(); - p.ready = desired.iter().filter(|c| spawned(c)).count(); p.focus_tile = Some((cx, cy)); // Resident = the focus tile is furnished — judged from the DESIRED set, not from the // presence of a `state.tiles` entry: a desired tile whose request hasn't landed yet is @@ -946,35 +956,6 @@ fn stream_terrain( } else { true }; - // The focus neighbourhood's placements join the accounting (bar + scene term). A placement - // is *up* once its own model spawned AND its WMO props (each an M2 arriving on its own - // schedule) have — the furniture the reveal would otherwise pop in. Placements of tiles not - // yet spawned aren't known yet; the missing tile itself holds `scene_ready` down. - let mut near_pending = 0usize; - let mut near_tile_missing = false; - for dx in -1..=1i32 { - for dy in -1..=1i32 { - let c = (cx + dx, cy + dy); - if !desired.contains(&c) { - continue; // off-grid or WDT says no tile — nothing to wait for - } - match state.tiles.get(&c) { - Some(t) if t.furnished => { - for uid in &t.placements { - if let Some(pl) = placements.by_id.get(uid) { - let up = pl.spawned && pl.doodads.iter().all(|d| d.spawned); - p.total += 1; - p.ready += usize::from(up); - near_pending += usize::from(!up); - } - } - } - _ => near_tile_missing = true, - } - } - } - p.placements_pending = near_pending; - p.scene_ready = p.focus_resident && !near_tile_missing && near_pending == 0; // Until the WDT answers we don't yet know what this map is made of, so nothing under the // focus can be called resident. Without this the post-worldport frames before the index // lands read as "ground is up" — harmless on an ADT map (the tile entries below close the @@ -982,21 +963,79 @@ fn stream_terrain( // difference between a loading screen and a glimpse of the void. if wdt_index.is_none() && !state.wdt_ungated { p.focus_resident = false; - p.scene_ready = false; } - // A WMO-only map's residency IS its one building (0688) — there are no tiles to count, so - // the bar and the clear-condition ride the placement instead. Counting it keeps - // `total > 0`, which is what `is_ready` requires before it will clear the screen at all. - // The backstop/`world_stale` term takes the building's spawn; the scene term also waits - // for its props (the Stockade's 740 candles are the reveal). + // A WMO-only map's focus residency IS its one building's spawn (0688) — there is no + // tile to gate on. The bar/scene accounting for it lives in the gated block below. if state.global_wmo { - let pl = placements.by_id.get(&GLOBAL_WMO_UID); - let up = pl.is_some_and(|p| p.spawned); - let full = pl.is_some_and(|p| p.spawned && p.doodads.iter().all(|d| d.spawned)); - p.total += 1; - p.ready += usize::from(full); - p.focus_resident &= up; - p.scene_ready &= full; + p.focus_resident &= placements + .by_id + .get(&GLOBAL_WMO_UID) + .is_some_and(|p| p.spawned); + } + // `focus_tile`/`focus_resident` above stay live every frame — the loading screen's + // backstop and the settle release key on them. The counting below is only consumed + // while a load is underway, so once it has reported fully ready it latches off + // (`complete`) instead of walking the focus neighbourhood's every placement per frame + // forever. A focus on unfurnished ground IS a new load beginning (a worldport's map + // swap, a far teleport), so it re-arms the latch; `release_world`'s reset covers the + // logout → re-entry path. + if !p.focus_resident { + p.complete = false; + } + if !p.complete { + // "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the + // cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a + // root whose ground is still landing (`furnished` lags root spawn by one frame here, + // since the furnisher runs after this system in the same chain). + let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.furnished); + p.total = desired.len(); + p.ready = desired.iter().filter(|c| spawned(c)).count(); + // The focus neighbourhood's placements join the accounting (bar + scene term). A + // placement is *up* once its own model spawned AND its WMO props (each an M2 arriving + // on its own schedule) have — the furniture the reveal would otherwise pop in. + // Placements of tiles not yet spawned aren't known yet; the missing tile itself holds + // `scene_ready` down. + let mut near_pending = 0usize; + let mut near_tile_missing = false; + for dx in -1..=1i32 { + for dy in -1..=1i32 { + let c = (cx + dx, cy + dy); + if !desired.contains(&c) { + continue; // off-grid or WDT says no tile — nothing to wait for + } + match state.tiles.get(&c) { + Some(t) if t.furnished => { + for uid in &t.placements { + if let Some(pl) = placements.by_id.get(uid) { + let up = pl.spawned && pl.doodads.iter().all(|d| d.spawned); + p.total += 1; + p.ready += usize::from(up); + near_pending += usize::from(!up); + } + } + } + _ => near_tile_missing = true, + } + } + } + p.placements_pending = near_pending; + // `focus_resident` already folds the WDT gate and the global WMO's spawn, so both + // ride into the scene term through it. + p.scene_ready = p.focus_resident && !near_tile_missing && near_pending == 0; + // A WMO-only map's bar and clear-condition ride the placement (0688). Counting it + // keeps `total > 0`, which is what `is_ready` requires before it will clear the + // screen at all; the scene term also waits for its props (the Stockade's 740 + // candles are the reveal). + if state.global_wmo { + let full = placements + .by_id + .get(&GLOBAL_WMO_UID) + .is_some_and(|p| p.spawned && p.doodads.iter().all(|d| d.spawned)); + p.total += 1; + p.ready += usize::from(full); + p.scene_ready &= full; + } + p.complete = p.scene_ready && p.is_ready(); } // Residency is *published* here and read by the mover's post-snap hold one stage later // (`player::release_post_snap_hold`). The streamer is still the only authority on which @@ -1006,6 +1045,13 @@ fn stream_terrain( activity.stream_ms += t0.elapsed().as_secs_f32() * 1000.0; } +/// `WOW_NO_TILE_DROP=1` — never release stale tiles (the ablation switch described at its use +/// site in [`stream_terrain`]): a crossing becomes a pure asset-ADD event. Dev-only. +fn tile_drop_disabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_NO_TILE_DROP").is_some()) +} + /// Register one M2 doodad placement: bump the refcount if it's already known, else load its model and /// record it (spawned later by [`spawn_loaded_placements`] once the asset is ready). fn register_doodad( @@ -1038,6 +1084,7 @@ fn register_doodad( owner: tile, }, ); + placements.pending_spawns += 1; } /// Register one WMO building placement (vanilla scale is 1, so no per-placement scale). @@ -1066,6 +1113,7 @@ fn register_wmo(placements: &mut Placements, asset_server: &AssetServer, w: &Wmo owner: (0, 0), // unread for WMOs — their prop hulls weld per placement (1369) }, ); + placements.pending_spawns += 1; } /// Despawn a tile's exclusively-owned entities — the terrain mesh, water surfaces, and per-chunk @@ -1312,11 +1360,12 @@ fn handoff_straddlers( commands.entity(e).try_despawn(); } p.spawned = false; + placements.pending_spawns += 1; } handed += 1; } if handed > 0 { - info!( + debug!( "straddler handoff: {handed} placement(s) re-owned off dead tile ({},{})", dead.0, dead.1 ); @@ -1340,6 +1389,10 @@ fn release_placement( }; if drop_it { if let Some(p) = placements.by_id.remove(&uid) { + // The removed record may still owe spawns (a model that never landed, a building's + // props still arriving) — its share leaves the pending count with it. + placements.pending_spawns -= + usize::from(!p.spawned) + p.doodads.iter().filter(|d| !d.spawned).count(); activity.placements_dropped += 1; activity.placement_entities_dropped += p.entities.len() as u32; for e in p.entities { diff --git a/crates/benilla-world/src/terrain_stream/furnish.rs b/crates/benilla-world/src/terrain_stream/furnish.rs index 41bdc0cb..7b9668bd 100644 --- a/crates/benilla-world/src/terrain_stream/furnish.rs +++ b/crates/benilla-world/src/terrain_stream/furnish.rs @@ -47,7 +47,7 @@ pub(super) fn furnish_tile_cells( // on it (a crossing's spike survived zero cells, exonerating the entity lane); the // `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern. Tiles still MARK furnished, so the // loading-screen residency (which now waits on furnishing) converges. - let ablated = std::env::var("WOW_NO_TILE_CELLS").is_ok(); + let ablated = tile_cells_disabled(); let focus = state.focus; let mut pending: Vec<(i32, (i32, i32))> = state @@ -116,6 +116,13 @@ pub(super) fn furnish_tile_cells( activity.furnish_ms += t0.elapsed().as_secs_f32() * 1000.0; } +/// `WOW_NO_TILE_CELLS=1` — furnish no per-chunk cell entities (the ablation switch described at +/// its use site in [`furnish_tile_cells`]). Dev-only. +fn tile_cells_disabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WOW_NO_TILE_CELLS").is_some()) +} + /// The one thing the furnisher promises the rest of the streamer: a tile is `furnished` only /// when its cursor walked every chunk (or cells are ablated off) — never merely "root exists". #[cfg(test)] diff --git a/crates/benilla-world/src/terrain_stream/merge.rs b/crates/benilla-world/src/terrain_stream/merge.rs index 40cc6476..da623393 100644 --- a/crates/benilla-world/src/terrain_stream/merge.rs +++ b/crates/benilla-world/src/terrain_stream/merge.rs @@ -524,7 +524,7 @@ pub(super) fn flush_static_merge( }); } if recon.0 > 0 { - info!( + debug!( "static-merge: reconsolidated {} cell(s): {} fragments → {} blob(s)", recon.0, recon.1, recon.2 ); @@ -554,7 +554,7 @@ pub(super) fn flush_static_merge( // and what the transform-baking duplication actually costs against the shared assets. if !merge.reported && merge.doodads.is_empty() && merge.props.is_empty() { merge.reported = true; - info!( + debug!( "static-merge: {} blobs from {} batches; baked {}kv vs {}kv shared ({:.2}x duplication)", merge.blobs, merge.batches, diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 26baa932..f30e57c7 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -103,6 +103,12 @@ pub(super) fn spawn_loaded_placements( let Some(shared_light) = shared_light else { return; }; + // Steady state is everything spawned, and the walk below still visits every placement and + // every WMO prop to find that out — the pending count (kept by the register/handoff/release + // sites) makes that frame free. + if placements.pending_spawns == 0 { + return; + } let t0 = Instant::now(); // The landing COUNT cap (B181), on top of the time budget below. The clock gates main-thread // cost — but ~1000 one-submesh doodads pass a 4 ms budget in 2.5 ms and hand the render world @@ -124,6 +130,7 @@ pub(super) fn spawn_loaded_placements( let Placements { by_id, materials: mat_cache, + pending_spawns, } = placements.into_inner(); // Per-frame spawn budget. On cold start every tile's doodads/WMOs finish decoding near-together; @@ -588,6 +595,10 @@ pub(super) fn spawn_loaded_placements( }; p.spawned = true; p.entities = entities; + // The placement's own model landed; a WMO's just-resolved props (all unspawned) + // are new work the count takes on in the same stroke. + *pending_spawns += p.doodads.iter().filter(|d| !d.spawned).count(); + *pending_spawns -= 1; activity.placements_spawned += 1; spawned_n += 1; // Spent the frame's spawn budget on this model (a WMO with two collider meshes is the @@ -840,6 +851,7 @@ pub(super) fn spawn_loaded_placements( ); p.entities.extend(ents); d.spawned = true; + *pending_spawns -= 1; activity.placements_spawned += 1; spawned_n += 1; if Instant::now() >= deadline || spawned_n >= count_cap { diff --git a/crates/benilla-world/src/vis_chain.rs b/crates/benilla-world/src/vis_chain.rs new file mode 100644 index 00000000..7f58a243 --- /dev/null +++ b/crates/benilla-world/src/vis_chain.rs @@ -0,0 +1,68 @@ +//! The visibility-chain-only idiom: entities that carry hide-propagation but never render. +//! +//! Bevy's visibility pipeline sweeps **every `ViewVisibility` row** three times a frame — +//! `reset_view_visibility` and `mark_newly_hidden_entities_invisible` once each, and +//! `check_visibility` once **per active camera** (its query takes `VisibilityClass` as an +//! `Option`, so carrying no renderable class does not exempt a row — verified in +//! bevy_camera 0.18.1, `visibility/mod.rs`). A second camera (the portrait booth) re-bills +//! the whole population. +//! +//! Most of our world entities that carry `Visibility` never render anything themselves: rig +//! anchors, joint entities, anim-host roots, tile roots, net-object roots, attachment +//! wrappers. They hold `Visibility` for one reason only — hide-propagation to renderable +//! *descendants* (a hidden unit must hide its weapon), and that chain runs entirely on +//! `Visibility` + `InheritedVisibility`. Their `ViewVisibility` row is pure sweep tax: at the +//! Goldshire pin it was ~6.7k of the 25.2k-row population (decision 1441). +//! +//! `Visibility` `#[require]`s `ViewVisibility`, so the component can't be left out at spawn — +//! it has to be removed right after. [`VisChainOnly::vis_chain_only`] is that removal, named: +//! chain a call onto the spawn of any never-rendering node. The inheritance chain stays whole +//! (B0004 validates `InheritedVisibility`, which stays), every `Mut`-write flip of +//! `Visibility` keeps working, and the row leaves all three sweeps. +//! +//! One trap this idiom creates: a later `.insert(Visibility::…)` on the same entity re-adds +//! `ViewVisibility` through the require machinery. Flip visibility on chain nodes through a +//! `Mut` write (as every cull authority already does), or re-strip after the +//! insert (see `transport.rs`, the one insert-based flip). + +use bevy::camera::visibility::ViewVisibility; +use bevy::ecs::system::EntityCommands; + +/// Chain onto the spawn of a never-rendering hierarchy node — see the module doc. +pub trait VisChainOnly { + /// Keep `Visibility` + `InheritedVisibility` (the hide-propagation chain), remove + /// `ViewVisibility` (the per-camera sweep row). + fn vis_chain_only(&mut self) -> &mut Self; +} + +impl VisChainOnly for EntityCommands<'_> { + fn vis_chain_only(&mut self) -> &mut Self { + self.remove::() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::camera::visibility::{InheritedVisibility, Visibility}; + use bevy::prelude::*; + + /// The contract: a chain-only node keeps the inheritance pair and sheds the sweep row. + #[test] + fn a_chain_only_node_keeps_inheritance_and_leaves_the_sweep() { + let mut world = World::new(); + let mut queue = bevy::ecs::world::CommandQueue::default(); + let mut commands = Commands::new(&mut queue, &world); + let e = commands + .spawn((Transform::IDENTITY, Visibility::default())) + .vis_chain_only() + .id(); + queue.apply(&mut world); + assert!(world.get::(e).is_some()); + assert!(world.get::(e).is_some()); + assert!( + world.get::(e).is_none(), + "the require chain re-added ViewVisibility — the sweep row is back" + ); + } +}