widget identity, a funded purse and a walkable quest log

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:59:47 +07:00
co-authored by Claude Fable 5
parent db19f52346
commit ab75aecb9c
18 changed files with 1058 additions and 47 deletions
+16 -1
View File
@@ -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<Option<usize>> = 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("")
);
}
+127 -1
View File
@@ -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::<i64>("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::<String>("local t = GetQuestLogTitle(1) return t")
.unwrap(),
"Elwynn Forest"
);
assert!(
s.eval::<bool>("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::<Option<i64>>("local _,_,_,_,_,c = GetQuestLogTitle(2) return c")
.unwrap()
.is_none());
assert_eq!(
s.eval::<i64>("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::<i64>("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::<i64>("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::<String>(r#"return GetInventoryItemTexture("player", 16)"#)
+25 -6
View File
@@ -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<String>,
@@ -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();
+3
View File
@@ -479,6 +479,9 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, 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()),
+60 -1
View File
@@ -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<String> {
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 `<FontString>`'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=`/`<FontHeight>`/`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<i>` 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: <FontString>: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}: <FontString inherits=\"{name}\">: {e}"),
+47
View File
@@ -1314,6 +1314,53 @@ mod region_template_tests {
report.warnings
);
assert_eq!(s.eval::<String>("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: <FontString>: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<i>` inherits the template.
#[test]
fn a_fontstring_template_carries_the_font_object_it_inherits() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Font name="GameFontHighlight" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<FontString name="LineTemplate" inherits="GameFontHighlight" virtual="true"
justifyH="LEFT"/>
<Frame name="Host3">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="Line1" inherits="LineTemplate" text="one"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(s.eval::<String>("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"
);
}
}
+1 -1
View File
@@ -446,7 +446,7 @@ fn with_unit_stats<T>(
/// `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.
+32 -2
View File
@@ -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<String, UnitState>,
///
/// **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<String, UnitState>,
/// 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,
@@ -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::<Model>().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::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".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| {
+1 -1
View File
@@ -503,7 +503,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(move |lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().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)?),
+94 -11
View File
@@ -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<RegionHandle> {
@@ -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::<Model>().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::<Model>().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
// `<unnamed>`, 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::<Model>().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(|| "<unnamed>".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<String> {
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),
+102 -2
View File
@@ -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<String>, Option<i64>)| {
lua.create_function(|lua, (fmt, when): (Option<String>, Option<i64>)| {
// 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::<i64>(&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::<i64>(&format!("return date(\"!*t\", {t}).wday"))
.unwrap(),
4
);
assert!(
!s.eval::<String>(&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::<String>(&format!("return date(\"%A\", {t})"))
.unwrap(),
"Wednesday"
);
}
}
+1 -1
View File
@@ -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::<Model>().expect("model");
Ok(unit == "player" && model.taxi_riding)
Ok(unit.eq_ignore_ascii_case("player") && model.taxi_riding)
})?,
)?;
@@ -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::<String>("return TITex:GetObjectType()").unwrap(),
"Texture"
);
assert_eq!(
s.eval::<String>("return TIStr:GetObjectType()").unwrap(),
"FontString"
);
assert_eq!(
s.eval::<i64>("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::<i64>(&format!("return {obj}:IsObjectType('{leaf}')"))
.unwrap(),
1,
"{obj} is its own leaf type"
);
assert_eq!(
s.eval::<i64>(&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::<Option<i64>>(&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::<Option<i64>>("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::<i64>(&format!("return TITex:IsObjectType('{spelling}')"))
.unwrap(),
1,
"'{spelling}' must match — the compare folds case"
);
}
for partial in ["Tex", "TextureX", "Regio", ""] {
assert!(
s.eval::<Option<i64>>(&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::<String>("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::<i64>(&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::<Option<i64>>("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
// `<unnamed>` 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: <unnamed>:IsObjectType("TYPE")"#),
"an anonymous region reports <unnamed>, 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::<String>("return TC:GetObjectType()").unwrap(),
leaf,
"{kind}:GetObjectType()"
);
for want in *chain {
assert_eq!(
s.eval::<i64>(&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::<Option<i64>>(&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::<String>("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}"
);
}
+2 -2
View File
@@ -123,7 +123,7 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
let (unit, player_level) = {
let model = lua.app_data_mut::<Model>().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::<Model>().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());
}
+34 -8
View File
@@ -67,7 +67,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"UnitLevel",
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().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::<Model>().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<String>| {
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::<Model>().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 {
+23 -4
View File
@@ -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<String>, b: &Option<String>) -> Option<String> {
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<T>(
f: impl FnOnce(&UnitState) -> T,
) -> T {
let model = lua.app_data_ref::<Model>().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,
}
+147
View File
@@ -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::<i64>(r#"return UnitIsCharmed("target")"#).unwrap(),
1
);
assert_eq!(
s.eval::<String>(r#"return type(UnitIsCharmed("target"))"#)
.unwrap(),
"number",
"the reference pushes a number, not a boolean"
);
assert_eq!(
s.eval::<i64>(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::<Option<i64>>(&format!(r#"return UnitIsCharmed("{token}")"#))
.unwrap()
.is_none(),
"{token} is not charmed, so the answer is nil"
);
assert_eq!(
s.eval::<String>(&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::<Option<i64>>(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::<String>(&format!(r#"return UnitName("{spelling}")"#))
.unwrap(),
"Benilla",
"UnitName(\"{spelling}\") must resolve"
);
assert_eq!(
s.eval::<i64>(&format!(r#"return UnitLevel("{spelling}")"#))
.unwrap(),
12
);
assert!(s
.eval::<bool>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap());
}
for spelling in ["target", "Target", "TARGET"] {
assert_eq!(
s.eval::<String>(&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::<bool>(
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::<bool>(&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::<String>(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::<bool>(r#"return UnitExists("target")"#).unwrap(),
"removal folds too — no shadowed entry survives"
);
}