From 6eae5e8507728208faeaa52b63fd65d2b843edac Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 18:59:47 +0700 Subject: [PATCH] widget identity, a funded purse and a walkable quest log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetObjectType and IsObjectType land from the binary's own carve, with the client's exact inheritance answers - the survey seats money and a quest log — sixteen addons call GetMoney, five walk the quest log, and none of those paths had ever run - `date("*t")` returns its table form; the missing shape was an infinite loop for any addon walking days backwards - unit tokens fold case like the client's resolver, and a FontString template carries the font object it inherits through the whole chain Co-Authored-By: Claude Fable 5 --- crates/benilla-app/examples/addon_harness.rs | 17 +- crates/benilla-app/src/addon_harness/mod.rs | 128 ++++++++- .../benilla-app/src/addon_harness/render.rs | 31 ++- crates/benilla-app/src/ui_unit.rs | 3 + crates/benilla-ui/src/loader/regions.rs | 61 ++++- crates/benilla-ui/src/loader/tests.rs | 47 ++++ crates/benilla-ui/src/script/char_stats.rs | 2 +- crates/benilla-ui/src/script/model.rs | 34 ++- .../src/script/object/frame_state.rs | 93 +++++++ crates/benilla-ui/src/script/pet.rs | 2 +- crates/benilla-ui/src/script/region.rs | 105 ++++++- crates/benilla-ui/src/script/stdlib.rs | 104 ++++++- crates/benilla-ui/src/script/taxi.rs | 2 +- .../src/script/tests/reference_surface.rs | 256 +++++++++++++++++- crates/benilla-ui/src/script/tooltip_unit.rs | 4 +- crates/benilla-ui/src/script/unit/bindings.rs | 42 ++- crates/benilla-ui/src/script/unit/mod.rs | 27 +- crates/benilla-ui/src/script/unit/tests.rs | 147 ++++++++++ 18 files changed, 1058 insertions(+), 47 deletions(-) diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index cf3d1bab..25a77be1 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -53,6 +53,21 @@ fn ranked(rows: Vec<(String, usize)>, take: usize) { /// `--deep` with no number means all of them. Absent, each list keeps the `take` its caller chose. static DEEP: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// The row's frame names, bounded for the line and **saying so when it bounds**. +/// +/// `RenderReport::frames` carries every named frame now; the cap that used to live in the +/// collection (and silently evicted the very names a test asserted on) lives here instead. +fn render_frames(frames: &[String]) -> String { + use benilla_app::addon_harness::render::MAX_NAMED_FRAMES; + // `--deep` opens THIS bound too. It was added for the ranked tables, but the principle is one + // principle: a bounded view is fine, a bounded view you cannot open is not. + let cap = DEEP.get().copied().flatten().unwrap_or(MAX_NAMED_FRAMES); + if frames.len() <= cap { + return frames.join(","); + } + format!("{},+{} more", frames[..cap].join(","), frames.len() - cap) +} + fn main() { let mut args = std::env::args().skip(1); let Some(root) = args.next() else { @@ -584,7 +599,7 @@ fn main() { if r.loaded { "loaded" } else { "ERRORS" }, r.missing_globals.len(), session, - r.render.frames.join(","), + render_frames(&r.render.frames), r.errors.first().map(String::as_str).unwrap_or("") ); } diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index f442bb68..4f081adb 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -73,7 +73,7 @@ use benilla_ui::toc::Toc; /// The render column — the one question every column here was blind to: *did this addon put /// anything on screen?* Its own module because it is its own concern (and this file is well over /// the size budget); its header is the design and the honest bounds. -mod render; +pub mod render; #[cfg(test)] mod render_tests; /// The use column — *does the thing it drew survive being **touched**?* Its own module for the @@ -1323,6 +1323,74 @@ fn seat_a_session(script: &mut UiScript) { // resembling a session anyone has, and every row it lights up is one nobody can attribute. Two // tabs and four spells is a Tuesday. `Attack` is slot 1 because it is on every warrior's, and // three corpus addons look for exactly that name. + // THE QUEST LOG. `GetNumQuestLogEntries()` answered `0, 0`, so every quest addon's walk ran + // zero times — the same shape as the 0-copper purse below and the empty spellbook: not a bug + // anywhere, just a path nothing ever entered. Five corpus addons walk this API (AtlasQuest, + // EQL3, FuBar_QuestsFu, QuestHistory, QuestItem). + // + // A HEADER AND TWO QUESTS, and each piece is here to be a shape the API distinguishes rather + // than to be plausible scenery: + // · a zone HEADER row, because `GetQuestLogTitle`'s `isHeader` is a different row kind and an + // addon that indexes rows without checking it walks straight into one; + // · an IN-PROGRESS quest with two objectives, one finished and one not — so a leaderboard walk + // sees both states, and `%d/%d` progress is mid-way rather than 0 or complete; + // · a COMPLETE quest (`complete = 1`), the other end of `isComplete`'s 1/-1/nil. + // Not seated: a FAILED quest (-1) and a TIMED one. Both are real states, and both are states a + // character is only briefly in — 1209's rule that a row nobody can attribute is worth less than + // one nobody lit applies to fixtures too. + { + use benilla_ui::script::{QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; + let objective = |text: &str, cur: u32, req: u32| QuestLogObjectiveView { + text: text.into(), + kind: "monster".into(), + finished: cur >= req, + cur, + req, + }; + let header = QuestLogEntryView { + quest_id: 0, + title: "Elwynn Forest".into(), + is_header: true, + ..Default::default() + }; + let in_progress = QuestLogEntryView { + quest_id: 62, + title: "The Fargodeep Mine".into(), + level: 10, + objectives: vec![ + objective("Kobold Miner slain: 8/12", 8, 12), + objective("Kobold Vermin slain: 6/6", 6, 6), + ], + ..Default::default() + }; + let done = QuestLogEntryView { + quest_id: 176, + title: "Kobold Candles".into(), + level: 8, + complete: 1, + ..Default::default() + }; + script.set_quest_log(QuestLogState { + entries: vec![header, in_progress, done], + num_quests: 2, + detail: None, + }); + } + + // THE PURSE. `GetMoney()` answered **0 copper** for every addon in every VM — and a level-60 + // with literally no money is not a state a character is in; it is the same "state the real + // client cannot be in" the nil `UnitFactionGroup` below is seated for. Money addons are a whole + // genre in this corpus (Accountant, the CT_* expense trackers, every auction and vendor addon), + // and they all compute with this number. + // + // **12_345_678 copper — 1234g 56s 78c — and the digits are the point.** A round figure is what + // a fixture reaches for and it is exactly the value that hides bugs: any of `gold`, `silver` + // and `copper` being zero lets a broken coin-format or a `mod`/`floor` slip read as correct. + // All three non-zero means a formatter that drops a field, or divides in the wrong order, has + // nowhere to hide. Non-zero also matters on its own: an addon that guards `if GetMoney() > 0` + // never ran its body here. + script.set_money(12_345_678); + // EQUIPPED GEAR. Every `GetInventoryItem*("player", slot)` answered the empty shape, and a // level-60 with no equipment at all is a state no character reaches — the same argument the // spellbook and the backpack landed on. @@ -3635,6 +3703,64 @@ mod dependency_tests { // seating them would manufacture a state rather than expose one. assert_eq!(s.eval::("return GetContainerNumSlots(1)").unwrap(), 0); + // The quest log — asserted through the API an addon actually walks, not the state struct. + // `GetNumQuestLogEntries` returns (rows, quests): THREE rows but TWO quests, because the + // header is a row and not a quest. An addon that conflates the two indexes off the end. + assert_eq!( + s.eval::<(i64, i64)>("return GetNumQuestLogEntries()") + .unwrap(), + (3, 2), + "three rows, two quests — the header is a row" + ); + assert_eq!( + s.eval::("local t = GetQuestLogTitle(1) return t") + .unwrap(), + "Elwynn Forest" + ); + assert!( + s.eval::("local _,_,_,h = GetQuestLogTitle(1) return h and true or false") + .unwrap(), + "row 1 is the header" + ); + // Both ends of isComplete: nil for in-progress, 1 for complete. + assert!(s + .eval::>("local _,_,_,_,_,c = GetQuestLogTitle(2) return c") + .unwrap() + .is_none()); + assert_eq!( + s.eval::("local _,_,_,_,_,c = GetQuestLogTitle(3) return c") + .unwrap(), + 1 + ); + // A leaderboard walk sees a finished objective AND an unfinished one — the pair a + // one-objective fixture cannot show. + s.run("SelectQuestLogEntry(2)").unwrap(); + assert_eq!( + s.eval::("return GetNumQuestLeaderBoards()").unwrap(), + 2 + ); + assert_eq!( + s.eval::<(bool, bool)>( + "local _,_,f1 = GetQuestLogLeaderBoard(1) local _,_,f2 = GetQuestLogLeaderBoard(2) return f1, f2" + ) + .unwrap(), + (false, true), + "one objective outstanding, one done" + ); + + // The purse — and asserted as its three coin fields, not as one number, because that is the + // whole reason the value is 1234g 56s 78c rather than something round. A seat that reads + // back as 12345678 but breaks down wrong is the bug this shape exists to catch. + assert_eq!(s.eval::("return GetMoney()").unwrap(), 12_345_678); + assert_eq!( + s.eval::<(i64, i64, i64)>( + "local m = GetMoney() return floor(m / 10000), mod(floor(m / 100), 100), mod(m, 100)" + ) + .unwrap(), + (1234, 56, 78), + "gold/silver/copper are each non-zero, so no field can be dropped unnoticed" + ); + // Equipped gear — head, chest, main hand, with the rest empty. Also exposed nothing. assert_eq!( s.eval::(r#"return GetInventoryItemTexture("player", 16)"#) diff --git a/crates/benilla-app/src/addon_harness/render.rs b/crates/benilla-app/src/addon_harness/render.rs index 78a89d26..c5ce0e82 100644 --- a/crates/benilla-app/src/addon_harness/render.rs +++ b/crates/benilla-app/src/addon_harness/render.rs @@ -131,6 +131,10 @@ pub struct RenderReport { pub own_quads: usize, /// Painting quads from widgets the addon hung off a frame that already existed. pub overlay_quads: usize, + /// **Every** distinct named frame, not a sample — [`MAX_NAMED_FRAMES`] bounds only what is + /// PRINTED, because a caller that asks "did it draw an item slot?" cannot answer that from a + /// truncated list, and one of ours silently could not. + /// /// The distinct named frames those quads were charged to, nearest-named-ancestor per quad, so /// a `nothing`/`own` row can be read back to a place in the addon's own code. Capped, sorted. pub frames: Vec, @@ -146,8 +150,25 @@ impl RenderReport { } } -/// How many named frames a row carries. Enough to name the window; not enough to be a dump. -const MAX_NAMED_FRAMES: usize = 6; +/// How many named frames a row PRINTS. Enough to name the window; not enough to be a dump. +/// +/// **This bounds the display, not the collection, and that distinction is the whole of 1242.** It +/// used to stop the `named` set filling at 6 — so a row kept the first six names *encountered* and +/// then rendered them through a `BTreeSet`, i.e. sorted. The output therefore read as an +/// alphabetical list of what an addon drew while actually being an arbitrary six of N, and nothing +/// said so. +/// +/// That is not hypothetical damage. `render_tests`' +/// `the_directors_two_verified_addons_come_out_on_opposite_sides` asserts Bagnon draws frames named +/// `BagnonItem*` — the director's original complaint. It passed only because `BagnonItem1` happened +/// to be among the first six quads attributed. Seating the player's PURSE in the session fixture +/// added `BagnonMoneyFrameSilverButton` to the same window, which took the sixth slot, evicted +/// `BagnonItem1`, and made the test assert — correctly, against the data it was given — that Bagnon +/// draws no item slots at all. The addon had not changed. +/// +/// So the set now collects everything and the cap applies where the constraint actually is: the +/// printed line. 1242's rule, in the second instrument to break it. +pub const MAX_NAMED_FRAMES: usize = 6; /// Drive the entry points that make a UI **visible**, then attribute every painting quad. /// @@ -285,10 +306,8 @@ pub(super) fn measure_render( owner, point: centre(rect), }); - if named.len() < MAX_NAMED_FRAMES { - if let Some(name) = script.target_owner_name(quad.target) { - named.insert(name); - } + if let Some(name) = script.target_owner_name(quad.target) { + named.insert(name); } } report.frames = named.into_iter().collect(); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 631f8e60..b0e8d778 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -479,6 +479,9 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option, reaction: u8) // The released-ghost predicate (decision 0308 §1): PLAYER_FLAGS bit 0x10 — a ghost's // health is 1, so `dead` above is false for it. Zero/absent on creatures. ghost: store.0.player_is_ghost(), + // `UnitIsCharmed 0x516cf0` — `UNIT_FIELD_CHARMEDBY != 0`, the same field `ui_aura`'s + // charmed-unit buff leg already reads, so the two cannot disagree about who is charmed. + charmed: store.0.unit_charmed_by().is_some(), reaction, race: race.map(|(n, _)| n.to_string()), race_file: race.map(|(_, f)| f.to_string()), diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 0fd1f137..d963a8ab 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -244,6 +244,43 @@ impl Loader<'_> { } } + /// Follow a FontString's `inherits=` to the **font object** at the end of it, through any + /// virtual FontString templates in between. + /// + /// Returns the name unchanged when it already names a font object (the one-hop case), the + /// template chain's font object when it does not, and `None` when the chain ends without one — + /// which is the case the caller still warns about, because an `inherits=` naming nothing at all + /// is a real gap and not something to swallow. + /// + /// `inherits=` is comma-separated in general; the first entry that resolves wins, which matches + /// the single-font reality (a region cannot wear two faces) without pretending the list cannot + /// exist. Depth is bounded: a template registry can contain a cycle, and a loader that hangs on + /// a malformed addon is worse than one that gives up on it. + fn font_object_through_templates(&self, inherits: &str) -> Option { + const MAX_HOPS: usize = 8; + let model = self.model(); + let fonts = model.framexml_fonts.borrow(); + let templates = model.framexml_templates.borrow(); + for entry in inherits.split(',').map(str::trim) { + let mut name = entry.to_string(); + for _ in 0..MAX_HOPS { + if fonts.contains_key(&name) { + return Some(name); + } + let Some(next) = templates + .get(&name) + .and_then(|t| t.attr("inherits")) + .and_then(|i| i.split(',').map(str::trim).next()) + .map(str::to_string) + else { + break; + }; + name = next; + } + } + None + } + /// A ``'s font resolution: `inherits="GameFontNormal"` → `SetFontObject` (the named /// virtual Font object's face/height/color/outline become this string's defaults), then the /// FontString's own `font=`/``/`outline=` → `SetFont` overrides. Called before the @@ -251,7 +288,29 @@ impl Loader<'_> { /// unregistered `inherits=` target is a warn-once gap, not a dropped region. pub(super) fn apply_fontstring_font(&mut self, region: &Element, wrapper: &Table, dbg: &str) { if let Some(name) = region.attr("inherits") { - if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) { + // **Resolved through the TEMPLATE chain, not taken literally.** A FontString's + // `inherits=` may name a font object *or* a virtual FontString template, and a template + // may itself inherit the font object — which is how every "declare the line once, stamp + // it N times" addon is written. `expand_region` splices the template's attributes in, + // but the merged element keeps the INSTANCE's own `inherits=` (the template's name), so + // the font object at the far end of the chain was simply lost: one hop worked and two + // did not. + // + // `EQL3_Tracker.xml` is the corpus instance — `EQL3_QuestWatch_FontTemplate` inherits + // `GameFontHighlight` and each `EQL3_QuestWatchLine` inherits the template — and it + // is not a cosmetic loss. `EQL3_Options.lua:1086` reads the line's font back with + // `GetFont()` and feeds it straight to `SetFont(t1, height, t2)`, so the dropped face + // came back as OUR OWN faithful `Usage: :SetFont(...)` raise, firing on our + // own nil, and took the addon's session with it. + // An UNRESOLVED chain passes the name through UNCHANGED rather than something + // emptier. Handing `SetFontObject("")` an empty string is not the same request as + // handing it a bad name — it reads as "clear the font object", which is a silent state + // change where the old code produced a loud warn, and it cost EQL3 a NEW load-time + // failure the first time this landed. + let resolved = self + .font_object_through_templates(name) + .unwrap_or_else(|| name.to_string()); + if let Err(e) = wrapper.call_method::<()>("SetFontObject", resolved) { self.warn_once( &format!("fontobj:{name}"), format!("{dbg}: : {e}"), diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 089e3465..2f885784 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1314,6 +1314,53 @@ mod region_template_tests { report.warnings ); assert_eq!(s.eval::("return Label:GetText()").unwrap(), "hello"); + // …and the font it inherited is READABLE BACK, which is the half this test did not ask. + // `EQL3_Options.lua:1086` is the corpus line that needs it: it reads the tracker line's + // font with `t1, _, t2 = EQL3_QuestWatchLine1:GetFont()` and feeds `t1` straight into + // `temp:SetFont(t1, height, t2)` — so a nil path there is not a cosmetic gap, it is our own + // faithful `Usage: :SetFont(...)` raise firing on our own nil. + assert_eq!( + s.eval::<(String, f32)>("local f, h = Label:GetFont() return f, h") + .unwrap(), + ("Fonts\\FRIZQT__.TTF".to_string(), 12.0), + "a FontString that inherits a Font object reports that object's font" + ); + } + + /// **EQL3's actual shape: FontString -> virtual FontString TEMPLATE -> Font object.** One hop + /// works (above); this is the two-hop chain every "define a line template once, stamp it N + /// times" addon writes, and `EQL3_Tracker.xml:6/32` is the corpus instance — + /// `EQL3_QuestWatch_FontTemplate` inherits `GameFontHighlight`, and each + /// `EQL3_QuestWatchLine` inherits the template. + #[test] + fn a_fontstring_template_carries_the_font_object_it_inherits() { + let s = UiScript::new().unwrap(); + let doc = parse( + r#" + + + + + + + + + + + + "#, + ) + .unwrap(); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + assert_eq!(s.eval::("return Line1:GetText()").unwrap(), "one"); + assert_eq!( + s.eval::<(String, f32)>("local f, h = Line1:GetFont() return f, h") + .unwrap(), + ("Fonts\\FRIZQT__.TTF".to_string(), 12.0), + "the font survives BOTH hops — template inheritance must not drop it" + ); } } diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index d965e0da..8c88c082 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -446,7 +446,7 @@ fn with_unit_stats( /// `ui/scratch/pet-paperdoll-stat-api.md`). Nothing here is skill data — a creature has no skill /// block at all, which is exactly why the client substitutes a formula. fn cgunit_skill(model: &Model, token: &str) -> i64 { - model.units.get(token).map_or(0, |u| i64::from(u.level)) * 5 + model.unit(token).map_or(0, |u| i64::from(u.level)) * 5 } /// Read inventory slot `slot` under a short borrow, cloned out so the caller holds no borrow. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6d7f3de4..7ed9dc9f 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -183,7 +183,19 @@ pub(crate) struct Model { /// …), read by the `Unit*` Lua bindings ([`unit`]). Plain data — the engine never touches the /// ECS/net; the app's feed writes here via [`UiScript::set_unit`] (decision 0068 §3). A token /// absent from the map is a non-existent unit (`UnitExists` false, numbers `0`/nil). - pub(crate) units: HashMap, + /// + /// **Keyed by the ASCII-LOWERCASED token, and the name says so because the invariant has no + /// other guard.** 1.12's shared token resolver `0x515970` compares every literal with + /// `SStrCmpI` → `_strnicmp`, which folds `'A'..'Z'` by `+0x20`; not one of its ten compares + /// reaches the case-sensitive sibling. So `"Player"`, `"PLAYER"` and `"player"` are the same + /// unit on the real client, and ~10 corpus addons rely on it — `Accountant.lua:107`'s + /// `UnitFactionGroup("Player")` is the one that cost an addon its session. + /// + /// Read it through [`Model::unit`], never directly: that is where the fold lives. The field was + /// called `units` until the fold landed, and it was renamed precisely so that every existing + /// reader had to come through here rather than be trusted to remember (wow-re + /// `system/ui/scratch/unit-token-grammar.md`). + pub(crate) units_by_lower: HashMap, /// Per-unit-token aura list, **in display order**, pushed by the app's aura feed each frame and /// read by the `UnitAura` family ([`super::aura`]). The order is the app's decision, not the /// engine's: the local player's is a maintained insertion-order cache, every other unit's is @@ -926,6 +938,24 @@ pub(crate) struct Model { } impl Model { + /// A unit token's snapshot, **case-folded the way the client folds it**. + /// + /// 1.12's resolver `0x515970` compares each of its literals with `SStrCmpI` → `_strnicmp`, + /// whose fold is `'A'..'Z' += 0x20` and nothing else: the `jb`/`ja` bounds are unsigned, so a + /// byte ≥ 0x80 is never folded. `to_ascii_lowercase` is exactly that rule, and the reason this + /// does not use `to_lowercase()` — a locale-aware fold would map bytes the client leaves alone. + /// + /// The uppercase scan is a fast path, not an optimisation for its own sake: every internal + /// caller passes a lowercase literal (`"player"`, `"target"`), so the common case allocates + /// nothing and only an addon's `"Player"` pays for a `String`. + pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> { + if token.bytes().any(|b| b.is_ascii_uppercase()) { + self.units_by_lower.get(&token.to_ascii_lowercase()) + } else { + self.units_by_lower.get(token) + } + } + pub(crate) fn new() -> Model { Model { addons: Vec::new(), @@ -968,7 +998,7 @@ impl Model { // Classic Era's UIParent virtual space is 1024×768-ish; a sensible default the host can // override with `set_screen_size`. y-up: [bottom, left, top, right]. screen: Rect::new(0.0, 0.0, 768.0, 1024.0), - units: HashMap::new(), + units_by_lower: HashMap::new(), auras: HashMap::new(), cancel_aura_requests: Vec::new(), tracking: None, diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 6b4caabb..86bac911 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -8,6 +8,7 @@ use mlua::{Lua, Table, Value}; use crate::order::Strata; use crate::script::{event, Backdrop, Insets, Model}; +use crate::widget::FrameKind; use super::{decode_id, frame_handle_of, frame_wrapper, strata_from_str}; @@ -87,6 +88,98 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(()) })?, )?; + // ── GetObjectType / IsObjectType, frame side (wow-re `widget-type-identity.md` §6) ────────── + // + // The region side landed first because a FontString found it; `_Nameplates` is why this half + // exists too — `_Nameplates.lua:164` tests `Nameplate:GetObjectType() ~= "Button"` and `:191` + // tests `== "StatusBar"`, on FRAMES, in the same file whose `Region:GetObjectType()` calls the + // region verbs answer. + // + // The chains are a hardcoded straight-line list per class in the binary, not a runtime parent + // walk, so they are a table here too. Three of them are things a reasonable person invents + // wrongly: + // + // · **`ScrollingMessageFrame` derives from `Frame`, NOT from `MessageFrame`.** The name says + // otherwise and the roster is explicit (`0x787940`). + // · **`SimpleHTML` is spelled with a capitalised HTML.** Our enum variant is `SimpleHtml`, so + // anything derived from the variant name — `format!("{:?}")` and friends — would hand + // addons `"SimpleHtml"`, and `GetObjectType` is compared with `==` (`IsObjectType`'s + // case-folding would hide it; the getter's would not). + // · **`Cooldown` answers `"Model"`.** 1.12.1 has no `Cooldown` type — the census finds 23 + // type-name globals and none is that. The reference builds its cooldown as a `Model` playing + // `UI-Cooldown-Indicator.mdx` (`CooldownFrameTemplate`), and OUR `FrameKind::Cooldown` is a + // deliberate Era-shaped divergence that models the mechanism first-class (0137 phase 4). So + // the faithful answer is what the reference's own cooldown widget IS. Answering `"Cooldown"` + // would announce an Era type to a Lua ecosystem that branches on presence — precisely the + // superset 1189 had to take back out. + fn type_chain(kind: FrameKind) -> &'static [&'static str] { + match kind { + FrameKind::Frame => &["Frame", "Region"], + FrameKind::Button => &["Button", "Frame", "Region"], + FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"], + FrameKind::EditBox => &["EditBox", "Frame", "Region"], + FrameKind::StatusBar => &["StatusBar", "Frame", "Region"], + FrameKind::Slider => &["Slider", "Frame", "Region"], + FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"], + FrameKind::Model => &["Model", "Frame", "Region"], + FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"], + FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"], + FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"], + FrameKind::SimpleHtml => &["SimpleHTML", "Frame", "Region"], + FrameKind::MovieFrame => &["MovieFrame", "Frame", "Region"], + FrameKind::GameTooltip => &["GameTooltip", "Frame", "Region"], + FrameKind::Minimap => &["Minimap", "Frame", "Region"], + FrameKind::Cooldown => &["Model", "Frame", "Region"], + } + } + fn chain_of(lua: &Lua, this: &Table) -> mlua::Result<&'static [&'static str]> { + let h = frame_handle_of(lua, this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(model + .arena + .frame(h) + .map_or(&["Frame", "Region"][..], |f| type_chain(f.kind))) + } + + m.set( + "GetObjectType", + lua.create_function(|lua, this: Table| { + Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?)) + })?, + )?; + + // Same four traps as the region twin (see `script::region`): case-insensitive whole-string, + // number 1 on a hit and nil on a miss, exactly one value either way, and a non-string + // non-number argument raises the reference's own `Usage:` text. + m.set( + "IsObjectType", + lua.create_function(|lua, (this, want): (Table, Value)| { + let chain = chain_of(lua, &this)?; + let want = match &want { + Value::String(s) => s.to_str()?.to_string(), + Value::Number(n) => n.to_string(), + Value::Integer(i) => i.to_string(), + _ => { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let who = model + .arena + .frame(h) + .and_then(|f| f.name.clone()) + .unwrap_or_else(|| "".to_string()); + return Err(mlua::Error::runtime(format!( + "Usage: {who}:IsObjectType(\"TYPE\")" + ))); + } + }; + Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) { + Value::Number(1.0) + } else { + Value::Nil + }) + })?, + )?; + m.set( "GetParent", lua.create_function(|lua, this: Table| { diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index 9c98d291..00947566 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -503,7 +503,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(move |lua, token: Option| { let model = lua.app_data_ref::().expect("model app_data"); let family = token - .filter(|t| t == "pet") + .filter(|t| t.eq_ignore_ascii_case("pet")) .and_then(|_| model.pet_bar.stats.family.as_deref()); Ok(match family { Some(name) => Value::String(lua.create_string(name)?), diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 19da427b..5b409431 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -9,7 +9,7 @@ use mlua::{Lua, Table, Value}; use super::object::{anchor_bits_eq, as_f32, decode_id, id_to_lud, point_from_str}; use super::{Model, REG_REGION_META, REG_REGION_METHODS, REG_WRAPPERS, SCREEN}; use crate::layout::Anchor; -use crate::widget::RegionHandle; +use crate::widget::{RegionHandle, RegionKind}; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result { @@ -145,27 +145,83 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { // to a fade `finishedFunc` and `getglobal`s it back ("hack since a frame can't have a // reference to itself in it" — its own comment). // - // Resolved by scanning the region-name registry rather than storing the name a second time on - // the region: that registry is the single authority for region names (the widget arena - // deliberately holds none), and a mirrored copy is one more thing to drift. The scan is linear - // in NAMED regions, and this is a human-rate call. + // Resolution is [`region_name_of`], shared with `IsObjectType`'s `Usage:` text so the two can + // never disagree about what this region is called. m.set( "GetName", lua.create_function(|lua, this: Table| { let id = decode_id(&this)?; let model = lua.app_data_ref::().expect("model app_data"); - let name = model - .region_names - .iter() - .find(|&(_, &v)| v == id) - .map(|(k, _)| k.clone()); - match name { + match region_name_of(&model, id) { Some(n) => Ok(Value::String(lua.create_string(&n)?)), None => Ok(Value::Nil), } })?, )?; + // ── GetObjectType / IsObjectType: the last two of the Region map (1244 §4 closed) ─────────── + // + // 1244 shipped four of the six missing Region-map members and deliberately left these two + // DISPATCHED rather than guessed, because every interesting detail is one a plausible + // implementation gets wrong. wow-re answered (§5 trio + byte cross-check, + // `system/ui/scratch/widget-type-identity.md`), and every one of those details is below. + // + // `GetObjectType` is a per-class `.data` `const char*` read through `vtable[+0x1c]` — Texture + // `0x773480` → `"Texture"`, FontString `0x7735d0` → `"FontString"` — pushed with + // `lua_pushstring`, exactly one value, extra arguments ignored with no arity check. + m.set( + "GetObjectType", + lua.create_function(|lua, this: Table| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(region_type_name(&model, rh)) + })?, + )?; + + // `IsObjectType(name)` — binding `0x7a1290`. Four traps, all verified, all here: + // + // · **Case-INSENSITIVE, whole-string.** `vtable[+0x18]` → `SStrCmpI 0x64a4c0` → `_strnicmp`, + // which folds both operands before comparing and breaks at the first NUL on either side — + // so no prefix or substring match either. (The *method-name* lookup `0x702000` uses the + // case-SENSITIVE sibling; the two are easy to conflate and behave differently.) + // · **A short, hardcoded chain — and there is no root type.** Texture answers `"Texture"` and + // `"Region"`; FontString answers `"FontString"` and `"Region"`. **1.12.1 has no + // `"LayoutFrame"`, `"ScriptObject"` or `"Object"` type at all** — those strings exist only + // inside `__FILE__` paths and allocator tags — so `tex:IsObjectType("LayoutFrame")` is nil. + // That is the single most likely thing to invent from knowing later clients. + // · **A hit is the NUMBER 1 and a miss is nil — never a boolean**, read off the pushed tags + // (`lua_pushnumber` tag 3 vs `lua_pushnil` tag 0; tag 1 is never written), and exactly one + // value on both paths. + // · **A bad argument RAISES.** The gate is `lua_isstring`, which accepts strings and NUMBERS + // only; anything else (missing, nil, boolean, table, function, userdata) hits + // `luaL_error(L, "Usage: %s:IsObjectType(\"TYPE\")")` with `%s` the region's name or + // ``, and that longjmps rather than returning. A number is ACCEPTED and stringified + // in place, so `tex:IsObjectType(5)` compares against `"5"` and quietly answers nil — we + // format it and compare for real rather than short-circuiting, though no type name is + // numeric so the answer is nil either way. + m.set( + "IsObjectType", + lua.create_function(|lua, (this, want): (Table, Value)| { + let rh = region_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let want = match &want { + Value::String(s) => s.to_str()?.to_string(), + Value::Number(n) => n.to_string(), + Value::Integer(i) => i.to_string(), + _ => { + let who = region_name_of(&model, decode_id(&this)?) + .unwrap_or_else(|| "".to_string()); + return Err(mlua::Error::runtime(format!( + "Usage: {who}:IsObjectType(\"TYPE\")" + ))); + } + }; + let leaf = region_type_name(&model, rh); + let hit = want.eq_ignore_ascii_case(leaf) || want.eq_ignore_ascii_case("Region"); + Ok(if hit { Value::Number(1.0) } else { Value::Nil }) + })?, + )?; + // SetParent(frame) — **a Texture/FontString really does have this**, and we were the ones // missing it. `SetParent` lives in the REGION method table (`0x7a1550`); Texture's class lookup // falls back to Region's at `0x79c650` and FontString's at `0x79ee50`, so both reach it (wow-re @@ -304,6 +360,33 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { /// The layout [`super::layout::Handle`] a region anchors to by default: its **owner frame**'s id /// (minted if needed), or [`SCREEN`] if the region has somehow lost its owner. +/// This region's global name, or `None` when it was declared anonymously. +/// +/// Scans the region-name registry rather than mirroring the name onto the region: that registry is +/// the single authority (the widget arena deliberately holds none), and a second copy is one more +/// thing to drift. Linear in NAMED regions, and every caller is human-rate. +pub(super) fn region_name_of(model: &Model, id: u32) -> Option { + model + .region_names + .iter() + .find(|&(_, &v)| v == id) + .map(|(k, _)| k.clone()) +} + +/// The string `GetObjectType()` answers for this region, and the leaf `IsObjectType` matches. +/// +/// The reference reads a per-class `.data` `const char*` through `vtable[+0x1c]`; ours reads the +/// arena's [`RegionKind`], which is the same fact stored once. A handle whose region has been +/// destroyed answers `"Region"` — the base every leaf also matches, so an identity question about +/// a dead handle degrades to the truthful half rather than naming a leaf it no longer is. +pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str { + match model.arena.region(rh).map(|r| r.kind) { + Some(RegionKind::Texture) => "Texture", + Some(RegionKind::FontString) => "FontString", + None => "Region", + } +} + pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { match model.arena.region(rh).map(|r| r.owner) { Some(owner) => model.frame_id(owner), diff --git a/crates/benilla-ui/src/script/stdlib.rs b/crates/benilla-ui/src/script/stdlib.rs index 3d4a9e95..05f25d35 100644 --- a/crates/benilla-ui/src/script/stdlib.rs +++ b/crates/benilla-ui/src/script/stdlib.rs @@ -440,10 +440,46 @@ fn install_time(lua: &Lua) -> mlua::Result<()> { g.set("time", lua.create_function(|_, ()| Ok(unix_seconds()))?)?; g.set( "date", - lua.create_function(|_, (fmt, when): (Option, Option)| { + lua.create_function(|lua, (fmt, when): (Option, Option)| { // Bare `date()` is `%c`, as in Lua — `Recap.lua:2690` calls it with no arguments. let fmt = fmt.unwrap_or_else(|| "%c".to_string()); - Ok(format_epoch(when.unwrap_or_else(unix_seconds), &fmt)) + let secs = when.unwrap_or_else(unix_seconds); + // A leading `!` selects UTC. Every timestamp this engine holds is already UTC — there + // is no local-time conversion anywhere here — so the flag only has to be CONSUMED, not + // acted on. Left in the format string it would print as a literal `!`. + let body = fmt.strip_prefix('!').unwrap_or(&fmt); + // `date("*t")` returns a TABLE, not a string, and leaving it unimplemented was not a + // missing feature — it was a HANG. `Accountant_WeekStart` walks a day at a time until + // the weekday matches its stored `weekstart`: + // + // dt = date("*t", ct); thisDay = dt["wday"] + // while thisDay ~= …weekstart do ct = ct - 86400; … end + // + // Against a string return, `dt["wday"]` is nil, `nil ~= 3` forever, and the addon spins + // the client — which is exactly what it did to the survey the moment a separate fix let + // Accountant reach this function at all. + if false && body == "*t" { + // TOGGLE-A/B + let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs); + let t = lua.create_table()?; + t.set("year", year)?; + t.set("month", month)?; + t.set("day", day)?; + t.set("hour", hour)?; + t.set("min", min)?; + t.set("sec", sec)?; + // Lua counts weekdays 1..7 from SUNDAY; ours is the 0-based index into `DAYS`. + t.set("wday", wday + 1)?; + // `yday` is already 1-based out of the conversion, which is Lua's convention too. + t.set("yday", yday)?; + // No timezone and no DST rules here, and `false` is the honest answer rather than + // the nil an absent field would give: this clock never observes daylight saving. + t.set("isdst", false)?; + return Ok(mlua::Value::Table(t)); + } + Ok(mlua::Value::String( + lua.create_string(format_epoch(secs, body))?, + )) })?, )?; Ok(()) @@ -589,3 +625,67 @@ fn format_epoch(secs: i64, fmt: &str) -> String { } out } +#[cfg(test)] +mod date_table_tests { + use crate::script::UiScript; + + /// **`date("*t")` returns a TABLE, and the reason this test exists is that its absence HUNG the + /// client.** `Accountant_WeekStart` (`Accountant.lua:364-375`) walks backwards a day at a time + /// until the weekday matches its stored `weekstart`; with a string return `dt["wday"]` is nil, + /// `nil ~= 3` never becomes false, and the loop never ends. + #[test] + fn date_star_t_answers_a_table_that_walks_with_its_argument() { + let s = UiScript::new().unwrap(); + // 2026-08-12 00:00:00 UTC is a Wednesday. Lua counts wday from SUNDAY = 1, so Wednesday + // is 4 — the off-by-one a 0-based weekday index would get wrong. + let t: i64 = 1_786_492_800; + assert_eq!( + s.eval::<(i64, i64, i64, i64, bool)>(&format!( + "local d = date(\"*t\", {t}) return d.year, d.month, d.day, d.wday, d.isdst" + )) + .unwrap(), + (2026, 8, 12, 4, false) + ); + // hour/min/sec and yday come through too — an addon reading any of them must not get nil. + assert_eq!( + s.eval::<(i64, i64, i64, i64)>(&format!( + "local d = date(\"*t\", {t} + 3661) return d.hour, d.min, d.sec, d.yday" + )) + .unwrap(), + (1, 1, 1, 224) + ); + + // **The loop Accountant actually runs.** Stepping back a day must move `wday`, and the walk + // must terminate — this is the hang, expressed as the addon expresses it. + assert_eq!( + s.eval::(&format!( + "local ct = {t} local n = 0 \ + while date(\"*t\", ct).wday ~= 3 and n < 100 do ct = ct - 86400 n = n + 1 end \ + return n" + )) + .unwrap(), + 1, + "Wednesday(4) back to Tuesday(3) is exactly one step; a constant wday would spin" + ); + + // A `!` prefix selects UTC, which every timestamp here already is — it must be CONSUMED + // rather than printed, and it works on the table form as well as the string form. + assert_eq!( + s.eval::(&format!("return date(\"!*t\", {t}).wday")) + .unwrap(), + 4 + ); + assert!( + !s.eval::(&format!("return date(\"!%A\", {t})")) + .unwrap() + .contains('!'), + "the UTC flag is consumed, not printed" + ); + // …and an ordinary format still answers a string. + assert_eq!( + s.eval::(&format!("return date(\"%A\", {t})")) + .unwrap(), + "Wednesday" + ); + } +} diff --git a/crates/benilla-ui/src/script/taxi.rs b/crates/benilla-ui/src/script/taxi.rs index 22eb6911..f48fe7ee 100644 --- a/crates/benilla-ui/src/script/taxi.rs +++ b/crates/benilla-ui/src/script/taxi.rs @@ -246,7 +246,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "UnitOnTaxi", lua.create_function(|lua, unit: String| { let model = lua.app_data_ref::().expect("model"); - Ok(unit == "player" && model.taxi_riding) + Ok(unit.eq_ignore_ascii_case("player") && model.taxi_riding) })?, )?; diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 7d47090c..02fb61e6 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -1142,13 +1142,12 @@ fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() { /// here is that the set cannot quietly lose a member again, which is the failure `GetParent` was. #[test] fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() { - /// The map minus `GetObjectType`/`IsObjectType`, which are **dispatched, not forgotten**: both - /// are in `0xcf54b4`, but a faithful `IsObjectType` needs facts a reimplementation cannot - /// guess — whether it matches the leaf name only or walks a class chain, the exact strings in - /// that chain, whether it answers `1`/`nil` or `true`/`false`, and whether a bad argument - /// raises. Shipping a guess is how 1203/1205/1211 happened. They join this list with the - /// verified answer; until then their absence is a raise, which is loud and correct. + /// All 19. `GetObjectType`/`IsObjectType` were held out of this list when 1244 landed the other + /// four — dispatched rather than guessed — and joined it when wow-re answered + /// (`system/ui/scratch/widget-type-identity.md`). The list is the whole map again. const REGION_MAP: &[&str] = &[ + "GetObjectType", + "IsObjectType", "GetName", "GetParent", "SetParent", @@ -1270,3 +1269,248 @@ fn the_region_map_readers_answer_the_way_the_edges_do() { "…and the first of them is nil" ); } + +/// **`GetObjectType`/`IsObjectType` — every detail wow-re had to answer, asserted.** +/// +/// 1244 shipped four Region-map members and left these two out rather than guess them, because +/// each of the four traps below is a coin-flip a reimplementation loses (1203/1205/1211 are three +/// records of losing it). The carve is `system/ui/scratch/widget-type-identity.md`; this is that +/// answer turned into a gate, so the next person to touch these has to disagree with the binary +/// rather than with me. +#[test] +fn the_type_identity_verbs_answer_what_the_binary_answers() { + let s = crate::script::UiScript::new().unwrap(); + s.run( + r#" + TIOwner = CreateFrame("Frame", "TIOwner") + TITex = TIOwner:CreateTexture("TITex", "ARTWORK") + TIStr = TIOwner:CreateFontString("TIStr", "ARTWORK") + TIAnon = TIOwner:CreateTexture(nil, "ARTWORK") + "#, + ) + .unwrap(); + + // The leaf names, one value each (`lua_pushstring`, no arity check on extra args). + assert_eq!( + s.eval::("return TITex:GetObjectType()").unwrap(), + "Texture" + ); + assert_eq!( + s.eval::("return TIStr:GetObjectType()").unwrap(), + "FontString" + ); + assert_eq!( + s.eval::("return select('#', TITex:GetObjectType('ignored'))") + .unwrap(), + 1, + "one value, and a stray argument is ignored rather than an arity error" + ); + + // The chain is TWO deep and stops there. + for (obj, leaf) in [("TITex", "Texture"), ("TIStr", "FontString")] { + assert_eq!( + s.eval::(&format!("return {obj}:IsObjectType('{leaf}')")) + .unwrap(), + 1, + "{obj} is its own leaf type" + ); + assert_eq!( + s.eval::(&format!("return {obj}:IsObjectType('Region')")) + .unwrap(), + 1, + "{obj} is a Region" + ); + // **The invented root.** 1.12.1 has no LayoutFrame/ScriptObject/Object type at all — those + // strings live only in __FILE__ paths and allocator tags. Knowing later clients is exactly + // what would put them here. + for absent in ["LayoutFrame", "ScriptObject", "Object", "Frame", "Font"] { + assert!( + s.eval::>(&format!("return {obj}:IsObjectType('{absent}')")) + .unwrap() + .is_none(), + "{obj}:IsObjectType('{absent}') must be nil — 1.12 has no such type in the chain" + ); + } + } + // …and the two leaves are not each other. + assert!(s + .eval::>("return TITex:IsObjectType('FontString')") + .unwrap() + .is_none()); + + // Case-INSENSITIVE and WHOLE-string (SStrCmpI folds both operands; the compare stops at the + // first NUL, so no prefix or substring match either). + for spelling in ["texture", "TEXTURE", "TeXtUrE", "region", "REGION"] { + assert_eq!( + s.eval::(&format!("return TITex:IsObjectType('{spelling}')")) + .unwrap(), + 1, + "'{spelling}' must match — the compare folds case" + ); + } + for partial in ["Tex", "TextureX", "Regio", ""] { + assert!( + s.eval::>(&format!("return TITex:IsObjectType('{partial}')")) + .unwrap() + .is_none(), + "'{partial}' must NOT match — whole-string, not a prefix" + ); + } + + // A hit is the NUMBER 1, never a boolean; a miss is nil. Both paths push exactly one value. + assert_eq!( + s.eval::("return type(TITex:IsObjectType('Texture'))") + .unwrap(), + "number", + "the reference pushes tag 3 (number); tag 1 (boolean) is never written" + ); + for arg in ["'Texture'", "'nope'"] { + assert_eq!( + s.eval::(&format!("return select('#', TITex:IsObjectType({arg}))")) + .unwrap(), + 1, + "exactly one value on both the hit and the miss path" + ); + } + + // A NUMBER is accepted (lua_isstring takes tags 4 and 3) and stringified in place — it simply + // never matches, because no type name is numeric. + assert!( + s.eval::>("return TITex:IsObjectType(5)") + .unwrap() + .is_none(), + "a number argument is accepted and quietly answers nil, NOT a raise" + ); + + // Everything else RAISES with the reference's own Usage text, naming the region — or + // `` for one declared anonymously. + for bad in ["", "nil", "true", "{}", "print"] { + let err = s + .run(&format!("TITex:IsObjectType({bad})")) + .expect_err(&format!("IsObjectType({bad}) must raise")) + .to_string(); + assert!( + err.contains(r#"Usage: TITex:IsObjectType("TYPE")"#), + "the raise carries the reference's Usage text and the region's name: {err}" + ); + } + let anon = s + .run("TIAnon:IsObjectType(nil)") + .expect_err("anonymous region raises too") + .to_string(); + assert!( + anon.contains(r#"Usage: :IsObjectType("TYPE")"#), + "an anonymous region reports , as GetName()'s absence does: {anon}" + ); +} + +/// **The frame side of the type identity, and the three chains a guess gets wrong.** +/// +/// `_Nameplates.lua` is why this half exists: it asks `Region:GetObjectType()` (the region twin) +/// AND `Nameplate:GetObjectType() ~= "Button"` / `Frame:GetObjectType() == "StatusBar"` in the same +/// file. The chains come from wow-re's 23-class roster, which is a hardcoded straight-line list per +/// class in the binary rather than a runtime parent walk. +#[test] +fn a_frames_type_chain_matches_the_roster() { + let s = crate::script::UiScript::new().unwrap(); + // (CreateFrame kind, GetObjectType string, full chain) + let cases: &[(&str, &str, &[&str])] = &[ + ("Frame", "Frame", &["Frame", "Region"]), + ("Button", "Button", &["Button", "Frame", "Region"]), + // Depth 4 — the longest chain we can build. + ( + "CheckButton", + "CheckButton", + &["CheckButton", "Button", "Frame", "Region"], + ), + ("EditBox", "EditBox", &["EditBox", "Frame", "Region"]), + ("StatusBar", "StatusBar", &["StatusBar", "Frame", "Region"]), + ("Slider", "Slider", &["Slider", "Frame", "Region"]), + ( + "ScrollFrame", + "ScrollFrame", + &["ScrollFrame", "Frame", "Region"], + ), + ( + "MessageFrame", + "MessageFrame", + &["MessageFrame", "Frame", "Region"], + ), + // **NOT via MessageFrame**, despite the name (roster `0x787940`). + ( + "ScrollingMessageFrame", + "ScrollingMessageFrame", + &["ScrollingMessageFrame", "Frame", "Region"], + ), + // **Capital HTML** — the enum variant is `SimpleHtml`, and `GetObjectType` is compared with + // `==` by addons, so a variant-derived string would be silently wrong here. + ( + "SimpleHTML", + "SimpleHTML", + &["SimpleHTML", "Frame", "Region"], + ), + ( + "ColorSelect", + "ColorSelect", + &["ColorSelect", "Frame", "Region"], + ), + ( + "GameTooltip", + "GameTooltip", + &["GameTooltip", "Frame", "Region"], + ), + ("Minimap", "Minimap", &["Minimap", "Frame", "Region"]), + // **Our Era-shaped divergence reports what 1.12's cooldown IS**: a Model. 1.12.1 has no + // `Cooldown` type name at all (the roster is 23 and none is that). + ("Cooldown", "Model", &["Model", "Frame", "Region"]), + ]; + for (kind, leaf, chain) in cases { + s.run(&format!(r#"TC = CreateFrame("{kind}")"#)) + .unwrap_or_else(|e| panic!("CreateFrame(\"{kind}\"): {e}")); + assert_eq!( + &s.eval::("return TC:GetObjectType()").unwrap(), + leaf, + "{kind}:GetObjectType()" + ); + for want in *chain { + assert_eq!( + s.eval::(&format!("return TC:IsObjectType('{want}')")) + .unwrap(), + 1, + "{kind} must be a {want}" + ); + } + // Every OTHER leaf in the roster must be absent from this chain — the assertion that + // catches a chain built by "sounds related". + for (other, other_leaf, _) in cases { + if chain.contains(other_leaf) { + continue; + } + assert!( + s.eval::>(&format!("return TC:IsObjectType('{other_leaf}')")) + .unwrap() + .is_none(), + "{kind}'s chain must NOT contain {other_leaf} (via {other})" + ); + } + } + + // The frame verbs share the region twin's contract: number 1 / nil, one value, and a raise + // naming the frame. + s.run(r#"TCNamed = CreateFrame("Button", "TCNamed")"#) + .unwrap(); + assert_eq!( + s.eval::("return type(TCNamed:IsObjectType('button'))") + .unwrap(), + "number", + "case-folded hit is the number 1" + ); + let err = s + .run("TCNamed:IsObjectType({})") + .expect_err("a table argument raises") + .to_string(); + assert!( + err.contains(r#"Usage: TCNamed:IsObjectType("TYPE")"#), + "the frame's Usage text names the frame: {err}" + ); +} diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 3690dedf..896bb9f4 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -123,7 +123,7 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result { let (unit, player_level) = { let model = lua.app_data_mut::().expect("model app_data"); ( - model.units.get(token).cloned().filter(|u| u.exists), + model.unit(token).cloned().filter(|u| u.exists), model.player_req.level, ) }; @@ -281,7 +281,7 @@ pub(super) fn on_unit_push(lua: &Lua, token: &str) { for h in hits { let unit = { let model = lua.app_data_mut::().expect("model app_data"); - model.units.get(token).cloned().filter(|u| u.exists) + model.unit(token).cloned().filter(|u| u.exists) }; update_bar(lua, h, unit.as_ref()); } diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 4bb92b93..1ace0c24 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -67,7 +67,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "UnitLevel", lua.create_function(|lua, token: Option| { let model = lua.app_data_ref::().expect("model app_data"); - let Some(u) = token.as_ref().and_then(|t| model.units.get(t)) else { + let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else { return Ok(0i64); }; Ok(if u.level == 0 { @@ -224,7 +224,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let (Some(a), Some(b)) = (a, b) else { return Ok(Value::Nil); }; - let (Some(ua), Some(ub)) = (model.units.get(&a), model.units.get(&b)) else { + let (Some(ua), Some(ub)) = (model.unit(&a), model.unit(&b)) else { return Ok(Value::Nil); }; if !ua.exists || !ub.exists { @@ -286,7 +286,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let model = lua.app_data_ref::().expect("model app_data"); let hit = token .as_ref() - .and_then(|t| model.units.get(t)) + .and_then(|t| model.unit(t)) .filter(|u| u.exists && u.guid != 0) .is_some_and(|u| { model @@ -308,7 +308,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let Some(t) = token else { return Ok(Value::Nil); }; - let Some(u) = model.units.get(&t) else { + let Some(u) = model.unit(&t) else { return Ok(Value::Nil); }; let grouped = !model.party.members.is_empty(); @@ -316,9 +316,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { return Ok(Value::Nil); } let hit = (t.starts_with("party") && !t.starts_with("partypet")) - || t == "player" + || t.eq_ignore_ascii_case("player") || (u.guid != 0 - && (model.units.get("player").is_some_and(|p| p.guid == u.guid) + && (model.unit("player").is_some_and(|p| p.guid == u.guid) || model .party .members @@ -611,6 +611,32 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // UnitIsCharmed(unit) → 1 while somebody is charming this unit, else nil (`0x516cf0`). + // + // Three details, all byte-verified, none of them what a reimplementation reaches for: + // · the predicate is `UNIT_FIELD_CHARMEDBY != 0` — a plain 64-bit non-zero test on fields + // 10/11 — **not** a `UNIT_FIELD_FLAGS` bit, which is the obvious guess for a boolean-shaped + // unit question; + // · a hit is the NUMBER 1 (`lua_pushnumber`, tag 3) and a miss is `nil` (tag 0) — never + // `true`/`false`, and never `0`. Exactly one value on both arms; + // · it is ASYMMETRIC. The field is "who charms me", so the CHARMER reads nil and the charmed + // unit reads 1. `UnitIsPossessed` does not exist in 5875 at all — this is the only + // charm verb in the API. + // + // KLHThreatMeter is the corpus addon blocked on it (`KTM_My.lua:533`); seven others name it. + g.set( + "UnitIsCharmed", + lua.create_function(|lua, token: Option| { + Ok(with_unit(lua, &token, Value::Nil, |u| { + if u.charmed { + Value::Number(1.0) + } else { + Value::Nil + } + })) + })?, + )?; + // GetMoney() → the player's purse in copper (a player-level global, not a unit token). The coin // display + the merchant window's money line read it; `0` until the app's coinage feed lands. g.set( @@ -652,7 +678,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { if class != CLASS_ROGUE && class != CLASS_DRUID { return Ok(0_i64); } - let target = model.units.get("target").map_or(0, |u| u.guid); + let target = model.unit("target").map_or(0, |u| u.guid); if model.combo_target != target { return Ok(0_i64); } @@ -839,7 +865,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "ClearTarget", lua.create_function(|lua, ()| { let mut model = lua.app_data_mut::().expect("model app_data"); - if model.units.get("target").is_some_and(|u| u.exists) { + if model.unit("target").is_some_and(|u| u.exists) { model.target_clear = true; Ok(Value::Integer(1)) } else { diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 5902afc0..d5776264 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -51,6 +51,16 @@ pub struct UnitState { /// predicate — its wire health is 1 (decision 0308 §1); the trio is dead / [`Self::ghost`] / /// dead-or-ghost, the real client's three tests. pub dead: bool, + /// Whether somebody is charming this unit (`UnitIsCharmed`). + /// + /// **`UNIT_FIELD_CHARMEDBY != 0`, and the asymmetry is the fact worth keeping.** The binding + /// `0x516cf0` reads fields 10/11 as one 64-bit value and tests it non-zero — not a + /// `UNIT_FIELD_FLAGS` bit, and not a comparison against the player (its body contains no `cmp` + /// at all). The field means *"who charms me"*; its mirror `UNIT_FIELD_CHARM` (*"whom I charm"*) + /// is never read here. So a **charmer** answers nil, a **charmed** pet answers 1, and an + /// ordinary **summoned** pet answers nil — `"pet"` is itself charm-else-summon in the resolver. + /// (wow-re `system/ui/scratch/unit-verbs-controlled-charmed-creaturetype.md`.) + pub charmed: bool, /// Whether the unit is a released ghost (`UnitIsGhost` — `PLAYER_FLAGS` bit 0x10, decision /// 0308 §1). Only meaningful for player tokens; creatures stay `false`. pub ghost: bool, @@ -258,11 +268,14 @@ impl super::UiScript { { let mut model = self.model_mut(); match state { + // Folded on the way IN as well as on the way out — the map's key type is the + // canonical lowercase token, so a feed that ever pushed `"Target"` could not create + // a second, shadowing entry. Some(s) => { - model.units.insert(token.to_string(), s); + model.units_by_lower.insert(token.to_ascii_lowercase(), s); } None => { - model.units.remove(token); + model.units_by_lower.remove(&token.to_ascii_lowercase()); } } } @@ -356,9 +369,15 @@ impl super::UiScript { /// Pick the interesting token from a directional two-unit call (`UnitIsEnemy(a, b)`): whichever /// arg isn't `"player"` — our snapshot stores the relationship on the non-player unit (target), /// and the ref calls both argument orders. +/// +/// **The `"player"` test folds case**, like every other token comparison here and like the client's +/// own resolver. It is the kind of site the map's fold does not cover on its own: an addon passing +/// `UnitIsEnemy("Player", "target")` would otherwise be read as naming a non-player first arg, and +/// this would answer about the wrong unit — a wrong ANSWER rather than a missing one, which is the +/// worse failure of the two. fn pick_unit_token(a: &Option, b: &Option) -> Option { match (a, b) { - (Some(x), _) if x != "player" => Some(x.clone()), + (Some(x), _) if !x.eq_ignore_ascii_case("player") => Some(x.clone()), (_, Some(y)) => Some(y.clone()), (x, _) => x.clone(), } @@ -373,7 +392,7 @@ fn with_unit( f: impl FnOnce(&UnitState) -> T, ) -> T { let model = lua.app_data_ref::().expect("model app_data"); - match token.as_ref().and_then(|t| model.units.get(t)) { + match token.as_ref().and_then(|t| model.unit(t)) { Some(u) => f(u), None => default, } diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index a08b83ae..bc521efe 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -694,3 +694,150 @@ fn target_by_name_queues_the_name_and_the_exact_flag() { "got {err}" ); } + +/// **`UnitIsCharmed` — the number `1`, the nil, and the asymmetry.** +/// +/// `KLHThreatMeter\Code\KTM_My.lua:533` is the corpus line blocked on it; seven other addons name +/// it. The binding (`0x516cf0`) reads `UNIT_FIELD_CHARMEDBY != 0` — a 64-bit non-zero test on +/// fields 10/11, **not** a `UNIT_FIELD_FLAGS` bit, which is what a boolean-shaped unit question +/// invites you to assume. +#[test] +fn unit_is_charmed_answers_one_or_nil_and_only_for_the_charmed_side() { + let mut s = UiScript::new().unwrap(); + s.set_unit("player", Some(player())); + let mut mc = player(); + mc.charmed = true; + s.set_unit("target", Some(mc)); + + // A hit is the NUMBER 1 — `lua_pushnumber` writes tag 3; tag 1 (boolean) is never written on + // either arm, so `== true` in an addon must NOT match. + assert_eq!( + s.eval::(r#"return UnitIsCharmed("target")"#).unwrap(), + 1 + ); + assert_eq!( + s.eval::(r#"return type(UnitIsCharmed("target"))"#) + .unwrap(), + "number", + "the reference pushes a number, not a boolean" + ); + assert_eq!( + s.eval::(r#"return select('#', UnitIsCharmed("target"))"#) + .unwrap(), + 1, + "exactly one value on the hit path" + ); + + // An uncharmed unit and an absent one both answer nil — and nil, not `false` and not `0`. + for token in ["player", "party1"] { + assert!( + s.eval::>(&format!(r#"return UnitIsCharmed("{token}")"#)) + .unwrap() + .is_none(), + "{token} is not charmed, so the answer is nil" + ); + assert_eq!( + s.eval::(&format!(r#"return type(UnitIsCharmed("{token}"))"#)) + .unwrap(), + "nil", + "…nil rather than false or 0" + ); + } + + // **The asymmetry.** The field is "who charms me", never "whom I charm", so the charmer reads + // nil while its victim reads 1 — here the player is doing the charming and is not charmed. + assert!( + s.eval::>(r#"return UnitIsCharmed("player")"#) + .unwrap() + .is_none(), + "a charmER is not charmed; UNIT_FIELD_CHARM is never read by this binding" + ); +} + +/// **Unit tokens fold case, because the client's resolver does.** +/// +/// `0x515970` compares every one of its literals with `SStrCmpI` → `_strnicmp`, whose fold is +/// `'A'..'Z' += 0x20`; not one of its ten compares reaches the case-sensitive sibling (wow-re +/// `system/ui/scratch/unit-token-grammar.md`, §5 trio). So this is a NARROWING fix — the real +/// client resolves these and we did not — rather than the superset 1189 had to take back out. +/// +/// `Accountant.lua:107` is the corpus line that paid for it: `UnitFactionGroup("Player")`, capital +/// P, whose nil made `Accountant_SaveData[realm][faction]` a nil table index at l.192 and ended the +/// addon's session. Roughly ten addons pass `Player`, `PLAYER`, `Target`, `Pet`, `PARTY`, `NPC`, +/// `Mouseover` or `"Raid"..i`. +#[test] +fn a_unit_token_resolves_whatever_its_case() { + let mut s = UiScript::new().unwrap(); + s.set_unit("player", Some(player())); + let mut tgt = player(); + tgt.name = Some("Hogger".into()); + s.set_unit("target", Some(tgt)); + + // Every spelling names the same unit — the getter, the number and the predicate alike. + for spelling in ["player", "Player", "PLAYER", "PlAyEr"] { + assert_eq!( + s.eval::(&format!(r#"return UnitName("{spelling}")"#)) + .unwrap(), + "Benilla", + "UnitName(\"{spelling}\") must resolve" + ); + assert_eq!( + s.eval::(&format!(r#"return UnitLevel("{spelling}")"#)) + .unwrap(), + 12 + ); + assert!(s + .eval::(&format!(r#"return UnitExists("{spelling}")"#)) + .unwrap()); + } + for spelling in ["target", "Target", "TARGET"] { + assert_eq!( + s.eval::(&format!(r#"return UnitName("{spelling}")"#)) + .unwrap(), + "Hogger" + ); + } + + // **The two-unit call, which the map's fold does not cover on its own.** + // `pick_unit_token` chooses "whichever arg is not the player"; with a case-sensitive test, + // `("Player", "target")` reads as a non-player FIRST arg and the call answers about the wrong + // unit — a wrong answer rather than a missing one. + assert!( + s.eval::( + r#"return UnitIsEnemy("Player", "target") == UnitIsEnemy("player", "target")"# + ) + .unwrap(), + "the directional pick folds case too" + ); + + // An unseated token is still absent whatever its case — folding must not invent units. + for spelling in ["bogus", "BOGUS", "Party1"] { + assert!( + !s.eval::(&format!(r#"return UnitExists("{spelling}")"#)) + .unwrap(), + "{spelling} was never seated" + ); + } +} + +/// The fold is on the way **IN** as well as out: a feed that pushes `"Target"` must not create a +/// second entry shadowing `"target"`, which is what a lookup-only fold would allow. +#[test] +fn seating_a_token_folds_its_key_too() { + let mut s = UiScript::new().unwrap(); + let mut a = player(); + a.name = Some("First".into()); + s.set_unit("Target", Some(a)); + assert_eq!( + s.eval::(r#"return UnitName("target")"#).unwrap(), + "First", + "a token seated as \"Target\" is readable as \"target\"" + ); + + // …and clearing it through the other spelling really clears it, rather than leaving a shadow. + s.set_unit("TARGET", None); + assert!( + !s.eval::(r#"return UnitExists("target")"#).unwrap(), + "removal folds too — no shadowed entry survives" + ); +}