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:
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user