From 710eb8f531986b2cafe01471e43e2f8f1c0fced3 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:36:25 +0700 Subject: [PATCH 01/60] Dev builds drop ThinLTO; dev and play build dependencies as one unit (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An incremental rebuild of `benilla-app` spent most of its time in ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and incremental compilation redoes it whole. `cargo play` already turns it off, which left its dependencies without cross-unit inlining. `[profile.dev]` now sets `lto = "off"`, and dev and play build dependencies as one codegen unit each. Measured on an M4 Max, one-constant edit in `benilla-app`, cpu_ms at the leg pin: | | before | after | |---|---|---| | `cargo build -p benilla` | 24.4 s, 5.2 GB | 5.6 s, 2.7 GB | | `cargo test -p benilla-app --lib --no-run` | 25.8 s, 7.0 GB | 8.3 s, 4.0 GB | | cold dev build | 5:19 | 4:06 | | dev cpu_ms | 11.06 | 11.71 | | play cpu_ms (release: 10.1) | 11.74 | 10.63 | Release and ship are unchanged. Fixes #852. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c046a0b6..66f9ead1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,11 +46,19 @@ kira = { path = "third_party/kira" } # Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps. [profile.dev.package."*"] opt-level = 3 +# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's +# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold +# build is no slower. +codegen-units = 1 # Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0 -# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0. +# Stormwind drops from a pinned 60 fps to ~45. [profile.dev] opt-level = 1 +# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an +# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its +# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin. +lto = "off" # No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache. @@ -71,3 +79,8 @@ incremental = true # Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols # unresolved and the link fails intermittently (rustc #86049). lto = "off" + +# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at +# the leg pin, at one the profile is +0.5, and a cold build is no slower. +[profile.play.package."*"] +codegen-units = 1 From 45da0a085065d43741717c9c3a82f6a85568d352 Mon Sep 17 00:00:00 2001 From: elipp <1861512+elipp@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:05:21 +0300 Subject: [PATCH 02/60] Optional feature to enable `bevy/dynamic_linking` in crates/benilla-app (#853) - Add bevy/dynamic_linking feature forward for benilla-app for faster dev builds - bevy_dynamic_linking says the binary starts only through cargo run Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Sam --- Cargo.lock | 10 ++++++++++ crates/benilla-app/Cargo.toml | 3 +++ crates/benilla/Cargo.toml | 2 ++ 3 files changed, 15 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 7afa1b73..f5c9af40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" dependencies = [ + "bevy_dylib", "bevy_internal", "tracing", ] @@ -1015,6 +1016,15 @@ dependencies = [ "sysinfo", ] +[[package]] +name = "bevy_dylib" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f" +dependencies = [ + "bevy_internal", +] + [[package]] name = "bevy_ecs" version = "0.18.1" diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 7dd1c49f..de8d45eb 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -16,6 +16,9 @@ dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:be tracy = ["bevy/trace_tracy"] # Writes every system's per-run span to `trace-.json` in the cwd. trace_chrome = ["bevy/trace_chrome"] +# Links Bevy as a shared library so an incremental rebuild relinks faster. The binary then starts +# only through `cargo run`, which puts the toolchain's libstd on the loader path. +bevy_dynamic_linking = ["bevy/dynamic_linking"] [dependencies] anyhow.workspace = true diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index 179cf1a0..d5af6336 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -15,6 +15,8 @@ dev = ["benilla-app/dev"] # Profiling switches, forwarded so `cargo run -p benilla --features tracy` works. tracy = ["benilla-app/tracy"] trace_chrome = ["benilla-app/trace_chrome"] +# Forwards to benilla-app's `bevy_dynamic_linking`, which enables bevy/dynamic_linking. +bevy_dynamic_linking = ["benilla-app/bevy_dynamic_linking"] [dependencies] # `default-features = false`, or `--no-default-features` here still builds benilla-app with `dev`. From ff49eb5b8fdd5d71565e7e87a1edae0fa798e189 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:32:43 +0700 Subject: [PATCH 03/60] UNIT_FACTION fires on the faction template's own edge (#858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A target whose faction template the server swaps live (35 to 14, neither naming a faction group) kept its friendly plate colour: `UNIT_FACTION` fired only when the pvp, FFA, faction-group or tapped state moved. The reference's per-field watch fires `UNIT_FACTION` on any change of `UNIT_FIELD_FACTIONTEMPLATE`, as it fires `UNIT_FLAGS` for that field. `fire_transitions` now also fires it on the faction template's edge. Test: `a_live_faction_template_change_recolours_the_target_plate`. From #855 by @kalistoking, landed here because the fork refuses maintainer pushes. Co-authored-by: kalistoking <148718830+kalistoking@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../src/ui_script/unit_frame_tests.rs | 51 +++++++++++++++++++ crates/benilla-app/src/ui_unit.rs | 18 +++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 9f759fef..843dacae 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -958,6 +958,57 @@ fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() { } } +/// A target whose faction template the server changes live recolours its plate: template 35 +/// (friendly) to 14 (hostile) names no side either way, so only the template's own edge fires +/// `UNIT_FACTION`. No edge, or another unit's, leaves the plate as it was. +#[test] +fn a_live_faction_template_change_recolours_the_target_plate() { + benilla_formats::wow_data_or_skip!(); + use crate::net::FieldEdges; + use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE; + + const MOB: u64 = 0xF130_0000_3796_0001; + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_unit_frames(&s); + let mob = |reaction: u8| UnitState { + exists: true, + has_object: true, + guid: MOB, + name: Some("Pusillin".into()), + health: 100, + max_health: 100, + level: 58, + reaction, + can_attack: reaction < 4, + ..UnitState::default() + }; + let is_green = |c: [f32; 4]| c[0].abs() < 1e-6 && (c[1] - 1.0).abs() < 1e-6; + let is_red = |c: [f32; 4]| (c[0] - 1.0).abs() < 1e-6 && c[1].abs() < 1e-6; + + let friendly = mob(5); + s.set_unit("target", Some(friendly.clone())); + s.fire_event("PLAYER_TARGET_CHANGED", vec![]); + let before = plate_color(&mut s); + assert!(is_green(before), "template 35 reads green, got {before:?}"); + + let hostile = mob(2); + s.set_unit("target", Some(hostile.clone())); + let fire = |s: &mut UiScript, prev: &UnitState, edges: &[(u64, u16)]| { + crate::ui_unit::fire_transitions(s, "target", Some(prev), &hostile, &FieldEdges::of(edges)); + }; + fire(&mut s, &friendly, &[]); + let stale = plate_color(&mut s); + assert!(is_green(stale), "no edge, no repaint, got {stale:?}"); + fire(&mut s, &hostile, &[(MOB + 1, FIELD_UNIT_FACTIONTEMPLATE)]); + let other = plate_color(&mut s); + assert!(is_green(other), "another unit's edge, got {other:?}"); + fire(&mut s, &hostile, &[(MOB, FIELD_UNIT_FACTIONTEMPLATE)]); + let after = plate_color(&mut s); + assert!(is_red(after), "template 14 reads red, got {after:?}"); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn /// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border), /// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it. diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 1f363c8f..a89fed08 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -1185,11 +1185,19 @@ pub(crate) fn fire_transitions( } // `UNIT_FACTION`, the PvP-icon repaint, on the fields the icon reads and on the tapped bit: the // reference's dynamic-flags watcher (`0x600440`) fires event 29 on bit `0x4` (`0x6005a1` → - // `0x6005b0`) and has no arm for `0x8`. - if prev.is_none_or(|p| { - (p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped) - != (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped) - }) { + // `0x6005b0`) and has no arm for `0x8`. Also on any change of `UNIT_FIELD_FACTIONTEMPLATE`, the + // watch bridge's id 29 (`0x51bbb0`, as for `UNIT_FLAGS` above): a template swap between two + // that name no faction group moves nothing in the tuple, yet changes the reaction. + let template_moved = edges.moved( + cur.guid, + benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE, + ); + if template_moved + || prev.is_none_or(|p| { + (p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped) + != (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped) + }) + { script.fire_event("UNIT_FACTION", vec![tok()]); } } From f356e90fe6c38b6fb865311f630816e45e826201 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:35:06 +0700 Subject: [PATCH 04/60] Addon files resolve case-insensitively, as on 1.12.1's Windows client (#860) An addon's Lua and XML files were read with the exact case its `.toc` or `"#, + ) + .unwrap(); + let mut script = UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + script + .run("Heard = {} seterrorhandler(function(m) table.insert(Heard, m) end)") + .unwrap(); + let failures = addon.load(&script, &mut Status::default()); + assert_eq!(failures.len(), 4, "{failures:#?}"); + script.dispatch_script_errors_to_handler(); + let heard: Vec = script.eval("return Heard").unwrap(); + assert_eq!(heard.len(), 4, "{heard:#?}"); + for m in &heard { + assert!( + !m.contains("runtime error") + && !m.contains("syntax error") + && !m.contains("stack traceback") + && !m.contains("OnLoad: ") + && !m.contains("Bare/") + && !m.contains('\n'), + "{m:?}" + ); + } + assert!( + heard[0].ends_with("top.lua:2: attempt to index local `t' (a nil value)"), + "{:?}", + heard[0] + ); + assert!( + heard[0].starts_with("Interface\\AddOns\\Bare\\top.lua"), + "{:?}", + heard[0] + ); + assert!( + heard[1].contains("syntax.lua:1:") && heard[1].contains("unexpected symbol"), + "{:?}", + heard[1] + ); + assert!( + heard[2].ends_with("attempt to call method `NoSuchMethod' (a nil value)"), + "{:?}", + heard[2] + ); + assert!( + heard[3].ends_with("attempt to index local `u' (a nil value)"), + "{:?}", + heard[3] + ); + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs index 9b22381a..b7d18fad 100644 --- a/crates/benilla-ui/src/loader/backdrop.rs +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -15,7 +15,7 @@ impl Loader<'_> { let table = match self.build_backdrop_table(bd) { Ok(t) => t, Err(e) => { - self.report.errors.push(format!("{dbg}: : {e}")); + self.report.fault(format!("{dbg}: : {e}")); return; } }; diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 84d3a756..94925791 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -146,9 +146,7 @@ impl Loader<'_> { let region: Table = match wrapper.call_method("CreateTitleRegion", ()) { Ok(r) => r, Err(e) => { - self.report - .errors - .push(format!("{dbg}: CreateTitleRegion: {e}")); + self.report.fault(format!("{dbg}: CreateTitleRegion: {e}")); return; } }; diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 238d4db6..2be04727 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -22,6 +22,33 @@ mod regions; mod scripts; mod widgets; +impl LoadReport { + /// A load fault that is no script raise: the handler hears the row's own text. + pub(super) fn fault(&mut self, text: String) { + self.handler_errors.push(text.clone()); + self.errors.push(text); + } + + /// A script raise: the row is `text`, the handler hears the bare Lua message of `e`. + pub(super) fn raise(&mut self, text: String, e: &mlua::Error) { + self.handler_errors.push(crate::script::lua_message(e)); + self.errors.push(text); + } + + /// Each of [`Self::errors`] with what the Lua error handler is called with for it. + pub fn raised(&self) -> impl Iterator { + debug_assert_eq!( + self.errors.len(), + self.handler_errors.len(), + "every error row is written through `fault` or `raise`" + ); + self.errors + .iter() + .map(String::as_str) + .zip(self.handler_errors.iter().map(String::as_str)) + } +} + /// The outcome of a [`load`]; nothing aborts a load, as the reference logs and continues. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LoadReport { @@ -31,6 +58,11 @@ pub struct LoadReport { /// Failures the reference raises on: a handler that fails to compile, a method call that /// errors, a malformed included document. pub errors: Vec, + /// What the Lua error handler is called with for each of [`Self::errors`], row for row + /// ([`Self::raised`]): the bare Lua message of a raise, as `lua_pcall` leaves it for the + /// reference's error function (`0x703b40`), and the row's own text for a fault that is no + /// script raise. + handler_errors: Vec, /// `` and `"#; + +/// A ScrollFrame's `LoadXML` (`0x786bc0`) runs the base load (`0x769820`), then builds its +/// `` through `Instantiate 0x6ee280` at `0x786bf4`: the child's whole lifecycle, +/// its own `` and OnLoad included, is over before the post-load hook `0x76a2f0` builds +/// the ScrollFrame's `` (`0x76a36f`) and fires its OnLoad. `SetScrollChild` (`0x786ce0`) +/// follows the child's return, so the child's own OnLoad sees none and `` sees it. +#[test] +fn a_scroll_child_is_built_before_the_frames_and_the_scroll_frames_onload() { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + register( + &s, + &format!( + r#"{LOG} + + + + + + Log("bar:" .. tostring(this:GetParent():GetScrollChild() ~= nil)) + + + + + + + + Log("inner") + + + Log("child:" .. tostring(this:GetParent():GetScrollChild() ~= nil)) + + + + Log("scroll:" .. tostring(this:GetScrollChild() == RollerChild)) + + + "# + ), + ); + + assert_eq!( + s.eval::("return Order").unwrap(), + "inner;child:false;bar:true;scroll:true;", + "the scroll child's subtree, then , then the ScrollFrame's own OnLoad" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The same order when the `` come from a template, which is how +/// `UIPanelScrollFrameTemplate`'s ScrollBar meets an instance's ``: the template's +/// `` are built by the post-load hook, after every `LoadXML` level (`0x76a060`). +#[test] +fn a_templates_frames_load_after_the_instances_scroll_child() { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + register( + &s, + &format!( + r#"{LOG} + + + + Log("bar") + + + Log("template-scroll") + + + + + + + + Log("child") + + + + "# + ), + ); + assert_eq!( + s.eval::("return Order").unwrap(), + "child;bar;template-scroll;", + "through inherits= in XML" + ); + assert!(s + .eval::("return ViaXml:GetScrollChild() == ViaXmlChild") + .unwrap()); + + // The runtime door builds through the same instantiator. + s.run( + r#"Order = "" + ViaLua = CreateFrame("ScrollFrame", "ViaLua", nil, "PanelScrollTemplate") + local c = CreateFrame("Frame", "ViaLuaChild", ViaLua) + ViaLua:SetScrollChild(c)"#, + ) + .unwrap(); + assert_eq!( + s.eval::("return Order").unwrap(), + "bar;template-scroll;" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A `` the template declares is built by the template's own `LoadXML` level +/// (`0x76985c` re-enters the virtual `LoadXML`, which is `0x786bc0` for a ScrollFrame), so it is +/// also ahead of ``; an instance's own follows it and wins `SetScrollChild`. +#[test] +fn a_template_scroll_child_loads_ahead_of_the_frames_and_the_instances_wins() { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + register( + &s, + &format!( + r#"{LOG} + + + + Log("bar") + + + + + Log("from-template") + + + + + + + Log("from-instance") + + + + "# + ), + ); + assert_eq!( + s.eval::("return Order").unwrap(), + "from-template;from-instance;bar;" + ); + assert!(s + .eval::("return Both:GetScrollChild() == BothFromInstance") + .unwrap()); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} 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 11/60] 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); From a1130c693a0f03c51e9f8abaaedbc85adbd8d1eb Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:37:01 +0700 Subject: [PATCH 12/60] Mouse disable drops the press; a chorded press goes to the held frame (#899) `EnableMouse(false)` only flipped the bit, so a frame disabled while pressed still got OnMouseUp and OnDragStart, and a hovered one kept its hover. A second mouse button pressed while one was held fired on the frame under the cursor rather than the held one. In the reference, a mouse disable on a visible frame runs the same removal a hide does: the capture and the press drop, the hover ends with OnLeave at once. The down handler takes the held capture first and fires there, and a Button clicks on release only while PUSHED and with the cursor inside its own rect. `EnableMouse` now runs that shared removal, the drag arm included. Presses go to the capture when one is held, and the Button release tests pushed and inside rather than topmost. Tests cover a disable while pressed or hovered, chorded presses and the release gate. Fixes #886 Fixes #887 Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-ui/src/script/button.rs | 9 + crates/benilla-ui/src/script/model.rs | 6 +- .../src/script/object/frame_state.rs | 5 +- crates/benilla-ui/src/script/pointer.rs | 164 +++++--- crates/benilla-ui/src/script/tests/capture.rs | 365 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/visibility.rs | 39 +- 7 files changed, 521 insertions(+), 68 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/capture.rs diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 528c6d2e..ddf7db6c 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -920,6 +920,15 @@ pub(super) fn wants_click(model: &Model, h: FrameHandle, name: &str) -> bool { } } +/// The state half of the release click gate (`0x7792fc`): a Button clicks only while PUSHED, which +/// the press art sets and a drag start, a hide or a lost press clears; any other kind has no state. +pub(super) fn release_may_click(model: &Model, h: FrameHandle) -> bool { + match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Button(bs)) => bs.button_state() == ButtonVisualState::Pushed, + _ => true, + } +} + /// Whether `h` is registered for `button` (`"LeftButton"`, …) as Up or Down, the press-art gate. /// `OnMouseDown` (`0x779210`) tests `m | m << 8` against `[this+0x330]` (Down in byte 0, Up in /// byte 1, `0x77924b`) before the Down-only `OnClick` test (`0x77926b`), so any registration for diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 57cece19..de900de0 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -209,9 +209,9 @@ pub(crate) struct Model { /// reference's `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into /// `0x7660d0`). pub(crate) hover_repick: bool, - /// Per button held, the frame its press landed on, for `OnClick`'s same-frame release test; - /// `None` once that frame hid (the removal tail emptied the capture), so the release has no - /// target but the button still counts as held. + /// Per button held, the frame its press went to, the capture else the hover (`0x7662c0`), for + /// the release; `None` once that frame left the mouse index (hide or mouse disable emptied + /// the capture), so the release has no target but the button still counts as held. pub(crate) mouse_down_on: HashMap>, /// The client's one mouse-capture slot (`root+0x80`), read by the mouse-down raise. Set at /// `0x7663e6` to the capture, else the hovered frame, so a chorded press keeps the capture; diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 1b8cd2ce..b0f2ff43 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -590,10 +590,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { "EnableMouse", lua.create_function(|lua, (this, enable): (Table, bool)| { let h = frame_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .arena - .set_mouse_enabled(h, enable); + visibility::set_mouse_enabled(lua, h, enable); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 6de75ad2..d20d99e2 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -7,6 +7,8 @@ //! in the order of the hover walk (`0x7661cd` in `0x7660d0`), which [`crate::order::hit_test`] //! implements. +use std::collections::HashMap; + use mlua::Value; use crate::layout::Rect; @@ -107,11 +109,7 @@ impl UiScript { // ground-targeted spell is armed a plate refuses the hit before its rect is // tested, so the reticle is placed through it. && !model.nameplates.vetoes(fh) - && model.resolved.get(&fh).is_some_and(|r| { - point_in_rect(hit_rect(&model, fh, *r), x, y) - }) - && effective_clip(&model, &scroll_sources, fh) - .is_none_or(|c| point_in_rect(c, x, y)) + && frame_contains(&model, &scroll_sources, fh, x, y) }) } @@ -263,32 +261,56 @@ impl UiScript { /// the button name. Returns whether the UI consumed the event. pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool { let hit_id = self.hit_test(x, y); + // ── The press target: the capture, else the hover ────────────────────────────────────── + // + // The press handler `0x7662c0` takes `root+0x80` first (`0x76637f`) and the hover + // `root+0x7c` only when none is held (`0x766389`); everything after, the raise, the + // title-region test, the capture store and the press slot (`vt+0x68`: `OnMouseDown`, the + // drag arm, a Button's press art and Down click), runs on that one frame. So a second + // button pressed over another frame fires on the held one. `mouse_down_on` keeps the + // frame per button for the release. + let press_target: Option = if down { + let mut model = self.model_mut(); + // A press for a button still held means its release never came (a focus loss); the + // reference's capture never loses one. That frame gets the release edge so it does not + // stay pushed, and with no other button down the stale capture goes too, so this + // press is not aimed at it. + if let Some(stale) = model.mouse_down_on.remove(button).flatten() { + button::edge(&mut model, stale, ButtonState::on_mouse_up); + } + if model.mouse_down_on.is_empty() { + model.mouse_capture = None; + } + let held = model + .mouse_capture + .filter(|&c| model.arena.frame(c).is_some()); + held.or_else(|| hit_id.and_then(|id| model.id_to_frame.get(&id).copied())) + } else { + None + }; + let press_id: Option = + press_target.and_then(|h| self.model_ref().frame_to_id.get(&h).copied()); // ── The raise, first ──────────────────────────────────────────────────────────────────── // - // The press handler `0x7662c0` raises the capture (`root+0x80`) else the hover frame - // (`root+0x7c`) through `0x76a5b0`, called unguarded at `0x766392` (the toplevel walk is - // inside `0x7650f0`), before the title-region test: a click on a child or a title bar - // brings its window forward, and a chorded press raises the window being dragged. The - // release handler `0x766420` never raises. - if down { - let target = self.model_ref().mouse_capture; - if let Some(t) = target.or_else(|| self.hit_test_frame(x, y)) { - let mut model = self.model_mut(); - super::object::toplevel::raise(&mut model, t); - } + // `0x76a5b0` is called unguarded at `0x766392` (the toplevel walk is inside `0x7650f0`), + // before the title-region test: a click on a child or a title bar brings its window + // forward, and a chorded press raises the window being dragged. The release handler + // `0x766420` never raises. + if let Some(t) = press_target { + let mut model = self.model_mut(); + super::object::toplevel::raise(&mut model, t); } // ── The title region swallows the press ───────────────────────────────────────────────── // - // A press inside the title region starts a mode-2 move and ends there: `0x7662c0` tests - // the region first and only a miss reaches `0x7663e6`, the capture and `OnMouseDown`. So - // no `OnMouseDown`, drag arm, `OnClick` or double-click stamp, and the event is consumed. - if down { - if let Some(h) = self.hit_test_frame(x, y) { - if self.title_region_hit(h, x, y) { - self.model_mut().cursor_pos = (x, y); - crate::script::object::movable::start_title_move(&mut self.model_mut(), h); - return true; - } + // A press inside the target's title region starts a mode-2 move and ends there: + // `0x7662c0` tests the region first and only a miss reaches `0x7663e6`, the capture and + // `OnMouseDown`. So no `OnMouseDown`, drag arm, `OnClick` or double-click stamp, and the + // event is consumed. + if let Some(h) = press_target { + if self.title_region_hit(h, x, y) { + self.model_mut().cursor_pos = (x, y); + crate::script::object::movable::start_title_move(&mut self.model_mut(), h); + return true; } } // ── The release ends a title move ─────────────────────────────────────────────────────── @@ -327,6 +349,7 @@ impl UiScript { return hit_id.is_some(); } }; + let mut press_art: Option = None; #[allow(clippy::type_complexity)] let ( click_id, @@ -348,48 +371,45 @@ impl UiScript { let mut model = self.model_mut(); let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied()); if down { - let displaced = match hit_handle { + match press_target { Some(h) => model.mouse_down_on.insert(button.to_string(), Some(h)), None => model.mouse_down_on.remove(button), - } - .flatten(); - // The press edge, `SetButtonState(PUSHED)` (`0x7792b1`), for a button registered - // for this mouse button up or down (`0x779210` tests `m | m << 8` at `0x77924b`). - if let Some(h) = hit_handle.filter(|&h| button::wants_press_visual(&model, h, button)) - { - button::edge(&mut model, h, ButtonState::on_mouse_down); - } - // A press while this mouse button still holds another frame means a release was - // lost (a focus loss); that frame gets it so it does not stay pushed. The - // reference's capture never loses one. - if let Some(h) = displaced { - button::edge(&mut model, h, ButtonState::on_mouse_up); - } - // `0x7663e6` sets the capture `root+0x80` only when none is held: a second button - // pressed elsewhere does not displace it. - model.mouse_capture = model.mouse_capture.or(hit_handle); + }; + // The Button press slot `0x779210` runs both legs only with the cursor inside the + // button (`0x77925c`) and the mouse button registered for it (`0x77924b` tests + // `m | m << 8`): the Down click for `…ButtonDown`, then the press edge, + // `SetButtonState(PUSHED)` (`0x7792b1`), for either registration, applied below + // after the scripts as the slot orders them. + let sources = scroll_clip_sources(&model); + let inside = press_target.filter(|&h| { + button::wants_press_visual(&model, h, button) + && frame_contains(&model, &sources, h, x, y) + }); + press_art = inside; + // `0x7663e9` stores the target as the capture: the one held, else the hover. + model.mouse_capture = press_target; // A press replaces any in-flight gesture; a started one gets its `OnDragStop` // below, outside this borrow. let abandoned = cursor::abandon_drag(&mut model); - cursor::arm_drag(&mut model, hit_handle, button, (x, y)); + cursor::arm_drag(&mut model, press_target, button, (x, y)); // A left press on a Slider grabs the thumb, or on the track seats the thumb under // the cursor first (the returned value jump). let jump = if button == "LeftButton" { - super::slider::begin_drag(&mut model, hit_handle, x, y) + super::slider::begin_drag(&mut model, press_target, x, y) } else { None }; // A left press in a ColorSelect's wheel or value strip also jumps the colour to the // click point: its press handler calls its cursor handler (`0x78bf70`). let color_jump = if button == "LeftButton" { - super::colorselect::begin_drag(&mut model, hit_handle, x, y) + super::colorselect::begin_drag(&mut model, press_target, x, y) } else { None }; let wants = format!("{button}Down"); - let click = hit_handle + let click = inside .filter(|&h| button::wants_click(&model, h, &wants)) - .and(hit_id); + .and(press_id); (click, None, false, jump, None, abandoned, color_jump) } else { let entry = model.mouse_down_on.remove(button); @@ -406,12 +426,15 @@ impl UiScript { super::slider::end_drag(&mut model); super::colorselect::end_drag(&mut model); } - let same_frame = matches!( - (pressed, hit_handle), - (Some(p), Some(released)) if p == released - ); let release = cursor::take_drag(&mut model, button); let started = release.as_ref().is_some_and(|r| r.started); + // The Button release slot `0x7792d0` clicks only a PUSHED button (`0x7792fc`) with + // the cursor inside its own rect (`0x779335`), not "the topmost frame under the + // cursor", read before the release edge below un-pushes it. + let sources = scroll_clip_sources(&model); + let may_click = pressed.filter(|&h| { + button::release_may_click(&model, h) && frame_contains(&model, &sources, h, x, y) + }); // The release edge, `SetButtonState(NORMAL)` (`0x7793de`), on the pressed frame // wherever the cursor is, skipped after a started drag (`0x7792df` tests // `0x76c040`), which already un-pressed it. @@ -431,16 +454,16 @@ impl UiScript { let click = if started { None } else { - hit_handle - .filter(|&h| same_frame && button::wants_click(&model, h, &wants)) - .and(hit_id) + may_click + .filter(|&h| button::wants_click(&model, h, &wants)) + .and_then(|h| model.frame_to_id.get(&h).copied()) }; // The double click, on the release that qualified as a click: `0x77938d` in the // mouse-up dispatcher `0x7792d0` fires `OnDoubleClick` instead of the second // `OnClick` (`0x77939d jmp 0x7793b5` skips the single leg), only for a frame with // an `OnDoubleClick` script (`[+0x4d4] != 0`). What suppresses the click // suppresses it too. - let target = click.and(hit_handle); + let target = click.and(may_click); let double = target.filter(|&h| { model .scripts @@ -488,7 +511,7 @@ impl UiScript { // that captured nothing fires nothing (`0x766498`). A resize grip dragged out from under // the cursor still gets its `OnMouseUp`, so its resize ends. let script = if down { "OnMouseDown" } else { "OnMouseUp" }; - if let Some(id) = if down { hit_id } else { captured_id } { + if let Some(id) = if down { press_id } else { captured_id } { if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) { self.push_error(e); } @@ -534,7 +557,7 @@ impl UiScript { // whatever its `autoFocus`, before any `OnClick` (`0x77b800`). if button == "LeftButton" { if down { - if let Some(id) = hit_id { + if let Some(id) = press_id { editbox::click(&self.lua, id, x, y); } } else { @@ -561,6 +584,12 @@ impl UiScript { // before `OnClick` fires. button::click_button(&self.lua, id, button, down, false); } + // The press art last (`0x7792b1` follows the Down click `0x779285`): `OnMouseDown` + // (`0x76bf70`, at the top of the slot) and a Down `OnClick` see the button unpushed, and + // the release's pushed gate reads what they left. + if let Some(h) = press_art { + button::edge(&mut self.model_mut(), h, ButtonState::on_mouse_down); + } // `OnDoubleClick(self, button)` in place of `OnClick` (`0x77938d call [edx+0x98]`), with // the same click sound: its firer `0x779650` is the twin of `OnClick`'s `0x779540` and // passes only the button name of the completing release. @@ -632,6 +661,23 @@ impl UiScript { } } +/// The probe `0x76b020` alone: whether `(x, y)` is inside the frame's hit rect and its effective +/// ScrollFrame clip, whatever its mouse bit. The Button click legs test it against the capture +/// (`0x77925c`, `0x779335`). +fn frame_contains( + model: &Model, + scroll_sources: &HashMap, + fh: FrameHandle, + x: f32, + y: f32, +) -> bool { + model + .resolved + .get(&fh) + .is_some_and(|r| point_in_rect(hit_rect(model, fh, *r), x, y)) + && effective_clip(model, scroll_sources, fh).is_none_or(|c| point_in_rect(c, x, y)) +} + /// Point-in-rect in UI space (y-up), inclusive on all four edges, as the reference's probe /// `0x76b020` tests it. pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool { diff --git a/crates/benilla-ui/src/script/tests/capture.rs b/crates/benilla-ui/src/script/tests/capture.rs new file mode 100644 index 00000000..10b5ce7f --- /dev/null +++ b/crates/benilla-ui/src/script/tests/capture.rs @@ -0,0 +1,365 @@ +//! The mouse capture (`root+0x80`): what drops it, and where a press goes while one is held. + +use super::common::script; +use crate::script::UiScript; + +/// Two side-by-side mouse frames A (x 100..300) and B (x 400..600), both y 200..400, each logging +/// `name:down: