RegisterEvent ignores case; a frame's first resize precedes OnLoad (#1001)

- `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 `<Frames>`, `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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 15:08:07 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 720dcf8188
commit 0f505a2ebf
11 changed files with 279 additions and 65 deletions
@@ -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: {:?}",
@@ -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<String> = 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]
+81
View File
@@ -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 `<Frames>` 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#"<Ui>
<Frame name="SizedBorn">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Scripts>
<OnSizeChanged>Log(SizedBornChild and "size+child" or "size")</OnSizeChanged>
<OnLoad>Log("load")</OnLoad>
<OnShow>Log("show")</OnShow>
</Scripts>
<Frames>
<Frame name="SizedBornChild">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Scripts>
<OnSizeChanged>Log("child-size")</OnSizeChanged>
<OnLoad>Log("child-load")</OnLoad>
</Scripts>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
let at_load = s.eval::<String>("return table.concat(LOG, ',')").unwrap();
s.resolve();
let after_drain = s.eval::<String>("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#"<Ui>
<Frame name="SizedHidden" hidden="true">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Scripts>
<OnSizeChanged>Log("size")</OnSizeChanged>
<OnLoad>Log("load")</OnLoad>
<OnShow>Log("show")</OnShow>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.resolve();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"load"
);
s.run("SizedHidden:Show()").unwrap();
assert_eq!(
s.eval::<String>("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.
+35 -11
View File
@@ -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<Vec<&'static str>> = std::sync::LazyLock::new(|| {
let mut names: Vec<&'static str> = Vec::new();
for line in IN_GAME_EVENT_TABLE.lines() {
// `id<TAB>NAME`; 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
+32 -8
View File
@@ -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<ScriptValue>)>,
/// 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 `<Frames>` 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<RegionHandle> = 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<u32> {
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.
+2 -1
View File
@@ -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
};
+59 -20
View File
@@ -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::<String>("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::<String>("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::<String>("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::<Vec<String>>("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::<Vec<String>>("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<String> = 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<String> = 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<String> = 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<String> = s.eval("return log").unwrap();
assert_eq!(
log,
@@ -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();
@@ -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)")
+7 -7
View File
@@ -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::<String>("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<String> = 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!(
+25 -13
View File
@@ -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
/// `<Frames>` (`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);
}
}