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

- the eight heaviest UI feeds — spellbook, party, character, bags, talents, units, pet, player — rebuild only when an input moved instead of every frame: 216 → 12 µs a frame parked
- the character window's cost settles once it closes: enchant countdowns feed by the second instead of firing an inventory event into all 20 slots every frame, the paper-doll booth parks with its camera, and a window hidden under the cursor fires OnLeave so its tooltip goes with it — the reference's hover-hide law
- the walk stops paying for finished work: the loading-screen tally's per-placement walk latches off once the world is ready, the settled placement spawner is one integer compare, and billboards stop dirtying every card's transform
- never-rendering hierarchy nodes — rig anchors, joints, doodad hosts — keep the hide chain but leave the per-camera visibility sweep: 25,198 → 18,026 swept entities at Goldshire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 19:01:11 +07:00
co-authored by Claude Fable 5
parent 447e92aed5
commit 5360a44f89
49 changed files with 1611 additions and 142 deletions
+33
View File
@@ -150,6 +150,39 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
})
.collect()
};
// **The hover-hide law** (wow-re `ui/scratch/hover-hide-and-tooltip-owner-law.md`, §5-
// arbitrated): hiding the hovered frame — directly or through an ancestor's cascade — fires
// its `OnLeave` SYNCHRONOUSLY, inside the hide and **before that frame's `OnHide`**
// (`0x764ba0`'s kind-2 tail runs mid-`effective_visible_hide`, the leave at `0x764cce`; the
// only silent case is destruction). It clears the hover cache and the drag-arm on that frame
// (`+0x100/+0x104`, the arg-1 flavor), and schedules the re-pick — the pump re-hovers
// whatever is now topmost at the unchanged cursor next tick ([`Model::hover_repick`]).
// A SHOW arms the re-pick too (`0x764b8d`: any mouse-bucket insert): a window opening under
// a stationary cursor gets hovered without a mouse move. This is what makes a slot button's
// FrameXML `OnLeave → GameTooltip:Hide()` actually run when its window closes under the
// cursor — the reference has NO engine-side owner-visibility fallback for the tooltip
// (byte-censused negative), so the leave firing here is the whole mechanism.
let left: Option<(FrameHandle, u32)> = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let hidden_hover = model
.mouseover
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
if let Some(m) = hidden_hover {
model.mouseover = None;
if model.drag.as_ref().is_some_and(|d| d.source == m) {
model.drag = None;
}
}
model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis);
hidden_hover.map(|m| (m, model.frame_id(m)))
};
if let Some((_, oid)) = left {
if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
}
for (h, id, visible) in items {
if visible {
let mut model = lua.app_data_mut::<Model>().expect("model");
+10
View File
@@ -275,6 +275,15 @@ pub(crate) struct Model {
/// The frame currently under the cursor (the last [`UiScript::mouse_move`] capture), so the next
/// move knows whether to fire `OnLeave`/`OnEnter`. `None` = cursor over no mouse-enabled frame.
pub(crate) mouseover: Option<FrameHandle>,
/// The hover **re-pick** is due: the world under a stationary cursor changed — the hovered
/// frame hid (its `OnLeave` already fired at hide time), or a frame was shown that may now
/// be the topmost under the cursor. The reference keeps exactly this flag on the frame
/// manager (`[root+0x1100]`) and its per-tick pump re-runs the hover walk at the **saved**
/// cursor position, explicitly bypassing the didn't-move coalesce — so the newly exposed
/// frame gets `OnEnter` with no physical mouse move (wow-re
/// `ui/scratch/hover-hide-and-tooltip-owner-law.md`: writers `0x764cbb`/`0x764b8d`, pump
/// tail `0x7657a1` → `0x7660d0` self-alias). [`UiScript::tick`] drains it the same way.
pub(crate) hover_repick: bool,
/// Per-button, the frame a mouse-down last captured (`button name → frame`), for the `OnClick`
/// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton`
/// press is not cleared by a `RightButton` release.
@@ -1220,6 +1229,7 @@ impl Model {
frame_events: HashMap::new(),
focused_editbox: None,
mouseover: None,
hover_repick: false,
mouse_down_on: HashMap::new(),
last_click: HashMap::new(),
pending_size_changed: Vec::new(),
@@ -623,3 +623,98 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
);
}
}
/// The hover-hide law (wow-re `ui/scratch/hover-hide-and-tooltip-owner-law.md`, §5-arbitrated):
/// hiding the hovered frame fires its `OnLeave` synchronously, BEFORE its own `OnHide`
/// (`0x764ba0`'s kind-2 tail runs mid-hide, the leave at `0x764cce`; OnHide fires at the hide
/// body's tail) — and an ancestor's `Hide()` reaches the hovered descendant through the cascade.
#[test]
fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local win = CreateFrame("Frame", "Win")
win:SetPoint("BOTTOMLEFT", 0, 0); win:SetSize(800, 600)
local slot = CreateFrame("Button", "Slot", win)
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetSize(100, 100); slot:EnableMouse(true)
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0); // hover the slot
s.run("Win:Hide()").unwrap(); // the ANCESTOR hides — the cascade reaches the slot
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"leave,hide",
"OnLeave fires synchronously inside the hide, before the frame's own OnHide"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The negative: hiding a frame the cursor is NOT over fires no OnLeave (the reference's removal
/// tail acts only when the removed frame IS the cached hover).
#[test]
fn hiding_an_unhovered_frame_fires_no_onleave() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
leaves = 0
local a = CreateFrame("Button", "Away")
a:SetPoint("BOTTOMLEFT", 500, 500); a:SetSize(50, 50); a:EnableMouse(true)
a:SetScript("OnLeave", function() leaves = leaves + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(10.0, 10.0); // nowhere near it
s.run("Away:Hide()").unwrap();
assert_eq!(s.eval::<f64>("return leaves").unwrap(), 0.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The re-pick (`[root+0x1100]` + the pump tail `0x7660d0` on the SAVED event): after the hovered
/// frame hides, the next tick re-hovers whatever is now topmost at the unchanged cursor — OnEnter
/// with no physical mouse move. And the symmetric insert law: a frame SHOWN under a stationary
/// cursor gets hovered on the next tick too (`0x764b8d`).
#[test]
fn the_repick_re_hovers_without_a_mouse_move() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
entered = {}
local under = CreateFrame("Button", "Under")
under:SetPoint("BOTTOMLEFT", 100, 100); under:SetSize(100, 100); under:EnableMouse(true)
under:SetScript("OnEnter", function() table.insert(entered, "under") end)
local over = CreateFrame("Button", "Over")
over:SetPoint("BOTTOMLEFT", 100, 100); over:SetSize(100, 100); over:EnableMouse(true)
over:SetScript("OnEnter", function() table.insert(entered, "over") end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0); // hovers Over (topmost)
s.run("Over:Hide()").unwrap();
s.tick(0.016); // the pump's re-pick — cursor has not moved
assert_eq!(
s.eval::<String>("return table.concat(entered, ',')")
.unwrap(),
"over,under",
"the frame beneath is entered on the next tick with no mouse move"
);
// The insert half: re-showing the top frame under the stationary cursor re-hovers it.
s.run("Over:Show()").unwrap();
s.tick(0.016);
assert_eq!(
s.eval::<String>("return table.concat(entered, ',')")
.unwrap(),
"over,under,over",
"a frame shown under the cursor is entered on the next tick"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+16
View File
@@ -170,6 +170,22 @@ impl UiScript {
// Advance fading tooltips (FadeOut's ramp + end-of-ramp hide) — engine behavior like the
// message fade above, decision 0274.
tooltip::tick_fades(&self.lua);
// The hover RE-PICK ([`Model::hover_repick`]): the world under a stationary cursor
// changed this tick (the hovered frame hid, or a frame was shown over the cursor), so
// re-run the hover walk at the SAVED cursor position — the reference's own pump tail
// (`0x765650` → `0x7660d0` with the saved event, didn't-move coalesce bypassed).
// `mouse_move` at the unchanged point is exactly that walk: the hidden frame's OnLeave
// already fired at hide time and `mouseover` is clear, so only the new winner's
// boundary fires — no second OnLeave, `OnEnter` with no physical mouse move.
let repick = {
let mut model = self.model_mut();
let due = model.hover_repick;
model.hover_repick = false;
due.then_some(model.cursor_pos)
};
if let Some((x, y)) = repick {
self.mouse_move(x, y);
}
// `WOW_UI_HANDLERS=<secs>` — who spent the frame (decision 1395). Last, so a report covers
// everything this tick fired; a no-op unless the instrument is armed.
self.report_handler_profile(elapsed);