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