From 07d8c8d4cf2e2df1cc400310325575ca1a248668 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:20:26 +0700 Subject: [PATCH] Kind updates run in the frame's walk slot; OnUpdateModel in draw order (#897) The tooltip fade, a model's animation clock and its OnAnimFinished ran after the whole OnUpdate walk, and OnUpdateModel fired in creation order. A tooltip hidden through its parent also kept its owner and fade. In the reference, the walk makes one call per frame: its OnUpdate, then its kind's engine update (a tooltip's fade step, a model pane's clock and completion test). OnUpdateModel fires later, from the paint, in draw order. A tooltip's own hide and show run from any caller, its parent's cascade included: the hide un-owns and clears it; the show needs an owner. The walk now runs a per-kind update in each frame's slot, OnUpdateModel fires per stratum in draw order after the hover re-pick, and show and hide dispatch to a kind's override. CharacterModelBase's override is left for #890. Tests pin each order and a parent's hide and show. Fixes #861 Fixes #863 Fixes #864 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-ui/src/script/mod.rs | 5 +- crates/benilla-ui/src/script/modelframe.rs | 4 +- .../src/script/tests/engine_updates.rs | 246 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/model_clock.rs | 19 +- .../benilla-ui/src/script/tests/visibility.rs | 77 ++++++ crates/benilla-ui/src/script/tick.rs | 197 +++++++------- crates/benilla-ui/src/script/tooltip/mod.rs | 149 ++++++----- crates/benilla-ui/src/script/tooltip/verbs.rs | 43 +-- crates/benilla-ui/src/script/tooltip_spell.rs | 2 +- crates/benilla-ui/src/script/tooltip_unit.rs | 13 +- crates/benilla-ui/src/script/update_walk.rs | 40 ++- crates/benilla-ui/src/script/visibility.rs | 52 +++- crates/benilla-ui/src/widget/kinds/mod.rs | 39 ++- crates/benilla-ui/src/widget/mod.rs | 36 ++- 15 files changed, 674 insertions(+), 249 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/engine_updates.rs diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 3b4e9a14..2df4a06f 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -408,8 +408,9 @@ const SCRIPT_KINDS: [&str; 39] = [ "OnLoad", "OnEvent", "OnUpdate", - // The model pane's two, fired by the tick's model pass (`tick_model_panes`): `OnUpdateModel` - // at the top of a visible pane's paint, `OnAnimFinished` when the armed sequence completes. + // The model pane's two: `OnUpdateModel` from its paint in the draw pass + // (`paint_model_panes`), `OnAnimFinished` from its clock advance in the update walk + // (`advance_pane`) when the armed sequence completes. "OnUpdateModel", "OnAnimFinished", "OnShow", diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index d35c32ab..d834accb 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -655,8 +655,8 @@ impl crate::script::UiScript { }; let path = m.path.as_deref()?; let facts = model.model_facts.get(&model_key(path))?; - // A pane whose camera is still pending paints nothing, not even its - // `OnUpdateModel` (`0x76d5f0`). + // A pane whose camera is still pending draws nothing (`0x76d5f0`); its + // `OnUpdateModel` still fires, from the paint hook ahead of that gate (`0x76d5ea`). if m.camera_pending.is_some() { return None; } diff --git a/crates/benilla-ui/src/script/tests/engine_updates.rs b/crates/benilla-ui/src/script/tests/engine_updates.rs new file mode 100644 index 00000000..2d25bdf6 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/engine_updates.rs @@ -0,0 +1,246 @@ +//! The engine's own per-kind updates, each run in its frame's slot of the update walk (`0x765650`, +//! `vtable+0x38` at `0x765710`) right after that frame's `OnUpdate`: GameTooltip's fade +//! (`0x530b20`) and a Model pane's clock and completion (`0x76d7f0`). `OnUpdateModel` fires later, +//! from the draw pass (`0x7657d0`), in draw order. + +use crate::script::UiScript; +use crate::widget::{ModelFileFacts, SequenceFacts}; + +use super::common::script; + +/// `LOG` joined by spaces, then emptied. +fn take_log(s: &UiScript) -> String { + s.eval::("local l = table.concat(LOG, ' ') LOG = {} return l") + .unwrap() +} + +/// `UI-Cooldown-Indicator.m2`'s two clamped 1000 ms sequences. +const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx"; +fn cooldown_facts() -> ModelFileFacts { + ModelFileFacts { + sequences: [(0, 1000), (1, 1000)] + .into_iter() + .map(|(anim_id, duration_ms)| SequenceFacts { + anim_id, + duration_ms, + looping: false, + }) + .collect(), + bbox: ([0.0; 3], [0.0; 3]), + cameras: 0, + } +} + +fn lua_path(path: &str) -> String { + path.replace('\\', "\\\\") +} + +/// A fading tooltip, owned and with a line, whose `OnHide` logs `hide`. +const FADING_TIP: &str = r#" + LOG = {} + local root = CreateFrame("Frame", "FadeRoot") + local owner = CreateFrame("Button", "FadeOwner", root) + owner:SetPoint("CENTER", 0, 0); owner:SetWidth(10); owner:SetHeight(10) + local tt = CreateFrame("GameTooltip", "FadeTip", root) + tt:SetFrameStrata("HIGH") + tt:SetOwner(owner, "ANCHOR_RIGHT") + tt:SetText("Fading") + tt:SetScript("OnHide", function() table.insert(LOG, "hide") end) +"#; + +/// The fade is the tooltip's own update (`0x530b9c`, after its `OnUpdate` at `0x530b2d`): a frame +/// walked after it reads this frame's alpha, and on the frame the fade ends the hide (`0x530be9`) +/// and its `OnHide` come before that frame's `OnUpdate`. +#[test] +fn a_frame_walked_after_the_tooltip_sees_this_frames_fade() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(FADING_TIP).unwrap(); + s.run( + r#" + local w = CreateFrame("Frame", "FadeWatch") + w:SetFrameStrata("TOOLTIP") + w:SetScript("OnUpdate", function() + if FadeTip:IsShown() then + table.insert(LOG, string.format("shown@%.1f", FadeTip:GetAlpha())) + else + table.insert(LOG, "hidden") + end + end) + FadeTip:FadeOut() + "#, + ) + .unwrap(); + take_log(&s); + s.tick(0.3); + assert_eq!(take_log(&s), "shown@0.4", "this frame's ramp, not the last"); + s.tick(0.3); + assert_eq!( + take_log(&s), + "hide hidden", + "the hide lands in the tooltip's slot" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A pane's update (`0x76d7f0`) runs its `OnUpdate`, then advances its clock, whose completion +/// test drains `OnAnimFinished` in the same call (`0x7074d8`, `0x7075af`): in the pane's place, +/// before frames walked after it and before the draw pass fires `OnUpdateModel`. +#[test] +fn on_anim_finished_fires_in_the_panes_walk_slot() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_model_facts(COOLDOWN_FILE, cooldown_facts()); + s.run(&format!( + r#" + LOG = {{}} + local p = CreateFrame("Model", "SlotPane") + p:SetFrameStrata("LOW") + p:SetModel("{}") + p:SetSequence(1) + p:SetScript("OnUpdate", function() table.insert(LOG, "pane") end) + p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end) + p:SetScript("OnUpdateModel", function() table.insert(LOG, "model") end) + local h = CreateFrame("Frame", "SlotHigh") + h:SetFrameStrata("HIGH") + h:SetScript("OnUpdate", function() table.insert(LOG, "high") end) + "#, + lua_path(COOLDOWN_FILE) + )) + .unwrap(); + take_log(&s); + s.tick(0.6); + assert_eq!(take_log(&s), "pane high model"); + s.tick(0.6); // 1200 ms ≥ the 1000 ms sequence + assert_eq!(take_log(&s), "pane finished high model"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// One virtual call runs both halves (`0x76d7fa`, then `0x76d843`): a pane that hides itself in +/// its `OnUpdate` still advances this frame, and a completion its advance reaches still fires. +/// A tooltip shows nothing here: its hide disarms the fade it would step (`0x530002`). +#[test] +fn a_pane_hidden_by_its_own_on_update_still_advances() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_model_facts(COOLDOWN_FILE, cooldown_facts()); + s.run(&format!( + r#" + LOG = {{}} + local p = CreateFrame("Model", "SelfHide") + p:SetModel("{}") + p:SetSequence(1) + p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end) + "#, + lua_path(COOLDOWN_FILE) + )) + .unwrap(); + s.tick(0.6); + s.run(r#"SelfHide:SetScript("OnUpdate", function() this:Hide() end)"#) + .unwrap(); + take_log(&s); + s.tick(0.6); + assert!(!s.frame_visible("SelfHide")); + assert_eq!(super::model_clock::pane(&s, "SelfHide").clock_ms, 1200); + assert_eq!(take_log(&s), "finished"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A zero advance returns before the completion test and the drain (`0x7074b7`): a sequence +/// already past its end fires nothing until a frame advances the clock. +#[test] +fn a_zero_advance_tests_no_completion() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_model_facts(COOLDOWN_FILE, cooldown_facts()); + s.run(&format!( + r#" + LOG = {{}} + local p = CreateFrame("Model", "ZeroDt") + p:SetModel("{}") + p:SetSequenceTime(1, 1000) + p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end) + "#, + lua_path(COOLDOWN_FILE) + )) + .unwrap(); + take_log(&s); + s.tick(0.0009); // trunc(0.9 ms) = 0 + assert_eq!(super::model_clock::pane(&s, "ZeroDt").clock_ms, 0); + assert_eq!(take_log(&s), "", "no advance, no test"); + s.tick(0.016); + assert_eq!(take_log(&s), "finished"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Completion is judged against the arm the previous paint left: an `OnUpdateModel` that scrubs +/// the sequence back each paint does not hide the completion the walk already reported. +#[test] +fn completion_is_read_before_the_paint_re_arms() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_model_facts(COOLDOWN_FILE, cooldown_facts()); + s.run(&format!( + r#" + LOG = {{}} + local p = CreateFrame("Model", "Scrub") + p:SetModel("{}") + p:SetSequence(1) + p:SetScript("OnAnimFinished", function() table.insert(LOG, "finished") end) + "#, + lua_path(COOLDOWN_FILE) + )) + .unwrap(); + s.tick(0.6); + s.run(r#"Scrub:SetScript("OnUpdateModel", function() this:SetSequenceTime(1, 0) end)"#) + .unwrap(); + take_log(&s); + s.tick(0.6); // the arm from tick one ends at 1000 ms; this tick's clock reads 1200 + assert_eq!(take_log(&s), "finished"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `OnUpdateModel` fires from the paint (`0x76d1bc`), in the draw pass's order: stratum from 0 +/// (`0x7657ff`), level from 0 (`0x7658c0`), a level's frames first linked first (`0x765920`). +#[test] +fn on_update_model_fires_in_draw_order() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_model_facts(COOLDOWN_FILE, cooldown_facts()); + s.run(&format!( + r#" + LOG = {{}} + function Pane(name, tag, strata, level) + local p = CreateFrame("Model", name) + p:SetFrameStrata(strata) + if level then p:SetFrameLevel(level) end + p:SetModel("{}") + p:SetScript("OnUpdateModel", function() table.insert(LOG, tag) end) + return p + end + Pane("PaneDialog", "dialog", "DIALOG") + Pane("PaneLow", "low", "LOW") + Pane("PaneLevel9", "level9", "MEDIUM", 9) + Pane("PaneLevel2", "level2", "MEDIUM", 2) + "#, + lua_path(COOLDOWN_FILE) + )) + .unwrap(); + take_log(&s); + s.tick(0.016); + assert_eq!(take_log(&s), "low level2 level9 dialog"); + + // Within a level the latest link draws last: a pane shown again goes after its peers. + s.run( + r#" + Pane("LinkA", "a", "BACKGROUND", 3) + Pane("LinkB", "b", "BACKGROUND", 3) + LinkA:Hide() LinkA:Show() + "#, + ) + .unwrap(); + take_log(&s); + s.tick(0.016); + assert_eq!(take_log(&s), "b a low level2 level9 dialog"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index a7468ce1..c4df50cc 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -13,6 +13,7 @@ mod create_frame_template; mod cvar_bindings; mod dispatch_bench; mod end_to_end; +mod engine_updates; mod events; mod font_object; mod frame_api; diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index cc1ee2bd..78494ca5 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -235,7 +235,7 @@ fn the_cooldown_machine_runs_on_the_two_handlers() { cd:SetScript("OnAnimFinished", CooldownFrame_OnAnimFinished) cd:Hide() fired = {{}} - cd:SetScript("OnAnimFinished", function() table.insert(fired, "finished") CooldownFrame_OnAnimFinished() end) + cd:SetScript("OnAnimFinished", function() table.insert(fired, this.stopping) CooldownFrame_OnAnimFinished() end) CooldownFrame_SetTimer(cd, GetTime(), 2, 1) "#, COOLDOWN_FILE.replace('\\', "\\\\") @@ -244,25 +244,32 @@ fn the_cooldown_machine_runs_on_the_two_handlers() { assert!(s.frame_visible("CD")); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 0))); + let fired = |s: &UiScript| s.eval::("return table.concat(fired, ' ')").unwrap(); s.tick(0.5); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 250))); + // Each scrub is an arm, and the walk reads the one the last paint left: between paints this + // scene runs at twice the scrub's rate, so 250 + 1000 ≥ 1000 completes sequence 0. The stock + // `stopping` guard ignores it. s.tick(1.0); + assert_eq!(fired(&s), "0"); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 750))); // A scrub is an anchor, not a freeze: between paints the clock runs on from it. assert_eq!(pane(&s, "CD").armed.map(|a| a.anchor_ms), Some(1500 - 750)); + // 750 + 600 ≥ 1000 completes sequence 0 again, before this paint moves to sequence 1. s.tick(0.6); + assert_eq!(fired(&s), "0 0"); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 0))); assert!(s.frame_visible("CD")); assert_eq!(s.eval::("return cd.stopping").unwrap(), 1); s.tick(0.5); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 500))); - assert!(s.eval::("return table.getn(fired) == 0").unwrap()); + assert_eq!(fired(&s), "0 0"); s.tick(0.6); assert_eq!( - s.eval::("return table.getn(fired)").unwrap(), - 1, + fired(&s), + "0 0 1", "OnAnimFinished on the clamped sequence's natural completion" ); assert!( @@ -270,12 +277,12 @@ fn the_cooldown_machine_runs_on_the_two_handlers() { "…which the stock handler turns into Hide()" ); s.tick(1.0); - assert_eq!(s.eval::("return table.getn(fired)").unwrap(), 1); + assert_eq!(fired(&s), "0 0 1"); assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 1000))); assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// `OnUpdateModel` runs before the completion is read, so re-arming each paint completes nothing. +/// A paint that re-arms each frame completes nothing: each walk reads the arm the last paint left. #[test] fn on_update_model_fires_per_visible_paint_with_this() { let mut s = script(); diff --git a/crates/benilla-ui/src/script/tests/visibility.rs b/crates/benilla-ui/src/script/tests/visibility.rs index 314c355f..1aebb4a1 100644 --- a/crates/benilla-ui/src/script/tests/visibility.rs +++ b/crates/benilla-ui/src/script/tests/visibility.rs @@ -212,3 +212,80 @@ fn show_on_a_shown_but_invisible_frame_makes_it_visible() { assert_eq!(s.eval::("return SHOWS").unwrap(), 1); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// A tooltip, owned with a line and fading, under `TipRoot`; its handlers log to `LOG`. +const OWNED_TIP: &str = r#" + LOG = {} + local root = CreateFrame("Frame", "TipRoot") + local owner = CreateFrame("Button", "TipOwner", root) + owner:SetPoint("CENTER", 0, 0); owner:SetWidth(10); owner:SetHeight(10) + local tt = CreateFrame("GameTooltip", "CascadeTip", root) + tt:SetOwner(owner, "ANCHOR_RIGHT") + tt:SetText("Owned") + tt:SetScript("OnTooltipCleared", function() table.insert(LOG, "cleared") end) + tt:SetScript("OnHide", function() table.insert(LOG, "hide") end) + tt:SetScript("OnShow", function() table.insert(LOG, "show") end) +"#; + +fn take_log(s: &crate::script::UiScript) -> String { + s.eval::("local l = table.concat(LOG, ' ') LOG = {} return l") + .unwrap() +} + +/// A parent's hide reaches the tooltip through its own hide slot (`0x76adee` → `0x530a60`): the +/// SetOwner core with no owner clears it, fires `OnTooltipCleared` (`0x530263`), restores full +/// alpha, disarms the fade and drops the owner, and only then does the base hide fire `OnHide`. +/// The shown bit is the Lua `Hide`'s to clear, so it stays set. +#[test] +fn a_parents_hide_un_owns_a_fading_tooltip() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(OWNED_TIP).unwrap(); + s.run("CascadeTip:FadeOut()").unwrap(); + s.tick(0.3); + take_log(&s); + s.run("TipRoot:Hide()").unwrap(); + assert_eq!(take_log(&s), "cleared hide"); + s.run( + r#" + assert(not CascadeTip:IsOwned(TipOwner), "un-owned by its parent's hide") + assert(CascadeTip:NumLines() == 0, "cleared") + assert(CascadeTip:GetAlpha() == 1, "full alpha, fade disarmed") + assert(CascadeTip:IsShown(), "the shown bit is untouched") + "#, + ) + .unwrap(); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A parent's show reaches the tooltip through its own show slot (`0x76aed5` → `0x530a80`): one +/// with an owner and a line shows, one without clears its shown bit and hides itself through its +/// own hide, so it stays hidden. So a parent hidden and shown again (Alt-Z) drops the tooltip. +#[test] +fn a_parents_show_brings_back_only_an_owned_tooltip() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(OWNED_TIP).unwrap(); + s.run("TipRoot:Hide()").unwrap(); + take_log(&s); + s.run("TipRoot:Show()").unwrap(); + assert_eq!(take_log(&s), "cleared", "its own hide again; no OnShow"); + assert!(!s.frame_visible("CascadeTip")); + s.run(r#"assert(not CascadeTip:IsShown(), "the show cleared the shown bit")"#) + .unwrap(); + + // Owned again while the parent is hidden: the parent's show brings it back. + s.run( + r#" + TipRoot:Hide() + CascadeTip:SetOwner(TipOwner, "ANCHOR_RIGHT") + CascadeTip:SetText("Again") + "#, + ) + .unwrap(); + take_log(&s); + s.run("TipRoot:Show()").unwrap(); + assert_eq!(take_log(&s), "show"); + assert!(s.frame_visible("CascadeTip")); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 41fd5d18..809c0690 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -5,7 +5,7 @@ use mlua::Lua; use crate::widget::FrameHandle; -use super::{event, tooltip, Model, ScriptValue, UiScript}; +use super::{event, Model, ScriptValue, UiScript}; impl UiScript { /// Queue an event for the start of the next tick, behind everything queued earlier and before @@ -70,10 +70,12 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { } impl super::UiScript { - /// Advance a frame: the `GetTime()` clock and queued events, then the update walk - /// (`0x765650`), each linked frame's update in strata, level and link order: its - /// `OnUpdate(self, elapsed)` (`0x704f10`), and for an EditBox its flush and caret blink after - /// it; then the engine's own fades, model panes and hover. + /// Advance a frame: the `GetTime()` clock and queued events, then the pump `0x765650` and the + /// draw pass `0x7657d0`, in that order (`0x764340`, `0x764347`). The pump is the update walk, + /// each linked frame's update in strata, level and link order: its `OnUpdate(self, elapsed)` + /// (`0x704f10`) and its kind's own update after it (an EditBox's flush, a tooltip's fade, a + /// model pane's clock); then the hover re-pick at its tail (`0x7657be`). The draw pass fires + /// `OnUpdateModel`. pub fn tick(&mut self, elapsed: f32) { self.model_mut().now += f64::from(elapsed); // Events queued since the last tick fire before this tick's OnUpdate. @@ -82,7 +84,6 @@ impl super::UiScript { self.fire_event(&event, args); } self.update_walk(elapsed); - self.tick_model_panes(elapsed); // The engine's line fades, apart from any OnUpdate script: ScrollingMessageFrame's // (`0x788460`) and MessageFrame's (`0x786200`), which also keeps only the lines that fit // its height, so its rows are read from the resolved rect before the mutable walk. @@ -116,8 +117,6 @@ impl super::UiScript { } } drop(model); - // Tooltip fades: `FadeOut`'s ramp and the hide at its end. - tooltip::tick_fades(&self.lua); // A frame hid or showed under a still cursor: re-run the hover walk at the saved position, // as the reference's pump tail does (`0x765650` → `0x7660d0`). The hidden frame's OnLeave // already fired, so only the new winner's `OnEnter` fires. @@ -130,115 +129,115 @@ impl super::UiScript { if let Some((x, y)) = repick { self.mouse_move(x, y); } + self.paint_model_panes(); // `WOW_UI_HANDLERS=`: last, so its report covers everything this tick fired. self.report_handler_profile(elapsed); } } impl UiScript { - /// The model panes' per-frame pass, for visible panes, in the reference's order: the pane's - /// `OnUpdate` (`0x76d7f0`) advances its clock by `trunc(elapsed · 1000)` ms, the paint - /// (`0x76d1a0`) fires `OnUpdateModel`, and the animate fires `OnAnimFinished` (`0x76cdc0`) once - /// per arm when the armed sequence completes naturally, a loop's first pass included - /// (`0x719370`). A hidden pane's clock stands still. Completion is read after `OnUpdateModel`, - /// whose handler may re-arm. - fn tick_model_panes(&mut self, elapsed: f32) { + /// A model pane's update after its `OnUpdate` (`0x76d7f0`): its scene clock advances by + /// `trunc(elapsed · 1000)` ms, no `+0.5` (`0x76d843`–`0x76d863`, `0x7074b0`), and the advance + /// tests the armed sequence (`0x7074d8` → `0x719370`) and drains the completions in the same + /// call (`0x7075af`), so `OnAnimFinished` (`0x76cdc0`) fires here, once per arm, against the + /// arm the last paint left. A zero advance tests nothing (`0x7074b7`). + pub(super) fn advance_pane(&mut self, h: FrameHandle, elapsed: f32) { let dt_ms = (elapsed * 1000.0).trunc().max(0.0) as u64; - let update_ids: Vec = { + let finished = { let mut model = self.model_mut(); - let ticked: Vec = model.arena.ticked_kinds().to_vec(); - for h in ticked { - let Some(f) = model.arena.frame_mut(h) else { - continue; - }; - if !f.effective_visible { - continue; - } - if let crate::widget::KindState::Model(m) = &mut f.kind_state { - m.clock_ms += dt_ms; - } - } - // Kept by `SetScript`; a destroyed frame's handle compacts out on its first miss. - if model - .on_update_model_frames - .iter() - .any(|&h| model.arena.frame(h).is_none()) - { - let arena = &model.arena; - let live: Vec = model - .on_update_model_frames - .iter() - .copied() - .filter(|&h| arena.frame(h).is_some()) - .collect(); - model.on_update_model_frames = live; - } - // Only visible Model panes with a file set, resident or streaming: the paint that fires - // the handler (`0x76d1bc`) is gated on `[widget+0x318] ≠ 0` (`0x76d24c`). On any other - // kind the script is inert. - let frames: Vec = model - .on_update_model_frames - .iter() - .copied() - .filter(|&h| { - model.arena.frame(h).is_some_and(|f| { - f.effective_visible - && matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some()) - }) - }) - .collect(); - let mut ids: Vec = frames.into_iter().map(|h| model.frame_id(h)).collect(); - ids.sort_unstable(); // creation order - ids - }; - for id in update_ids { - if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnUpdateModel", Vec::new()) { - self.push_error(e); - } - } - let finished_ids: Vec = { - let mut model = self.model_mut(); - let ticked: Vec = model.arena.ticked_kinds().to_vec(); - let mut due: Vec = Vec::new(); - for h in ticked { - let Some(f) = model.arena.frame(h) else { - continue; - }; - if !f.effective_visible { - continue; - } - let crate::widget::KindState::Model(m) = &f.kind_state else { - continue; - }; - let Some(path) = m.path.as_deref() else { - continue; - }; - let Some(facts) = model.model_facts.get(&crate::widget::model_key(path)) else { - continue; - }; - if m.completion_due(facts) { - due.push(h); - } - } - let mut ids = Vec::with_capacity(due.len()); - for h in due { - if let Some(crate::widget::KindState::Model(m)) = - model.arena.frame_mut(h).map(|f| &mut f.kind_state) - { + let model = &mut *model; + let Some(crate::widget::KindState::Model(m)) = + model.arena.frame_mut(h).map(|f| &mut f.kind_state) + else { + return; + }; + m.clock_ms += dt_ms; + let facts = m + .path + .as_deref() + .and_then(|p| model.model_facts.get(&crate::widget::model_key(p))); + match facts { + Some(facts) if dt_ms > 0 && m.completion_due(facts) => { if let Some(a) = &mut m.armed { a.finished = true; } + Some(model.frame_id(h)) } - ids.push(model.frame_id(h)); + _ => None, } - ids.sort_unstable(); - ids }; - for id in finished_ids { + if let Some(id) = finished { if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnAnimFinished", Vec::new()) { self.push_error(e); } } } + + /// The draw pass's `OnUpdateModel` (`0x7657d0`): each pane's paint fires it (`0x76d1bc`) from + /// the render callback its layer emit adds to its level's ARTWORK batch (`0x76d160`). Strata + /// go from 0 (`0x7657ff`); a stratum's batches are emitted before any is drained (`0x765896`, + /// then `0x7658e2`), level 0 up, a level's frames first linked first (`0x765920`). So the + /// panes are read per stratum when its turn comes, and a pane an earlier handler of that + /// stratum hid still paints this frame; only the callback's own gate, a model file set + /// (`0x76d24c`), is read at the call. + fn paint_model_panes(&mut self) { + let mut model = self.model_mut(); + if model.on_update_model_frames.is_empty() { + return; + } + // Kept by `SetScript`; a destroyed frame's handle compacts out on its first miss. + if model + .on_update_model_frames + .iter() + .any(|&h| model.arena.frame(h).is_none()) + { + let arena = &model.arena; + let live: Vec = model + .on_update_model_frames + .iter() + .copied() + .filter(|&h| arena.frame(h).is_some()) + .collect(); + model.on_update_model_frames = live; + } + drop(model); + for stratum in crate::order::Strata::ALL { + let panes: Vec = { + let model = self.model_ref(); + let mut panes: Vec<(u16, u64, FrameHandle)> = model + .on_update_model_frames + .iter() + .filter_map(|&h| { + let f = model.arena.frame(h)?; + (f.strata == stratum && f.effective_visible && has_model_file(f)) + .then_some((f.level, u64::from(f.insertion_seq), h)) + }) + .collect(); + panes.sort_unstable_by_key(|&(level, seq, _)| (level, seq)); + panes.into_iter().map(|(_, _, h)| h).collect() + }; + for h in panes { + let id = { + let mut model = self.model_mut(); + match model.arena.frame(h) { + Some(f) if has_model_file(f) => model.frame_id(h), + _ => continue, + } + }; + if let Err(e) = + event::fire_widget_handler(&self.lua, id, "OnUpdateModel", Vec::new()) + { + self.push_error(e); + } + } + } + } +} + +/// A Model-family pane with a file set, resident or streaming (`[widget+0x318] ≠ 0`), which its +/// layer emit and its render callback both require (`0x76d24c`); on any other kind +/// `OnUpdateModel` is inert. +fn has_model_file(f: &crate::widget::Frame) -> bool { + matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some()) } diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 30a0644f..dfbca95c 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -15,6 +15,7 @@ use mlua::{Lua, Table, Value}; use super::event; use super::object::{frame_handle_of, publish_global}; use super::region::region_wrapper; +use super::visibility; use super::{FontObject, Model, RegionData}; use crate::layout::{Anchor, Point}; use crate::order::DrawLayer; @@ -319,7 +320,8 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) { /// A content `Set*`'s tail: shown with lines, hidden without. The reference leaves an uncached /// tooltip cleared and collapsed to nothing; this plate keeps its declared size, so it hides. It -/// hides without [`hide_tooltip`] to keep the owner, which the hover loop's `IsOwned` needs. +/// takes the base transitions past the tooltip's own ([`hide_override`] would un-own it), to keep +/// the owner, which the hover loop's `IsOwned` needs. pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) { let lines = { let mut model = lua.app_data_mut::().expect("model app_data"); @@ -328,14 +330,67 @@ pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) { Err(_) => 0, } }; - set_shown(lua, h, lines > 0); + visibility::set_shown_bit(lua, h, lines > 0); + if lines > 0 { + visibility::base_show(lua, h); + } else { + visibility::base_hide(lua, h); + } +} + +/// The tooltip's show (`0x530a80`, slot `+0x88`), however the show comes: with an owner and a line +/// (`0x530a83`–`0x530a95`) it restores full alpha, disarms the fade (`0x530aa3`) and runs the base +/// show; else it clears its shown bit and hides through its own hide (`0x530abe`, `0x530ac8`). +/// So a parent's show brings back only a tooltip that still has an owner. +pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) { + let live = { + let mut model = lua.app_data_mut::().expect("model app_data"); + let live = tip_mut(&mut model, h).is_ok_and(|t| t.owner.is_some() && t.num_lines > 0); + if live { + full_alpha(&mut model, h); + } + live + }; + if live { + visibility::base_show(lua, h); + } else { + visibility::set_shown_bit(lua, h, false); + visibility::hide(lua, h); + } +} + +/// The tooltip's hide (`0x530a60`, slot `+0x84`), however the hide comes, its parent's included: +/// the SetOwner core with no owner (`0x52ffe0`), then the base hide. The core clears the content +/// and fires `OnTooltipCleared` (`0x530050`, whose last act is the fire at `0x530263`), then +/// restores full alpha (`0x52fff4`), disarms the fade (`0x530002`) and drops the owner +/// (`0x53000c`); the mode it leaves at 7 (`0x530012`) is not kept (see `GetAnchorType`). It runs +/// whether or not the tooltip is shown: only the base hide gates on visibility (`0x76ad5b`). +pub(in crate::script) fn hide_override(lua: &Lua, h: FrameHandle) { + { + let mut model = lua.app_data_mut::().expect("model app_data"); + if tip_mut(&mut model, h).is_err() { + drop(model); + visibility::base_hide(lua, h); + return; + } + clear_content(&mut model, h); + } + fire_cleared(lua, h); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + full_alpha(&mut model, h); + if let Ok(t) = tip_mut(&mut model, h) { + t.owner = None; + } + } + visibility::base_hide(lua, h); } /// New content cancels a running fade at full alpha; with no fade running, an alpha the addon set /// stays, since only the paths in [`full_alpha`] reset it. fn cancel_fade(model: &mut Model, h: FrameHandle) { if let Ok(t) = tip_mut(model, h) { - if t.fade_start.take().is_some() { + if t.fade_left.take().is_some() { model.arena.set_alpha(h, 1.0); } } @@ -345,16 +400,11 @@ fn cancel_fade(model: &mut Model, h: FrameHandle) { /// Show (`0x530a80`) and its self-hide (`0x530a60`, the core with no owner) reach it too. fn full_alpha(model: &mut Model, h: FrameHandle) { if let Ok(t) = tip_mut(model, h) { - t.fade_start = None; + t.fade_left = None; } model.arena.set_alpha(h, 1.0); } -/// The engine's `GetTime` clock, which [`super::UiScript::tick`] advances. -fn now(lua: &Lua) -> f64 { - crate::script::clock::now(lua) -} - /// Shows or hides as the Lua verbs do, handlers included. pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) { crate::script::visibility::set_shown(lua, h, shown); @@ -519,20 +569,6 @@ pub(super) fn append_line( mod verbs; pub(super) use verbs::install; -/// The full hide shared by `Hide`, a `Show` with no owner or no lines, and the end of a fade. -pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) { - { - let mut model = lua.app_data_mut::().expect("model app_data"); - full_alpha(&mut model, h); - if let Ok(t) = tip_mut(&mut model, h) { - t.owner = None; - } - clear_content(&mut model, h); - } - set_shown(lua, h, false); - fire_cleared(lua, h); -} - /// A line cell's measured extent; `None` for a hidden or unmeasured cell, which adds no size and /// no gap, so a fresh tooltip keeps its declared size until its lines are measured. type Cell = Option<(f32, f32)>; @@ -669,47 +705,34 @@ pub(super) fn layout_tooltips(model: &mut Model) { } } -/// Advances every fading tooltip: alpha ramps from 1 to 0 over [`TOOLTIP_FADE_SECS`], then the -/// full hide. -pub(super) fn tick_fades(lua: &Lua) { - let t = now(lua); - let mut ramping: Vec<(FrameHandle, f32)> = Vec::new(); - let mut finished: Vec = Vec::new(); - { - let model = lua.app_data_ref::().expect("model app_data"); - // Roster-gated as in `layout_tooltips`, since `hide_tooltip` below writes layout. - for &h in model.arena.tooltip_kinds() { - if !model.frame_to_id.contains_key(&h) { - continue; - } - let Some(frame) = model.arena.frame(h) else { - continue; - }; - let KindState::Tooltip(tip) = &frame.kind_state else { - continue; - }; - let Some(start) = tip.fade_start else { - continue; - }; - if !frame.shown { - finished.push(h); // hidden mid-fade by other means: just tidy the state - continue; - } - let a = 1.0 - ((t - start) / TOOLTIP_FADE_SECS) as f32; - if a <= 0.0 { - finished.push(h); - } else { - ramping.push((h, a)); - } - } - } - { +/// The tooltip's own update after its `OnUpdate` (`0x530b20`, from `0x530b9c`): an armed fade +/// counts down by the elapsed time (`0x530baa`–`0x530bc2`) and ramps the alpha; at zero the full +/// hide (`0x530bd8` full alpha, `0x530be9` the hide slot), whose `OnHide` runs here, in the +/// tooltip's place in the walk. Nothing gates it on shown, owner or lines. +pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, elapsed: f32) { + let done = { let mut model = lua.app_data_mut::().expect("model app_data"); - for (h, a) in ramping { - model.arena.set_alpha(h, a); + // Roster-gated as in `layout_tooltips`, since the hide writes layout. + if !model.frame_to_id.contains_key(&h) { + return; } - } - for h in finished { - hide_tooltip(lua, h); + let Ok(tip) = tip_mut(&mut model, h) else { + return; + }; + let Some(left) = tip.fade_left else { + return; + }; + let left = left - elapsed; + if left <= 0.0 { + true + } else { + tip.fade_left = Some(left); + model.arena.set_alpha(h, left / TOOLTIP_FADE_SECS); + false + } + }; + // The end: shown bit cleared, then the hide slot, as the Lua `Hide` does. + if done { + set_shown(lua, h, false); } } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 34cfc9d8..b3a745a7 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -7,12 +7,11 @@ use crate::script::binding_abi::optional_string; use crate::script::object::frame_handle_of; use crate::script::region::region_handle_of; use crate::script::Model; -use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; +use crate::widget::{KindState, TooltipAnchor, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; use super::{ - append_line, bool_arg, clear_content, fire_cleared, full_alpha, hide_tooltip, now, - parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell, - REG_TOOLTIP_METHODS, + append_line, bool_arg, clear_content, fire_cleared, full_alpha, parse_line_color, + parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell, REG_TOOLTIP_METHODS, }; pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { @@ -322,45 +321,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { m.set( "FadeOut", lua.create_function(|lua, this: Table| { - let t = now(lua); with_tip(lua, &this, |tip| { - if tip.fade_start.is_none() { - tip.fade_start = Some(t); + if tip.fade_left.is_none() { + tip.fade_left = Some(TOOLTIP_FADE_SECS); } }) })?, )?; - // Show and Hide shadow the shared pair. Show (`0x530a80`, the override the generic Show at - // `0x7a3350` calls) shows only a plate with an owner and at least one line, checked at Show - // time only; otherwise it takes the full hide (`0x530a60`): un-owned, cleared, - // OnTooltipCleared fired. Hide is the full hide. - m.set( - "Show", - lua.create_function(|lua, this: Table| { - let h = frame_handle_of(lua, &this)?; - let live = { - let mut model = lua.app_data_mut::().expect("model app_data"); - full_alpha(&mut model, h); - tip_mut(&mut model, h) - .map(|t| t.owner.is_some() && t.num_lines > 0) - .unwrap_or(false) - }; - match live { - true => set_shown(lua, h, true), - false => hide_tooltip(lua, h), - } - Ok(()) - })?, - )?; - m.set( - "Hide", - lua.create_function(|lua, this: Table| { - let h = frame_handle_of(lua, &this)?; - hide_tooltip(lua, h); - Ok(()) - })?, - )?; - // The content methods: items, spells and auras, talents, units. crate::script::tooltip_item::install_methods(lua, &m)?; crate::script::tooltip_spell::install_methods(lua, &m)?; diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 8ebee599..01f65334 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -218,7 +218,7 @@ fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32, alt_caster: bool) -> ml else { return Ok(()); }; - let fading = tip_mut(&mut model, h)?.fade_start.is_some(); + let fading = tip_mut(&mut model, h)?.fade_left.is_some(); if fading || drawn_lines(&mut model, h)? != wait.drawn { model.spell_tooltip_waits.remove(&h); return Ok(()); diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index deb18907..2106c57b 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -12,7 +12,7 @@ use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject}; use super::{KindState, Model, UnitState}; use crate::layout::{Anchor, Point}; use crate::strings::{fill, Arg}; -use crate::widget::FrameHandle; +use crate::widget::{FrameHandle, TOOLTIP_FADE_SECS}; const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; @@ -303,7 +303,7 @@ impl super::UiScript { let mut model = self.model_mut(); if let Some(f) = model.arena.frame_mut(h) { if let KindState::Tooltip(t) = &mut f.kind_state { - if t.fade_start.take().is_some() { + if t.fade_left.take().is_some() { model.arena.set_alpha(h, 1.0); } } @@ -404,7 +404,7 @@ impl super::UiScript { let mut model = self.model_mut(); if let Some(f) = model.arena.frame_mut(h) { if let KindState::Tooltip(t) = &mut f.kind_state { - if t.fade_start.take().is_some() { + if t.fade_left.take().is_some() { model.arena.set_alpha(h, 1.0); } } @@ -469,7 +469,7 @@ impl super::UiScript { let mut model = self.model_mut(); if let Some(f) = model.arena.frame_mut(h) { if let KindState::Tooltip(t) = &mut f.kind_state { - if t.fade_start.take().is_some() { + if t.fade_left.take().is_some() { model.arena.set_alpha(h, 1.0); } } @@ -525,15 +525,14 @@ impl super::UiScript { /// On hover loss, arm the world tooltip's timed fade (the reference's unit plate fades rather /// than hiding); a no-op unless a world-owned plate is shown. pub fn world_tooltip_fade(&mut self) { - let now = self.now(); let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return; }; if let Some(f) = model.arena.frame_mut(h) { if let KindState::Tooltip(t) = &mut f.kind_state { - if t.world_owned && f.shown && t.fade_start.is_none() { - t.fade_start = Some(now); + if t.world_owned && f.shown && t.fade_left.is_none() { + t.fade_left = Some(TOOLTIP_FADE_SECS); } } } diff --git a/crates/benilla-ui/src/script/update_walk.rs b/crates/benilla-ui/src/script/update_walk.rs index 0b2e3aa9..48588a15 100644 --- a/crates/benilla-ui/src/script/update_walk.rs +++ b/crates/benilla-ui/src/script/update_walk.rs @@ -10,12 +10,16 @@ //! ends (`0x76489b`, spliced at `0x76573e`) and so waits a tick, as does one linked into a level //! already walked; one linked into a level still ahead is updated there, after that level's //! older links. +//! +//! A frame's turn is its kind's update (`vtable+0x38`, `0x765710`): the `OnUpdate`, then, for a +//! kind with a [`KindUpdate`], that kind's engine work, so whatever it fires runs in the frame's +//! place, ahead of every frame walked after it. use std::collections::{BTreeMap, HashMap, HashSet}; -use crate::widget::{FrameHandle, FrameKind}; +use crate::widget::{FrameHandle, KindUpdate}; -use super::{editbox, event, UiScript}; +use super::{editbox, event, tooltip, UiScript}; /// A place in the walk: stratum, level, then within the level the frames linked before the walk /// began (0, by link stamp) ahead of those linked during it (1, by the order they linked). @@ -23,8 +27,7 @@ type Place = (u8, u16, u8, u64); impl UiScript { /// Run the walk: each frame's `OnUpdate(self, elapsed)` (`0x76b2c0`, reading the handler when - /// its turn comes), and for an EditBox its flush after it (`0x77a79a`, then `0x77a7a1`), so the - /// events an edit raised fire in the box's place. + /// its turn comes), then its kind's own update ([`Self::kind_update`]). pub(super) fn update_walk(&mut self, elapsed: f32) { let mut ahead: BTreeMap = BTreeMap::new(); let (renumbers, mut joins, mut seen) = { @@ -44,12 +47,12 @@ impl UiScript { .collect(); model.on_update_frames = live; } - // Only frames with an `OnUpdate` or an EditBox's flush do anything when walked. + // Only frames with an `OnUpdate` or a kind's own update do anything when walked. let mut seen = HashSet::new(); for &h in model .on_update_frames .iter() - .chain(model.arena.editbox_kinds()) + .chain(model.arena.walked_kinds()) { if let Some(f) = model.arena.frame(h).filter(|f| f.effective_visible) { let place = (f.strata as u8, f.level, 0, u64::from(f.insertion_seq)); @@ -64,7 +67,7 @@ impl UiScript { let mut relinked: HashMap = HashMap::new(); let mut links = 0u64; while let Some((place, h)) = ahead.pop_first() { - let (on_update, editbox) = { + let (on_update, kind_update) = { let mut model = self.model_mut(); let Some(f) = model.arena.frame(h) else { continue; @@ -75,21 +78,23 @@ impl UiScript { 0 => !relinked.contains_key(&h), _ => relinked.get(&h) == Some(&place.3), }; - let editbox = f.kind == FrameKind::EditBox; + // Fixed before the `OnUpdate` runs: one virtual call does both, so a frame that + // hides itself in its `OnUpdate` still takes its kind's update. + let kind_update = f.kind.kind_update().filter(|_| here); let on_update = here && model .scripts .get(&h) .is_some_and(|s| s.contains("OnUpdate")); - (on_update.then(|| model.frame_id(h)), here && editbox) + (on_update.then(|| model.frame_id(h)), kind_update) }; if let Some(id) = on_update { if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) { self.push_error(e); } } - if editbox { - editbox::update(&self.lua, h, elapsed); + if let Some(k) = kind_update { + self.kind_update(k, h, elapsed); } let mut model = self.model_mut(); for linked in model.arena.take_linked() { @@ -128,4 +133,17 @@ impl UiScript { } self.model_mut().arena.close_link_log(); } + + /// A kind's engine work after its `OnUpdate`, in its place in the walk. + fn kind_update(&mut self, kind: KindUpdate, h: FrameHandle, elapsed: f32) { + match kind { + // The flush (`0x77a7a1`), so the events an edit raised fire in the box's place, then + // the caret blink. + KindUpdate::EditBox => editbox::update(&self.lua, h, elapsed), + // The fade (`0x530b9c`), whose end hides the tooltip in its place. + KindUpdate::Tooltip => tooltip::update(&self.lua, h, elapsed), + // The scene clock (`0x76d843`), whose completions fire `OnAnimFinished` here. + KindUpdate::Model => self.advance_pane(h, elapsed), + } + } } diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 73d08f0b..8c562309 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -2,11 +2,16 @@ //! frame before anything runs, walks the live child list re-gating each child as the walk reaches //! it, and fires its own notify last, so handlers run inside the walk, children before their //! parent, and a frame an earlier handler hid is never notified. +//! +//! Both are virtual (slots `+0x88` and `+0x84`), and every caller reaches them through the slot: +//! the Lua `Show`/`Hide`, the parent's child walk (`0x76aed5`, `0x76adee`) and `SetParent` +//! (`0x76abfd`, `0x76ab41`). [`show`] and [`hide`] are that dispatch, so a kind's override runs +//! the same however the transition comes. use mlua::{Lua, Table, Value}; use super::{event, Model}; -use crate::widget::{ButtonState, ChildWalk, FrameHandle}; +use crate::widget::{ButtonState, ChildWalk, FrameHandle, FrameKind}; /// The Lua `Show`/`Hide` (`0x775750`/`0x775810`): write the shown bit (`0x7757f1`/`0x7758b1`), /// then dispatch the transition (`0x7757fb`/`0x7758bb`), which gates itself, so `Show()` on a @@ -23,9 +28,44 @@ pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) { } } -/// The show transition (`0x76ae10`, slot `+0x88`): the frame's own step, its children's -/// transitions in link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`). +/// The show slot (`+0x88`): the kind's override, else the base [`base_show`]. GameTooltip's is +/// `0x530a80`. CharacterModelBase's (`0x505d00`, which rebuilds the doll from its unit or file) +/// is not modelled. pub(super) fn show(lua: &Lua, h: FrameHandle) { + match kind(lua, h) { + Some(FrameKind::GameTooltip) => super::tooltip::show_override(lua, h), + _ => base_show(lua, h), + } +} + +/// The hide slot (`+0x84`): the kind's override, else the base [`base_hide`]. GameTooltip's is +/// `0x530a60`. CharacterModelBase's (`0x505ce0`, which releases the doll's model) is not +/// modelled. +pub(super) fn hide(lua: &Lua, h: FrameHandle) { + match kind(lua, h) { + Some(FrameKind::GameTooltip) => super::tooltip::hide_override(lua, h), + _ => base_hide(lua, h), + } +} + +fn kind(lua: &Lua, h: FrameHandle) -> Option { + lua.app_data_ref::() + .expect("model app_data") + .arena + .frame(h) + .map(|f| f.kind) +} + +/// The shown bit alone (`[+0xd0]`), which an override writes before it re-dispatches. +pub(super) fn set_shown_bit(lua: &Lua, h: FrameHandle, shown: bool) { + if let Some(f) = model_mut(lua).arena.frame_mut(h) { + f.shown = shown; + } +} + +/// The base show transition (`0x76ae10`): the frame's own step, its children's transitions in +/// link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`). +pub(super) fn base_show(lua: &Lua, h: FrameHandle) { { let mut model = model_mut(lua); if !model.arena.show_step(h) { @@ -45,9 +85,9 @@ pub(super) fn show(lua: &Lua, h: FrameHandle) { notify(lua, h, true); } -/// The hide transition (`0x76ad50`, slot `+0x84`): the frame's own step with its hover leave, -/// its children's transitions in link order, then its own OnHide (`0x76adfd`). -pub(super) fn hide(lua: &Lua, h: FrameHandle) { +/// The base hide transition (`0x76ad50`): the frame's own step with its hover leave, its +/// children's transitions in link order, then its own OnHide (`0x76adfd`). +pub(super) fn base_hide(lua: &Lua, h: FrameHandle) { let left = { let mut model = model_mut(lua); if !model.arena.hide_step(h) { diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index b30fab67..50578072 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -49,6 +49,37 @@ pub enum FrameKind { Minimap, } +/// A kind's own per-frame update: the `vtable+0x38` override the update walk calls in the frame's +/// place (`0x765710`), which fires the frame's `OnUpdate` (`0x76b2c0`) first and then does the +/// kind's engine work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KindUpdate { + /// `CSimpleEditBox`'s `0x77a790`: the flush, then the caret blink. + EditBox, + /// `CGameTooltip`'s `0x530b20`: the fade. + Tooltip, + /// `CSimpleModel`'s `0x76d7f0`, in the slot of every model kind's vtable (`0x81c608`, + /// `0x8084f8`, `0x8083a8`, `0x808260`): the scene clock and the completions it reports. + Model, +} + +impl FrameKind { + /// The kind's own update, run in the walk after the frame's `OnUpdate`. The reference + /// overrides the slot on more kinds; benilla runs the message frames' fades (`0x786200`, + /// `0x788460`) after the walk, and models none of the rest there. + pub fn kind_update(self) -> Option { + match self { + FrameKind::EditBox => Some(KindUpdate::EditBox), + FrameKind::GameTooltip => Some(KindUpdate::Tooltip), + FrameKind::Model + | FrameKind::PlayerModel + | FrameKind::DressUpModel + | FrameKind::TabardModel => Some(KindUpdate::Model), + _ => None, + } + } +} + /// The kind of a [`Region`] leaf, the client's `CScriptRegion`-derived non-frame objects. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum RegionKind { @@ -187,8 +218,10 @@ pub struct TooltipState { pub anchor: TooltipAnchor, /// `SetMinimumWidth`: a floor on the auto-sized width, cleared with the content. pub min_width: f32, - /// `FadeOut()`'s start on the `GetTime` clock; any fresh content or `Show` cancels the fade. - pub fade_start: Option, + /// `FadeOut()`'s countdown in seconds (`[+0x3b4]` armed, `[+0x3b8]` left): the tooltip's own + /// update counts it down by its elapsed, so it stands still while the tooltip is not walked. + /// Any fresh content or `Show` cancels it. + pub fade_left: Option, /// The guid of the unit this tooltip shows (`+0x368`/`+0x36c`, stored at `0x52a070`), whose /// health watcher (`0x467e70`, registered at `0x52a0a8`) drives the bar; a `set_unit` push of /// that unit, under any token, re-drives it. @@ -212,7 +245,7 @@ pub const TOOLTIP_LINE_GAP: f32 = 2.0; /// The least double-line column gap, the stock `TextRightN` offset; the reference's is untraced. pub const TOOLTIP_DOUBLE_GAP: f32 = 40.0; /// `FadeOut`'s ramp length in seconds; the reference's constant is untraced. -pub const TOOLTIP_FADE_SECS: f64 = 0.5; +pub const TOOLTIP_FADE_SECS: f32 = 0.5; /// A wrap-flagged line's width, pinned when it is appended; the reference's column is untraced. pub const TOOLTIP_WRAP_WIDTH: f32 = 260.0; diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index e82c7df5..3e532730 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -120,12 +120,13 @@ mod kinds; pub use kinds::{ model_key, slider_fraction, slider_grab, slider_set_value, ArmedSequence, ButtonFont, ButtonState, ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, - EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, - ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState, - ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipAnchor, TooltipState, - MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, - MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, - TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, + EditUnit, FrameKind, InsertMode, KindState, KindUpdate, MessageFrameState, MessageLine, + MinimapState, ModelFileFacts, ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, + ScrollFrameState, ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, + TooltipAnchor, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, + MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, + MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, + TOOLTIP_WRAP_WIDTH, }; // ── Frame and Region nodes ─────────────────────────────────────────────────────────────────── @@ -265,15 +266,18 @@ pub struct WidgetArena { pub(crate) order_cache: crate::order::OrderCache, /// Minimap frames ever created, so the state feed notices a new one without a walk. minimap_created: u64, - /// The live frames the host ticks (message fades, model scene clocks), so the tick skips the - /// rest of the arena. Added at creation, as kinds never change; `destroy` removes its own. + /// The live message frames and model panes, so the host's passes over them (message fades, + /// model paint) skip the rest of the arena. Added at creation, as kinds never change; + /// `destroy` removes its own. ticked_kinds: Vec, - /// The live GameTooltips, kept the same way, for the layout pre-pass, fade tick and unit push. + /// The live frames whose kind has a [`KindUpdate`], kept the same way: the update walk reaches + /// each one shown, with an `OnUpdate` or without. + walked_kinds: Vec, + /// The live GameTooltips, kept the same way, for the layout pre-pass and unit push. tooltip_kinds: Vec, /// The live Minimaps, kept the same way, for the containment, zoom and arrow-facing feeds. minimap_kinds: Vec, - /// The live EditBoxes, kept the same way, for the resize re-seat (`reseat_resized`) and the - /// tick's walk, which flushes every shown box. + /// The live EditBoxes, kept the same way, for the resize re-seat (`reseat_resized`). editbox_kinds: Vec, } @@ -318,6 +322,7 @@ impl WidgetArena { minimap_created: 0, order_cache: Default::default(), ticked_kinds: Vec::new(), + walked_kinds: Vec::new(), tooltip_kinds: Vec::new(), minimap_kinds: Vec::new(), editbox_kinds: Vec::new(), @@ -334,6 +339,11 @@ impl WidgetArena { &self.ticked_kinds } + /// The frames whose kind has an update of its own in the walk ([`FrameKind::kind_update`]). + pub fn walked_kinds(&self) -> &[FrameHandle] { + &self.walked_kinds + } + pub fn tooltip_kinds(&self) -> &[FrameHandle] { &self.tooltip_kinds } @@ -558,6 +568,9 @@ impl WidgetArena { if ticked { self.ticked_kinds.push(handle); } + if kind.kind_update().is_some() { + self.walked_kinds.push(handle); + } if matches!(kind, FrameKind::GameTooltip) { self.tooltip_kinds.push(handle); } @@ -627,6 +640,7 @@ impl WidgetArena { let name = frame.name.clone(); // Each recursive call removes its own handle, so the registries hold no dead frame. self.ticked_kinds.retain(|&t| t != h); + self.walked_kinds.retain(|&t| t != h); self.tooltip_kinds.retain(|&t| t != h); self.minimap_kinds.retain(|&t| t != h); self.editbox_kinds.retain(|&t| t != h);