event-driven UI feeds, and the character window's cost lets go

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 19:01:11 +07:00
co-authored by Claude Fable 5
parent 2fbe661a1d
commit 74c3b47a7f
49 changed files with 1611 additions and 142 deletions
@@ -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<String> = 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::<usize>());
let vis_n = rows.iter().filter(|r| r.1).map(|r| r.0).sum::<usize>();
let other_n = rows
.iter()
.skip(ENTITY_CENSUS_ROWS)
.map(|r| r.0)
.sum::<usize>();
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),
);
+72 -1
View File
@@ -131,6 +131,12 @@ pub(crate) struct Cooldowns {
records: Vec<Record>,
/// 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
@@ -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
@@ -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)
+3 -1
View File
@@ -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();
+128 -2
View File
@@ -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<u64> {
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]
+50
View File
@@ -46,6 +46,12 @@ pub(crate) struct NameCache {
pending_players: HashSet<u64>,
pending_creatures: HashSet<u32>,
pending_pets: HashSet<u32>,
/// 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<CreatureRecord>) {
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).
@@ -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));
}
@@ -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 {
+11 -3
View File
@@ -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<super::BoothCam>>,
cams: Query<(&GlobalTransform, &RenderLayers, &Camera), With<super::BoothCam>>,
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(),
))
@@ -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
+11 -2
View File
@@ -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<GluePreview>,
bake: Res<GluePreviewBake>,
booths: Res<Booths>,
mut booths: ResMut<Booths>,
mut scene: Option<ResMut<CreateScene>>,
mut booth_light: ResMut<BoothLight>,
mut materials: ResMut<Assets<WowModelMaterial>>,
@@ -754,7 +757,7 @@ pub(super) fn sync_glue_booth(
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
mut last: Local<Option<(u64, f32, u64)>>,
) {
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::<Children>();
// 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
+92 -1
View File
@@ -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<Handle<Image>>,
/// 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<Image>` 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<f64>,
/// 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<Entity>,
/// 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<bool> = 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::<Children>();
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<Booths>,
preview: Res<GluePreview>,
panes: Res<BoothPanes>,
@@ -1614,6 +1662,7 @@ fn gate_booth_cameras(
warm: Res<crate::pipe_warm::WarmPass>,
time: Res<Time<bevy::time::Real>>,
mut cams: Query<(&BoothCam, &mut Camera)>,
mut seq_drives: Query<&mut benilla_world::rig_anim::GlobalSeqDrive>,
mut env_cache: Local<Option<bool>>,
) {
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::<bevy::animation::AnimatedBy>();
}
}
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);
}
+6 -1
View File
@@ -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 {
+78 -13
View File
@@ -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<UnitCombatStats>,
last_inv: Option<InventorySlots>,
/// 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<u64>; 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<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `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
+10
View File
@@ -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<u32, Identity>,
/// 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<u32>,
/// 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
+145 -14
View File
@@ -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<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
proficiencies: Res<crate::net::Proficiencies>,
reputations: Res<crate::net::Reputations>,
factions: Option<Res<crate::target::Factions>>,
@@ -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<ms>` per enchant slot.
let enchant_ms: [Option<u64>; 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<NonSendMut<UiScript>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the tooltip's enchant lines (decision 0915).
enchants: Option<Res<crate::items::Enchants>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// 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<SelfPlayer>>,
Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
),
commands: Res<NetCommands>,
cooldowns: Res<crate::cooldowns::Cooldowns>,
spells: Option<Res<crate::ui_action::Spells>>,
@@ -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<HashMap<i64, ContainerState>>,
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<i64, ContainerState>,
) {
) -> bool {
let changed: Vec<i64> = 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)]
+73 -8
View File
@@ -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<GroupLootInfo>,
invite: Option<String>,
units: [Option<UnitState>; 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<PartyState>,
/// 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<GroupState>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
self_q: Query<(&Guid, &ObjectStore), With<SelfPlayer>>,
factions: Option<Res<crate::target::Factions>>,
names: Res<NameCache>,
@@ -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<u64> = 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",
+43 -2
View File
@@ -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<u32>)>,
/// 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<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<PetUnitMemory>>,
@@ -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(
@@ -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());
+176
View File
@@ -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<T>::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<T>`/
//! `RemovedComponents<T>` 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<u64>);
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<bool> = 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<bool> = 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<Option<std::collections::HashMap<&'static str, Instant>>> = 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");
}
}
+5
View File
@@ -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::`.
@@ -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);
}
}
+35 -2
View File
@@ -66,12 +66,26 @@ impl<T: Default> VmMemo<T> {
/// 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<u32> = 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");
+62 -2
View File
@@ -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<Res<WorldAssets>>) {
#[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<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
@@ -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()
+29 -2
View File
@@ -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<NonSendMut<UiScript>>,
talents: Option<Res<Talents>>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
mut chat: ResMut<ChatLog>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
) {
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);
}
}
+83 -9
View File
@@ -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<SelfPlayer>>,
selection: Res<Selection>,
stores: Query<&ObjectStore>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut feed: ResMut<UnitFeedState>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
@@ -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 {
+10 -1
View File
@@ -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::<ViewVisibility>()` 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
+33
View File
@@ -150,6 +150,39 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
})
.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::<Model>().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::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
}
for (h, id, visible) in items {
if visible {
let mut model = lua.app_data_mut::<Model>().expect("model");
+10
View File
@@ -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<FrameHandle>,
/// 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(),
@@ -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::<String>("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::<f64>("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::<String>("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::<String>("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());
}
+16
View File
@@ -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=<secs>` — 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);
+4
View File
@@ -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",
+13 -2
View File
@@ -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<Transform>`+`Changed<GlobalTransform>` 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;
}
}
}
+7 -1
View File
@@ -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
+1
View File
@@ -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;
+8 -2
View File
@@ -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<bool> = 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
@@ -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,
+23 -3
View File
@@ -452,7 +452,7 @@ pub(super) fn simulate_particles(
// rule. `Without<ParticleEmitter>`/`Without<ChildDraw>` keep the read provably disjoint from
// the `&mut GlobalTransform` writes above.
booth_cams: Query<
(Entity, &GlobalTransform, &RenderLayers),
(Entity, &GlobalTransform, &RenderLayers, &Camera),
(
With<Camera3d>,
Without<WorldCamera>,
@@ -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)
}
@@ -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;
}
}
@@ -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));
+4 -1
View File
@@ -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();
+3 -3
View File
@@ -58,7 +58,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
.sort_by_key(|i| ((u8::from(i.cutout) << 1) | u8::from(i.two_sided), i.texture));
remap_fader_items(state);
let baked = bake_cell(&state.items, &mut meshes);
info!(
debug!(
"static-gx: cell ({},{}) baked — {} item(s), {} vert(s)",
cell.0,
cell.1,
@@ -87,7 +87,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
)
});
let baked = bake_cell(&state.items, &mut meshes);
info!(
debug!(
"static-gx: wmo {instance} baked — {} item(s), {} group(s), {} vert(s)",
state.items.len(),
baked.groups.len(),
@@ -116,7 +116,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
});
let mut baked = bake_cell(&state.items, &mut meshes);
baked.sets = state.sets.clone();
info!(
debug!(
"static-gx: props {instance} baked — {} item(s), {} set(s), {} vert(s)",
state.items.len(),
baked.sets.len(),
+1 -1
View File
@@ -679,7 +679,7 @@ impl StaticGx {
owner: b.owner,
texture: b.texture.as_ref().map(Handle::id),
_texture_handle: b.texture,
cutout: b.blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err(),
cutout: b.blend == ModelBlend::AlphaTest && !crate::model_render::alphatest_disabled(),
two_sided: b.two_sided,
unlit: b.unlit,
fog_off: matches!(b.fog_policy, benilla_formats::FogPolicy::Off),
+104 -51
View File
@@ -32,6 +32,7 @@ use crate::liquid::{spawn_liquids, LiquidAssets};
use crate::model_render::MaterialCache;
use crate::schedule::WorldStage;
use crate::view::WorldCamera;
use crate::vis_chain::VisChainOnly;
use crate::world_map::CurrentMap;
use benilla_assets::materials::{TerrainExtension, TerrainMaterial};
use benilla_assets::MapCatalogRes;
@@ -162,6 +163,12 @@ struct Placements {
/// Material dedup, so submeshes sharing a (texture, blend, sidedness, kind, fade-variant) share one
/// `WowModelMaterial` handle — what lets Bevy batch them (mirrors the old `model_material` cache).
materials: MaterialCache,
/// Registered-but-unspawned work — placements awaiting their model plus WMO props awaiting
/// their own M2s. Zero means the spawner's full [`Self::by_id`] walk has nothing to find, so
/// it returns without paying it (a resident city is thousands of placements, all spawned, in
/// steady state). Kept by the register/handoff/release sites here; the spawner itself settles
/// it as models land (and adds a WMO's props the moment they resolve).
pending_spawns: usize,
}
/// A shared placement: its resident model handle, world transform, and spawned submesh entities. Doodad
@@ -234,6 +241,13 @@ pub struct WorldLoadProgress {
/// standing on another — the fail-closed guard that makes any future focus/snap ordering
/// regression cost a logged 6 s timeout instead of a silent fall through the world.
pub focus_tile: Option<(i32, i32)>,
/// 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<bool> = 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 {
@@ -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<bool> = 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)]
@@ -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,
@@ -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 {
+68
View File
@@ -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<Visibility>` 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::<ViewVisibility>()
}
}
#[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::<Visibility>(e).is_some());
assert!(world.get::<InheritedVisibility>(e).is_some());
assert!(
world.get::<ViewVisibility>(e).is_none(),
"the require chain re-added ViewVisibility — the sweep row is back"
);
}
}