From eebbcc050fedd41cd4ef376e61b7f83d5a38eed8 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Sep 2026 21:03:49 +0700 Subject: [PATCH] NTFS load order, a title-sorted AddOns list, and tooltips at the cursor - addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178) - GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175) - SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176) - a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176) - a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/examples/addon_harness.rs | 8 +- crates/benilla-app/src/addon_harness/mod.rs | 41 +- .../benilla-app/src/addon_harness/together.rs | 5 +- crates/benilla-app/src/char_select/addons.rs | 8 +- crates/benilla-app/src/net.rs | 12 + crates/benilla-app/src/net/apply.rs | 13 +- crates/benilla-app/src/net/apply/session.rs | 5 + crates/benilla-app/src/net/io.rs | 22 + crates/benilla-app/src/ui_script/addons.rs | 409 ++++++++++++++++-- .../benilla-app/src/ui_script/fade_tests.rs | 2 +- crates/benilla-app/src/ui_script/lifecycle.rs | 14 + .../src/ui_script/panel_template_tests.rs | 4 +- .../src/ui_script/resolve_bench.rs | 11 +- crates/benilla-app/src/ui_script/test_ui.rs | 7 + crates/benilla-app/tests/item_text_frame.rs | 12 +- crates/benilla-protocol/src/events.rs | 6 + .../benilla-protocol/src/messages/addons.rs | 20 + crates/benilla-protocol/src/messages/mod.rs | 2 +- .../benilla-protocol/src/messages/opcode.rs | 3 + .../benilla-protocol/src/messages/packet.rs | 14 + crates/benilla-protocol/src/messages/parse.rs | 56 +++ crates/benilla-protocol/src/world/session.rs | 23 +- crates/benilla-protocol/tests/client.rs | 53 +++ crates/benilla-ui/src/loader/geometry.rs | 24 +- crates/benilla-ui/src/loader/mod.rs | 56 ++- crates/benilla-ui/src/loader/regions.rs | 24 +- crates/benilla-ui/src/script/addon.rs | 89 +++- crates/benilla-ui/src/script/model.rs | 13 + crates/benilla-ui/src/script/object.rs | 20 + .../src/script/object/anchor_args.rs | 262 +++++++++++ .../src/script/object/events_regions.rs | 14 + .../src/script/object/layout_methods.rs | 174 ++------ crates/benilla-ui/src/script/region.rs | 143 ++---- crates/benilla-ui/src/script/region/layout.rs | 24 +- .../src/script/tests/addon_argument_abi.rs | 5 + .../src/script/tests/addon_index_space.rs | 149 +++++++ crates/benilla-ui/src/script/tests/anchors.rs | 166 ++++++- .../benilla-ui/src/script/tests/backdrop.rs | 4 +- .../src/script/tests/font_object.rs | 20 +- crates/benilla-ui/src/script/tests/measure.rs | 20 +- crates/benilla-ui/src/script/tests/minimap.rs | 4 +- crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/model_clock.rs | 4 +- .../src/script/tests/name_targets.rs | 68 ++- crates/benilla-ui/src/script/tests/regions.rs | 14 +- crates/benilla-ui/src/script/tests/taxi.rs | 2 +- .../src/script/tests/texcoord_font.rs | 6 +- crates/benilla-ui/src/script/tests/tooltip.rs | 156 ++++++- .../src/script/tests/tooltip_unit.rs | 2 +- crates/benilla-ui/src/script/tooltip/mod.rs | 75 +++- crates/benilla-ui/src/script/tooltip/verbs.rs | 170 ++++---- .../benilla-ui/src/script/worldmap_arrow.rs | 4 +- crates/benilla-ui/src/widget/kinds/mod.rs | 56 ++- 53 files changed, 1978 insertions(+), 541 deletions(-) create mode 100644 crates/benilla-ui/src/script/object/anchor_args.rs create mode 100644 crates/benilla-ui/src/script/tests/addon_index_space.rs diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index f89ab53c..a454c1d9 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -692,9 +692,11 @@ fn main() { println!( "\n what was WARNED about ({addons} addons raised at least one, by addon count):" ); - for (w, count) in rows.into_iter().take(12) { - println!(" {count:>4} {w}"); - } + // Through `ranked`, not a bare `take(12)`: this list is a queue like every other one here, + // and a silent cut reads as "that is the whole list" (1242's rule, which this block was + // written outside of). It cost a measurement — asking the corpus how many `SetPoint` + // targets fail to resolve, the answer sat below the cut and the column read zero. + ranked(rows, 12); } // The distribution, because a mean would hide the shape. diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 4e49402a..b60465fd 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -397,7 +397,11 @@ fn corpus( .filter_map(|e| e.file_name().to_str().map(str::to_owned)) .filter(|n| manifest_path(root, n).is_some()) .collect(); - names.sort(); + // **The live walk's order, not a second opinion.** This used to be a bare `names.sort()`, + // a private copy of the ordering `ui_script::addons` decides — so the instrument could + // measure a walk the client does not perform. It calls the one definition now, which is what + // makes a `--together` row about shared-library provenance mean anything. + crate::ui_script::addons::sort_by_directory_order(&mut names); let installed: BTreeSet = names.iter().map(|n| n.to_ascii_lowercase()).collect(); @@ -1867,6 +1871,16 @@ fn seat_a_session(script: &mut UiScript) { // — ten subscripts into a list that answered nothing, so `AucCore.lua:106` died on // `table index is nil` and took `BeanCounter`, `Informant`, `Enchantrix` and `FonzAppraiser` // with it. Five corpus addons read the tree; four of them at file scope. + // **The `SMSG_ADDON_INFO` reply, hiding nothing** (2175) — the same class of state correction + // as the two catalogues below and the empty spellbook above. The Lua index space + // (`GetNumAddOns`, and the index form of every AddOn verb) is built only when the server + // answers, and a surveyed VM is an IN-WORLD VM: the reference cannot be in the world without + // having had that reply. A survey that left it unanswered would measure a client whose + // `GetNumAddOns()` is 0, which is what AceAddon and AceLibrary scan. + // + // Empty, not the twelve: the corpus registry holds no `## Secure:` addon, so a real server + // would have nothing to hide here. + script.note_addon_info_reply(&[]); script.set_auction_item_classes(auction_item_classes()); // **The talent tree.** `GetTalentInfo(tab, i)` answers off `Talent.dbc` × `TalentTab.dbc`; @@ -3169,6 +3183,23 @@ pub fn normalise(raw: &str) -> String { normalise_error(raw) } +/// The byte index of the next `'` that actually OPENS a quote — one with a non-alphanumeric on at +/// least one side. See [`normalise_error`] step 2. +fn quote_open(s: &str) -> Option { + let b = s.as_bytes(); + let mut from = 0usize; + while let Some(rel) = s[from..].find('\'') { + let i = from + rel; + let prev_word = i > 0 && b[i - 1].is_ascii_alphanumeric(); + let next_word = b.get(i + 1).is_some_and(u8::is_ascii_alphanumeric); + if !(prev_word && next_word) { + return Some(i); + } + from = i + 1; + } + None +} + /// One load error with everything addon-specific removed, so two addons hitting the same wall /// produce the same string. See [`blockers`] for why the crudeness is the point. fn normalise_error(raw: &str) -> String { @@ -3184,10 +3215,16 @@ fn normalise_error(raw: &str) -> String { // 2 · Every quoted name becomes `'X'` — the name is what varies between two addons that hit // the same wall, and it is already ranked by `demand`. Both quote kinds, because mlua // writes a chunk name as `[string "MyFrame:OnLoad"]`. + // + // **An APOSTROPHE INSIDE A WORD is not a quote.** The reference's own diagnostics are + // written in English — `Couldn't find region named '%s'`, `Couldn't find relative frame: + // %s` — and the `'` in `Couldn't` opened a quote that then swallowed the rest of the line, + // so the row read `Couldn'X'` and named nothing. Letters on both sides is the whole test: + // a real opening quote is preceded by a space, a bracket or the line start. let squashed = core.replace('"', "'"); let mut collapsed = String::with_capacity(squashed.len()); let mut rest = squashed.as_str(); - while let Some(open) = rest.find('\'') { + while let Some(open) = quote_open(rest) { collapsed.push_str(&rest[..open]); collapsed.push_str("'X'"); match rest[open + 1..].find('\'') { diff --git a/crates/benilla-app/src/addon_harness/together.rs b/crates/benilla-app/src/addon_harness/together.rs index 7f8e1b8e..4b45dfd5 100644 --- a/crates/benilla-app/src/addon_harness/together.rs +++ b/crates/benilla-app/src/addon_harness/together.rs @@ -244,8 +244,9 @@ mod tests { std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); std::fs::write(dir.join(file), body).unwrap(); }; - // Loads FIRST (the walk is alphabetical) and puts the library in the one global state, - // exactly as any of the seventy corpus packages that ship a copy of `Tablet-2.0` does. + // Loads FIRST (`A` before `Z` under the walk's NTFS collation) and puts the library in + // the one global state, exactly as any of the seventy corpus packages that ship a copy of + // `Tablet-2.0` does. write( "AlphaShipsIt", "## Interface: 11200\nlib.lua\n", diff --git a/crates/benilla-app/src/char_select/addons.rs b/crates/benilla-app/src/char_select/addons.rs index d1181a98..fb3dccf1 100644 --- a/crates/benilla-app/src/char_select/addons.rs +++ b/crates/benilla-app/src/char_select/addons.rs @@ -246,9 +246,11 @@ impl AddonsPanel { self.staged.push(rows.iter().map(|a| a.enabled).collect()); self.list = rows; } else { - // Same folder, same deterministic (alphabetical) discovery, read - // back-to-back: every call answers the same rows in the same order, only the - // `enabled` bits differ. That is what lets ONE metadata list carry N columns. + // Same folder, same deterministic discovery order, read back-to-back: + // every call answers the same rows in the same order, only the `enabled` bits + // differ. That is what lets ONE metadata list carry N columns. The order is + // `addons::sort_by_directory_order` — NTFS's, the reference's own listing + // order — and the point here is only that it is a pure function of the names. debug_assert_eq!( rows.len(), self.list.len(), diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 3cd4dfc2..42f5d9e2 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -118,6 +118,7 @@ impl Plugin for NetPlugin { .insert_resource(PingShared(handles.ping)) .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -2427,6 +2428,17 @@ pub(crate) struct EnteredWorldMessage { pub(crate) tutorial_flags: Option>, } +/// **`SMSG_ADDON_INFO`'s verdict for the live session** (decision 2175) — the addons the server +/// hid from the Lua index space, or `None` when it never answered our addon block. +/// +/// A resource rather than a field on [`EnteredWorldMessage`], because of *when* it is needed: the +/// index space has to exist before the first addon's file-scope code runs, and the world-entry UI +/// load ([`crate::ui_script::lifecycle::load_ingame_ui_on_world_entry`]) is inside that same edge. +/// A message read a frame later by some other feed would seat it after every addon had already +/// asked. Rewritten on every login, so a second server's silence cannot inherit the first's answer. +#[derive(Resource, Default)] +pub(crate) struct AddonInfoReply(pub(crate) Option>); + /// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc` /// id. Read by [`crate::cinematic`], which owns the playback *and* the ack (decision 0196: the ack /// is not optional, only late). diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 741c34df..ca308d20 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -137,7 +137,13 @@ pub(crate) fn apply_net_updates( MessageWriter, MessageWriter, ), - MessageWriter, + // **The world-entry pair** — the tuple is at Bevy's 16-element ceiling, and these two are + // one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that the entry's own UI + // load reads before the first addon's file-scope code asks `GetNumAddOns()` (2175). + ( + MessageWriter, + ResMut, + ), MessageWriter, MessageWriter, // The two server-authored mover edges the controller both *applies* and *answers*, paired to @@ -544,7 +550,7 @@ pub(crate) fn apply_net_updates( mut worldports, (mut char_lists, mut realm_lists), (mut char_actions, mut char_login_failures), - mut entered_world, + (mut entered_world, mut addon_reply), mut logged_out, mut speed_changes, (mut move_modes, mut knockbacks), @@ -620,11 +626,14 @@ pub(crate) fn apply_net_updates( name, billing_time_rested, tutorial_flags, + addon_info, } => session::connected( guid, name, billing_time_rested, tutorial_flags, + addon_info, + &mut addon_reply, &mut self_guid, &mut status, &mut names, diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index fb0f4aa1..04c0d2f8 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -118,6 +118,8 @@ pub(super) fn connected( name: String, billing_time_rested: u32, tutorial_flags: Option>, + addon_info: Option>, + addon_reply: &mut crate::net::AddonInfoReply, self_guid: &mut SelfGuid, status: &mut NetStatus, names: &mut NameCache, @@ -129,6 +131,9 @@ pub(super) fn connected( info!("net: in world as {name} (guid {guid})"); // Our own name came with the login — seed the cache so "player" never queries. names.insert_player(guid, name, None); + // Seated before the world-entry UI load reads it (2175), and overwritten every login so a + // server that answers nothing cannot inherit the previous one's verdict. + addon_reply.0 = addon_info; entered_world.write(EnteredWorldMessage { billing_time_rested, tutorial_flags, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 48af490e..09f1fdc6 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -802,6 +802,27 @@ fn run( let billing_time_rested = session.billing_time_rested(); let tutorial_flags = session.take_tutorial_flags(); + // `SMSG_ADDON_INFO`'s verdict, paired back against the block we sent — the reply carries no + // names (decision 2175). No reply at all means no hidden addons *and* no Lua index space; + // the distinction is the script's to draw, so both arrive as the same empty list here and + // the "did it answer" bit rides separately. + let statuses = session.take_addon_info(); + let addon_info = statuses.as_deref().map(|statuses| { + benilla_protocol::messages::hidden_from_reply( + statuses, + &benilla_protocol::messages::STOCK_SECURE_ADDONS, + ) + }); + match (&statuses, &addon_info) { + (Some(statuses), Some(hidden)) => bevy::log::info!( + "net: SMSG_ADDON_INFO — {} record(s), {} hidden from the AddOn index space", + statuses.len(), + hidden.len() + ), + _ => bevy::log::warn!( + "net: no SMSG_ADDON_INFO — GetNumAddOns() stays 0, as the reference's would" + ), + } let (mut reader, writer) = session.into_split()?; if events_tx .send(SessionEvent::Connected { @@ -809,6 +830,7 @@ fn run( name, billing_time_rested, tutorial_flags, + addon_info, }) .is_err() { diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 16d8e737..c270cc83 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -462,13 +462,6 @@ fn root_from(home: Option) -> Option { home.map(|h| h.join(ADDONS_DIR)) } -/// Every third-party addon we can see, in load order: alphabetical by folder name. -/// -/// Alphabetical is **our** choice, not a fidelity claim — the reference enumerates its own record -/// list and we have not established that order. What matters is that it is deterministic, so two -/// runs on one machine load the same interface. Real ordering constraints between addons are -/// expressed by `## Dependencies:` and honoured by [`load_third_party`]'s recursion, which is the -/// mechanism the reference actually uses. /// The reference's own LoadOnDemand addons, as its `Interface\\AddOns\\` folder names them — the /// twelve `Blizzard_*` folders on a 1.12 install, each a `.pub` decoy on disk with the real files /// inside the archive (the InspectUI header in the manifest). Probed against the chain, so a @@ -508,13 +501,119 @@ fn chain_addons() -> Vec { .collect() } +/// Every registered addon, in the reference's own **registration order**: the archive pass first, +/// then the loose folders (decision 2175). +/// +/// **Two passes, archive before loose, and the first pass wins a duplicate.** Byte-read in wow-re +/// `system/ui/scratch/addon-registry-scan-and-order.md` §1–§5: `AddOn_ScanAddOnDir 0x51c760` runs +/// `0x401470` over each mounted archive's `(listfile)` (`0x648fb0`, textual line order) and only +/// then `0x42ad10`'s `FindFirstFileW` walk of the loose directories; both funnel into +/// `0x51c9b0`, which probes the name hash and **returns immediately on a hit** (`0x51ca10`), and +/// links each new record at the TAIL (`0x521ad0` mode 2 — a tail insert with no comparison of any +/// kind, so registry order *is* registration order). §10 measures the consequence on a stock +/// install: the twelve `Blizzard_*` `.toc`s are the only ones in any archive, so Blizzard occupies +/// registry positions 1..12 and third-party always follows. +/// +/// We had it the other way round — loose first, chain appended — which put our chain rows last and +/// left a player folder named `Blizzard_TalentUI` registered *twice*, once from each source. The +/// duplicate is the half with teeth: two records share one name, and every name lookup finds only +/// the first. +/// +/// The load walk barely notices (all twelve are `## LoadOnDemand: 1`, so `0x51f600` skips them), +/// but the registry order is what the Lua index space is built from, and it is what decides which +/// addon's copy of a shared global wins — the last writer, per §9. fn discover() -> Vec { - let mut found = discover_folder(); - found.extend(chain_addons()); + // PASS 1 — the archive. `BLIZZARD_ADDONS` is in ascending name order, which is also the order + // the shipped `patch.MPQ` listfile happens to carry those twelve lines in (§10, measured). The + // note is explicit that a re-implementation may NOT rely on a listfile being sorted, so this is + // our deterministic choice for a set we enumerate ourselves, not a claim about the format. + let mut found = chain_addons(); + // PASS 2 — the loose folders, minus anything the archive already registered. + let seen: HashSet = found.iter().map(|a| a.name.to_ascii_lowercase()).collect(); + found.extend( + discover_folder() + .into_iter() + .filter(|a| !seen.contains(&a.name.to_ascii_lowercase())), + ); found } -/// The player's own addons — every folder under the AddOns root with a `.toc`. +/// **The reference's directory-listing order, as a sort key** — the one thing 2166 §7 left open. +/// +/// The reference's loose-folder pass is `0x42ad10`'s kernel32 `FindFirstFileW`/`FindNextFileW` +/// walk (wow-re `system/ui/scratch/addon-registry-scan-and-order.md`), and that walk **imposes no +/// ordering of its own**: the client never sorts, so its walk order is whatever the host +/// filesystem hands back. wow-re states the consequence outright — *"a re-implementation cannot be +/// order-identical to the reference, because the reference's order is not a property of the +/// reference."* +/// +/// So there is no order to copy, and exactly one order worth choosing: **NTFS's**. It is the order +/// every 1.12 install actually walked in, because that is the filesystem the client shipped on, and +/// it is therefore the order the whole vanilla addon corpus was written and tested against. A +/// directory's `$I30` index is a B-tree sorted by collation rule `COLLATION_FILE_NAME`, which +/// compares UTF-16 code units after mapping each through the volume's `$UpCase` table, and breaks a +/// case-insensitive tie with a case-sensitive compare of the raw units. Documented in ntfs-3g's +/// `layout.h`/`unistr.c` (`ntfs_names_full_collate`); Microsoft's `FindNextFileW` remarks say the +/// order is not *guaranteed* but that NTFS returns names "usually in alphabetical order", and +/// Raymond Chen's *"Why do NTFS and Explorer disagree on filename sorting?"* names it an ordinal +/// compare over the format-time case table rather than a locale collation. +/// +/// **This is the whole difference from the `names.sort()` it replaces, and it is not cosmetic.** +/// Byte order sorts every uppercase name before every lowercase one and puts `_` between them; +/// NTFS folds case away and puts `_` after `Z`. On the 219-addon vanilla corpus **153 of the 219 +/// positions move** — `Fubar_*` interleaves with `FuBar_*` instead of following it wholesale, +/// `oRA2` climbs 31 places, `zBar` overtakes `Zorlen`, and `_LazyPig`/`_Nameplates` fall from the +/// middle to dead last. Since the client runs every addon in one Lua state and adds no versioning +/// (wow-re §9: *which provider of a shared library wins is exactly "whichever addon's files run +/// last"*), that reshuffle decides which copy of `Tablet-2.0` a corpus of seventy providers ends up +/// with. +/// +/// The corpus corroborates the mechanism from the other side: the `!`-prefix convention +/// (`!OmniCC`) only ever bought an addon an early slot because the walk was **name-collated** +/// rather than creation-ordered — which is what NTFS does and FAT/exFAT do not. +/// +/// **We sort rather than take `read_dir`'s word even on Windows**, deliberately. Raw host order +/// would be byte-identical to a reference install on NTFS and arbitrary everywhere else, and it +/// would cost three things we need: a capture baseline that cannot depend on the host +/// (`crate::local_state`, 0008), [`installed`]'s contract that back-to-back reads answer the same +/// rows in the same order, and a corpus harness whose `--diff` means anything (2166 §4 already +/// fights Lua-side nondeterminism; filesystem-side would end it). One fixed order, and it is the +/// one the corpus grew up under. +/// +/// **Where this stops.** `$UpCase` is captured when a volume is formatted, so two Windows volumes +/// can disagree about non-ASCII names and the reference has no single answer there either. For +/// ASCII — every folder name in the corpus — `$UpCase` is the identity outside `a`–`z`, which is +/// exactly what [`upcase_unit`] reproduces. +pub(crate) fn sort_by_directory_order(names: &mut [String]) { + names.sort_by_cached_key(|n| collation_key(n)); +} + +/// One name's `COLLATION_FILE_NAME` key: the upcased UTF-16 units, then the raw ones as the +/// tiebreak the rule specifies for names equal but for case. +fn collation_key(name: &str) -> (Vec, Vec) { + let upper: String = name.chars().map(upcase_unit).collect(); + ( + upper.encode_utf16().collect(), + name.encode_utf16().collect(), + ) +} + +/// `$UpCase`'s mapping for one character: **1:1 or nothing**. +/// +/// The table is 65536 UTF-16 entries wide, so it can only express a mapping that keeps a name the +/// same length. `char::to_uppercase` is Rust's *full* Unicode uppercase and sometimes yields more +/// than one char (`ß` → `SS`); `$UpCase` cannot, and leaves such a character alone. Taking the +/// mapping only when it is a single char is that constraint, not an approximation of it. +fn upcase_unit(c: char) -> char { + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(u), None) => u, + _ => c, + } +} + +/// The player's own addons — every folder under the AddOns root with a `.toc`, in +/// [`sort_by_directory_order`]. fn discover_folder() -> Vec { let Some(root) = root() else { return Vec::new(); @@ -527,7 +626,7 @@ fn discover_folder() -> Vec { .filter(|e| e.path().is_dir()) .filter_map(|e| e.file_name().to_str().map(str::to_owned)) .collect(); - names.sort(); + sort_by_directory_order(&mut names); names .into_iter() .filter_map(|name| { @@ -603,6 +702,10 @@ pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnI .into_iter() .map(str::to_owned) .collect(), + // Visible to `GetAddOnInfo(index)` until `SMSG_ADDON_INFO` says otherwise (2175). The + // manifest cannot answer this: the exclusion is the SERVER's verdict on a `## Secure:` + // record, not a directive. + hidden: false, enabled: true, // an addon nobody has disabled is enabled; the file below overrides saved_enabled: true, // re-stamped from `enabled` at registration — see `register_addons` loaded: false, @@ -694,16 +797,28 @@ impl InstalledAddOn { } } -/// Every installed addon, in load order, with `character`'s enable state applied. +/// Every installed addon **in the glue list's own order** — `## Title`-sorted, case-insensitively, +/// with the folder name as the fallback — and `character`'s enable state applied. /// /// The AddOns screens' whole data source. `identity` is `(realm, character)`; `None` reads the /// folder with nobody's enable file, which is the "no character picked yet" case and shows /// everything as enabled. +/// +/// **The order is the reference's, and it is not the folder's** (decision 2175). The glue's +/// `AddonList_Update` walks `GetAddOnInfo(i)` for `i = 1..GetNumAddOns()`, and glue `0x46d460` +/// resolves that index through the same `0x51df00` the in-game binding does — the flat array +/// `SMSG_ADDON_INFO` builds, sorted by comparator `0x51deb0` on `AddOn_GetTitle 0x51df20` with +/// `SStrCmpI`. So the rows a player sees are in title order, not folder order, and this screen +/// showed folder order because it reads the folder. +/// +/// The array's *membership* needs nothing here: it excludes the addons the server hid, which on a +/// stock install is Blizzard's twelve, and this walk is the player's own folder only — they were +/// never in it. pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec { let disabled = disabled_set(identity); // The player's own folder only: the glue's list is what a player can toggle, and the // reference's Blizzard addons are not in it. - discover_folder() + let mut rows: Vec = discover_folder() .into_iter() .map(|addon| InstalledAddOn { enabled: !disabled.contains(&addon.name.to_ascii_lowercase()), @@ -720,7 +835,12 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec 0x5A) rather than + /// between the cases. + /// + /// The names are the corpus's own, and each pair is one that actually moved when this + /// replaced `names.sort()`. + #[test] + fn the_walk_orders_names_the_way_ntfs_lists_a_directory() { + let mut names: Vec = [ + "_LazyPig", + "Zorlen", + "zBar", + "oRA2", + "FuBar_TinyTipFu", + "Fubar_EmoteFu", + "!OmniCC", + "Ace2", + "AceGUI", + ] + .iter() + .map(|s| (*s).to_string()) + .collect(); + sort_by_directory_order(&mut names); + + assert_eq!( + names, + vec![ + // `!` (0x21) is ahead of everything — which is why the corpus's `!`-prefix + // convention ever worked, and evidence the real walk is name-collated. + "!OmniCC", + // `Ace2` before `AceGUI`: the digit `2` (0x32) beats `G` (0x47), case-blind. + "Ace2", + "AceGUI", + // Case folded: `Fubar_EmoteFu` lands next to its `FuBar_*` siblings instead of + // after every one of them. `E` < `T` decides it, not `u` vs `B`. + "Fubar_EmoteFu", + "FuBar_TinyTipFu", + // Lowercase initials sort in their own letter's block, not after `Z`. + "oRA2", + "zBar", + "Zorlen", + // `_` (0x5F) sorts AFTER every letter, so this is last rather than mid-list. + "_LazyPig", + ] + ); + + // Byte order — what we did before — disagrees on all three rules. Asserted so the test + // fails if someone "simplifies" the key back to `str`'s `Ord`. + let mut plain = names.clone(); + plain.sort(); + assert_ne!(plain, names, "NTFS collation is not byte order"); + } + + /// The tie `COLLATION_FILE_NAME` specifies: names equal but for case fall back to a + /// **case-sensitive** compare of the raw units, so uppercase (`A` 0x41) precedes lowercase + /// (`a` 0x61). NTFS itself cannot hold both — it is case-insensitive — but a case-sensitive + /// host filesystem can, and without the tiebreak the sort would be stable-on-`read_dir` and + /// therefore not deterministic at all, which is the property [`installed`] depends on. + #[test] + fn names_equal_but_for_case_break_the_tie_uppercase_first() { + let mut names: Vec = ["fubar", "FuBar", "FUBAR"] + .iter() + .map(|s| (*s).to_string()) + .collect(); + sort_by_directory_order(&mut names); + assert_eq!(names, vec!["FUBAR", "FuBar", "fubar"]); + } + + /// `$UpCase` is 65536 UTF-16 entries wide, so it can only express a length-preserving + /// mapping: `ß` has no single-unit uppercase and the table leaves it alone, where Rust's full + /// `to_uppercase` would expand it to `SS` and sort it under `S`. + #[test] + fn the_upcase_fold_is_one_to_one_like_the_table_it_models() { + assert_eq!(upcase_unit('a'), 'A'); + assert_eq!(upcase_unit('_'), '_'); + assert_eq!(upcase_unit('ß'), 'ß'); + assert_eq!(upcase_unit('é'), 'É'); + } + + /// **The AddOns screen's rows are in the glue list's order — `## Title`, case-insensitively, + /// with the folder name as the fallback** (decision 2175). + /// + /// The reference's `AddonList_Update` walks `GetAddOnInfo(i)` for `i = 1..GetNumAddOns()`, and + /// glue `0x46d460` resolves that index through the same `0x51df00` array the in-game binding + /// does — `## Title`-sorted by comparator `0x51deb0`. This screen reads the folder instead + /// (1197, so the list works before any VM has addons in it), which is why it showed folder + /// order until now. + /// + /// The three folder names and the three titles are deliberately *different permutations*, and + /// `Middle` is the case control: a byte-wise sort would answer `Middle, aardvark, zebra`. + #[test] + fn the_addons_screen_lists_in_title_order_not_folder_order() { + let _l = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let (home, _h) = hermetic_root("listorder"); + write_addon( + &home, + "Alpha", + "## Interface: 11200\n## Title: zebra\n", + &[], + ); + write_addon( + &home, + "Mike", + "## Interface: 11200\n## Title: Middle\n", + &[], + ); + write_addon( + &home, + "Zulu", + "## Interface: 11200\n## Title: aardvark\n", + &[], + ); + // No `## Title` at all — sorts under its folder name, the comparator's own fallback. + write_addon(&home, "bravo", "## Interface: 11200\n", &[]); + + let rows = installed(None); + assert_eq!( + rows.iter().map(|a| a.name.as_str()).collect::>(), + vec!["Zulu", "bravo", "Mike", "Alpha"], + "titles: {:?}", + rows.iter() + .map(InstalledAddOn::display_title) + .collect::>() + ); + } + + /// **The archive pass registers before the loose one, and wins a duplicate** (decision 2175). + /// + /// `AddOn_ScanAddOnDir 0x51c760` runs `0x401470` over each mounted archive's `(listfile)` at + /// `0x51c777` and only then `0x42ad10`'s `FindFirstFileW` walk at `0x51c78f`; both funnel into + /// `0x51c9b0`, whose name-hash probe **returns immediately on a hit** (`0x51ca10`), and the + /// list is tail-inserted with no comparison of any kind (`0x521ad0` mode 2). So registry order + /// is registration order, and a name in both sources is registered once, by the archive. + /// + /// We had it the other way round. The duplicate is the half with teeth: before this, a player + /// folder named `Blizzard_TalentUI` produced TWO records with one name, and every name lookup + /// found only the first. + #[test] + fn the_archive_pass_registers_first_and_wins_a_duplicate() { + let _data = benilla_formats::wow_data_or_skip!(); + let _l = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let (home, _h) = hermetic_root("scanorder"); + let chain = chain_addons(); + if chain.is_empty() { + eprintln!("skipping: the chain carries no Blizzard addon rows"); + return; + } + // A loose folder that COLLIDES with an archive name, and one that does not. + let collide = chain[0].name.clone(); + write_addon( + &home, + &collide, + "## Interface: 11200\n## Title: Impostor\n", + &[], + ); + write_addon(&home, "Loose", "## Interface: 11200\n", &[]); + + let found = discover(); + let names: Vec<&str> = found.iter().map(|a| a.name.as_str()).collect(); + + // Every archive row precedes every loose one. + let first_loose = names.iter().position(|n| *n == "Loose").expect("Loose"); + assert_eq!( + first_loose, + chain.len(), + "the loose folder follows all {} archive rows: {names:?}", + chain.len() + ); + + // The collision is registered ONCE, and by the archive — the loose `## Title: Impostor` + // never reaches the registry. + assert_eq!( + names.iter().filter(|n| **n == collide).count(), + 1, + "{collide} is registered once: {names:?}" + ); + let row = found.iter().find(|a| a.name == collide).unwrap(); + assert!( + matches!(row.source, Source::Chain), + "the archive's copy won" + ); + assert_ne!( + row.toc.directive("Title"), + Some("Impostor"), + "the loose manifest did not overwrite the archive's" + ); + } + /// The chain's Blizzard LoadOnDemand addons are registry rows (1957): every row is /// LoadOnDemand and read off the chain, the eight windows the interface opens through them /// are among the rows, and the manifest lists no addon at all — the reference's own @@ -1914,12 +2234,12 @@ mod tests { assert!(failures.is_empty(), "load errors: {failures:?}"); // Discovery saw it at all — the `.toc` decoded rather than read as absent. - // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that - // has a chain (1957) — the registry is one list, as the reference's is. - assert_eq!( - script.eval::("return GetNumAddOns()").ok(), - Some(1 + chain_addons().len() as i64) - ); + // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve + // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers + // `status = 2` for every secure addon and the array rebuild drops them, which is why the + // reference's own AddOns list shows the player's addons and none of Blizzard's. + seat_stock_addon_reply(&mut script); + assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( script .eval::("return GetAddOnMetadata('Umlaut', 'Title')") @@ -1977,6 +2297,22 @@ mod tests { let _ = home; } + /// What [`crate::ui_script::lifecycle::load_ingame_ui_on_world_entry`] seats before the load + /// (decision 2175): the `SMSG_ADDON_INFO` reply, hiding the secure addons the way a real + /// server's does. + /// + /// A test that registers a registry and then asks an *index* question needs this, because the + /// Lua index space does not exist until the server answers — the reference's own behaviour, + /// not a fixture convenience. The hidden set is the same list production pairs the reply + /// against, so a test and a live login agree by construction. + fn seat_stock_addon_reply(script: &mut UiScript) { + let hidden: Vec = benilla_protocol::messages::STOCK_SECURE_ADDONS + .iter() + .map(|a| a.name.to_string()) + .collect(); + script.note_addon_info_reply(&hidden); + } + /// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's /// first acceptance test, and the reason the registry carries directives rather than a name. /// @@ -2017,12 +2353,12 @@ mod tests { let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, None, true); - // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that - // has a chain (1957) — the registry is one list, as the reference's is. - assert_eq!( - script.eval::("return GetNumAddOns()").ok(), - Some(1 + chain_addons().len() as i64) - ); + // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve + // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers + // `status = 2` for every secure addon and the array rebuild drops them, which is why the + // reference's own AddOns list shows the player's addons and none of Blizzard's. + seat_stock_addon_reply(&mut script); + assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( script .eval::>( @@ -2178,12 +2514,12 @@ mod tests { let _ = load_third_party(&mut script, None, true); // Discovered and described, but NOT run. - // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that - // has a chain (1957) — the registry is one list, as the reference's is. - assert_eq!( - script.eval::("return GetNumAddOns()").ok(), - Some(1 + chain_addons().len() as i64) - ); + // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve + // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers + // `status = 2` for every secure addon and the array rebuild drops them, which is why the + // reference's own AddOns list shows the player's addons and none of Blizzard's. + seat_stock_addon_reply(&mut script); + assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( script .eval::("return IsAddOnLoadOnDemand(1) == 1") @@ -2433,12 +2769,15 @@ mod tests { save_enable_state(&script, Some(&id)); let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap(); - // The folder's two in registry order, then the chain's Blizzard rows (1957) — every - // registry row has a line, the reference's own one-line-per-addon format. - let mut expected = String::from("Drop: disabled\nKeep: enabled\n"); + // **The chain's Blizzard rows first, then the folder's two** — registry order, which since + // decision 2175 is the reference's own: the archive pass registers before the loose one + // (`0x51c777` then `0x51c78f`), and the list is tail-inserted with no sort. Every registry + // row still gets a line; the format is the reference's one-line-per-addon. + let mut expected = String::new(); for a in chain_addons() { expected.push_str(&format!("{}: enabled\n", a.name)); } + expected.push_str("Drop: disabled\nKeep: enabled\n"); assert_eq!( written, expected, "the reference's own one-line-per-addon format" diff --git a/crates/benilla-app/src/ui_script/fade_tests.rs b/crates/benilla-app/src/ui_script/fade_tests.rs index b09c5501..ef331008 100644 --- a/crates/benilla-app/src/ui_script/fade_tests.rs +++ b/crates/benilla-app/src/ui_script/fade_tests.rs @@ -83,7 +83,7 @@ fn a_flash_alternates_then_stops() { // Parked until something asks — the whole point of the driver over UIParent's own OnUpdate. s.run( r#"Probe = CreateFrame("Frame", "BenillaFlashProbe", UIParent) - Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT") + Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT", 0, 0) UIFrameFlash(Probe, 0.05, 0.05, 0.3, nil, 0, 0)"#, ) .unwrap(); diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 7fc4ac27..b12ff93b 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -363,6 +363,20 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { let identity = world .get_resource::() .and_then(crate::ui_macro::identity); + // **The Lua index space, before a single addon file runs** (decision 2175). The reference has + // the array in hand well before `UI_Init 0x48fbf0` reaches the addon walk — `SMSG_ADDON_INFO` + // lands during the handshake — so an addon reading `GetNumAddOns()` at file scope sees a + // populated one. Seated here rather than off a message for exactly that reason: a feed running + // a frame later would seat it after every addon had already asked. + // + // A server that never answered leaves this `None`, and the array then stays empty for the + // session — which is the reference's behaviour too, not a fallback we chose. + if let Some(reply) = world + .get_resource::() + .and_then(|r| r.0.clone()) + { + script.note_addon_info_reply(&reply); + } // **The CVar table goes in BEFORE the UI loads**, for the same reason and with a shipped-file // consumer rather than an addon one (decision 2115): the reference's own `UIOptionsFrame.xml` // — hidden, on the manifest for the addons that name it — reads `cameraSmoothStyle` and diff --git a/crates/benilla-app/src/ui_script/panel_template_tests.rs b/crates/benilla-app/src/ui_script/panel_template_tests.rs index 2d206688..c264eec8 100644 --- a/crates/benilla-app/src/ui_script/panel_template_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_template_tests.rs @@ -248,10 +248,10 @@ fn the_templated_close_button_hides_the_frame_it_sits_on() { s.run( r#"MyPanel = CreateFrame("Frame", "MyPanel", UIParent) MyPanel:SetWidth(200) MyPanel:SetHeight(100) - MyPanel:SetPoint("CENTER") + MyPanel:SetPoint("CENTER", 0, 0) MyPanel:Show() MyClose = CreateFrame("Button", "MyClose", MyPanel, "UIPanelCloseButton") - MyClose:SetPoint("TOPRIGHT")"#, + MyClose:SetPoint("TOPRIGHT", 0, 0)"#, ) .unwrap(); assert_eq!( diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index f1986109..de0f32d6 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -664,7 +664,9 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() { /// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes /// through `GameTooltip_SetDefaultAnchor` (stock `ActionButton_SetTooltip`, and the stance/pet/bonus bars the /// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a -/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors. +/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors +/// (`0x52fe90`'s mode-7 leg reaches `0x52fec2 call 0x767ed0` like every mode but PRESERVE — 2176 +/// re-confirmed that at the bytes after 2142 predicted the opposite). /// /// That drop took the conservative touch, so it re-derived the whole graph on every button the /// cursor crossed — and on nothing else, which is why it survived two rounds of fixing and a @@ -721,9 +723,10 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() { let derives = s.layout_derivations() - derives_before; assert_eq!( derives, 0, - "24 action-bar hovers derived the layout graph {derives} times. `SetOwner`'s ANCHOR_NONE \ - arm drops the tooltip's anchors, which is a retarget to the EMPTY target set and names \ - its node like any other (decision 1630, extending 1625)." + "24 action-bar hovers derived the layout graph {derives} times. The `ClearAllPoints()` on \ + `GameTooltip_SetDefaultAnchor`'s next line is a retarget to the EMPTY target set and must \ + name its node like any other (decision 1630, extending 1625; 2176 moved the emptying \ + itself off `SetOwner`'s ANCHOR_NONE arm, which the reference leaves alone)." ); } diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 6d0d3a06..d7370e15 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -272,6 +272,13 @@ pub(super) const UIPARENT_STAND_INS: &str = r#" local named = {} for name, row in pairs(UIPARENT_MANAGED_FRAME_POSITIONS) do if not row.isVar then table.insert(named, name) end + -- …and every row's ANCHOR TARGET (`anchorTo`, l.1668), which is a different set: the + -- keys are the frames being MOVED, the targets are what they move relative to, and + -- several targets (`ActionButton1`, `MainMenuBarArtFrame`) are declared in files a + -- one-window kit never loads. An unresolvable name is a RAISE now (decision 2176), so + -- a target the kit is missing aborts `UIParent_ManageFramePositions` mid-pass where it + -- used to anchor to the parent and carry on. + if row.anchorTo then table.insert(named, row.anchorTo) end end benilla_seat(named) for _, name in ipairs({ "SlidingActionBarTexture0", "SlidingActionBarTexture1" }) do diff --git a/crates/benilla-app/tests/item_text_frame.rs b/crates/benilla-app/tests/item_text_frame.rs index 7a404703..538be523 100644 --- a/crates/benilla-app/tests/item_text_frame.rs +++ b/crates/benilla-app/tests/item_text_frame.rs @@ -150,8 +150,14 @@ fn a_letter_reads_with_the_creator_tail() { /// The scrollbar track (the ref's black `$parentMiddle` strip) belongs in the scrollbar column, /// right of the page — regression for the black bar over the parchment: the ref declares the /// ARTWORK layer before BACKGROUND because `Middle` anchors to `Top` by name and anchors resolve -/// at SetPoint time; a reordered transcription silently fell back to the parent. The warning -/// check pins the tripwire that now catches any such unresolved named anchor at load. +/// at SetPoint time; a reordered transcription silently fell back to the parent. +/// +/// **The tripwire beside it is the whole stock chain's, and it moved with decision 2176.** An +/// unresolvable `relativeTo` used to anchor to the parent and warn; a *Lua* one now raises +/// (`0x87ccd4`) and an *XML* one is reported and skipped the way `0x767800` reports and skips it +/// (`"Couldn't find relative frame: %s"`, `0x878440`). So the assertion is on the new spelling — +/// zero of those over every file the player's own chain loads — and the raise's own tripwire is +/// `take_errors()` at the end, which a raise inside any `OnLoad` would land in. #[test] fn the_scrollbar_track_sits_right_of_the_page() { let _data = benilla_formats::wow_data_or_skip!(); @@ -160,7 +166,7 @@ fn the_scrollbar_track_sits_right_of_the_page() { let unresolved: Vec = s .warnings() .into_iter() - .filter(|w| w.contains("does not resolve")) + .filter(|w| w.contains("Couldn't find relative frame")) .collect(); assert!(unresolved.is_empty(), "unresolved anchors: {unresolved:#?}"); diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 393f4338..645e3f9b 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -218,6 +218,12 @@ pub enum SessionEvent { /// The tutorial bank, when `SMSG_TUTORIAL_FLAGS` landed during the login handshake /// (decision 1976); `None` when it will arrive in the world stream instead. tutorial_flags: Option>, + /// The addons `SMSG_ADDON_INFO` hid from the Lua index space, or **`None` when the server + /// never answered our addon block** (decision 2175) — and the two are different: an empty + /// list is a reply that hid nothing, `None` is the state in which the reference's + /// `GetNumAddOns()` answers 0. It rides the connect event for the same reason the billing + /// minutes do: the reply lands during the handshake and nothing downstream can ask again. + addon_info: Option>, }, /// The server **refused** the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — we are not /// in the world and never were, whatever [`Self::Connected`] said a moment ago. diff --git a/crates/benilla-protocol/src/messages/addons.rs b/crates/benilla-protocol/src/messages/addons.rs index c4aeeacb..7edb9576 100644 --- a/crates/benilla-protocol/src/messages/addons.rs +++ b/crates/benilla-protocol/src/messages/addons.rs @@ -59,6 +59,26 @@ /// are byte-identical to the `modulus`/`tdata` array carried in vmangos and cmangos alike. pub const STANDARD_MODULUS_CRC: u32 = 0x4C1C_776D; +/// **Which addons `SMSG_ADDON_INFO` hid** — the reply's `status` bytes paired back against the +/// records we sent (decision 2175). +/// +/// The reply carries no names: the client re-walks its own `## Secure:` list in the same order it +/// sent it, so record *i* is `sent[i]`. A `status` of **2** is what makes the reference set +/// `[rec+0x29] = 1` (`0x51db84`) and drop the addon from the Lua index space — on a stock install +/// that is all twelve, which is why the AddOns list shows only the player's own addons. +/// +/// A reply with **more** records than we sent is truncated to what we sent, and one with fewer +/// simply says nothing about the rest: both are the server disagreeing with us about the block, +/// and neither is worth failing a login over. +pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec { + statuses + .iter() + .zip(sent) + .filter(|(status, _)| **status == 2) + .map(|(_, addon)| addon.name.to_string()) + .collect() +} + /// One record in the addon block — a secure addon as the client describes it to the server. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SecureAddon<'a> { diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index f8f71315..fc6336e7 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -74,7 +74,7 @@ pub use action_bar::{ set_action_button, set_actionbar_toggles, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL, }; -pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS}; +pub use addons::{hidden_from_reply, SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS}; pub use area_trigger::area_trigger; pub use attack::{attack_swing, AttackSwingError, AttackerState}; pub use auction::{ diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 77087e60..2729344a 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -90,6 +90,9 @@ pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE; /// in the world. benilla does not implement Warden; [`crate::WorldSession::connect`] refuses such /// a server at the handshake rather than entering a 30-second kick/reconnect cycle. pub const SMSG_WARDEN_DATA: u16 = 0x02E6; +/// The server's answer to `CMSG_AUTH_SESSION`'s addon block — one record per `## Secure:` addon we +/// sent, in the order we sent them, with no count and no names (decision 2175). +pub const SMSG_ADDON_INFO: u16 = 0x02EF; pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6; /// A zlib envelope holding a **batch of whole movement packets** (763, VERIFIED vmangos /// `Opcodes_1_12_1.h`). Not an edge case: vmangos moves a session onto this carrier the moment it diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 90b60c12..cbf12a9f 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -1589,6 +1589,19 @@ pub enum ServerPacket { id: u32, value: u32, }, + /// `SMSG_ADDON_INFO` (`0x2ef`) — the server's per-addon verdict on the block we sent in + /// `CMSG_AUTH_SESSION` (decision 2175). + /// + /// **It carries no count and no names.** The client re-walks its own `## Secure:` list in the + /// same order it sent it and reads one record per addon, so record *i* is + /// `STOCK_SECURE_ADDONS[i]` (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6). + /// Only the `status` byte matters to us: **2** is what makes the client set `[rec+0x29] = 1` + /// and drop the addon from the Lua index space, which is why a stock install's AddOns list + /// shows the player's addons and none of Blizzard's. + AddonInfo { + /// One `status` per record, in the order the records arrived. + statuses: Vec, + }, Other { opcode: u16, }, @@ -1934,6 +1947,7 @@ impl ServerPacket { ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(), ServerPacket::InitWorldStates(_) => "SMSG_INIT_WORLD_STATES".into(), ServerPacket::UpdateWorldState { .. } => "SMSG_UPDATE_WORLD_STATE".into(), + ServerPacket::AddonInfo { .. } => "SMSG_ADDON_INFO".into(), ServerPacket::Other { opcode } => format!("opcode {opcode:#06x}"), } } diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 649ec379..78da65f1 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -180,6 +180,59 @@ const fn is_movement_relay(o: u16) -> bool { } /// Parse a server packet body (already decrypted + sized) by opcode. +/// `SMSG_ADDON_INFO`'s record stream — **no count, no names, no trailer** (decision 2175). +/// +/// The grammar is the one `AddOn_ReadAddonInfoReply 0x51da70` reads, per record (wow-re +/// `system/net/scratch/cmsg-auth-session-addon-block.md` §6): +/// +/// ```text +/// u8 status ; 2 -> [rec+0x29] = 1 (hidden from the Lua index space) +/// ; 0 -> [rec+0x24] = 2 (rejected) +/// ; else -> verify the .toc/Bindings.xml signature +/// u8 infoProvided ; persisted into the .pub and echoed next logon +/// if infoProvided: +/// u8 keyProvided +/// if keyProvided: u8[256] modulus +/// u32 revision +/// u8 urlProvided +/// if urlProvided: u8[256] url +/// ``` +/// +/// The retail capture is the minimal form — 12 x 8 bytes of `{2, 1, 0, 0u32, 0}`. +/// +/// **A truncated record ends the walk and keeps the records before it**, rather than failing the +/// packet. Only a record that parsed whole contributes a status, so `statuses[i]` is always +/// record *i*'s and the pairing against what we sent cannot slip. The stream is self-delimiting only if every field is read exactly right, so a grammar +/// error here would otherwise turn a working login into an unparseable packet; the statuses we did +/// read are still true, and a short read is visible as a count that does not match what we sent. +fn read_addon_info(r: &mut &[u8]) -> Vec { + let mut statuses = Vec::new(); + while !r.is_empty() { + let Ok(status) = read_u8(r) else { break }; + let Ok(info_provided) = read_u8(r) else { break }; + if info_provided != 0 { + let Ok(key_provided) = read_u8(r) else { break }; + if key_provided != 0 && skip(r, 256).is_none() { + break; + } + if read_u32_le(r).is_err() { + break; + } + } + let Ok(url_provided) = read_u8(r) else { break }; + if url_provided != 0 && skip(r, 256).is_none() { + break; + } + statuses.push(status); + } + statuses +} + +/// Advance `r` by `n` bytes, or `None` when it holds fewer. +fn skip(r: &mut &[u8], n: usize) -> Option<()> { + (r.len() >= n).then(|| *r = &r[n..]) +} + pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { let mut r = body; Ok(match opcode { @@ -1596,6 +1649,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { transport: info.transport, } } + opcode::SMSG_ADDON_INFO => ServerPacket::AddonInfo { + statuses: read_addon_info(&mut r), + }, other => ServerPacket::Other { opcode: other }, }) } diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index ab8a197b..b05b6717 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -97,6 +97,10 @@ pub struct WorldSession { /// `SMSG_TUTORIAL_FLAGS` if it landed during the login handshake rather than in the world /// stream (decision 1976) — handed to the world entry the way the billing minutes are. tutorial_flags: Option>, + /// `SMSG_ADDON_INFO`'s per-record `status` bytes, in arrival order — `None` until the server + /// answers our addon block, which is the state that keeps the Lua index space empty + /// (decision 2175). Read by [`Self::take_addon_info`]. + addon_info: Option>, } impl WorldSession { @@ -187,6 +191,7 @@ impl WorldSession { chat_language: messages::LANGUAGE_COMMON, billing_time_rested: 0, tutorial_flags: None, + addon_info: None, }; // 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first @@ -276,7 +281,23 @@ impl WorldSession { /// Read + decrypt + parse one server packet. pub fn recv(&mut self) -> Result { - recv_packet(&mut self.stream, Some(self.crypto.decrypter())) + let packet = recv_packet(&mut self.stream, Some(self.crypto.decrypter()))?; + // **Captured here rather than in a loop arm**, because it is not one loop's business: + // `SMSG_ADDON_INFO` lands somewhere between `CMSG_AUTH_SESSION` and the roster, and which + // of the handshake's three read loops sees it is the server's timing, not our contract. + // `recv` is the one place all of them go through (decision 2175). + if let ServerPacket::AddonInfo { statuses } = &packet { + self.addon_info = Some(statuses.clone()); + } + Ok(packet) + } + + /// The `SMSG_ADDON_INFO` verdict, taken once — `None` when the server never answered our addon + /// block, which is a real state and not a failure: vmangos only replies when + /// `BuildAddonPacket` accepts the block, and a rejection leaves the session alive and silent + /// (`WorldSocket.cpp:447`). The reference behaves the same way — no reply, no Lua index space. + pub fn take_addon_info(&mut self) -> Option> { + self.addon_info.take() } /// Send a client packet (encrypted header + plaintext body). diff --git a/crates/benilla-protocol/tests/client.rs b/crates/benilla-protocol/tests/client.rs index d638de84..aea89f75 100644 --- a/crates/benilla-protocol/tests/client.rs +++ b/crates/benilla-protocol/tests/client.rs @@ -723,3 +723,56 @@ fn ping_body_golden_and_pong_roundtrip() { other => panic!("expected one Pong event, got {other:?}"), } } + +/// **`SMSG_ADDON_INFO`, against the retail capture's own bytes** (decision 2175). +/// +/// The reply carries no count and no names: the client re-walks the `## Secure:` list it sent and +/// reads one record per addon. The 2006 capture is the minimal form — 96 bytes, 12 x +/// `{status = 2, infoProvided = 1, keyProvided = 0, revision = 0u32, urlProvided = 0}` — and +/// `status = 2` is what sets `[rec+0x29] = 1` (`0x51db84`) and drops the addon from the Lua index +/// space (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6). +#[test] +fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() { + let capture: Vec = std::iter::repeat_n(hx("0201000000000000"), 12) + .flatten() + .collect(); + assert_eq!(capture.len(), 96, "the capture is 12 x 8 bytes"); + let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &capture).unwrap(); + let ServerPacket::AddonInfo { statuses } = packet else { + panic!("expected AddonInfo"); + }; + assert_eq!(statuses, vec![2u8; 12]); + + // Paired back against the block we send, every stock addon is hidden from the index space — + // which is why the reference's AddOns list shows the player's addons and none of Blizzard's. + let hidden = messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS); + assert_eq!(hidden.len(), 12); + assert!( + hidden.iter().all(|n| n.starts_with("Blizzard_")), + "{hidden:?}" + ); + + // A record that says it carries a key and a url is 8 + 256 + 256 bytes, and `status = 1` + // leaves the addon VISIBLE — the exclusion is `2` alone, not "the server said something". + let mut fat = vec![1u8, 1, 1]; + fat.extend(std::iter::repeat_n(0xABu8, 256)); // modulus + fat.extend([0u8; 4]); // revision + fat.push(1); // urlProvided + fat.extend(std::iter::repeat_n(b'x', 256)); // url + let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &fat).unwrap(); + let ServerPacket::AddonInfo { statuses } = packet else { + panic!("expected AddonInfo"); + }; + assert_eq!(statuses, vec![1u8], "the fat record parsed whole"); + assert!(messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS).is_empty()); + + // A truncated record contributes nothing, and the whole ones before it survive — the pairing + // against what we sent cannot slip. + let mut short = hx("0201000000000000").to_vec(); + short.extend([2u8, 1]); // a second record that stops mid-way + let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &short).unwrap(); + let ServerPacket::AddonInfo { statuses } = packet else { + panic!("expected AddonInfo"); + }; + assert_eq!(statuses, vec![2u8], "only the whole record counted"); +} diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index b4dd90da..df2d2a7f 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -284,21 +284,15 @@ impl Loader<'_> { x.unwrap_or(0.0), y.unwrap_or(0.0), ); - // A target that is not built yet waits for the frame's subtree (`Loader::deferred_anchors`). - if args - .1 - .as_deref() - .is_some_and(|n| !self.anchor_target_exists(n)) - { - self.deferred_anchors.push(super::DeferredAnchor { - wrapper: wrapper.clone(), - region: false, - args, - dbg: dbg.to_string(), - }); - continue; - } - self.call(wrapper, "SetPoint", args, dbg); + let d = super::DeferredAnchor { + wrapper: wrapper.clone(), + region: false, + args, + dbg: dbg.to_string(), + }; + // The XML path's own law, which resolves the name itself and defers a target + // the enclosing frame's subtree has not built yet (`Loader::apply_anchor`). + self.apply_anchor(d, true); } } } diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 8b547e45..a9a7bb80 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -975,10 +975,52 @@ impl Loader<'_> { /// Returns the frame's own name (`None` if it is anonymous) and the name of its nearest /// **named** ancestor — rf27 rule 3's walk, with [`framexml::DEFAULT_PARENT_NAME`] when there /// is none. - /// Can a `relativeTo` name resolve right now — a frame in the arena or a named region? - pub(super) fn anchor_target_exists(&self, name: &str) -> bool { - let model = self.model(); - model.arena.lookup(name).is_some() || model.region_names.contains_key(name) + pub(super) fn apply_anchor(&mut self, d: DeferredAnchor, may_defer: bool) { + let rel: Value = match d.args.1.as_deref() { + Some(name) => { + match crate::script::object::anchor_args::resolve_xml_relative_to(self.lua(), name) + { + Some(t) => Value::Table(t), + // Nothing by that name YET — a target declared later in the enclosing frame's + // subtree. Hold it until the subtree is complete (`deferred_anchors`) and try + // once more; a miss THEN is a real miss and takes the reference's leg. + None if may_defer => { + self.deferred_anchors.push(d); + return; + } + None => { + self.report + .warnings + .push(format!("{}: Couldn't find relative frame: {name}", d.dbg)); + return; + } + } + } + // No `relativeTo=`: `ebx` still holds `[ebp-0x14]`, the layout parent default that + // `0x76785b call [edx+0xc]` (= `0x76c6e0`) seeded before the `` loop. A frame + // with no parent gets the screen root there, which is what a Lua `nil` means here. + None => match d + .wrapper + .call_method::>("GetParent", ()) + .ok() + .flatten() + { + Some(t) => Value::Table(t), + None => Value::Nil, + }, + }; + let DeferredAnchor { + wrapper, + region, + args: (point, _, rel_point, x, y), + dbg, + } = d; + let args = (point, rel, rel_point, x, y); + if region { + self.call_region(&wrapper, "SetPoint", args, &dbg); + } else { + self.call(&wrapper, "SetPoint", args, &dbg); + } } /// Apply the anchors deferred since `mark` (the enclosing `decorate`'s entry), in order. A @@ -986,11 +1028,7 @@ impl Loader<'_> { pub(super) fn drain_deferred_anchors(&mut self, mark: usize) { let pending: Vec = self.deferred_anchors.drain(mark..).collect(); for d in pending { - if d.region { - self.call_region(&d.wrapper, "SetPoint", d.args, &d.dbg); - } else { - self.call(&d.wrapper, "SetPoint", d.args, &d.dbg); - } + self.apply_anchor(d, false); } } diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 83e54dd7..5c047819 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -283,21 +283,15 @@ impl Loader<'_> { x.unwrap_or(0.0), y.unwrap_or(0.0), ); - // A target that is not built yet waits for the frame's subtree (`Loader::deferred_anchors`). - if args - .1 - .as_deref() - .is_some_and(|n| !self.anchor_target_exists(n)) - { - self.deferred_anchors.push(super::DeferredAnchor { - wrapper: wrapper.clone(), - region: true, - args, - dbg: dbg.to_string(), - }); - continue; - } - self.call_region(wrapper, "SetPoint", args, dbg); + let d = super::DeferredAnchor { + wrapper: wrapper.clone(), + region: true, + args, + dbg: dbg.to_string(), + }; + // The XML path's own law, which resolves the name itself and defers a target + // the enclosing frame's subtree has not built yet (`Loader::apply_anchor`). + self.apply_anchor(d, true); } } } diff --git a/crates/benilla-ui/src/script/addon.rs b/crates/benilla-ui/src/script/addon.rs index 73701815..3de8c80a 100644 --- a/crates/benilla-ui/src/script/addon.rs +++ b/crates/benilla-ui/src/script/addon.rs @@ -91,6 +91,17 @@ pub struct AddOnInfo { /// `## Interface` as the client parses it (`Toc::interface_version` — the leading integer, /// `0` when the line is absent). What the version gate compares (decision 1292). pub interface: u32, + /// **The server excluded this record from the Lua index space** — `[rec+0x29]`, which + /// `AddOn_ReadAddonInfoReply 0x51da70` sets to 1 for every `SMSG_ADDON_INFO` record whose + /// `status` byte is **2** (`0x51db84`), and which the array rebuild then drops (`0x51dc4f + /// mov al,[ebx+0x29]` / `0x51dc54 jne`). It is written nowhere else but the ctor's zero + /// (`0x52059f`), so an addon the reply never covers stays visible. + /// + /// The reply covers exactly the `## Secure:` addons, in the order the client sent them, and + /// the 2006 retail capture answers `status = 2` for all twelve — which is why the stock + /// AddOns list shows the player's addons and none of Blizzard's (wow-re + /// `system/net/scratch/cmsg-auth-session-addon-block.md` §6). + pub hidden: bool, /// The addon's files sit in the player's patch chain, not the AddOns folder — Blizzard's own /// LoadOnDemand addons (`Blizzard_TrainerUI` and its eleven siblings), which the reference /// loads through the same `LoadAddOn` path as a player's (1957). Read through the host's @@ -163,11 +174,15 @@ fn addon_key(lua: &Lua, model: &Model, key: &Value, usage: &'static str) -> mlua // `_ftol 0x40a2b0` truncates toward zero (not `floor`), then `dec eax`, then an unsigned // compare — so the whole out-of-range family collapses onto one `u32` bound test. let index0 = (n as i64 as i32).wrapping_sub(1) as u32 as usize; - return match index0 < model.addons.len() { - true => Ok(AddonKey::Index(index0)), - false => Err(mlua::Error::RuntimeError(format!( + // **The index space is NOT the registry** (decision 2175). `0x51df00` reads + // `[[0xbe1b94] + 4*idx]` — the flat, Title-sorted, hidden-filtered name array — and its + // bound is that array's own count `[0xbe1b90]`, which `GetNumAddOns 0x51def0` returns. + // The registry list is a different order over a different set. + return match model.addon_index.get(index0) { + Some(&row) => Ok(AddonKey::Index(row)), + None => Err(mlua::Error::RuntimeError(format!( "AddOn index must be in the range of 1 to {}", - model.addons.len() + model.addon_index.len() ))), }; } @@ -179,6 +194,44 @@ fn addon_key(lua: &Lua, model: &Model, key: &Value, usage: &'static str) -> mlua } } +/// **Rebuild the Lua index space** — the tail block `[0x51dc30, 0x51dcdf)` of +/// `AddOn_ReadAddonInfoReply 0x51da70`, and the only place that array is ever built +/// (decision 2175, wow-re `system/ui/scratch/addon-registry-scan-and-order.md` §7). +/// +/// Three properties, all byte-read, all easy to get wrong: +/// +/// - **The set is filtered.** `0x51dc4f mov al,[ebx+0x29]` / `0x51dc54 jne` drops every record the +/// server marked `status = 2`. On a stock install that is all twelve `Blizzard_*` addons, which +/// is why the AddOns list shows only the player's own. +/// - **The order is by `## Title:`, not by folder name.** Comparator `0x51deb0` resolves both +/// sides through `AddOn_GetTitle 0x51df20` and falls back to the folder name only when the +/// record declares no `Title` (`0x51ded0`/`0x51ded6`), then compares with `SStrCmpI` — case +/// INSENSITIVE. A materially different permutation from the registry's. +/// - **No reply, no array.** `[0xbe1b90]` starts at 0 and is written nowhere else, so +/// `GetNumAddOns()` answers 0 until the server replies. +/// +/// The reference's `qsort 0x73f727` is **not stable**, so two records with equal keys have no +/// defined relative order there; ours is stable, which is a strict refinement of "undefined" and +/// cannot disagree with a defined case. +fn rebuild_index(model: &mut Model) { + let Some(hidden) = model.addon_info_hidden.as_ref() else { + model.addon_index = Vec::new(); // no reply yet — the reference's own empty array + return; + }; + let hidden: std::collections::HashSet<&str> = hidden.iter().map(String::as_str).collect(); + for a in &mut model.addons { + a.hidden = hidden.contains(a.name.to_ascii_lowercase().as_str()); + } + let mut index: Vec = (0..model.addons.len()) + .filter(|&i| !model.addons[i].hidden) + .collect(); + index.sort_by_key(|&i| { + let a = &model.addons[i]; + a.title.as_deref().unwrap_or(&a.name).to_ascii_lowercase() + }); + model.addon_index = index; +} + /// A name → a position, folded. Names compare case-insensitively for the same reason dependency /// lookup does: a `.toc` may spell a name any way. fn by_name(model: &Model, name: &str) -> Option { @@ -252,7 +305,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetNumAddOns", lua.create_function(|lua, ()| { - Ok(lua.app_data_ref::().expect("model").addons.len()) + // `0x51def0` is six bytes: `return [0xbe1b90]` — the count of the SORTED array, not the + // registry size (decision 2175). + Ok(lua + .app_data_ref::() + .expect("model") + .addon_index + .len()) })?, )?; @@ -817,6 +876,26 @@ impl super::UiScript { model.addons_root = root; model.addons_saved_account = saved_account; model.addons_saved_character = saved_character; + // The reference builds the registry at glue login and the Lua array only when the server + // answers; our registry is built at world entry, by which time the reply has long landed. + // So the rebuild runs here, off whatever the reply said — and does nothing at all if no + // reply ever came, which is the reference's own pre-reply state. + rebuild_index(&mut model); + } + + /// **The `SMSG_ADDON_INFO` (`0x2ef`) verdict** — the names the server answered `status = 2` + /// for, which the client stores as `[rec+0x29] = 1` and which the Lua index array then drops + /// (decision 2175). + /// + /// The reply carries no count and no names: it is one record per `## Secure:` addon, in the + /// order the client itself sent them in `CMSG_AUTH_SESSION`, so the caller does the pairing + /// and hands us the names (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6). + /// Calling this with an empty slice is meaningful and different from never calling it: it + /// records that a reply arrived and hid nothing. + pub fn note_addon_info_reply(&mut self, hidden: &[String]) { + let mut model = self.model_mut(); + model.addon_info_hidden = Some(hidden.iter().map(|n| n.to_ascii_lowercase()).collect()); + rebuild_index(&mut model); } /// Execute one addon's saved-variables files, at the startup walk's verified position — the diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index c2c9fa7f..41d030b5 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -64,6 +64,17 @@ pub(crate) struct Model { /// Every discovered addon, in load order — the AddOn API's registry, filled by the host at /// world entry ([`super::UiScript::register_addons`]). See [`super::addon`]. pub(crate) addons: Vec, + /// **The Lua index space** — positions into [`Self::addons`], `## Title`-sorted and + /// hidden-filtered (decision 2175). NOT the registry: the reference keeps two structures and + /// they are different permutations of different sets (wow-re + /// `system/ui/scratch/addon-registry-scan-and-order.md` §7). + pub(crate) addon_index: Vec, + /// The lowercased names `SMSG_ADDON_INFO` marked `status = 2`, or **`None` when no reply has + /// arrived this session** — and `None` is why [`Self::addon_index`] can be legitimately empty + /// (decision 2175). `[0xbe1b90]` is zeroed by the registry reset `0x51fad1` and written + /// nowhere but the reply's own rebuild, so `GetNumAddOns()` really is 0 until the server + /// answers. + pub(crate) addon_info_hidden: Option>, /// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding. pub(crate) addons_root: Option, /// The host's reader for a chain-sourced addon's files (`AddOnInfo::chain`), by @@ -1812,6 +1823,8 @@ impl Model { pub(crate) fn new() -> Model { Model { addons: Vec::new(), + addon_index: Vec::new(), + addon_info_hidden: None, addons_root: None, addons_chain_reader: None, measurer: None, diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 732d88d9..1334504b 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -26,6 +26,7 @@ use crate::widget::{FrameHandle, FrameKind}; // The frame method-table clusters — split out purely for size (each module's own doc says what // lives there); this file keeps the shared id/handle plumbing, `install`, and `CreateFrame`. +pub(crate) mod anchor_args; mod events_regions; mod frame_state; mod layout_methods; @@ -694,6 +695,25 @@ pub(super) fn create_frame( } } + // **A leading `$parent` in the NAME is expanded, here as in XML.** `CreateFrame 0x7060b0` + // does not store the name itself: it builds a synthetic node, sets `name=` on it + // (`0x706225 push 0x838090 "name"` → `0x70622d` SetAttribute) alongside `parent=` and + // `inherits=`, and hands it to the XML frame builder `0x6ee280` with the parent object in + // `edx` — so the name reaches `CScriptRegion::SetName 0x76c650` by exactly the route an XML + // `name=` does, and `0x76c691` is one of the expander's two call sites (wow-re + // `name-string-widget-resolution.md` §5/§6). + // + // The base is the frame's **parent's** first named ancestor — the same walk `$parent` in a + // `relativeTo` takes, one link higher than the anchoring frame's own. + // + // Not academic: `FonzAppraiser` names every widget it builds this way (`"$parentDropdown"..n`, + // `"$parentCloseButton"`, ~30 sites), and until decision 2176 made an unresolvable anchor + // raise, the unexpanded name only showed up as a warning nobody read. + let name = name.map(|n| { + let model = lua.app_data_ref::().expect("model app_data"); + crate::framexml::resolve_name(&n, &parent_token_base(&model, parent_handle)) + }); + // Create in the arena, mint the id, seed a default layout input. All under one write borrow. let id = { let mut model = lua.app_data_mut::().expect("model app_data"); diff --git a/crates/benilla-ui/src/script/object/anchor_args.rs b/crates/benilla-ui/src/script/object/anchor_args.rs new file mode 100644 index 00000000..2b9cb7cd --- /dev/null +++ b/crates/benilla-ui/src/script/object/anchor_args.rs @@ -0,0 +1,262 @@ +//! `SetPoint`/`SetAllPoints`' argument ladder — **one** transcription of `0x7a2540` / `0x7a2940`. +//! +//! The two bindings are `CScriptRegion` methods registered **once**, at `.data 0x87ca38` and +//! `0x87ca40` inside the 19-record table `[0x87c9b8, 0x87ca50)` whose registrar `0x7a2e70` supplies +//! base and count — so Frame, Texture, FontString and Model all run the *same* function (wow-re +//! `system/ui/ui.md` §(b2), `scratch/name-string-widget-resolution.md` §3-4, ledger rows +//! `0x7a2540`/`0x7a2940`). This module is that single function's argument half; what each caller +//! keeps for itself is only the thing the reference reads off the receiver — the layout parent's +//! default id, and where the anchor is stored. +//! +//! **Every failure leg RAISES**, which is the point of the module (decision 2176, closing 2105's +//! "Named, not done"). `luaL_error` does not return: `luaG_errormsg [0x6fc780,0x6fc861)` and +//! `luaD_throw [0x6f5d80,0x6f5da5)` hold zero `ret`s, and `luaD_throw`'s *elided* epilogue is the +//! positive control that says so. There is no fallback and no silent no-op on any of them. +//! +//! The **XML** `` path is a different function and a different law — it +//! resolves through the same `0x76c700` but reports `"Couldn't find relative frame: %s"` at +//! severity 1 and **skips the anchor** (`0x767969 test ebx,ebx` → `0x76796d` → `jmp 0x7679dd`, +//! past the `0x7679c8 call 0x767c70`). That half lives in `crate::loader::geometry`, which +//! therefore resolves its own names and never reaches the ladder here. + +use mlua::{Lua, MultiValue, Table, Value}; + +use crate::layout::Point; +use crate::script::{Model, SCREEN}; + +use super::{decode_id, point_from_str, prefetch_named_target, resolve_named_target, NamedTarget}; + +/// What the reference prints where a widget has no name — the literal `0x84c7f0`, reached from +/// `call [primary_vt+0x4]` (`GetName` → `[widget+0x98]`) returning NULL. +pub(crate) const UNNAMED: &str = ""; + +/// `relativeTo`, decoded — the four tags `lua_type(L,3)` accepts (`0x7a25e6`–`0x7a2620`); anything +/// else, **absent included** (`TNONE` = −1), is a Usage raise on the `SetPoint` side. +pub(crate) enum RelTarget { + /// A widget wrapper table (`0x7a26c9`: `rawgeti(3,0)` → `touserdata` → `+0x24`). + /// + /// **The reference runs no `IsA` check on this arm** — unlike the `this` ladder and unlike the + /// string arm, whose check lives inside `0x76c760` — so `add eax,0x24` is applied to whatever + /// `lua_touserdata` returned and a table carrying any light userdata at `[0]` is accepted as a + /// widget. That is a wild pointer, not a behaviour: [`resolve_rel_target`] takes the layout + /// parent for a table that decodes to no live widget, and says so there. + Wrapper(Table), + /// A name string, already read out of `_G` with no `Model` guard alive ([`NamedTarget`]). + Named(NamedTarget), + /// An explicit `nil` — the **screen root** `ds:[0xcf0bd8]` (`0x7a2710`), *not* the parent. One + /// stock site takes it (`UIParent.lua:1549`) and fourteen corpus sites do, every one of them + /// meaning "the screen" (`pfUI`'s screenshot caption, `FixCTGroups`' cursor-space plate). + Screen, + /// A NUMBER in the `relativeTo` slot: `edi` stays 3 and the number is `offsetX` — the + /// `SetPoint(point, x, y)` shorthand (`0x7a270e`). `relativeTo` keeps the layout parent's + /// default (`0x76c6e0`: `[+0x9c]`'s layout sub-object, else the screen root). + DefaultParent, +} + +/// `SetPoint`'s parsed arguments (`0x7a2540`), ready for the caller's own commit. +pub(crate) struct PointArgs { + pub(crate) point: Point, + pub(crate) target: RelTarget, + pub(crate) rel_point: Point, + pub(crate) x: f32, + pub(crate) y: f32, +} + +/// `Usage: %s:SetPoint(…)` — `luaL_error(0x87cc28)`, the string read out of the reference image. +fn usage(who: &str) -> mlua::Error { + mlua::Error::runtime(format!( + "Usage: {who}:SetPoint(\"point\" [, region or nil] [, \"relativePoint\"] \ + [, offsetX, offsetY])" + )) +} + +/// `%s:SetPoint(): Unknown region point` — `0x87cd04`, raised by the 9-entry name scan `0x6f1840` +/// for either the `point` or a `relativePoint` that is a string it rejects. +fn unknown_point(who: &str) -> mlua::Error { + mlua::Error::runtime(format!("{who}:SetPoint(): Unknown region point")) +} + +/// `%s:%s(): Couldn't find region named '%s'` — `0x87ccd4` for `SetPoint`, `0x87cd84` for +/// `SetAllPoints`. **No fallback and no no-op**: this is the leg benilla answered with the parent +/// plus a warning until 2176. +pub(crate) fn no_such_region(who: &str, verb: &str, name: &str) -> mlua::Error { + mlua::Error::runtime(format!( + "{who}:{verb}(): Couldn't find region named '{name}'" + )) +} + +/// `%s:%s(): trying to anchor to itself` — `0x87cca8` / `0x87cd54`, the `[ebp-4] == this+0x24` +/// compare at `0x7a2764` / `0x7a2aa8`. +fn anchor_to_self(who: &str, verb: &str) -> mlua::Error { + mlua::Error::runtime(format!("{who}:{verb}(): trying to anchor to itself")) +} + +/// `lua_isnumber 0x6f34d0` — a number, or a string Lua can read as one. +fn as_number(lua: &Lua, v: &Value) -> Option { + lua.coerce_number(v.clone()) + .ok() + .flatten() + .map(|n| n as f32) +} + +/// `lua_isstring 0x6f3510` — tag 4 **or** 3, so a number is a string here. +fn as_string(lua: &Lua, v: &Value) -> Option { + match v { + Value::String(_) | Value::Number(_) | Value::Integer(_) => lua + .coerce_string(v.clone()) + .ok() + .flatten() + .and_then(|s| s.to_str().ok().map(|r| r.to_string())), + _ => None, + } +} + +/// Parse `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` in the reference's own order: +/// the two tag gates first (both Usage), then the point-name scan, then `relativePoint`, then the +/// offsets. The order is observable — an unknown point with an *absent* `relativeTo` raises Usage, +/// not "Unknown region point". +/// +/// `who` names the receiver for the error strings, and `parent_base` is what a leading `$parent` +/// expands to. Both are the caller's to compute, under a short `Model` borrow, **before** this +/// runs: the `_G` read here is a `lua_gettable` and an `__index` on `_G` can call straight back +/// into a binding ([`NamedTarget`]). +pub(crate) fn parse_set_point( + lua: &Lua, + args: &MultiValue, + who: &str, + parent_base: &str, +) -> mlua::Result { + // arg 2 = point. `lua_isstring(L,2)` false → `0x7a290d` Usage (`0x7a25d2`). + let point_str = args.front().and_then(|v| as_string(lua, v)); + let Some(point_str) = point_str else { + return Err(usage(who)); + }; + + // arg 3 = relativeTo. `lua_type(L,3)` ∈ {4 STRING, 5 TABLE, 0 NIL, 3 NUMBER}, else Usage — + // and an **absent** argument is `TNONE` = −1, which fails all four (`0x7a25e6`–`0x7a2620`). + let Some(rel_arg) = args.get(1) else { + return Err(usage(who)); + }; + let target = match rel_arg { + Value::String(s) => RelTarget::Named(match s.to_str() { + Ok(n) => prefetch_named_target(lua, n.as_ref(), Some(parent_base)), + Err(_) => NamedTarget::unreadable(), + }), + Value::Table(t) => RelTarget::Wrapper(t.clone()), + Value::Nil => RelTarget::Screen, + Value::Number(_) | Value::Integer(_) => RelTarget::DefaultParent, + _ => return Err(usage(who)), + }; + + // The point name, scanned by `0x6f1840` only now (`0x7a264a`) — after both tag gates. + let point = point_from_str(&point_str).ok_or_else(|| unknown_point(who))?; + + // `relativePoint` is seeded from `point` (`0x7a2697`) and read at index `edi`, which the + // number arm above left at 3 and every other arm advanced to 4. **Only a STRING is consumed** + // (`0x7a2810 lua_type == 4`, strictly — not `lua_isstring`); anything else leaves the seed and + // the offsets are taken from here. + let mut cur = if matches!(target, RelTarget::DefaultParent) { + 1 + } else { + 2 + }; + let mut rel_point = point; + if let Some(Value::String(s)) = args.get(cur) { + let name = s.to_str()?; + rel_point = point_from_str(name.as_ref()).ok_or_else(|| unknown_point(who))?; + cur += 1; + } + + // **Both offsets, or neither** (`0x7a2888 je 0x7a28e6`): each is gated on `lua_isnumber`, and + // one failing zeroes the pair rather than only itself. + let (x, y) = match ( + args.get(cur).and_then(|v| as_number(lua, v)), + args.get(cur + 1).and_then(|v| as_number(lua, v)), + ) { + (Some(x), Some(y)) => (x, y), + _ => (0.0, 0.0), + }; + + Ok(PointArgs { + point, + target, + rel_point, + x, + y, + }) +} + +/// Parse `SetAllPoints([relativeTo])` (`0x7a2940`) — **the same resolver, a different argument +/// law**, and the three differences are per-binding facts rather than a family rule: +/// +/// 1. An **omitted** argument is legal: it falls through to the layout parent with no raise, where +/// `SetPoint` raises Usage. +/// 2. The name path is gated on **`lua_isstring`** (`0x7a29ea`), tag 4 **or** 3 — so +/// `f:SetAllPoints(42)` stringifies and looks up `_G["42"]`, where `SetPoint` tests +/// `lua_type == 4` strictly and routes a number to the offset shorthand. +/// 3. There is no `relativePoint` or offset parsing at all. +pub(crate) fn parse_set_all_points(lua: &Lua, arg: Option<&Value>, parent_base: &str) -> RelTarget { + match arg { + Some(Value::Table(t)) => RelTarget::Wrapper(t.clone()), + Some(Value::Nil) => RelTarget::Screen, + // `lua_isstring` — a NUMBER takes the name path here, unlike `SetPoint`. + Some(v) => match as_string(lua, v) { + Some(n) => RelTarget::Named(prefetch_named_target(lua, &n, Some(parent_base))), + None => RelTarget::DefaultParent, + }, + None => RelTarget::DefaultParent, + } +} + +/// Turn a parsed [`RelTarget`] into the layout id to anchor against, raising the reference's own +/// errors. `me` is the receiver's own id — the self-anchor compare (`0x7a2764` / `0x7a2aa8`). +/// +/// The **wrapper** arm takes `default_parent` where the reference would take a wild pointer (see +/// [`RelTarget::Wrapper`]); every other arm is the reference's. +pub(crate) fn resolve_rel_target( + model: &Model, + target: &RelTarget, + who: &str, + verb: &str, + me: u32, + default_parent: u32, +) -> mlua::Result { + let id = match target { + RelTarget::Wrapper(t) => decode_id(t) + .ok() + .filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)) + .unwrap_or(default_parent), + RelTarget::Named(nt) => { + resolve_named_target(model, nt).ok_or_else(|| no_such_region(who, verb, &nt.name))? + } + RelTarget::Screen => SCREEN, + RelTarget::DefaultParent => default_parent, + }; + if id == me { + return Err(anchor_to_self(who, verb)); + } + Ok(id) +} + +/// The **XML** `` resolution — `CLayoutFrame::LoadXML 0x767800`'s own, +/// which is a different law from the Lua ladder above and belongs to `crate::loader`. +/// +/// `0x767964 call [eax+0x28]` is the same `0x76c700` the Lua string arm uses, so the *lookup* is +/// identical; what differs is the miss. `0x767969 test ebx,ebx` → `0x76796d` pushes +/// `0x878440 "Couldn't find relative frame: %s"` at severity 1 through the XML report's +/// `[ecx+0xc]` and then `jmp 0x7679dd`, which lands **past** the `0x7679c8 call 0x767c70` — the +/// anchor is reported and SKIPPED. It does not raise (there is no Lua state to unwind here) and it +/// does not fall back to the parent, which is what benilla did until 2176. +/// +/// `$parent` is already expanded by the caller, so no base is taken. +pub(crate) fn resolve_xml_relative_to(lua: &Lua, name: &str) -> Option { + // ONE `_G` read, and the wrapper it produced is the one handed back — re-reading `_G` to fetch + // the table again would be a second `lua_gettable`, and an `__index` is allowed to answer + // twice differently. + let nt = prefetch_named_target(lua, name, None); + let model = lua.app_data_ref::().expect("model"); + resolve_named_target(&model, &nt)?; + // The wrapper the ladder's TABLE arm takes — resolved here so the binding never sees a name + // from the XML path and so its raise can never reach a loading document. + nt.wrapper.clone() +} diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 7ab311c1..866eedf2 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -520,6 +520,20 @@ fn create_region( let wrapper = region_wrapper(lua, id)?; if let Some(name) = name { + // **A leading `$parent` in the NAME is expanded, here as in XML** — `CreateTexture + // 0x773a20` and `CreateFontString 0x773c30` build a synthetic node carrying + // `name=` and read it back through the same `GetAttribute(node, "name")` → + // `CScriptRegion::SetName 0x76c650` the XML path uses, and `0x76c691` is one of that + // expander's two call sites (wow-re `name-string-widget-resolution.md` §5/§6, whose + // `name=` reader census names `0x773ba1` and `0x773dc4` — the addresses *inside* these two + // bindings). A region's `$parent` is its OWNER frame, so the walk starts there. + // + // `pfQuest/browser.lua:723` is `pfBrowser.input:CreateTexture("$parentSearchIcon", + // "OVERLAY")`, and every named widget FonzAppraiser builds is this idiom. + let name = { + let model = lua.app_data_ref::().expect("model"); + crate::framexml::resolve_name(&name, &super::parent_token_base(&model, Some(owner))) + }; publish_global(lua, &name, &wrapper)?; // Publish into the region-name registry too (first-wins, the frame rule) — this is what // lets a sibling region's SetPoint name us as its `relativeTo` (see `resolve_target`). diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 98886efe..962c90d6 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -3,40 +3,26 @@ //! (`GetLeft`/`GetRight`/`GetTop`/`GetBottom`). Split out of [`super`] purely for size — see //! its module doc for the shared id/handle plumbing and method-table wiring. -use mlua::{Lua, Table, Value}; +use mlua::{Lua, MultiValue, Table, Value}; use crate::layout::{Anchor, Point}; use crate::script::{Model, SCREEN}; use crate::widget::FrameHandle; -use super::{ - as_f32, decode_id, frame_handle_of, frame_parent_token_base, frame_wrapper, point_from_str, - point_name, prefetch_named_target, resolve_named_target, NamedTarget, -}; +use super::anchor_args::{parse_set_all_points, parse_set_point, resolve_rel_target, UNNAMED}; +use super::{frame_handle_of, frame_parent_token_base, frame_wrapper, point_name}; -/// Phase 1 of a `relativeTo`/`SetAllPoints` target: when the argument is a **name string**, expand -/// a leading `$parent` against `h`'s first named ancestor and read `_G` — all of it with **no** -/// `Model` guard alive, because the reference's read is a gettable and an `__index` on `_G` can run -/// Lua that calls straight back into a binding ([`NamedTarget`]). -/// -/// Anything else needs no prefetch and answers `None`. -fn prefetch_relative_to( - lua: &Lua, - arg: Option<&Value>, - h: FrameHandle, -) -> mlua::Result> { - let Some(Value::String(s)) = arg else { - return Ok(None); - }; - // A name we cannot even read names nothing; the caller's miss path reports it as before. - let Ok(raw) = s.to_str() else { - return Ok(Some(NamedTarget::unreadable())); - }; - let base = { - let model = lua.app_data_ref::().expect("model"); - frame_parent_token_base(&model, h) - }; - Ok(Some(prefetch_named_target(lua, raw.as_ref(), Some(&base)))) +/// The two things the shared ladder needs off the receiver, read under **one short** `Model` +/// borrow that is dropped before the `_G` read: the name the reference puts in its error strings +/// (`GetName`, else the literal `""`), and the name a leading `$parent` expands to. +fn frame_ladder_context(lua: &Lua, h: FrameHandle) -> (String, String) { + let model = lua.app_data_ref::().expect("model"); + let who = model + .arena + .frame(h) + .and_then(|f| f.name.clone()) + .unwrap_or_else(|| UNNAMED.to_string()); + (who, frame_parent_token_base(&model, h)) } /// Populate `m`'s layout (anchor/size) methods (see the module doc). @@ -44,11 +30,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // Layout: SetPoint / ClearAllPoints / SetWidth / SetHeight / GetWidth / GetHeight m.set( "SetPoint", - lua.create_function( - |lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| { - set_point(lua, &this, &p, [a2, a3, a4, a5]) - }, - )?, + lua.create_function(|lua, (this, rest): (Table, MultiValue)| set_point(lua, &this, &rest))?, )?; m.set( "ClearAllPoints", @@ -135,30 +117,17 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path). m.set( "SetAllPoints", - lua.create_function(|lua, (this, target): (Table, Value)| { + lua.create_function(|lua, (this, rest): (Table, MultiValue)| { let h = frame_handle_of(lua, &this)?; - // The `_G` read runs before the guard — see `set_point` and `object::NamedTarget`. - let named = prefetch_relative_to(lua, Some(&target), h)?; + // `who`/`$parent` first, then the `_G` read, then the guard — see `set_point` and + // `object::NamedTarget`. + let (who, base) = frame_ladder_context(lua, h); + let target = parse_set_all_points(lua, rest.front(), &base); let mut model = lua.app_data_mut::().expect("model"); - let rel_id: u32 = match &target { - // Same id_to_region fallback as `set_point` above (and `region.rs`'s own - // `resolve_target`, which already checked both) — a table wrapper may name a region. - Value::Table(t) => decode_id(t) - .ok() - .filter(|id| { - model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id) - }) - .unwrap_or_else(|| default_parent_id(&mut model, h)), - Value::String(_) => { - // `_G[name]` — the client's one widget namespace, frames and regions alike - // (`object::NamedTarget`). The real XML anchors frames to REGIONS too (gossip - // option rows → the greeting FontString), and both publish there. - let nt = named.as_ref().expect("a String argument is prefetched"); - let hit = resolve_named_target(&model, nt); - hit.unwrap_or_else(|| default_parent_id(&mut model, h)) - } - _ => default_parent_id(&mut model, h), - }; + let me = model.frame_id(h); + let parent = default_parent_id(&mut model, h); + let rel_id: u32 = + resolve_rel_target(&model, &target, &who, "SetAllPoints", me, parent)?; let pair = [ Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), @@ -369,91 +338,22 @@ fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 { .unwrap_or(0.0) } -/// `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` (RF-0023/RF-0024). Defaults per the -/// client: `relativeTo` = the frame's parent (the screen root if top-level), `relativePoint` = -/// `point`. Overload disambiguated by argument *type* (a string/table after `point` is `relativeTo`; -/// a number is the first offset). -fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Result<()> { - let point = point_from_str(point) - .ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?; +/// `SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — `0x7a2540`, whose argument ladder +/// (and whose five raises) live in [`super::anchor_args`] because the reference runs **one** +/// function for frames and regions alike. What is this side's own is only what the reference reads +/// off a *frame*: the layout parent's default id, and the anchor list the commit writes. +fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; - - // A name target is read out of `_G` FIRST, with no model guard alive — the reference's read is - // a gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`). - let named = prefetch_relative_to(lua, rest.first(), h)?; + let (who, base) = frame_ladder_context(lua, h); + // The `_G` read inside the ladder runs with NO model guard alive — the reference's read is a + // gettable, so an `__index` on `_G` can run Lua that calls back in (`object::NamedTarget`). + let p = parse_set_point(lua, args, &who, &base)?; let mut model = lua.app_data_mut::().expect("model"); - - // relativeTo: a table (wrapper) or a string (name), else default to the parent/screen. A table - // wrapper may name a FRAME or a REGION (a Button anchored to a FontString by direct reference — - // e.g. `button:SetPoint("TOPLEFT", someFontString, "BOTTOMLEFT", ...)` — is exactly as legal in - // the real client as anchoring to a frame, and the string-branch below already accepts both), so - // this must check id_to_region too, not just id_to_frame: an id that only lives in id_to_region - // used to fail the frame-only filter and silently fall back to the default parent, misdirecting - // the anchor with no error (caught via the quest-log reward rows, which anchor Buttons to - // FontString regions by table reference — QuestLogFrame.xml's own comment on the port). - let mut cursor = 0usize; - let rel_to_id: u32 = match rest.first() { - Some(Value::Table(t)) => { - cursor = 1; - decode_id(t) - .ok() - .filter(|id| { - model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id) - }) - .unwrap_or_else(|| default_parent_id(&mut model, h)) - } - Some(Value::String(_)) => { - cursor = 1; - // `_G[name]`, frames and regions alike (see SetAllPoints above — same rationale), with - // a leading `$parent` already expanded against this frame's first named ancestor. - let nt = named.as_ref().expect("a String argument is prefetched"); - let hit = resolve_named_target(&model, nt); - hit.unwrap_or_else(|| { - // **The reference RAISES here** — `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't - // find region named '%s'")`, no fallback and no no-op (`luaG_errormsg`/`luaD_throw` - // contain no `ret`). Ours still falls back to the parent and warns, because we - // cannot yet see how many of our own transcriptions and of the addon corpus that - // raise would abort — the harness reports no engine warnings, which is the - // instrument that has to come first (decision 2105, "Named, not done"). - // - // The warning is worth keeping regardless: a *named* target that doesn't resolve is - // almost always a bug, and this exact silence cost a hunt twice (QuestLogFrame's - // reward rows, ItemTextFrame's scrollbar track). - let who = model - .arena - .frame(h) - .and_then(|f| f.name.clone()) - .unwrap_or_else(|| "".into()); - model.record_warning(format!( - "SetPoint({who}): relativeTo '{}' does not resolve — anchored to the parent", - nt.name - )); - default_parent_id(&mut model, h) - }) - } - // An *explicit* `nil` relativeTo (`SetPoint(point, nil, relPoint, x, y)`) means "default - // target" but still occupies its argument slot — consume it so the relativePoint/offsets - // that follow line up. Only a leading *number* (the `SetPoint(point, x, y)` overload) or a - // truly-absent arg leaves the cursor at 0. - Some(Value::Nil) => { - cursor = 1; - default_parent_id(&mut model, h) - } - _ => default_parent_id(&mut model, h), - }; - - // relativePoint: a string in the next slot, else defaults to `point`. - let mut rel_point = point; - if let Some(Value::String(s)) = rest.get(cursor) { - if let Some(p) = s.to_str().ok().and_then(|n| point_from_str(n.as_ref())) { - rel_point = p; - cursor += 1; - } - } - - let x = rest.get(cursor).map(as_f32).unwrap_or(0.0); - let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0); + let me = model.frame_id(h); + let parent = default_parent_id(&mut model, h); + let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, parent)?; + let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y); let input = model.layout_inputs.entry(h).or_default(); let new = Anchor::new(point, rel_to_id, rel_point, x, y); diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 470c7ce7..569364d9 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -4,11 +4,11 @@ //! axis — frames grow per-kind method tables ([`super::statusbar`], [`super::button`]), regions //! grow paint/coords methods here. -use mlua::{Lua, Table, Value}; +use mlua::{Lua, MultiValue, Table, Value}; +use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED}; use super::object::{ - anchor_bits_eq, anchor_retarget_is_structural, as_f32, decode_id, id_to_lud, point_from_str, - NamedTarget, + anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget, }; use super::{ Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS, @@ -716,77 +716,27 @@ pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua:: Ok(()) } -/// Phase 1 of a region's `relativeTo`/`SetAllPoints` target — the frame twin is -/// `object::layout_methods`' `prefetch_relative_to`, and the reason is the same: the reference -/// reads `_G` with a *gettable*, so the read must happen with **no** `Model` guard alive -/// ([`super::object::NamedTarget`]). +/// The two things the shared ladder needs off a **region** receiver, read under one short `Model` +/// borrow that is dropped before the `_G` read (`super::object::anchor_args`'s contract, and the +/// frame twin's `frame_ladder_context`): the name the reference puts in its error strings, and the +/// name a leading `$parent` expands to. /// /// A region's `$parent` is its **owner** frame (the region's own `+0x9c`), so the walk starts -/// there rather than one link higher. -pub(super) fn prefetch_region_target( - lua: &Lua, - target: &Value, - rh: RegionHandle, -) -> Option { - let Value::String(s) = target else { - return None; - }; - let Ok(raw) = s.to_str() else { - return Some(NamedTarget::unreadable()); - }; - let base = { - let model = lua.app_data_ref::().expect("model"); - let owner = model.arena.region(rh).map(|r| r.owner); - super::object::parent_token_base(&model, owner) - }; - Some(super::object::prefetch_named_target( - lua, - raw.as_ref(), - Some(&base), - )) -} - -/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a -/// widget name prefetched by [`prefetch_region_target`], or nil) to a layout id, defaulting to -/// `owner` when absent/unresolved. -pub(super) fn resolve_target( - model: &mut Model, - target: &Value, - named: Option<&NamedTarget>, - owner: u32, -) -> u32 { - match target { - Value::Table(t) => decode_id(t) - .ok() - .filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id)) - .unwrap_or(owner), - Value::String(_) => { - // The client's global namespace is ONE, so a frame and a region answer the same - // lookup — the real XML anchors regions to sibling regions by name too (merchant - // label plate → `$parentSlot`, and that `$parent` is expanded on this path). - let nt = named.expect("a String argument is prefetched"); - let hit = super::object::resolve_named_target(model, nt); - hit.unwrap_or_else(|| { - // The reference RAISES on this leg (`0x87ccd4`); ours warns and falls back to the - // owner — the frame twin carries the why, and decision 2105 the deferral. The - // warning earns its keep either way: a *named* target that doesn't resolve is - // almost always a bug (a typo, or an XML forward reference — ItemTextFrame's - // scrollbar track landed on the parchment this way). - let who = model - .id_to_frame - .get(&owner) - .and_then(|&h| model.arena.frame(h)) - .and_then(|f| f.name.clone()) - .unwrap_or_else(|| "".into()); - model.record_warning(format!( - "SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner", - nt.name - )); - owner - }) - } - _ => owner, - } +/// there rather than one link higher. Its own name comes back out of `Model::region_names` — the +/// registry is name→id, and this is the only place that wants the inverse, so it is a scan on the +/// **error path** rather than a second map kept in step. +pub(super) fn region_ladder_context(lua: &Lua, rh: RegionHandle) -> (String, String) { + let mut model = lua.app_data_mut::().expect("model"); + let id = model.region_id(rh); + let who = model + .region_names + .iter() + .find(|(_, &v)| v == id) + .map(|(n, _)| n.clone()) + .unwrap_or_else(|| UNNAMED.to_string()); + let owner = model.arena.region(rh).map(|r| r.owner); + let base = super::object::parent_token_base(&model, owner); + (who, base) } /// Bit-exact equality for a region's explicit size — the layout gate's own lens @@ -802,45 +752,22 @@ pub(super) fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool } } -/// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the region twin of -/// [`super::object`]'s frame `SetPoint`, writing [`super::RegionData::anchors`]. The overload is -/// disambiguated by argument *type* exactly as the frame version. -pub(super) fn region_set_point( - lua: &Lua, - this: &Table, - point: &str, - rest: [Value; 4], -) -> mlua::Result<()> { - let point = point_from_str(point) - .ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?; +/// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the *same* binding as the +/// frame's (`0x7a2540` is registered once, on `CScriptRegion`), so the argument ladder and every +/// one of its raises come from [`super::object::anchor_args`]; this side supplies only what the +/// reference reads off a region — the owner as the layout parent, and +/// [`super::RegionData::anchors`] as the store. +pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> { let rh = region_handle_of(lua, this)?; - // The `_G` read runs before the guard — see `prefetch_region_target`. - let named = rest - .first() - .and_then(|v| prefetch_region_target(lua, v, rh)); + let (who, base) = region_ladder_context(lua, rh); + // The `_G` read inside the ladder runs with no model guard alive — see `region_ladder_context`. + let p = parse_set_point(lua, args, &who, &base)?; + let mut model = lua.app_data_mut::().expect("model"); + let me = model.region_id(rh); let owner = region_owner_id(&mut model, rh); - - let mut cursor = 0usize; - let rel_to_id: u32 = match rest.first() { - Some(Value::Table(_) | Value::String(_) | Value::Nil) => { - cursor = 1; - resolve_target(&mut model, &rest[0], named.as_ref(), owner) - } - // A leading number is the `SetPoint(point, x, y)` overload — cursor stays at 0. - _ => owner, - }; - - let mut rel_point = point; - if let Some(Value::String(s)) = rest.get(cursor) { - if let Some(p) = s.to_str().ok().and_then(|n| point_from_str(n.as_ref())) { - rel_point = p; - cursor += 1; - } - } - - let x = rest.get(cursor).map(as_f32).unwrap_or(0.0); - let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0); + let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, owner)?; + let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y); let data = model.region_data.entry(rh).or_default(); let new = Anchor::new(point, rel_to_id, rel_point, x, y); diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index df575d1e..f4e92332 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -1,15 +1,17 @@ //! Region method-table cluster: **layout** — size, anchors and the resolved-rect readers. //! Split out of `region.rs` at the 0716 file-size budget. -use mlua::{Lua, Table, Value}; +use mlua::{Lua, MultiValue, Table, Value}; use crate::layout::{Anchor, Point}; +use crate::script::object::anchor_args::{parse_set_all_points, resolve_rel_target}; use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name}; use crate::script::{Model, SCREEN}; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. use super::{ - measured_wh, region_handle_of, region_owner_id, region_set_point, resolve_target, size_bits_eq, + measured_wh, region_handle_of, region_ladder_context, region_owner_id, region_set_point, + size_bits_eq, }; /// Populate `m`'s layout methods (see the module doc). @@ -106,11 +108,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`). m.set( "SetPoint", - lua.create_function( - |lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| { - region_set_point(lua, &this, &p, [a2, a3, a4, a5]) - }, - )?, + lua.create_function(|lua, (this, rest): (Table, MultiValue)| { + region_set_point(lua, &this, &rest) + })?, )?; m.set( @@ -134,13 +134,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { m.set( "SetAllPoints", - lua.create_function(|lua, (this, target): (Table, Value)| { + lua.create_function(|lua, (this, rest): (Table, MultiValue)| { let rh = region_handle_of(lua, &this)?; - // The `_G` read runs before the guard — see `region::prefetch_region_target`. - let named = super::prefetch_region_target(lua, &target, rh); + // `who`/`$parent` first, then the `_G` read, then the guard — `region_ladder_context`. + let (who, base) = region_ladder_context(lua, rh); + let target = parse_set_all_points(lua, rest.front(), &base); let mut model = lua.app_data_mut::().expect("model"); + let me = model.region_id(rh); let owner = region_owner_id(&mut model, rh); - let rel_id = resolve_target(&mut model, &target, named.as_ref(), owner); + let rel_id = resolve_rel_target(&model, &target, &who, "SetAllPoints", me, owner)?; let pair = [ Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0), Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), diff --git a/crates/benilla-ui/src/script/tests/addon_argument_abi.rs b/crates/benilla-ui/src/script/tests/addon_argument_abi.rs index 82cf4f74..06d14e7c 100644 --- a/crates/benilla-ui/src/script/tests/addon_argument_abi.rs +++ b/crates/benilla-ui/src/script/tests/addon_argument_abi.rs @@ -31,6 +31,11 @@ fn seeded() -> UiScript { None, None, ); + // **The index space exists only once the server has answered** (decision 2175). These tests + // are about the prologue's bounds and raises, and a bound of zero would make every one of + // them pass vacuously — so the fixture seats the reply an in-world VM has always had, hiding + // nothing. + s.note_addon_info_reply(&[]); s } diff --git a/crates/benilla-ui/src/script/tests/addon_index_space.rs b/crates/benilla-ui/src/script/tests/addon_index_space.rs new file mode 100644 index 00000000..9277f681 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/addon_index_space.rs @@ -0,0 +1,149 @@ +//! **The Lua index space is not the registry** (decision 2175) — a different order, over a +//! different set, that does not exist until the server answers. +//! +//! `GetNumAddOns` and the *index* form of every AddOn verb address a flat array the client +//! rebuilds in exactly one place: the tail of `AddOn_ReadAddonInfoReply 0x51da70` +//! (`[0x51dc30, 0x51dcdf)`), driven by `SMSG_ADDON_INFO`. It is `## Title`-sorted with +//! `SStrCmpI`, filtered by `[rec+0x29]`, and empty before the reply — three properties benilla had +//! none of, because it indexed the registry list directly. wow-re +//! `system/ui/scratch/addon-registry-scan-and-order.md` §7 (byte-read, and §11-executed over the +//! binary's own `qsort 0x73f727` and comparator `0x51deb0`). + +use crate::script::{AddOnInfo, UiScript}; + +/// Three addons whose FOLDER order and TITLE order are deliberately different permutations, so a +/// test cannot pass by accident on a registry that happens to be sorted. +/// +/// Folder order (registration): `Zulu`, `Alpha`, `Mike`. +/// Title order: `Zulu` (`"aardvark"`), `Mike` (`"Middle"`), `Alpha` (`"zebra"`). +fn seeded() -> UiScript { + let mut s = UiScript::new().unwrap(); + let row = |name: &str, title: Option<&str>| AddOnInfo { + name: name.into(), + title: title.map(str::to_owned), + interface: 11200, + enabled: true, + ..Default::default() + }; + s.register_addons( + vec![ + row("Zulu", Some("aardvark")), + row("Alpha", Some("zebra")), + row("Mike", Some("Middle")), + ], + None, + None, + None, + ); + s +} + +/// The names `GetAddOnInfo(i)` answers, in index order. +fn by_index(s: &UiScript) -> Vec { + s.eval::>( + "local t = {} for i = 1, GetNumAddOns() do t[i] = (GetAddOnInfo(i)) end return t", + ) + .unwrap() +} + +/// **The index space is `## Title`-sorted, case-insensitively — not folder order.** +/// +/// Comparator `0x51deb0` resolves both sides through `AddOn_GetTitle 0x51df20` and compares with +/// `SStrCmpI` (`0x64a4c0` → `_strnicmp 0x414310`, which folds `'A'..'Z'` by `+0x20` on both +/// operands — ASCII only, which is what `to_ascii_lowercase` is). +/// +/// `"Middle"` is the case control: a byte-wise sort would put every capital before every +/// lowercase and answer `Middle, aardvark, zebra`. +#[test] +fn the_index_space_is_title_sorted_case_insensitively_and_not_registry_order() { + let mut s = seeded(); + s.note_addon_info_reply(&[]); + assert_eq!(by_index(&s), vec!["Zulu", "Mike", "Alpha"]); +} + +/// **A record with no `## Title` sorts under its FOLDER name**, which is the comparator's +/// fallback: `AddOn_GetTitle` misses (`0x51e046` returns 0) and `0x51ded0`/`0x51ded6` substitute +/// the name the array holds. +#[test] +fn a_titleless_addon_sorts_under_its_folder_name() { + let mut s = UiScript::new().unwrap(); + s.register_addons( + vec![ + AddOnInfo { + name: "Yankee".into(), + title: Some("zzz".into()), + interface: 11200, + enabled: true, + ..Default::default() + }, + // No title at all — sorts as "bravo", between nothing and "zzz". + AddOnInfo { + name: "bravo".into(), + interface: 11200, + enabled: true, + ..Default::default() + }, + ], + None, + None, + None, + ); + s.note_addon_info_reply(&[]); + assert_eq!(by_index(&s), vec!["bravo", "Yankee"]); +} + +/// **There is no index space until `SMSG_ADDON_INFO` arrives** — `[0xbe1b90]` is zeroed by the +/// registry reset `0x51fad1` and written nowhere but the reply's own rebuild. +/// +/// The registry is fully populated here; only the array is absent. The raise is the same one an +/// out-of-range index takes, because it *is* the same unsigned bound against a count of zero. +#[test] +fn there_is_no_index_space_until_the_server_answers() { + let s = seeded(); + assert_eq!(s.eval::("return GetNumAddOns()").ok(), Some(0)); + let raised = s + .eval::( + "local ok, e = pcall(function() GetAddOnInfo(1) end) \ + if ok then return \"\" end return tostring(e)", + ) + .unwrap(); + assert!( + raised.contains("AddOn index must be in the range of 1 to 0"), + "{raised}" + ); + // The registry is there all along — the NAME form never went through the array, it probes the + // master registry hash (`0x51df20`'s own lookup), so it answers for an addon the index space + // cannot reach. + assert_eq!( + s.eval::("return (GetAddOnInfo('Alpha'))").ok(), + Some("Alpha".into()) + ); +} + +/// **A record the server hid is gone from the index space and still answers by name.** +/// +/// `status = 2` sets `[rec+0x29] = 1` (`0x51db84`) and the rebuild's `0x51dc4f`/`0x51dc54` drops +/// it. On a stock install that is all twelve `Blizzard_*` addons, which is why the reference's own +/// AddOns list shows the player's addons and none of Blizzard's — and it is why the name form has +/// to keep working, since `LoadAddOn("Blizzard_TalentUI")` is how the talent window opens. +#[test] +fn a_hidden_addon_leaves_the_index_space_but_still_answers_by_name() { + let mut s = seeded(); + s.note_addon_info_reply(&["mike".into()]); // case-insensitive, as every name compare here is + assert_eq!(s.eval::("return GetNumAddOns()").ok(), Some(2)); + assert_eq!(by_index(&s), vec!["Zulu", "Alpha"]); + assert_eq!( + s.eval::("return (GetAddOnInfo('Mike'))").ok(), + Some("Mike".into()) + ); +} + +/// **An empty reply is not the same as no reply.** A server that hides nothing still brings the +/// array into existence; only silence leaves it at zero. +#[test] +fn a_reply_that_hides_nothing_still_builds_the_array() { + let mut s = seeded(); + assert_eq!(s.eval::("return GetNumAddOns()").ok(), Some(0)); + s.note_addon_info_reply(&[]); + assert_eq!(s.eval::("return GetNumAddOns()").ok(), Some(3)); +} diff --git a/crates/benilla-ui/src/script/tests/anchors.rs b/crates/benilla-ui/src/script/tests/anchors.rs index 512d78b7..923ca467 100644 --- a/crates/benilla-ui/src/script/tests/anchors.rs +++ b/crates/benilla-ui/src/script/tests/anchors.rs @@ -75,39 +75,157 @@ fn getwidth_falls_back_to_explicit_size_before_resolve() { assert_eq!(w, 123.0); } -// A *named* relativeTo that doesn't resolve falls back to the parent/owner — the client's -// behavior — but must SAY so: the silent version misdirected ItemTextFrame's scrollbar track -// onto the parchment (an XML forward reference; anchors resolve at SetPoint time). Both the -// frame and the region SetPoint paths warn. +/// A *named* `relativeTo` that does not resolve **RAISES** and abandons the call — the reference's +/// `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't find region named '%s'")`, with no fallback and +/// no no-op (decision 2176; 2105 pinned the bytes and deferred the change until the addon survey +/// could see engine warnings). The region path is the same registered function, so it raises the +/// same way — and both quote the receiver's own name, `` (`0x84c7f0`) when it has none. +/// +/// **This replaces a parent/owner fallback plus a warning.** That fallback is what misdirected +/// ItemTextFrame's scrollbar track onto the parchment; the XML half of the same miss is a +/// different law and keeps its warning (`Loader::apply_anchor`, `0x767800`). #[test] -fn setpoint_unresolved_name_warns() { +fn setpoint_unresolved_name_raises() { let mut s = script(); - s.run( - r#" - local f = CreateFrame("Frame", "Orphan") - f:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0) - local t = f:CreateTexture(nil, "ARTWORK") - t:SetPoint("TOPRIGHT", "NoSuchRegion") - "#, - ) - .unwrap(); - let w = s.take_warnings(); + s.run(r#"f = CreateFrame("Frame", "Orphan")"#).unwrap(); + + let e = s + .run(r#"Orphan:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0)"#) + .unwrap_err() + .to_string(); assert!( - w.iter() - .any(|w| w.contains("Orphan") && w.contains("NoSuchFrame")), - "frame path: {w:#?}" + e.contains("Orphan:SetPoint(): Couldn't find region named 'NoSuchFrame'"), + "frame path: {e}" ); + + let e = s + .run( + r#"t = Orphan:CreateTexture(nil, "ARTWORK") + t:SetPoint("TOPRIGHT", "NoSuchRegion")"#, + ) + .unwrap_err() + .to_string(); assert!( - w.iter() - .any(|w| w.contains("Orphan") && w.contains("NoSuchRegion")), - "region path: {w:#?}" + e.contains(":SetPoint(): Couldn't find region named 'NoSuchRegion'"), + "region path (anonymous receiver): {e}" ); - // A resolvable name stays silent. + + // The anchor was ABANDONED, not applied to the parent: the frame still has none. + let n: i64 = s.eval("return Orphan:GetNumPoints()").unwrap(); + assert_eq!(n, 0, "the raise must leave no anchor behind"); + + // `SetAllPoints` has its own string, and a NUMBER takes the name path there (`lua_isstring`) + // where `SetPoint` would read it as an offset. + let e = s + .run(r#"Orphan:SetAllPoints("NoSuchFrame")"#) + .unwrap_err() + .to_string(); + assert!( + e.contains("Orphan:SetAllPoints(): Couldn't find region named 'NoSuchFrame'"), + "{e}" + ); + let e = s.run("Orphan:SetAllPoints(42)").unwrap_err().to_string(); + assert!( + e.contains("Orphan:SetAllPoints(): Couldn't find region named '42'"), + "a number is a string to `lua_isstring`, so it is looked up as `_G[\"42\"]`: {e}" + ); + + // A resolvable name still works, and warns about nothing. s.run(r#"CreateFrame("Frame", "Target"); Orphan:SetPoint("BOTTOMLEFT", "Target", "TOPLEFT")"#) .unwrap(); assert!(s.take_warnings().is_empty()); } +/// The other four raises of the same ladder, each read out of the reference image. +/// +/// * **Usage** `0x87cc28` — an absent `relativeTo` is `TNONE` and fails the `lua_type(L,3)` gate +/// (`0x7a25e6`–`0x7a2620`), so a one-argument `SetPoint` is an error on a 1.12 client. Stock +/// content makes none (349 call sites, arity histogram `{3:5, 4:1, 5:343}`); five sites in the +/// 219-addon vanilla corpus do, all of them in multi-client code. +/// * **Unknown region point** `0x87cd04`, from the 9-entry scan `0x6f1840`. +/// * **trying to anchor to itself** `0x87cca8` / `0x87cd54`. +/// * And the ORDER is observable: the two tag gates run before the point-name scan, so an unknown +/// point with an absent `relativeTo` answers Usage, not "Unknown region point". +#[test] +fn the_other_setpoint_raises() { + let s = script(); + s.run(r#"f = CreateFrame("Frame", "Ladder")"#).unwrap(); + + let e = s + .run(r#"Ladder:SetPoint("CENTER")"#) + .unwrap_err() + .to_string(); + assert!(e.contains("Usage: Ladder:SetPoint(\"point\""), "{e}"); + + let e = s + .run(r#"Ladder:SetPoint("MIDDLE", nil, "MIDDLE", 0, 0)"#) + .unwrap_err() + .to_string(); + assert!(e.contains("Ladder:SetPoint(): Unknown region point"), "{e}"); + + // Unknown point AND absent relativeTo: the tag gate is first, so this is Usage. + let e = s + .run(r#"Ladder:SetPoint("MIDDLE")"#) + .unwrap_err() + .to_string(); + assert!(e.contains("Usage:"), "the tag gate runs first: {e}"); + + let e = s + .run(r#"Ladder:SetPoint("CENTER", Ladder, "CENTER", 0, 0)"#) + .unwrap_err() + .to_string(); + assert!( + e.contains("Ladder:SetPoint(): trying to anchor to itself"), + "{e}" + ); +} + +/// **A trailing explicit `nil` is PRESENT, and absent is absent** — the one thing that would make +/// the Usage raise over-fire. `lua_type(L,3)` distinguishes `LUA_TNIL` (0, the screen-root arm) +/// from `LUA_TNONE` (−1, which fails the gate), so the ladder must see the argument count Lua saw +/// and not a tuple padded or trimmed on the way in. +#[test] +fn a_trailing_nil_relative_to_is_present_where_an_absent_one_is_not() { + let s = script(); + s.run(r#"f = CreateFrame("Frame", "Trail")"#).unwrap(); + // Present-and-nil: legal, the screen root, no offsets. + s.run(r#"Trail:SetPoint("CENTER", nil)"#).unwrap(); + assert_eq!(s.eval::("return Trail:GetNumPoints()").unwrap(), 1); + // Absent: Usage. + let e = s + .run(r#"Trail:SetPoint("CENTER")"#) + .unwrap_err() + .to_string(); + assert!(e.contains("Usage:"), "{e}"); +} + +/// An explicit `nil` `relativeTo` is the **SCREEN ROOT** (`0x7a2710 mov eax,ds:0xcf0bd8`), not the +/// parent — the arm benilla answered with the parent until 2176. `UIParent.lua:1549` is the one +/// stock site; fourteen corpus sites take it, every one of them meaning "the screen" +/// (`pfUI`'s screenshot caption, `FixCTGroups`' cursor-space plate). +#[test] +fn an_explicit_nil_relative_to_is_the_screen_not_the_parent() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + host = CreateFrame("Frame", "NilHost") + NilHost:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 200, 100) + NilHost:SetWidth(100) NilHost:SetHeight(100) + kid = CreateFrame("Frame", "NilKid", NilHost) + NilKid:SetWidth(10) NilKid:SetHeight(10) + NilKid:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + let left: f32 = s.eval("return NilKid:GetLeft()").unwrap(); + assert_eq!( + left, 0.0, + "the screen's BOTTOMLEFT, not NilHost's (which is 200)" + ); +} + /// **A region whose OWNER has no rect still resolves from its own anchors.** /// /// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame. @@ -141,7 +259,7 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() { -- control: identical region, owner that DOES resolve sized = CreateFrame("Frame", "SizedOwner") - sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER") + sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER", 0, 0) ctl = sized:CreateTexture("SizedMark", "ARTWORK") ctl:SetWidth(20) ctl:SetHeight(10) ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7) @@ -201,7 +319,7 @@ fn an_unanchored_owners_region_chain_resolves_nowhere() { -- $parentLeft: anchored to the OWNER (no relativeTo = the owner frame), which has no rect. cap = bare:CreateTexture("StrayLeft", "ARTWORK") cap:SetWidth(25) cap:SetHeight(64) - cap:SetPoint("TOPLEFT") + cap:SetPoint("TOPLEFT", 0, 0) -- $parentMiddle: the sibling chain that turned a zero rect into 115x64 of visible capsule. mid = bare:CreateTexture("StrayMiddle", "ARTWORK") diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index f452450d..9007afd0 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -132,7 +132,7 @@ fn get_backdrop_round_trips_through_set_backdrop() { s.run( r#" local f = CreateFrame("Frame", "BC2Plate") - f:SetPoint("CENTER") + f:SetPoint("CENTER", 0, 0) f:SetWidth(54); f:SetHeight(54) f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true, tileSize = 32, edgeSize = 32, @@ -169,7 +169,7 @@ fn set_backdrop_nil_tears_down() { s.run( r#" local f = CreateFrame("Frame", "Plate2") - f:SetPoint("CENTER") + f:SetPoint("CENTER", 0, 0) f:SetWidth(100); f:SetHeight(100) f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) f:SetBackdrop(nil) diff --git a/crates/benilla-ui/src/script/tests/font_object.rs b/crates/benilla-ui/src/script/tests/font_object.rs index e2802be9..57e9b3ed 100644 --- a/crates/benilla-ui/src/script/tests/font_object.rs +++ b/crates/benilla-ui/src/script/tests/font_object.rs @@ -255,7 +255,7 @@ fn set_font_object_takes_the_object_or_the_name() { s.run( r#" f = CreateFrame("Frame", "TwoWays") - f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER") + f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER", 0, 0) byObject = f:CreateFontString(nil, "ARTWORK") byObject:SetText("obj") byObject:SetFontObject(GameFontHighlightSmall) @@ -320,7 +320,7 @@ fn dewdrop_recolors_a_row_from_its_own_font_object() { s.run( r#" f = CreateFrame("Frame", "DdRow") - f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER") + f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER", 0, 0) button = { text = f:CreateFontString(nil, "ARTWORK") } button.text:SetText("row") button.text:SetFontObject(GameFontHighlightSmall) @@ -362,7 +362,7 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() { s.run( r#" f = CreateFrame("Frame", "ThemeHost") - f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER") + f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER", 0, 0) a = f:CreateFontString(nil, "ARTWORK"); a:SetText("inherited") a:SetFontObject(ThemeFont) b = f:CreateFontString(nil, "ARTWORK"); b:SetText("overridden") @@ -445,7 +445,7 @@ fn propagation_is_scoped_to_the_object_that_changed() { s.run( r#" f = CreateFrame("Frame", "ScopeHost") - f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER") + f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER", 0, 0) one = f:CreateFontString(nil, "ARTWORK"); one:SetText("one"); one:SetFontObject(FontOne) two = f:CreateFontString(nil, "ARTWORK"); two:SetText("two"); two:SetFontObject(FontTwo) FontOne:SetFont("Fonts\\CHANGED.TTF", 30) @@ -477,7 +477,7 @@ fn create_font_mints_publishes_and_paints() { Nameplate.Font:SetTextColor(0, 0.5, 1) f = CreateFrame("Frame", "PlateHost") - f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER") + f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER", 0, 0) Name = f:CreateFontString(nil, "ARTWORK") Name:SetText("plate") Name:SetFontObject(Nameplate.Font) @@ -626,7 +626,7 @@ fn button_state_fonts_take_the_object_and_follow_its_mutation() { s.run( r#" b = CreateFrame("Button", "StateFontButton") - b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER") + b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER", 0, 0) b:SetText("go") b:SetTextFontObject(GameFontNormal) "#, @@ -671,7 +671,7 @@ fn an_empty_font_object_copies_nothing_onto_a_fontstring() { s.run( r#" f = CreateFrame("Frame", "EmptyFontHost") - f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER") + f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER", 0, 0) fs = f:CreateFontString(nil, "ARTWORK") fs:SetText("keep") fs:SetFontObject(DressedFont) @@ -859,7 +859,7 @@ fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() { let mut s = script(); s.run( "f = CreateFrame('Frame', 'ClearAxisFrame')\n\ - f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER')\n\ + f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER', 0, 0)\n\ fs = f:CreateFontString()\n\ fs:SetAllPoints(f)\n\ fs:SetText('erased')", @@ -924,9 +924,9 @@ fn the_font_block_reaches_both_message_frame_tables() { "fo = CreateFont('MsgBlockFont')\n\ fo:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\ mf = CreateFrame('MessageFrame', 'MsgBlockPlain')\n\ - mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER')\n\ + mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER', 0, 0)\n\ smf = CreateFrame('ScrollingMessageFrame', 'MsgBlockScroll')\n\ - smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT')", + smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT', 0, 0)", ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/measure.rs b/crates/benilla-ui/src/script/tests/measure.rs index 40e28a3b..c2f4bee7 100644 --- a/crates/benilla-ui/src/script/tests/measure.rs +++ b/crates/benilla-ui/src/script/tests/measure.rs @@ -382,9 +382,9 @@ fn a_width_read_in_the_tick_that_set_the_text_is_not_zero() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) + f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") - fs:SetPoint("TOPLEFT") + fs:SetPoint("TOPLEFT", 0, 0) -- the corpus idiom: set, then measure, in one statement sequence fs:SetText("Onewarrior") Answer = fs:GetStringWidth() @@ -408,9 +408,9 @@ fn with_no_engine_installed_the_round_trip_is_still_the_only_answer() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) + f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") - fs:SetPoint("TOPLEFT") + fs:SetPoint("TOPLEFT", 0, 0) fs:SetText("Onewarrior") Answer = fs:GetStringWidth() "#, @@ -435,9 +435,9 @@ fn resolve_closes_the_round_trip_when_an_engine_is_installed() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) + f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") - fs:SetPoint("TOPLEFT") + fs:SetPoint("TOPLEFT", 0, 0) fs:SetWidth(35) -- a declared width ⇒ the two extents differ fs:SetText("Onewarrior") -- 70 natural, wraps to 2 rows inside 35 "#, @@ -469,9 +469,9 @@ fn a_synchronous_measure_satisfies_the_batch_request_too() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) + f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") - fs:SetPoint("TOPLEFT") + fs:SetPoint("TOPLEFT", 0, 0) fs:SetText("hello") Answer = fs:GetStringWidth() "#, @@ -505,9 +505,9 @@ fn a_settled_region_is_refound_after_each_measure_input_write() { s.run( r#" local f = CreateFrame("Frame", "Host") - f:SetPoint("TOPLEFT"); f:SetWidth(400); f:SetHeight(300) + f:SetPoint("TOPLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) local fs = f:CreateFontString("Label", "ARTWORK") - fs:SetPoint("TOPLEFT") + fs:SetPoint("TOPLEFT", 0, 0) fs:SetText("one") "#, ) diff --git a/crates/benilla-ui/src/script/tests/minimap.rs b/crates/benilla-ui/src/script/tests/minimap.rs index 9ae6c1f7..9675f278 100644 --- a/crates/benilla-ui/src/script/tests/minimap.rs +++ b/crates/benilla-ui/src/script/tests/minimap.rs @@ -13,7 +13,7 @@ fn minimap_zoom_api_and_extract() { s.run( r#" m = CreateFrame("Minimap", "TestMinimap") - m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT") + m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT", 0, 0) "#, ) .unwrap(); @@ -80,7 +80,7 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() { s.run( r#" m = CreateFrame("Minimap", "TestMinimap") - m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT") + m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT", 0, 0) "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 1a1661e1..d2c81558 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -5,6 +5,7 @@ //! Split by subject; the shared `script()` fixture lives in [`common`]. mod addon_argument_abi; +mod addon_index_space; mod anchors; mod backdrop; mod button; diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index 82633f9b..512a7fd0 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -435,9 +435,9 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() { s.run( r#" a = CreateFrame("Model", "Sized", UIParent) - a:SetPoint("CENTER") + a:SetPoint("CENTER", 0, 0) b = CreateFrame("Model", "Authored", UIParent) - b:SetPoint("CENTER") b:SetWidth(50) b:SetHeight(20) + b:SetPoint("CENTER", 0, 0) b:SetWidth(50) b:SetHeight(20) a:SetModel("Interface\\Minimap\\MinimapArrow.mdx") b:SetModel("Interface\\Minimap\\MinimapArrow.mdx") "#, diff --git a/crates/benilla-ui/src/script/tests/name_targets.rs b/crates/benilla-ui/src/script/tests/name_targets.rs index e1afb921..57128077 100644 --- a/crates/benilla-ui/src/script/tests/name_targets.rs +++ b/crates/benilla-ui/src/script/tests/name_targets.rs @@ -107,8 +107,9 @@ fn setpoint_relative_to_resolves_a_region_alias_global() { assert_eq!(bottom, 20.0); } -/// A global that is not a widget resolves to nothing — the reference's type-5 + tag check. The -/// binding then takes its usual unresolved-target path (parent + a warning), never the value. +/// A global that is not a widget resolves to nothing — the reference's type-5 + tag check — and +/// the binding then takes its unresolved-name leg, which **raises** (`0x87ccd4`; decision 2176). +/// The value is never used, and no anchor is left behind. #[test] fn setpoint_relative_to_ignores_a_non_widget_global() { let mut s = script(); @@ -121,20 +122,19 @@ fn setpoint_relative_to_ignores_a_non_widget_global() { NotAWidget = 5 Child = CreateFrame("Frame", "Child", Host) Child:SetWidth(20); Child:SetHeight(20) - Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0) "#, ) .unwrap(); - s.resolve(); - - // Fell back to the parent Host, whose BOTTOMRIGHT is (110, 10). - let left: f32 = s.eval("return Child:GetLeft()").unwrap(); - assert_eq!(left, 110.0); - let warnings = s.take_warnings(); + let e = s + .run(r#"Child:SetPoint("BOTTOMLEFT", "NotAWidget", "BOTTOMRIGHT", 0, 0)"#) + .unwrap_err() + .to_string(); assert!( - warnings.iter().any(|w| w.contains("NotAWidget")), - "an unresolved named target is reported, not silently redirected: {warnings:?}" + e.contains("Child:SetPoint(): Couldn't find region named 'NotAWidget'"), + "a global holding a non-widget names nothing: {e}" ); + let n: i64 = s.eval("return Child:GetNumPoints()").unwrap(); + assert_eq!(n, 0, "the raise leaves no anchor behind"); } /// **`$parent` is expanded at RUNTIME, on the anchor path only.** `0x76c5b0` has exactly two call @@ -242,3 +242,49 @@ fn setallpoints_resolves_an_alias_global() { .unwrap(); assert_eq!((l, b, w, h), (30.0, 40.0, 120.0, 60.0)); } + +/// **A leading `$parent` in a Lua constructor's NAME argument is expanded**, exactly as in XML — +/// `CreateFrame 0x7060b0`, `CreateTexture 0x773a20` and `CreateFontString 0x773c30` all build a +/// synthetic node carrying `name=` and read it back through `CScriptRegion::SetName 0x76c650`, +/// which is one of the expander `0x76c5b0`'s two call sites. +/// +/// Found by decision 2176's raise, which is the point of a raise: the unexpanded name had been +/// there all along, degrading quietly into a parent-anchored fallback. `FonzAppraiser` names +/// roughly thirty widgets this way and `pfQuest/browser.lua:723` builds its search icon as +/// `input:CreateTexture("$parentSearchIcon", "OVERLAY")`. +#[test] +fn a_constructor_name_expands_parent() { + let s = script(); + s.run( + r#" + Root = CreateFrame("Frame", "Root") + Mid = CreateFrame("Frame", nil, Root) -- anonymous: the walk skips it + Kid = CreateFrame("Frame", "$parentKid", Mid) + Icon = Kid:CreateTexture("$parentIcon", "OVERLAY") + "#, + ) + .unwrap(); + assert_eq!( + s.eval::("return Kid:GetName()").unwrap(), + "RootKid", + "the base is the first NAMED ancestor of the frame's parent, not the parent itself" + ); + // …and the expanded name is what reached `_G`, which is the only widget namespace (2105). + assert_eq!( + s.eval::("return RootKid:GetName()").unwrap(), + "RootKid" + ); + assert!( + s.eval::(r#"return getglobal("$parentKid") == nil"#) + .unwrap(), + "the raw token names nothing" + ); + // A region's `$parent` is its OWNER frame, so the walk starts one link lower. + assert_eq!( + s.eval::("return RootKidIcon:GetName()").unwrap(), + "RootKidIcon" + ); + // The payoff: an anchor by the built name resolves instead of raising. + s.run(r#"Icon:SetPoint("TOPLEFT", "RootKid", "TOPLEFT", 0, 0)"#) + .unwrap(); +} diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index 7b19ba14..21b0aafa 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -1025,9 +1025,9 @@ fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_hei local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Sized = f:CreateFontString("Sized", "ARTWORK") - Sized:SetPoint("TOPLEFT"); Sized:SetWidth(300); Sized:SetText("Name") + Sized:SetPoint("TOPLEFT", 0, 0); Sized:SetWidth(300); Sized:SetText("Name") Auto = f:CreateFontString("Auto", "ARTWORK") - Auto:SetPoint("TOPLEFT"); Auto:SetText("Name") + Auto:SetPoint("TOPLEFT", 0, 0); Auto:SetText("Name") "#, ) .unwrap(); @@ -1067,9 +1067,9 @@ fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() { local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Pending = f:CreateFontString("Pending", "ARTWORK") - Pending:SetPoint("TOPLEFT"); Pending:SetText("Name") + Pending:SetPoint("TOPLEFT", 0, 0); Pending:SetText("Name") Empty = f:CreateFontString("Empty", "ARTWORK") - Empty:SetPoint("TOPLEFT"); Empty:SetText("") + Empty:SetPoint("TOPLEFT", 0, 0); Empty:SetText("") assert(Pending:GetHeight() == 0, "a pending measure is not a size, got " .. tostring(Pending:GetHeight())) assert(Empty:GetHeight() == 1, "an EMPTY string is one unit, got " .. tostring(Empty:GetHeight())) assert(Empty:GetWidth() == 1, "both axes") @@ -1093,11 +1093,11 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() { local f = CreateFrame("Frame", "Owner") f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(50) Art = f:CreateTexture("Art", "ARTWORK") - Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT") + Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT", 0, 0) Half = f:CreateTexture("Half", "ARTWORK") - Half:SetTexture("Interface\\Crest"); Half:SetPoint("TOPLEFT"); Half:SetHeight(13) + Half:SetTexture("Interface\\Crest"); Half:SetPoint("TOPLEFT", 0, 0); Half:SetHeight(13) Bare = f:CreateTexture("Bare", "ARTWORK") - Bare:SetPoint("TOPLEFT") + Bare:SetPoint("TOPLEFT", 0, 0) assert(Art:GetWidth() == 128 and Art:GetHeight() == 96, "the art's own texels") assert(Half:GetWidth() == 128 and Half:GetHeight() == 13, "per AXIS: authored 13 wins, width still derived") assert(Bare:GetWidth() == 0, "no art, no span — and no floor on a texture") diff --git a/crates/benilla-ui/src/script/tests/taxi.rs b/crates/benilla-ui/src/script/tests/taxi.rs index a6856c66..534b9244 100644 --- a/crates/benilla-ui/src/script/tests/taxi.rs +++ b/crates/benilla-ui/src/script/tests/taxi.rs @@ -34,7 +34,7 @@ fn taxi_snapshot_surfaces_and_intents_drain() { s.run( r#" f = CreateFrame("Frame", "TaxiHost") - f:SetWidth(316); f:SetHeight(352); f:SetPoint("CENTER") + f:SetWidth(316); f:SetHeight(352); f:SetPoint("CENTER", 0, 0) map = f:CreateTexture("TaxiMapTex", "OVERLAY") SetTaxiMap(map) diff --git a/crates/benilla-ui/src/script/tests/texcoord_font.rs b/crates/benilla-ui/src/script/tests/texcoord_font.rs index 15ad60c7..60b897b9 100644 --- a/crates/benilla-ui/src/script/tests/texcoord_font.rs +++ b/crates/benilla-ui/src/script/tests/texcoord_font.rs @@ -13,7 +13,7 @@ fn set_tex_coord_changes_extracted_uv() { s.run( r#" f = CreateFrame("Frame", "TcF") - f:SetWidth(100); f:SetHeight(100); f:SetPoint("CENTER") + f:SetWidth(100); f:SetHeight(100); f:SetPoint("CENTER", 0, 0) t = f:CreateTexture("TcTex", "ARTWORK") t:SetTexture("Interface\\Foo") t:SetTexCoord(0.1, 0.6, 0.2, 0.8) @@ -107,7 +107,7 @@ fn set_font_object_repoints_fontstring() { s.run( r#" f = CreateFrame("Frame", "FoF") - f:SetWidth(120); f:SetHeight(30); f:SetPoint("CENTER") + f:SetWidth(120); f:SetHeight(30); f:SetPoint("CENTER", 0, 0) fs = f:CreateFontString("FoText", "ARTWORK") fs:SetText("Hi") fs:SetFontObject("Big") @@ -170,7 +170,7 @@ fn justify_v_defaults_middle_and_overrides() { s.run( r#" f = CreateFrame("Frame", "JvF") - f:SetWidth(100); f:SetHeight(30); f:SetPoint("CENTER") + f:SetWidth(100); f:SetHeight(30); f:SetPoint("CENTER", 0, 0) fs = f:CreateFontString("JvText", "ARTWORK") fs:SetText("Hi") "#, diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 846e8cb9..904fe48b 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -590,6 +590,144 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c assert!(s.take_errors().is_empty()); } +/// **`SetOwner`'s omitted `anchorType` is mode 0 = ANCHOR_LEFT**, and the clear-vs-keep split runs +/// between NONE and PRESERVE, not between "points" and "doesn't". +/// +/// 2142's open thread 1 predicted the opposite on both counts — a mode-7 default, and a NONE arm +/// that must stop dropping anchors — and the wow-re §5 dispatched for the CURSOR mechanism refuted +/// both at the bytes (`system/ui/scratch/tooltip-cursor-anchor-law.md` §0.2/§0.3/§2; decision +/// 2176). What is actually there: +/// +/// * `0x53120d` zeroes the binding's local mode before any compare, and `0x531214`'s +/// `lua_isstring` gate jumps the whole `SStrCmpI` chain when arg 3 is absent, nil, a boolean or +/// a table — so the answer is **0 = ANCHOR_LEFT**, silently, with no `luaL_error` anywhere in +/// `[0x531221, 0x53133a)`. The core's `mov [+0x318],7` at `0x530012` is an unconditional +/// pre-store `0x530031` overwrites; it survives only when the owner is NULL. +/// * `0x52fe90` returns for mode **8** at `0x52fead`, *before* `0x52fec2 call 0x767ed0`, and the +/// mode-7 skip beside that clear is gated on an arg1 `SetOwner`'s core always passes as 1. So +/// every mode 0..7 clears, and only PRESERVE keeps the placement. +#[test] +fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Plate = CreateFrame("GameTooltip", "Plate") + Owner = CreateFrame("Frame", "Owner") + Owner:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 300, 300) + Owner:SetWidth(40) Owner:SetHeight(40) + Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + Plate:SetWidth(50) Plate:SetHeight(20) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return Plate:GetLeft()").unwrap(), 100.0); + + // ANCHOR_PRESERVE (mode 8) is the ONE mode that leaves the plate alone. + s.run(r#"Plate:SetOwner(Owner, "ANCHOR_PRESERVE")"#) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return Plate:GetLeft()").unwrap(), + 100.0, + "mode 8 returns before the ClearAllPoints" + ); + assert_eq!(s.eval::("return Plate:GetNumPoints()").unwrap(), 1); + + // ANCHOR_NONE (mode 7) clears — which is what makes `GameTooltip_SetDefaultAnchor` safe. + s.run(r#"Plate:SetOwner(Owner, "ANCHOR_NONE")"#).unwrap(); + assert_eq!( + s.eval::("return Plate:GetNumPoints()").unwrap(), + 0, + "mode 7 reaches the clear: arg1 is always 1 from SetOwner's core" + ); + + // `SetOwner(f)` — no anchor string at all. Mode 0, and mode 0 PLACES: ANCHOR_LEFT pins the + // plate's BOTTOMRIGHT to the owner's TOPLEFT, so its right edge sits at the owner's left. + s.run(r#"Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)"#) + .unwrap(); + s.run("Plate:SetOwner(Owner)").unwrap(); + assert_eq!( + s.eval::("return Plate:GetAnchorType()").unwrap(), + "ANCHOR_LEFT", + "the omitted anchorType is the binding's zero-initialised local, mode 0" + ); + s.resolve(); + assert_eq!( + s.eval::("return Plate:GetLeft()").unwrap(), + 250.0, + "the owner's left edge (300) less the plate's own 50 wide" + ); + + // An UNRECOGNISED string is the same silent mode 0 — the compare chain falls through with the + // local untouched, and raises nothing. + s.run(r#"Plate:SetOwner(Owner, "ANCHOR_SIDEWAYS")"#) + .unwrap(); + assert_eq!( + s.eval::("return Plate:GetAnchorType()").unwrap(), + "ANCHOR_LEFT" + ); +} + +/// **`ANCHOR_CURSOR` is a real ninth mode**, not a string to warn about (2142's open thread 2, and +/// nine corpus files ask for it): mode 6 clears at `SetOwner` time like mode 7, and then the +/// per-frame update `0x530b20` pins the plate's **BOTTOM** to the screen's **BOTTOMLEFT** at the +/// cursor's absolute position, divided by the plate's own effective scale. See +/// `script::tooltip::cursor_anchor`. +#[test] +fn anchor_cursor_follows_the_cursor_every_frame() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Tip = CreateFrame("GameTooltip", "Tip") + Host = CreateFrame("Frame", "Host") + Tip:SetOwner(Host, "ANCHOR_CURSOR") + Tip:AddLine("Copper Ore") + -- An explicit size, because this VM has no text measurer: the auto-size pre-pass leaves a + -- plate with no measured extents at its declared size, and the placement is what is under + -- test, not the width. + Tip:SetWidth(120) Tip:SetHeight(40) + Tip:Show() + "#, + ) + .unwrap(); + assert_eq!( + s.eval::("return Tip:GetAnchorType()").unwrap(), + "ANCHOR_CURSOR", + "the mode round-trips; it is no longer recorded as whatever it was placed by" + ); + assert!( + !s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")), + "a mode we honour is not warned about: {:?}", + s.warnings() + ); + + s.mouse_move(300.0, 200.0); + s.resolve(); + let (left, right, bottom): (f32, f32, f32) = s + .eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()") + .unwrap(); + assert_eq!(bottom, 200.0, "the plate's BOTTOM sits at the cursor"); + assert!( + ((left + right) / 2.0 - 300.0).abs() < 0.001, + "centred horizontally on the cursor: {left}..{right}" + ); + + // It follows. The whole point of mode 6 is that no second SetOwner is needed. + s.mouse_move(120.0, 480.0); + s.resolve(); + let (left, right, bottom): (f32, f32, f32) = s + .eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()") + .unwrap(); + assert_eq!(bottom, 480.0); + assert!( + ((left + right) / 2.0 - 120.0).abs() < 0.001, + "{left}..{right}" + ); +} + /// `GameTooltip:GetAnchorType()` — ONE string, the reference's own spelling, round-tripping /// whatever `SetOwner` was given (`0x5313e0`, table `0x854198`, argc 1, arity 1, kinds `(string)`, /// reading `[+0x318]` back through the name table `0x531530`). @@ -625,7 +763,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() { "a plate nothing has owned is anchored to nothing" ); - // The eight modes SetOwner accepts, each answered back verbatim — ANCHOR_PRESERVE included, + // All NINE modes SetOwner accepts, each answered back verbatim — ANCHOR_PRESERVE included, // which the reference stores like any other rather than resolving back to what it preserved. for mode in [ "ANCHOR_RIGHT", @@ -634,6 +772,7 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() { "ANCHOR_TOPLEFT", "ANCHOR_BOTTOMRIGHT", "ANCHOR_BOTTOMLEFT", + "ANCHOR_CURSOR", "ANCHOR_NONE", "ANCHOR_PRESERVE", ] { @@ -656,20 +795,19 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() { "ANCHOR_LEFT" ); - // An anchor SetOwner cannot honour is recorded as the ANCHOR_RIGHT it is actually PLACED by, - // never as the string it was handed — the getter reports the plate, not the request. - // `ANCHOR_CURSOR` is the live case (nine corpus files ask for it; mode 6 follows the cursor per - // frame and this engine has no such driver), and SetOwner warns rather than pretending. - s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_CURSOR")"#) + // An anchor SetOwner does not recognise is the binding's zero-initialised local, mode 0 — + // silently, with no raise (`0x53120d`, and no `luaL_error` in the compare chain). Ours adds a + // warning the reference does not have, which is a diagnostic and not API surface. + s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_SIDEWAYS")"#) .unwrap(); assert_eq!( s.eval::("return AnchorTip:GetAnchorType()") .unwrap(), - "ANCHOR_RIGHT" + "ANCHOR_LEFT" ); assert!( - s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")), - "the unhonoured anchor is warned, not silently taken: {:?}", + s.warnings().iter().any(|w| w.contains("ANCHOR_SIDEWAYS")), + "the unrecognised anchor is still reported to us: {:?}", s.warnings() ); } diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index b2b189aa..5869e6dc 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -512,7 +512,7 @@ fn minimap_blip_tooltip_shows_and_fades() { s.run( r#" local up = CreateFrame("Frame", "UIParent") - up:SetPoint("BOTTOMLEFT"); up:SetWidth(800); up:SetHeight(600) + up:SetPoint("BOTTOMLEFT", 0, 0); up:SetWidth(800); up:SetHeight(600) "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index ee395fee..90a33ed5 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -33,8 +33,8 @@ use super::{FontObject, Model, RegionData}; use crate::layout::{Anchor, Point}; use crate::order::DrawLayer; use crate::widget::{ - FrameHandle, KindState, RegionKind, TooltipState, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, - TOOLTIP_LINE_GAP, TOOLTIP_PAD, + FrameHandle, KindState, RegionKind, TooltipAnchor, TooltipState, TOOLTIP_DOUBLE_GAP, + TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, }; /// Registry key of the GameTooltip method table (the MAXCSTACK discipline: named registry root). @@ -673,6 +673,67 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell { /// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs /// a second time with real extents); without one it is the host's batch round-trip, one frame /// later, which is the engine-less path every measurer-free VM still takes. +/// **ANCHOR_CURSOR — mode 6, re-anchored to the live cursor every frame** (`0x530b20`, the +/// `CGameTooltip` override of `vtable+0x38` on `0x808f60`; wow-re +/// `system/ui/scratch/tooltip-cursor-anchor-law.md` §1, three independent derivations agreeing +/// instruction-for-instruction). Nine corpus files ask for it — `pfUI`'s tooltip, xpbar and chat +/// modules, `pfQuest/browser.lua`, `TipBuddy` — and until decision 2176 benilla warned and placed +/// them by some other mode. +/// +/// The whole of the re-anchor is one `SetPoint`: +/// +/// ```text +/// 530b32 cmp [this+0x318],6 ; jne -- every other mode skips +/// 530b3b eax = [this+0xa0] -- the CSimpleTop root == [0xcf0bd8] +/// 530b4f ecx = [eax+0x111c] ; edx = [eax+0x1118] -- the SAVED cursor, normalised [0,1] +/// 530b63 call 0x41ad80 -- x *= G44 (screen w), y *= G48 (screen h) +/// 530b68 fld [this+0x7c] ; fdivr 1.0f -- 1 / the tooltip's own effective scale +/// 530b97 call 0x767c70(BOTTOM=7, [0xcf0bd8], BOTTOMLEFT=6, x/scale, y/scale, doResolve=1) +/// ``` +/// +/// So **the plate's BOTTOM-centre is pinned to the screen's BOTTOMLEFT plus the cursor's absolute +/// position** — centred horizontally on the cursor and sitting directly above it. Four details are +/// worth keeping because none is guessable from the name: +/// +/// * The `relativeTo` is the screen root passed **raw**, not `+0x24` like every other anchor — the +/// `CSimpleTop`'s `CLayoutFrame` base sits at offset 0. Ours is [`SCREEN`](super::SCREEN), which +/// is the same object. +/// * The **divide by the tooltip's own effective scale** is the "cursor box recip": an anchor +/// offset is stored raw and multiplied by the child's scale at resolve time, so dividing here is +/// what makes the result land at the cursor's *absolute* position. Ours does the same divide, +/// against the same scale [`GetWidth`](super::object::eff_scale) reads. +/// * **The `SetOwner`-time offsets `+0x3c4/+0x3c8` do not participate** — 12 reads of them, every +/// one on a modes-0..5 arm of `0x52fe90`. +/// * There is **no `ClearAllPoints` here** (earned zero over the function's complete call set): one +/// anchor slot is overwritten, and `0x767c70`'s own no-op gate — same target, same +/// `relativePoint`, both offsets within 2⁻²² — is what keeps a still cursor free. +/// +/// It runs from the per-frame update pump, which a hidden tooltip is not in (`0x76ad9d` +/// deregisters on hide), and in the **same frame** as the placement it produces: the layout drain +/// `0x768ed0` runs at `0x765799`, right after the `+0x38` loop and before the draw. Hence its home +/// here, at the top of the resolve, beside the auto-size pass. +fn cursor_anchor(model: &mut Model, h: FrameHandle) { + let scale = crate::script::object::eff_scale(model, h); + let (cx, cy) = model.cursor_pos; + let new = Anchor::new( + Point::Bottom, + super::SCREEN, + Point::BottomLeft, + cx / scale, + cy / scale, + ); + let input = model.layout_inputs.entry(h).or_default(); + // `0x767c70`'s change-detect, in our own terms: one slot, replaced only on a real change. + let same = + input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); + if same { + return; + } + let old_targets: Vec = input.anchors.iter().map(|a| a.relative_to).collect(); + input.anchors = vec![new]; + model.touch_layout_retarget_frame(h, &old_targets, &[super::SCREEN]); +} + pub(super) fn layout_tooltips(model: &mut Model) { // The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at // the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most @@ -686,6 +747,16 @@ pub(super) fn layout_tooltips(model: &mut Model) { .filter(|h| model.frame_to_id.contains_key(h)) .collect(); for h in tips { + // Mode 6 first, and **before the line gate below**: the reference's per-frame update is + // not gated on content at all, only on the plate being in the pump's list — which is the + // shown list. See [`cursor_anchor`]. + let cursor_mode = match model.arena.frame(h).map(|f| (&f.kind_state, f.shown)) { + Some((KindState::Tooltip(t), shown)) => shown && t.anchor == TooltipAnchor::Cursor, + _ => false, + }; + if cursor_mode { + cursor_anchor(model, h); + } let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Tooltip(t)) => ( t.num_lines, diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 53b25987..70f77d63 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -20,24 +20,35 @@ use super::{ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; - // SetOwner(owner, anchorType [, x, y]) — remember the owner, clear the content (the engine - // fires OnTooltipCleared: a fresh owner never inherits the last hover's lines or money), and - // point the plate per the anchor law. The six owner anchors are the documented 1.12 API set - // (spec-faithful, same posture as StatusBar's contract; the corpus exercises RIGHT/LEFT/ - // BOTTOMRIGHT); ANCHOR_NONE leaves pointing to the caller (GameTooltip_SetDefaultAnchor), - // ANCHOR_PRESERVE keeps the previous placement. The mode is stored on the tooltip and read back - // by `GetAnchorType` below. + // SetOwner(owner, anchorType [, x, y]) — the binding `0x5310d0` and the core it calls, + // `0x52ffe0(owner_region, mode, dx, dy)`: SetAlpha(255) (`0x52fff4`), fade-disarm + // (`0x530002`), `+0x314 = owner` (`0x53000c`), the mode (`0x530031`), the offsets, then the + // anchor-apply `0x52fe90`. We clear the content too, because the engine fires + // OnTooltipCleared: a fresh owner never inherits the last hover's lines or money. // - // **The `anchorType`-omitted default here is ANCHOR_RIGHT and the reference's is ANCHOR_NONE.** - // The reference's SetOwner core defaults `[+0x318]` to mode 7 = ANCHOR_NONE (`0x530012`, wow-re - // `bag-portrait-and-appendtext.md` §5) — no `SetPoint` at all, the plate left wherever it was. - // Two stock 1.12 files reach that leg (`QuestTimerFrame.xml:19`, `WorldStateFrame.xml:681`, - // both `GameTooltip:SetOwner(this)`), and neither is on `benilla.toc` yet, so nothing today - // takes it. It is left alone deliberately rather than quietly flipped: our ANCHOR_NONE arm - // below DROPS the plate's anchors where the reference merely skips the SetPoint, so changing - // the default would land those two hovers at the layout origin rather than "wherever it was" — - // the two questions have to be settled together, and the second one is a visible-placement - // change. Recorded here so the next reader sees a stated divergence, not an oversight. + // **The omitted / unreadable / unrecognised anchorType is mode 0 = ANCHOR_LEFT, silently.** + // `0x53120d` writes 0 into the binding's local mode *before any compare runs*; `0x531214`'s + // `lua_isstring` gate jumps the whole `SStrCmpI` chain when arg 3 is absent, nil, boolean or a + // table; the chain's last `jne` reaches the same join untouched; and `[0x531221, 0x53133a)` + // contains no `luaL_error` (earned zero, positive-controlled against the five this function + // *does* have in its owner-validation prologue). `0x5313c0` then hands that local to the core. + // + // The core's own `mov [+0x318],7` at `0x530012` is **not** that default: it is an + // unconditional pre-store that `0x530031` overwrites with the argument, and it survives only + // when the owner pointer is NULL — the un-own path `0x530a60` takes on every hide. + // + // **The anchor-apply clears, and only ANCHOR_PRESERVE escapes it** (`0x52fe90` read + // contiguously): a NULL owner returns at `0x52fe9e`; **mode 8 returns at `0x52fead`, before + // the clear**; then `0x52feb8` tests the caller's arg1, which `0x530037` always passes as + // **1** (`0x53002d push 0x1`), so the mode-7 skip at `0x52feba` is dead on this path and + // `0x52fec2 call 0x767ed0` (ClearAllPoints) runs for **every mode 0..7**; and only then does + // `0x52fed0 ja` send 6 and 7 home while 0..5 take the SetPoint jump table `0x52ffbc`. + // + // So the three arms below are: 0..5 clear-and-point, **6 and 7 clear**, 8 nothing. Decision + // 2176 replaces 2142's open thread 1, which had it backwards on both counts — it read the + // core's pre-store as the Lua default and read "no SetPoint" as "no clear", and a wow-re §5 + // dispatched for the CURSOR mechanism refuted both at the bytes (`system/ui/scratch/` + // `tooltip-cursor-anchor-law.md` §0.2/§0.3/§2). m.set( "SetOwner", lua.create_function( @@ -59,29 +70,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let owner_id = model.frame_id(owner_h); // The anchor mode is resolved ONCE, into the reference's own enum, and both // the placement below and `GetAnchorType`'s answer are taken from that one - // value — so the plate and the getter can never tell different stories. An - // unrecognised string (`ANCHOR_CURSOR` among them, which this engine cannot - // honour — see [`TooltipAnchor::Cursor`]) warns and is recorded as the - // ANCHOR_RIGHT it is actually placed by, not as the string it was handed. + // value — so the plate and the getter can never tell different stories. + // + // Absent, unreadable and unrecognised all land on **ANCHOR_LEFT**, which is + // the binding's zero-initialised local (`0x53120d`) surviving the + // `lua_isstring` gate or the compare chain, with no raise. The warning on the + // unrecognised leg is ours and stays: the reference is silent, but nothing + // observable to Lua changes, and this exact warning is what made + // `ANCHOR_CURSOR` visible as a real ninth mode nine corpus files ask for + // rather than a typo (2142's open thread 2). let anchor = match anchor .as_deref() .map(str::to_ascii_uppercase) .as_deref() - .unwrap_or("ANCHOR_RIGHT") + .unwrap_or("") { - "ANCHOR_RIGHT" => TooltipAnchor::Right, "ANCHOR_LEFT" => TooltipAnchor::Left, - "ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight, - "ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft, - "ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight, + "ANCHOR_RIGHT" => TooltipAnchor::Right, "ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft, + "ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight, + "ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft, + "ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight, + "ANCHOR_CURSOR" => TooltipAnchor::Cursor, "ANCHOR_NONE" => TooltipAnchor::None, "ANCHOR_PRESERVE" => TooltipAnchor::Preserve, + "" => TooltipAnchor::Left, other => { model.record_warning(format!( - "SetOwner: unknown anchor '{other}' (kept ANCHOR_RIGHT)" + "SetOwner: unknown anchor '{other}' (mode 0, ANCHOR_LEFT)" )); - TooltipAnchor::Right + TooltipAnchor::Left } }; tip_mut(&mut model, h)?.anchor = anchor; @@ -96,58 +114,56 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { None } }; - match pts { - Some((own, rel)) => { - let new = - Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0)); - let input = model.layout_inputs.entry(h).or_default(); - // The no-op compare keeps the per-frame SetOwner idiom (the bag - // hover's OnUpdate re-enter) from dirtying tier 1 by itself; the - // content clear above already reports its own writes. - let same = input.anchors.len() == 1 - && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - // The plate re-points at the button under the cursor, which moves - // an EDGE — structural under 1388, and therefore a whole-graph - // derivation on every bag slot and every spellbook button a hover - // sweep crossed. It names its node now (decision 1625): the old - // and new target lists are both right here, so the cached graph's - // edges are re-pointed instead of thrown away. - let old_targets: Vec = - input.anchors.iter().map(|a| a.relative_to).collect(); - input.anchors = vec![new]; - model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); + // **Modes 6 and 7 CLEAR and stop; mode 8 does neither.** `0x52fe90` reaches + // `0x52fec2 call 0x767ed0` for every mode 0..7 — the mode-7 skip beside it is + // gated on an arg1 that `SetOwner`'s core always passes as 1 — and mode 8 + // returned two compares earlier, at `0x52fead`, without touching anything. + // + // For ANCHOR_NONE that clear is what makes `GameTooltip_SetDefaultAnchor` + // (`SetOwner(owner, "ANCHOR_NONE")` → `ClearAllPoints()` → `SetPoint(...)`, + // the path of every action-bar hover) safe: a stale owner anchor cannot win + // the frame the caller forgets to re-point. For ANCHOR_CURSOR it is the + // opening move of a placement the per-frame update finishes + // (`script::tooltip::cursor_anchor`). + // + // Dropping every anchor is a retarget to the EMPTY target set, and it names + // its node like any other (decision 1625). It matters here more than + // anywhere: left on the conservative touch, this line was a whole-graph + // derivation on every action button the cursor crossed and on nothing else — + // exactly the shape the director's recorder reported (decision 1630). + if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { + let dropped = match model.layout_inputs.get_mut(&h) { + Some(input) if !input.anchors.is_empty() => { + Some(input.anchors.drain(..).map(|a| a.relative_to).collect()) } + _ => None, + }; + if let Some(old_targets) = dropped { + let old_targets: Vec = old_targets; + model.touch_layout_retarget_frame(h, &old_targets, &[]); } - None if anchor == TooltipAnchor::None => { - // The caller points it (ClearAllPoints+SetPoint) — drop ours now so a - // stale owner anchor never wins the frame the caller forgets to. - // - // Dropping every anchor is a retarget to the EMPTY target set, and it - // names its node like any other (decision 1625). It matters here more - // than anywhere: this arm is `GameTooltip_SetDefaultAnchor`'s, which is - // the path EVERY action-bar hover takes (stock `ActionButton_SetTooltip`, the - // `UberTooltips` default; the stance, pet and bonus bars the same) — - // while a bag slot anchors straight to its button and never comes - // through here. So a conservative touch on this line was a whole-graph - // derivation on every action button the cursor crossed, and on nothing - // else, which is exactly the shape the director's recorder reported (decision 1630): - // every derive frame owned by a MultiBar/BonusAction button. - let dropped = match model.layout_inputs.get_mut(&h) { - Some(input) if !input.anchors.is_empty() => { - Some(input.anchors.drain(..).map(|a| a.relative_to).collect()) - } - _ => None, - }; - if let Some(old_targets) = dropped { - let old_targets: Vec = old_targets; - model.touch_layout_retarget_frame(h, &old_targets, &[]); - } + } + if let Some((own, rel)) = pts { + let new = + Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0)); + let input = model.layout_inputs.entry(h).or_default(); + // The no-op compare keeps the per-frame SetOwner idiom (the bag + // hover's OnUpdate re-enter) from dirtying tier 1 by itself; the + // content clear above already reports its own writes. + let same = input.anchors.len() == 1 + && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + // The plate re-points at the button under the cursor, which moves + // an EDGE — structural under 1388, and therefore a whole-graph + // derivation on every bag slot and every spellbook button a hover + // sweep crossed. It names its node now (decision 1625): the old + // and new target lists are both right here, so the cached graph's + // edges are re-pointed instead of thrown away. + let old_targets: Vec = + input.anchors.iter().map(|a| a.relative_to).collect(); + input.anchors = vec![new]; + model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); } - // ANCHOR_PRESERVE. `TooltipAnchor::Cursor` shares the arm for - // exhaustiveness only — the string match above never produces it (it warns - // and records ANCHOR_RIGHT instead), so nothing reaches here that way. - None => {} } } fire_cleared(lua, h); diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index dda85d26..c9d27731 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -361,9 +361,9 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); s.run( - r#"WM = CreateFrame("Frame", "WM") WM:SetWidth(1024); WM:SetHeight(768) WM:SetPoint("CENTER") + r#"WM = CreateFrame("Frame", "WM") WM:SetWidth(1024); WM:SetHeight(768) WM:SetPoint("CENTER", 0, 0) Detail = CreateFrame("Frame", "WorldMapDetailFrame", WM) - Detail:SetWidth(1002); Detail:SetHeight(668) Detail:SetPoint("CENTER")"#, + Detail:SetWidth(1002); Detail:SetHeight(668) Detail:SetPoint("CENTER", 0, 0)"#, ) .unwrap(); s diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index db3092bd..bd1a6538 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -259,19 +259,27 @@ impl KindState { } } -/// A tooltip's anchor mode — the reference's `+0x318`, which `SetOwner` writes and -/// `GetAnchorType 0x5313e0` reads back through the 9-entry `{value, name}` table `0x531530` -/// (wow-re `bag-portrait-and-appendtext.md` §5; the mode ids in -/// `hover-hide-and-tooltip-owner-law.md` §4). +/// A tooltip's anchor mode — the reference's `+0x318`, which `SetOwner 0x5310d0` writes and +/// `GetAnchorType 0x5313e0` reads back through the 9-entry jump table `.rdata 0x531530`. /// -/// **All nine of the reference's modes are named here even though `SetOwner` cannot reach -/// [`TooltipAnchor::Cursor`].** The variant is not decoration: mode 6 is the cursor-following -/// tooltip, driven per frame by the class's own OnUpdate override `0x530b20` (re-anchor to -/// `[root+0x1118/0x111c]`), and nine corpus files ask for it by name — `pfUI`'s tooltip, xpbar and -/// chat modules, `pfQuest/browser.lua`, `TipBuddy`. Following the cursor is a mechanism this engine -/// does not have yet, and 1203 says a verb is not stubbed to make a caller happy, so -/// `tooltip::verbs`'s `SetOwner` still warns on the string and keeps its previous placement rather -/// than silently pretending. Naming the variant is what keeps that gap visible instead of implicit. +/// **The id order below is the jump table's, and it is NOT the setter's compare order** — a +/// distinction that cost wow-re a contradiction between two of its own notes (`bag-portrait-and- +/// appendtext.md` §5 had ids 2/3 and 4/5 swapped, because the setter compares `ANCHOR_BOTTOMRIGHT` +/// third and stores it as id 3 while comparing `ANCHOR_BOTTOMLEFT` fourth and storing it as id 2; +/// the getter's arm *bodies* sit in `.text` in that same compare order, so it reads as +/// corroboration). Settled from three independent ends and fixed in place there: +/// `system/ui/scratch/tooltip-cursor-anchor-law.md` §0/§0.1 (decision 2176). +/// +/// **All nine modes are reachable.** `SetOwner`'s absent / non-string / unrecognised argument is +/// mode **0** = [`TooltipAnchor::Left`], silently — the binding zero-initialises its local at +/// `0x53120d` and neither the `lua_isstring` gate nor the compare chain's fall-through raises. +/// +/// **What each mode does to the placement** (`0x52fe90`, read contiguously): a NULL owner returns +/// at once; mode **8** returns at `0x52fead`, *before* the clear; every other mode reaches +/// `0x52fec2 call 0x767ed0` (ClearAllPoints) — the mode-7 skip beside it is gated on an arg1 +/// `SetOwner`'s core always passes as 1 — and then modes **0..5** take the SetPoint jump table +/// `0x52ffbc` while **6** and **7** stop there. So: 0..5 clear and point, 6 and 7 clear, 8 does +/// neither. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TooltipAnchor { /// `ANCHOR_LEFT` — the plate's right edge on the owner's left. @@ -287,18 +295,28 @@ pub enum TooltipAnchor { TopLeft, /// `ANCHOR_TOPRIGHT`. TopRight, - /// `ANCHOR_CURSOR` — mode 6, the plate re-anchored to the live cursor every frame. Reachable - /// in the reference and NOT constructible here; see the type doc. + /// `ANCHOR_CURSOR` — mode 6: the anchors are cleared, and then the plate is re-anchored to the + /// live cursor **every frame** by the class's own update override `0x530b20` + /// (`script::tooltip::cursor_anchor` carries the law). Nine corpus files ask for it — `pfUI`'s + /// tooltip, xpbar and chat modules, `pfQuest/browser.lua`, `TipBuddy`. Cursor, - /// `ANCHOR_NONE` — mode 7: the owner is recorded and no `SetPoint` is applied, leaving the + /// `ANCHOR_NONE` — mode 7: the anchors are cleared and no `SetPoint` follows, leaving the /// caller to point the plate (`GameTooltip_SetDefaultAnchor` does exactly that on the next - /// line). **The default**, because it is the mode the reference's own `SetOwner` core stores - /// when the caller passes no anchor string (`0x530012`), and the only one of the nine that is - /// true of a plate nothing has anchored yet. + /// line, and that clear is what keeps a stale owner anchor from winning the frame a caller + /// forgets to re-point). + /// + /// **The default here**, which is a benilla choice rather than a read byte: the ctor + /// `0x529240` does not write `+0x318` at all, so what a never-owned plate holds depends on the + /// allocator, and `GetAnchorType` answers `"ANCHOR_NONE"` for anything out of range + /// (`0x53146f ja` shares the id-7 arm). ANCHOR_NONE is therefore the answer that is right + /// under the widest reading, and it is the one `pfUI/modules/tooltip.lua:97` compares against. + /// It is **not** `SetOwner`'s omitted-argument default, which is mode 0 — see the type doc. #[default] None, /// `ANCHOR_PRESERVE` — mode 8: the owner is recorded and the previous placement is kept - /// (`0x52fe90` skips the anchor-apply). Stored like any other mode, so `GetAnchorType` answers + /// *including its anchors* — `0x52fe90` returns at `0x52fead`, before the ClearAllPoints that + /// every other mode reaches. That clear-vs-keep split is the entire difference between this + /// and [`TooltipAnchor::None`]. Stored like any other mode, so `GetAnchorType` answers /// `"ANCHOR_PRESERVE"` after one — the reference does not resolve it back to what it preserved. Preserve, }