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:
@@ -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("")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)"#)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)?),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user