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