From 0f505a2ebffcff7ed83dc3b2d2db4c846db9842d Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:08:07 +0700 Subject: [PATCH] RegisterEvent ignores case; a frame's first resize precedes OnLoad (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `RegisterEvent` and `UnregisterEvent` match a name against the client's event table case-insensitively and drop a name that isn't in it (`0x702140`). A new gate checks that every event name the code fires is in the table. - A visible XML frame resolves its layout at the post-load hook (`0x76a310` → `0x7680e0(1)`). So its first `OnSizeChanged` fires before its ``, `OnLoad` and `OnShow`, as in 1.12.1. A hidden frame still waits for its show. - `pending_events`' doc now says what the queue is: `SignalEvent` (`0x703e50`) runs handlers in place, and the Lua verbs that still queue are a gap. - #890 was already fixed by #907; two stale comments around it are corrected. Fixes #959 Fixes #972 Fixes #560 Closes #890 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../src/ui_script/basic_controls_tests.rs | 4 +- .../src/ui_script/event_shape_gate.rs | 32 ++++++++ crates/benilla-ui/src/loader/tests.rs | 81 +++++++++++++++++++ crates/benilla-ui/src/script/glue.rs | 46 ++++++++--- crates/benilla-ui/src/script/model.rs | 40 +++++++-- crates/benilla-ui/src/script/object.rs | 3 +- crates/benilla-ui/src/script/tests/events.rs | 79 +++++++++++++----- .../src/script/tests/glue_surface.rs | 3 +- .../benilla-ui/src/script/tests/host_state.rs | 4 +- crates/benilla-ui/src/script/tests/mod.rs | 14 ++-- crates/benilla-ui/src/script/visibility.rs | 38 ++++++--- 11 files changed, 279 insertions(+), 65 deletions(-) diff --git a/crates/benilla-app/src/ui_script/basic_controls_tests.rs b/crates/benilla-app/src/ui_script/basic_controls_tests.rs index 5f5a4f69..e8773951 100644 --- a/crates/benilla-app/src/ui_script/basic_controls_tests.rs +++ b/crates/benilla-app/src/ui_script/basic_controls_tests.rs @@ -90,9 +90,9 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() { "reported" ); // An engine-caught error still lands in `errors()`; the dispatch adds the dialog on top. - s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('ERROR_PROBE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)") + s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('PLAYER_ALIVE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)") .unwrap(); - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); assert!( s.errors().iter().any(|e| e.contains("fault")), "engine-caught errors stay on the host channel: {:?}", diff --git a/crates/benilla-app/src/ui_script/event_shape_gate.rs b/crates/benilla-app/src/ui_script/event_shape_gate.rs index 63579a9b..1413dc5e 100644 --- a/crates/benilla-app/src/ui_script/event_shape_gate.rs +++ b/crates/benilla-app/src/ui_script/event_shape_gate.rs @@ -253,6 +253,38 @@ fn every_event_we_fire_carries_the_reference_s_arguments() { ); } +/// The in-game event table (`0x703d90`, name array `0xbe1198`) holds the names of +/// `reference/1.12-event-catalog.tsv` and the glue table the 26 of `GLUE_EVENTS`; `RegisterEvent` +/// drops any other name (`0x702140`), so an event fired under a name off both reaches no frame +/// that registered it by name. +#[test] +fn every_event_we_fire_is_in_the_reference_table() { + let tsv = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-event-catalog.tsv" + ); + let text = std::fs::read_to_string(tsv).expect("reference/1.12-event-catalog.tsv"); + let names: std::collections::BTreeSet<&str> = text + .lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('\t').map(|(_, n)| n)) + .chain(benilla_ui::script::GLUE_EVENTS) + .collect(); + assert!(names.len() > 390, "the catalog stopped being read"); + let mut off: Vec = fire_sites() + .into_iter() + .filter(|(name, _, _)| !names.contains(name.as_str())) + .map(|(name, _, at)| format!("{name} from {at}")) + .collect(); + off.sort(); + off.dedup(); + assert!( + off.is_empty(), + "fired under a name the reference's event table lacks:\n {}", + off.join("\n ") + ); +} + /// `0x51bbb0` registers one watch per named unit-window field, so each of the 25 named event ids /// below `0xb6` comes from that bridge with the unit token, and each needs a producer here. #[test] diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 8d08c78a..0d24483e 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -2775,6 +2775,87 @@ mod post_load_show_tests { assert!(s.errors().is_empty(), "{:?}", s.errors()); } + /// The pre-load hook defers the frame's layout (`0x7697cc`) and the post-load hook undefers a + /// visible one first of all (`0x76a310` → `0x768060(0)` → `0x7680e0(1)`), so its rect applies + /// and fires `OnSizeChanged` (`0x76b580`) before its `` are built (`0x76a36f`), its + /// `OnLoad` (`0x76a387`) and its `OnShow` (`0x76a3b3`); a child's own hook does the same + /// inside, so the order is parent size, child size, child load, parent load. + #[test] + fn a_visible_frames_first_onsizechanged_fires_before_its_children_and_onload() { + let mut s = UiScript::new().unwrap(); + s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end") + .unwrap(); + let doc = parse( + r#" + + + + + Log(SizedBornChild and "size+child" or "size") + Log("load") + Log("show") + + + + + + + Log("child-size") + Log("child-load") + + + + + "#, + ); + let report = load_in(&s, &doc, "Test.xml", &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + let at_load = s.eval::("return table.concat(LOG, ',')").unwrap(); + s.resolve(); + let after_drain = s.eval::("return table.concat(LOG, ',')").unwrap(); + assert_eq!( + at_load, "size,child-size,child-load,load,show", + "fired inside the load, not at the next drain" + ); + assert_eq!(after_drain, at_load, "and not again"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// A frame hidden at the end of its load stays deferred (`0x76a2f0` undefers only a visible + /// one), so its first `OnSizeChanged` is its first show's, ahead of its `OnShow`. + #[test] + fn a_hidden_frames_first_onsizechanged_waits_for_its_show() { + let mut s = UiScript::new().unwrap(); + s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end") + .unwrap(); + let doc = parse( + r#" + + + + + Log("size") + Log("load") + Log("show") + + + "#, + ); + let report = load_in(&s, &doc, "Test.xml", &no_files); + assert!(report.errors.is_empty(), "{:?}", report.errors); + s.resolve(); + assert_eq!( + s.eval::("return table.concat(LOG, ',')").unwrap(), + "load" + ); + s.run("SizedHidden:Show()").unwrap(); + assert_eq!( + s.eval::("return table.concat(LOG, ',')").unwrap(), + "load,size,show" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + /// The post-load OnShow is keyed on the frame's visibility at `0x76a38c`, and nothing records /// an OnShow already given: a frame first shown by its own OnLoad gets that `Show()`'s OnShow /// and then the hook's, while a frame still hidden gets none. diff --git a/crates/benilla-ui/src/script/glue.rs b/crates/benilla-ui/src/script/glue.rs index 1e5c0cd1..44bec0db 100644 --- a/crates/benilla-ui/src/script/glue.rs +++ b/crates/benilla-ui/src/script/glue.rs @@ -57,21 +57,45 @@ pub const GLUE_EVENTS: [&str; 26] = [ "PLAYER_ENTER_PIN", ]; +/// The in-game event table: the 549-slot name array `UI_Init` loads (`0x703d90` at `0x48feab`), +/// `reference/1.12-event-catalog.tsv`. The 130 slots the client never writes stay nameless, and +/// `0x703d90` skips a null name (`0x703e0c`), so the 419 names here are all it holds. +const IN_GAME_EVENT_TABLE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-event-catalog.tsv" +)); + +/// The in-game table's distinct names, in id order. +pub(crate) fn in_game_events() -> &'static [&'static str] { + static NAMES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let mut names: Vec<&'static str> = Vec::new(); + for line in IN_GAME_EVENT_TABLE.lines() { + // `idNAME`; a name two slots share (`UNIT_PET`) is one event to a name match. + let Some((_, name)) = line.split_once('\t') else { + continue; + }; + if !line.starts_with('#') && !names.contains(&name) { + names.push(name); + } + } + names + }); + &NAMES +} + impl VmKind { /// The event table's own spelling of `name` for `RegisterEvent`/`UnregisterEvent` (`0x702140`, /// `0x702280`), or `None` for a name the table lacks, which registers nothing. /// - /// The in-game VM answers every name as given: benilla's in-game events register by name and - /// carry no table, so a name off the reference's table registers and never fires, and only the - /// exact spelling matches. - pub(crate) fn event_key(self, name: &str) -> Option<&str> { - match self { - VmKind::InGame => Some(name), - VmKind::Glue => GLUE_EVENTS - .iter() - .copied() - .find(|e| e.eq_ignore_ascii_case(name)), - } + /// The reference compares the name with every table entry's, ASCII case folded (`0x64a4c0` → + /// `0x414310`), and a miss returns without an error. A name two slots share registers on both, + /// which a name-keyed listener list is one entry for. + pub(crate) fn event_key(self, name: &str) -> Option<&'static str> { + let table: &'static [&'static str] = match self { + VmKind::InGame => in_game_events(), + VmKind::Glue => &GLUE_EVENTS, + }; + table.iter().copied().find(|e| e.eq_ignore_ascii_case(name)) } /// Whether the host may fire `event` into this VM. The reference fires by id diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 1ef56e46..0a9e0fea 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1019,8 +1019,21 @@ pub(crate) struct Model { pub(crate) chat_bubbles: super::chat_bubble::ChatBubbles, /// The world-state readout's rows, gated and resolved by the app. pub(crate) worldstate: super::worldstate::WorldStateUiState, - /// Events bindings raise. Deviation: fired at the next tick, not synchronously as in the - /// reference, because a binding runs inside Lua and cannot re-enter handler dispatch. + /// Events raised outside a dispatch, fired at the start of the next tick ahead of its `OnUpdate` + /// pass (`UiScript::tick`). The reference queues nothing here: a fire site calls + /// `SignalEvent` (`0x703e50`, `0x703f50`), which runs every handler before it returns, so a + /// verb's event reaches its listeners inside the call. + /// + /// A binding can do the same: `tick::fire_event_into` re-enters dispatch from inside Lua, as + /// `SetCVar`'s `CVAR_UPDATE` does, once the binding holds no model borrow. So the queue is no + /// limit, and a Lua verb that pushes here instead is a gap against the reference: the cursor + /// verbs' `CURSOR_UPDATE`, grid, `ITEM_LOCK_CHANGED`, `DELETE_ITEM_CONFIRM` and + /// `PLAYER_MONEY`, the send-mail verbs' events, `SetGuildRecruitmentMode`'s + /// `UPDATE_CHAT_WINDOWS`, `SetBattlefieldScoreFaction`'s `UPDATE_BATTLEFIELD_SCORE`, + /// `UpdateInventoryAlertStatus`'s `UPDATE_INVENTORY_ALERTS`, `CancelAreaSpiritHeal`'s + /// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` and the trainer verbs' `TRAINER_UPDATE`. The host's own + /// setters (the world map's, the world-state rows, a dropped mail attachment) and + /// `UiScript::queue_event` have no verb to fire inside and queue by design. pub(crate) pending_events: Vec<(String, Vec)>, /// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`. pub(crate) cursor_pos: (f32, f32), @@ -2082,24 +2095,35 @@ impl Model { /// The post-load hook's step (`0x76a2f0`): a visible frame undefers, with its title region and /// each region it draws (`0x76a310`, `0x76a324`, `0x76a35b`). A hidden one stays deferred. - pub(crate) fn layout_load_end(&mut self, h: FrameHandle) { + /// Whether any owes a resolve now, as [`Self::layout_on_show_frame`]; the caller runs it + /// before the frame's `` are built. + pub(crate) fn layout_load_end(&mut self, h: FrameHandle) -> bool { let Some(f) = self.arena.frame(h) else { - return; + return false; }; if !f.effective_visible { - return; + return false; } let title = f.title_region; let regions: Vec = f.regions.clone(); - self.undefer_frame(h); + let mut owed = self.undefer_frame(h); if let Some(t) = title { - self.undefer_region(t); + owed |= self.undefer_region(t); } for rh in regions { if Some(rh) != title && self.region_draws(rh) { - self.undefer_region(rh); + owed |= self.undefer_region(rh); } } + owed || !self.layout_pass.is_empty() + } + + /// The layout ids [`Self::layout_load_end`] undeferred: the frame, its title region and the + /// regions it draws. + pub(crate) fn load_end_roots(&self, h: FrameHandle) -> Vec { + let mut out = self.show_frame_roots(h); + out.extend(self.show_region_roots(h)); + out } /// The region's own shown bit (`+0xc4`): not hidden, and linked to its owner. diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 49bf2737..35757b69 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -643,7 +643,8 @@ pub(super) fn create_frame( // The builder's pre-load and post-load hooks (`0x6ee4d6`, `0x6ee4ea`): a frame born hidden // is deferred, as a hidden parent leaves it. A template's load opens its own window. model.layout_load_begin(h); - model.layout_load_end(h); + // No handler is bound yet, so the resolve this owes fires nothing. + let _ = model.layout_load_end(h); id }; diff --git a/crates/benilla-ui/src/script/tests/events.rs b/crates/benilla-ui/src/script/tests/events.rs index 7be96f5f..c1fe31c6 100644 --- a/crates/benilla-ui/src/script/tests/events.rs +++ b/crates/benilla-ui/src/script/tests/events.rs @@ -47,12 +47,12 @@ fn globals_are_restored_after_firing_nesting_safe() { r#" this, event, arg1 = "outer_this", "outer_event", "outer_arg1" local f = CreateFrame("Frame", "NF") - f:RegisterEvent("E") + f:RegisterEvent("PLAYER_LOGIN") f:SetScript("OnEvent", function() end) "#, ) .unwrap(); - s.fire_event("E", vec![ScriptValue::Str("x".into())]); + s.fire_event("PLAYER_LOGIN", vec![ScriptValue::Str("x".into())]); // `0x704f10` sets the globals for the handler and restores the prior values after. let (t, e, a): (String, String, String) = s.eval("return this, event, arg1").unwrap(); assert_eq!( @@ -67,12 +67,12 @@ fn handler_errors_are_collected_not_panicked() { s.run( r#" local f = CreateFrame("Frame", "BoomF") - f:RegisterEvent("E") + f:RegisterEvent("PLAYER_LOGIN") f:SetScript("OnEvent", function() error("boom") end) "#, ) .unwrap(); - s.fire_event("E", vec![]); + s.fire_event("PLAYER_LOGIN", vec![]); let errs = s.errors(); assert_eq!(errs.len(), 1, "{errs:?}"); assert!(errs[0].contains("boom"), "{errs:?}"); @@ -89,28 +89,67 @@ fn events_fire_in_registration_order_fifo() { local a = CreateFrame("Frame", "FA") local b = CreateFrame("Frame", "FB") local c = CreateFrame("Frame", "FC") - a:RegisterEvent("E"); b:RegisterEvent("E"); c:RegisterEvent("E") + a:RegisterEvent("PLAYER_LOGIN"); b:RegisterEvent("PLAYER_LOGIN"); c:RegisterEvent("PLAYER_LOGIN") a:SetScript("OnEvent", function() order = order .. "A" end) b:SetScript("OnEvent", function() order = order .. "B" end) c:SetScript("OnEvent", function() order = order .. "C" end) "#, ) .unwrap(); - s.fire_event("E", vec![]); + s.fire_event("PLAYER_LOGIN", vec![]); assert_eq!(s.eval::("return order").unwrap(), "ABC"); // A duplicate registration keeps A's position. - s.run("FA:RegisterEvent('E'); order = ''").unwrap(); - s.fire_event("E", vec![]); + s.run("FA:RegisterEvent('PLAYER_LOGIN'); order = ''") + .unwrap(); + s.fire_event("PLAYER_LOGIN", vec![]); assert_eq!(s.eval::("return order").unwrap(), "ABC"); // Unregistering frees B's node, so re-registering appends it at the tail. - s.run("FB:UnregisterEvent('E'); FB:RegisterEvent('E'); order = ''") + s.run("FB:UnregisterEvent('PLAYER_LOGIN'); FB:RegisterEvent('PLAYER_LOGIN'); order = ''") .unwrap(); - s.fire_event("E", vec![]); + s.fire_event("PLAYER_LOGIN", vec![]); assert_eq!(s.eval::("return order").unwrap(), "ACB"); } +/// `RegisterEvent` (`0x774a40` → `0x702140`) walks the in-game event table with an ASCII +/// case-folding compare (`0x64a4c0` → `0x414310`): a name in any case registers that event, the +/// handler's `event` is the table's spelling, and a name the table lacks matches nothing and +/// raises nothing. `UnregisterEvent` (`0x702280`) compares the same way. +#[test] +fn register_event_matches_the_table_case_insensitively_and_drops_unknown_names() { + let mut s = script(); + s.run( + r#" + heard = {} + f = CreateFrame("Frame", "CaseF") + f:SetScript("OnEvent", function() table.insert(heard, event) end) + f:RegisterEvent("player_login") + f:RegisterEvent("Not_An_Event") + g = CreateFrame("Frame", "CaseG") + g:SetScript("OnEvent", function() table.insert(heard, "g:" .. event) end) + g:RegisterEvent("UNIT_health") + "#, + ) + .expect("a name off the table raises nothing"); + s.fire_event("PLAYER_LOGIN", vec![]); + s.fire_event("Not_An_Event", vec![]); + s.fire_event("UNIT_HEALTH", vec![]); + assert_eq!( + s.eval::>("return heard").unwrap(), + ["PLAYER_LOGIN", "g:UNIT_HEALTH"], + "the table's spelling registers and is what the handler reads; the unknown name never fires" + ); + + s.run(r#"heard = {} f:UnregisterEvent("Player_Login")"#) + .unwrap(); + s.fire_event("PLAYER_LOGIN", vec![]); + assert!( + s.eval::>("return heard").unwrap().is_empty(), + "UnregisterEvent matches in any case too" + ); +} + /// Callers ask before hooking, when nothing is set yet (`Tablet-2.0.lua:2409`). Our kind table is /// flat where the reference's is per widget type, so a Frame also answers true for `OnClick`. #[test] @@ -163,16 +202,16 @@ fn a_self_unregistering_handler_does_not_rob_its_successor() { log = {} for _, n in ipairs({"WalkA", "WalkB", "WalkC"}) do local f = CreateFrame("Frame", n) - f:RegisterEvent("E") + f:RegisterEvent("PLAYER_LOGIN") f:SetScript("OnEvent", function() table.insert(log, n) - if n == "WalkB" then WalkB:UnregisterEvent("E") end + if n == "WalkB" then WalkB:UnregisterEvent("PLAYER_LOGIN") end end) end "#, ) .unwrap(); - s.fire_event("E", vec![]); + s.fire_event("PLAYER_LOGIN", vec![]); assert!(s.errors().is_empty(), "{:?}", s.errors()); let log: Vec = s.eval("return log").unwrap(); assert_eq!( @@ -180,7 +219,7 @@ fn a_self_unregistering_handler_does_not_rob_its_successor() { vec!["WalkA", "WalkB", "WalkC"], "the once-idiom's self-removal must not skip the next listener" ); - s.fire_event("E", vec![]); + s.fire_event("PLAYER_LOGIN", vec![]); let log: Vec = s.eval("return log").unwrap(); assert_eq!(log, vec!["WalkA", "WalkB", "WalkC", "WalkA", "WalkC"]); } @@ -195,19 +234,19 @@ fn mid_dispatch_removal_of_the_next_stops_and_append_is_visited() { log = {} local function reg(n, body) local f = CreateFrame("Frame", n) - f:RegisterEvent("E2") + f:RegisterEvent("PLAYER_LOGOUT") f:SetScript("OnEvent", function() table.insert(log, n); if body then body() end end) return f end reg("NxA", function() - NxB:UnregisterEvent("E2") -- kill the walk's saved next + NxB:UnregisterEvent("PLAYER_LOGOUT") -- kill the walk's saved next end) reg("NxB") reg("NxC") "#, ) .unwrap(); - s.fire_event("E2", vec![]); + s.fire_event("PLAYER_LOGOUT", vec![]); let log: Vec = s.eval("return log").unwrap(); assert_eq!( log, @@ -220,19 +259,19 @@ fn mid_dispatch_removal_of_the_next_stops_and_append_is_visited() { log = {} local function reg(n, body) local f = CreateFrame("Frame", n) - f:RegisterEvent("E3") + f:RegisterEvent("PLAYER_ALIVE") f:SetScript("OnEvent", function() table.insert(log, n); if body then body() end end) end reg("ApA", function() local f = CreateFrame("Frame", "ApLate") - f:RegisterEvent("E3") + f:RegisterEvent("PLAYER_ALIVE") f:SetScript("OnEvent", function() table.insert(log, "ApLate") end) end) reg("ApB") "#, ) .unwrap(); - s.fire_event("E3", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); let log: Vec = s.eval("return log").unwrap(); assert_eq!( log, diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs index a8917c73..cb8c64d1 100644 --- a/crates/benilla-ui/src/script/tests/glue_surface.rs +++ b/crates/benilla-ui/src/script/tests/glue_surface.rs @@ -359,7 +359,8 @@ fn glue_events_register_by_the_glue_table() { ); } -/// The in-game VM keeps its by-name registry: an in-game event registers and fires there. +/// The in-game VM registers by its own event table (`0x48feab`): an in-game event registers and +/// fires there. #[test] fn the_in_game_vm_keeps_its_events() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/tests/host_state.rs b/crates/benilla-ui/src/script/tests/host_state.rs index 0fe7851e..9ad9794a 100644 --- a/crates/benilla-ui/src/script/tests/host_state.rs +++ b/crates/benilla-ui/src/script/tests/host_state.rs @@ -261,11 +261,11 @@ fn the_engine_reads_the_handler_slot_not_the_geterrorhandler_global() { seterrorhandler(function(m) table.insert(Heard, m) end) \ geterrorhandler = function() return function(m) table.insert(Decoy, m) end end \ local f = CreateFrame('Frame') \ - f:RegisterEvent('ERROR_PROBE') \ + f:RegisterEvent('PLAYER_ALIVE') \ f:SetScript('OnEvent', function() error('slot probe') end)", ) .unwrap(); - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); s.dispatch_script_errors_to_handler(); assert_eq!( s.eval::<(i64, i64)>("return table.getn(Heard), table.getn(Decoy)") diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 939c7f4d..9c0c67ce 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -188,13 +188,13 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_none_leaves_the_host_ch s.set_screen_size(1024.0, 768.0); s.run( "local f = CreateFrame('Frame') \ - f:RegisterEvent('ERROR_PROBE') \ + f:RegisterEvent('PLAYER_ALIVE') \ f:SetScript('OnEvent', function() error('boom from a handler') end)", ) .unwrap(); // With no handler set, as before FrameXML sets one, the dispatch adds nothing. - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); s.dispatch_script_errors_to_handler(); let errors = s.take_errors(); assert_eq!( @@ -206,7 +206,7 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_none_leaves_the_host_ch // A chosen handler gets the message, and the host channel still records it. s.run("caught = {} seterrorhandler(function(msg) table.insert(caught, msg) end)") .unwrap(); - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); s.dispatch_script_errors_to_handler(); assert!( s.eval::("return caught[1]") @@ -257,7 +257,7 @@ fn the_error_handler_receives_the_bare_lua_message() { s.run( "caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \ local f = CreateFrame('Frame') \ - f:RegisterEvent('ERROR_PROBE') \ + f:RegisterEvent('PLAYER_ALIVE') \ f:SetScript('OnEvent', function() this:NoSuchMethod() end) \ local b = CreateFrame('Button') \ b:SetScript('OnClick', function() local t = nil; t.x = 1 end) \ @@ -267,7 +267,7 @@ fn the_error_handler_receives_the_bare_lua_message() { c:Click()", ) .unwrap(); - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); s.dispatch_script_errors_to_handler(); let caught: Vec = s.eval("return caught").unwrap(); assert!(caught.len() >= 3, "{caught:?}"); @@ -316,11 +316,11 @@ fn an_error_handler_that_errors_does_not_recurse() { s.run( "seterrorhandler(function() error('the handler is broken too') end) \ local f = CreateFrame('Frame') \ - f:RegisterEvent('ERROR_PROBE') \ + f:RegisterEvent('PLAYER_ALIVE') \ f:SetScript('OnEvent', function() error('original fault') end)", ) .unwrap(); - s.fire_event("ERROR_PROBE", vec![]); + s.fire_event("PLAYER_ALIVE", vec![]); s.dispatch_script_errors_to_handler(); let errors = s.take_errors(); assert!( diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 33f973b6..4164be2d 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -30,7 +30,8 @@ pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) { /// 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. +/// is the app's: the script model holds no doll instance, and the app re-takes a body pane's +/// snapshot on its hidden-to-drawn edge, however the show came. pub(super) fn show(lua: &Lua, h: FrameHandle) { match kind(lua, h) { Some(FrameKind::GameTooltip) => super::tooltip::show_override(lua, h), @@ -39,8 +40,8 @@ pub(super) fn show(lua: &Lua, h: FrameHandle) { } /// 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. +/// `0x530a60`. CharacterModelBase's (`0x505ce0`, which releases the doll's model) is the app's +/// too: a hidden pane is not drawn, and its snapshot re-takes on the next show. pub(super) fn hide(lua: &Lua, h: FrameHandle) { match kind(lua, h) { Some(FrameKind::GameTooltip) => super::tooltip::hide_override(lua, h), @@ -278,20 +279,31 @@ fn notify(lua: &Lua, h: FrameHandle, visible: bool) { /// Open or close a frame's load window: the pre-load hook's `+0x114 = 1` (`0x7697c2`) and the /// post-load hook's first write, `+0x114 = 0` (`0x76a2ff`). +/// +/// The pre-load hook defers the frame's layout (`0x7697c2`–`0x769807`). The post-load hook, once +/// the window is down, undefers a visible one (`0x76a2ff`–`0x76a35b`), whose resolve runs there +/// (`0x76a310` → `0x768060(0)` → `0x7680e0(1)`), so its `OnSizeChanged` fires ahead of its +/// `` (`0x76a36f`), its `OnLoad` (`0x76a387`) and its `OnShow` (`0x76a3b3`), as a show's +/// does ([`base_show`]). pub(crate) fn set_load_window(lua: &Lua, wrapper: &Table, open: bool) { let Ok(h) = super::object::frame_handle_of(lua, wrapper) else { return; }; - let mut model = model_mut(lua); - if let Some(f) = model.arena.frame_mut(h) { - f.load_window = open; - } - // The pre-load hook defers the frame's layout (`0x7697c2`–`0x769807`); the post-load hook, - // once the window is down, undefers a visible one (`0x76a2ff`–`0x76a35b`). - if open { - model.layout_load_begin(h); - } else { - model.layout_load_end(h); + let roots = { + let mut model = model_mut(lua); + if let Some(f) = model.arena.frame_mut(h) { + f.load_window = open; + } + if open { + model.layout_load_begin(h); + return; + } + let owed = model.layout_load_end(h); + let roots = model.load_end_roots(h); + (owed && model.resolve_fires(&roots)).then_some(roots) + }; + if let Some(roots) = roots { + resolve_and_fire(lua, roots); } }