the inspect and talent windows are the reference's own

- the inspect and talent windows are the reference's own: `Blizzard_InspectUI` and `Blizzard_TalentUI` are LoadOnDemand addons whose real `.xml`/`.lua` live inside `patch.MPQ` — the loose `Interface\AddOns` folder holds only a `.pub` signature, which three notes in this tree had read as "packed, no source to migrate from", one wrong inference that kept five windows on hand-written transcriptions; 2300 lines of ours out, no engine work needed, and three things our talent transcription had quietly invented fell out (there was no level-10 gate, every tree scrolled where a shallow one does not, and a nil `TalentMicroButton` threw out of OnShow before the window painted)
- a nil unit token RAISES in 53 of 83 bindings: wow-re censused the registrar table at `0x850438` — `UnitName(nil)` raises and `UnitIsVisible(nil)` is quiet, one line apart in stock files, so there is no rule to derive, the table is the rule; ours answered a default and our own test had pinned it; both token positions of the seven two-token predicates are gated, `GetRaidTargetIndex` too, and `UnitIsAFK`/`UnitIsDND` are confirmed not to exist in 5875 at all (zero occurrences under a positive control) — we invented them, and they stay deliberately ungated
- `EditBox:SetNumber` did not exist (the only chain file calling it is `MoneyInputFrame.lua`, on the chain since the money kit): it is byte-identical to `SetText`, formats through `%.14g` in MSVC's CRT (`1e+020`, `1.#INF`), and `OnTextChanged` never fires from an edit — it fires from the dirty-word drain inside the box's own OnUpdate, coalesced to one per box per frame, where ours fired synchronously and `MoneyInputFrame_SetCopper` raised on it; `PanelTemplates_TabResize`'s `tab` argument is optional as the reference's is (stock macro tabs call it with `0`)
- the skull or star on your CURRENT target never appeared or cleared until you re-targeted — `RAID_TARGET_UPDATE` was never fired, and stock `TargetFrame.lua` hangs its repaint off it; a raid-to-party conversion now empties the mark board as `0x4ba550` does; and the group-loot roll popups were already on the chain inside stock `LootFrame.xml` — our 546-line `GroupLootFrame.xml` had been shadowing all four of them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:06:18 +07:00
co-authored by Claude Fable 5.1
parent 276d0896ad
commit eb554c0957
41 changed files with 1400 additions and 3134 deletions
@@ -163,6 +163,83 @@ pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
/// *strings* `"0"`/`"1"`, and a re-implementation wired to `lua_toboolean` inverts every
/// string-valued option in the game. Equally, `"0.5"` and `"-1"` are decided by their first byte
/// alone — false and `default` — never by their numeric value.
/// Lua 5.0's own number→string rule as the 1.12 client compiles it: `sprintf("%.14g")`
/// (`luaV_tostring 0x6f7c80`, format string `0x871960`). Decision 1831.
///
/// This is the whole of `EditBox:SetNumber`'s formatting, because **`SetNumber 0x798690` and
/// `SetText 0x7984c0` are byte-identical functions** — 245 bytes each, zero differences after
/// masking rel32 and absolute-VA operands, differing only in which usage string they carry. The
/// numeric verb does no numeric work of its own; the shared `lua_tostring` marshalling does it all.
///
/// The rule, and the three places it is not Rust's `{}`:
///
/// - **14 significant digits**, then trailing zeros and a bare trailing `.` are dropped.
/// - **Exponential iff `exp < -4 || exp >= 14`** — the C `%g` switch.
/// - **A three-digit exponent, always.** `1e20` prints `1e+020`, not `1e+20`: the exponent is
/// copied from the literal template `"e+000"` and digits are written onto it. This is MSVC's
/// `%g`, not C99's, and it is the single most surprising thing here.
///
/// **What this deliberately does NOT reproduce**, stated so it is not mistaken for an oversight:
/// the reference's printf is not correctly rounded. It generates 17 digits half-up and then
/// re-rounds *that string* to 14, also half-up and with no sticky bit, so it disagrees with a
/// correctly-rounded formatter on roughly 0.05% of doubles (`-0.11594045739607` where a correct
/// one gives `…606`). Reproducing that needs the two-stage decimal path, not a format string, and
/// no consumer we have is sensitive to the fourteenth digit. If one ever is, this is the note that
/// says where to look.
pub(crate) fn lua_number_text(v: f64) -> String {
if v.is_nan() {
// MSVC's own spellings, which are not "NaN".
return if v.is_sign_negative() {
"-1.#IND".into()
} else {
"1.#QNAN".into()
};
}
if v.is_infinite() {
return if v < 0.0 {
"-1.#INF".into()
} else {
"1.#INF".into()
};
}
if v == 0.0 {
// `%g` prints negative zero without the sign.
return "0".into();
}
const SIG: i32 = 14;
let exp = v.abs().log10().floor() as i32;
// Re-derive the exponent from the rounded form: 9.9999e2 rounds to 1e3 and changes style.
let exp = {
let probe = format!("{:.*e}", (SIG - 1) as usize, v);
probe
.rsplit('e')
.next()
.and_then(|e| e.parse::<i32>().ok())
.unwrap_or(exp)
};
// C's `%g` style rule verbatim: exponential when `exp < -4 || exp >= P`, else fixed.
if !(-4..SIG).contains(&exp) {
let mantissa = format!("{:.*e}", (SIG - 1) as usize, v);
let (m, _) = mantissa.split_once('e').unwrap_or((mantissa.as_str(), "0"));
let m = trim_g(m);
let sign = if exp < 0 { '-' } else { '+' };
// Three digits minimum, and MORE if the exponent needs them — the template is padded,
// never truncated.
format!("{m}e{sign}{:03}", exp.abs())
} else {
let decimals = (SIG - 1 - exp).max(0) as usize;
trim_g(&format!("{v:.decimals$}")).to_string()
}
}
/// Strip `%g`'s trailing zeros, and the decimal point if nothing follows it.
fn trim_g(s: &str) -> &str {
if !s.contains('.') {
return s;
}
s.trim_end_matches('0').trim_end_matches('.')
}
/// A widget **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
///
/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12 widget
+1 -1
View File
@@ -98,7 +98,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// BenillaDressUpModel_SetFacing(radians) — the pane's bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` / `BenillaInspectModel_SetFacing`.
// `BenillaPaperDollModel_SetFacing` (the inspect pane's twin went with 1832's migration).
g.set(
"BenillaDressUpModel_SetFacing",
lua.create_function(|lua, radians: f32| {
+57 -5
View File
@@ -97,7 +97,7 @@ pub(super) fn paste(lua: &Lua, text: &str) -> bool {
// this layer's.
if with_eb(lua, h, |eb| eb.paste(text)).is_some_and(|o| o.text_changed) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
true
}
@@ -339,7 +339,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
.errors
.push(e.to_string());
}
fire_script(lua, id, "OnTextChanged");
mark_text_changed(lua, h);
if fire_space {
for _ in 0..out.spaces {
fire_script(lua, id, "OnSpacePressed");
@@ -355,7 +355,7 @@ fn set_text(lua: &Lua, h: FrameHandle, s: &str) {
sync_text_region(lua, h);
let id = frame_id_of(lua, h);
fire_script(lua, id, "OnTextSet");
fire_script(lua, id, "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -369,7 +369,7 @@ fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState)
});
if changed == Some(true) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -379,7 +379,7 @@ fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) {
let changed = with_eb(lua, h, |eb| eb.delete_dir(forward));
if changed == Some(true) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -618,3 +618,55 @@ pub(super) use methods::install;
#[cfg(test)]
mod tests;
/// Raise the `textChanged` dirty bit (`[E+0x31c]` bit 0) rather than firing — **`OnTextChanged` is
/// deferred**, and this is the whole of decision 1831's second half.
///
/// The reference never fires it from an edit. `SetText 0x77be00`, `Insert 0x77bee0` and the deletes
/// all only OR the bit; the single fire site is `0x77d498`, inside the dirty-word drain `0x77d3e0`,
/// whose three callers are the box's own `OnUpdate` (`0x77a790`), its `OnKeyDown` (`0x77b160`) and
/// its `OnMouseDown` (`0x77b800`). So a Lua caller gets control back with the handler still
/// unrun, and repeated changes **coalesce**: the box appears once on the pending list however many
/// times it is written, and the one fire carries the final text.
pub(super) fn mark_text_changed(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.dirty_editboxes.contains(&h) {
model.dirty_editboxes.push(h);
}
}
/// The dirty-word drain (`0x77d3e0`): fire the pending `OnTextChanged`s.
///
/// Called from the frame tick — the first of the reference's three callers (`0x77a7a1`, inside the
/// box's own `OnUpdate` `0x77a790`, which is also where our caret blink lives). Its other two, the
/// box's `OnKeyDown` and `OnMouseDown`, are **not** wired: they only make a pending fire arrive
/// sooner within a frame, and our key and mouse paths reach the tick anyway. Decision 1831 says so
/// out loud rather than leaving it to be discovered.
///
/// A box that is **not effectively visible stays pending**: `Hide`
/// splices it out of the chain the update walk follows, so its fire waits for a `Show` (verified
/// chain mechanics; the "hidden ⇒ no OnUpdate" step is the RE's own stated inference).
///
/// A handler may itself write to a box — including this one — so the list is taken before any Lua
/// runs and anything re-marked during the sweep lands on the NEXT drain rather than extending this
/// one. That is the reference's shape too: its bit is cleared before its fire.
pub(in crate::script) fn drain_text_changed(lua: &Lua) {
let pending = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.dirty_editboxes.is_empty() {
return;
}
let (ready, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut model.dirty_editboxes)
.into_iter()
.partition(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible));
// A frame that has been destroyed drops out of both halves.
model.dirty_editboxes = waiting
.into_iter()
.filter(|&h| model.arena.frame(h).is_some())
.collect();
ready
};
for h in pending {
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
}
@@ -18,7 +18,7 @@
use mlua::Lua;
use super::{fire_script, frame_id_of, set_focus_handle, sync_text_region, with_eb};
use super::{set_focus_handle, sync_text_region, with_eb};
use crate::script::Model;
use crate::widget::{FrameHandle, FrameKind, KindState};
@@ -108,7 +108,7 @@ pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
let h = focused(lua)?;
let copied = with_eb(lua, h, |eb| eb.cut_selection()).flatten()?;
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
super::mark_text_changed(lua, h);
Some(copied)
}
@@ -45,6 +45,38 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"GetText",
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.text.clone()))?,
)?;
// SetNumber(v) — **the same function as SetText**. `0x798690` and `0x7984c0` are byte-identical
// (245 bytes each, zero differences after masking rel32 and absolute-VA operands; only the
// usage string differs), so this does no numeric work of its own: it hands the argument to the
// shared `lua_tostring` marshalling and sets the result as text. Decision 1831.
//
// The GATE is `lua_isstring 0x6f3510`, a pure type test over {number, string} — NOT
// `lua_isnumber` and NOT `luaL_checknumber`. So a STRING is accepted and passed through
// VERBATIM, never parsed: `SetNumber("abc")` sets the text "abc" and does not raise. Anything
// else — nil, boolean, table, or an ABSENT argument — raises the usage string and abandons the
// caller's statement.
//
// The live consumer is `MoneyInputFrame.lua:47/52/57`, on the chain since 1751, whose three
// boxes are `numeric="true"`. Our numeric filter already models the reference's wholesale
// abort, which matters here: on such a box `SetNumber(-5)` or `SetNumber(0.8)` leaves the box
// EMPTY rather than partially filled, because the sign or the point fails the digit test after
// the clear-all has already run. The money path only ever passes non-negative integers
// (`floor`/`mod` results), so it never takes that branch.
m.set(
"SetNumber",
lua.create_function(|lua, (this, v): (Table, Value)| {
let text = match &v {
Value::Integer(i) => crate::script::binding_abi::lua_number_text(*i as f64),
Value::Number(n) => crate::script::binding_abi::lua_number_text(*n),
// A string is not parsed — it is the text.
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("Usage: EditBox:SetNumber(number)")),
};
let h = frame_handle_of(lua, &this)?;
set_text(lua, h, &text);
Ok(())
})?,
)?;
// GetNumber: atof of the real text (0 on failure), matching `0x798790`.
m.set(
"GetNumber",
+112 -11
View File
@@ -220,14 +220,19 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine
// ── §3 text buffer + editing + OnTextChanged/OnTextSet ───────────────────────────────────────
/// **`OnTextChanged` is deferred and coalesced** (decision 1831). An edit only raises the
/// `textChanged` dirty bit; the fire belongs to the drain (`0x77d3e0`) that the box's own OnUpdate
/// runs. So three typed characters are three marks on ONE box and produce exactly ONE fire — this
/// test asserted three before the law was checked.
#[test]
fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
fn typing_coalesces_into_one_deferred_ontextchanged() {
let mut s = script();
s.run(
r#"
changed = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextChanged", function() changed = changed + 1 end)
seen = ""
E:SetScript("OnTextChanged", function() changed = changed + 1 seen = E:GetText() end)
E:SetFocus()
"#,
)
@@ -236,26 +241,50 @@ fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
assert!(s.char_input("b"));
assert!(s.char_input("c"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
assert_eq!(s.eval::<i64>("return changed").unwrap(), 3);
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
0,
"the text is already there, but nothing has drained yet"
);
s.tick(0.0);
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
1,
"three edits, one fire, carrying the final text"
);
assert_eq!(s.eval::<String>("return seen").unwrap(), "abc");
// Nothing is pending now, so a second drain fires nothing.
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
/// The two fires part company (decision 1831): **`OnTextSet` is synchronous, inside `SetText`
/// itself** (`0x77be6b`), while `OnTextChanged` waits for the drain. The equality short-circuit
/// still suppresses both. So writing "hi", "hi", "bye" before any drain logs two `set`s and then a
/// SINGLE `changed` carrying only the last value — `A → B` coalesces.
#[test]
fn set_text_short_circuits_on_same_value_else_fires_textset_then_textchanged() {
let s = script();
fn set_text_fires_ontextset_at_once_and_ontextchanged_at_the_drain() {
let mut s = script();
s.run(
r#"
log = {}
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextSet", function() table.insert(log, "set") end)
E:SetScript("OnTextChanged", function() table.insert(log, "changed") end)
E:SetText("hi") -- fires set then changed
E:SetText("hi") -- unchanged → short-circuit, no events
E:SetText("bye") -- fires set then changed
E:SetText("hi") -- fires set NOW, marks changed for the drain
E:SetText("hi") -- unchanged → short-circuit, no events at all
E:SetText("bye") -- fires set NOW; the pending change simply becomes "bye"
"#,
)
.unwrap();
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "changed", "set", "changed"]);
assert_eq!(log, vec!["set", "set"], "no drain has run yet");
s.tick(0.0);
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "set", "changed"]);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "bye");
}
@@ -328,8 +357,10 @@ fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
s.run("E:HighlightText(0, -1)").unwrap();
assert!(s.paste("xyz"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "xyz");
// Each paste that changed the text fired OnTextChanged once.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 2);
// Both pastes marked the same box, so the drain owes exactly one fire — not one per paste.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 0);
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
#[test]
@@ -1450,3 +1481,73 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
"the selection trio and the caret are blank quads"
);
}
/// `SetNumber` is `SetText` with Lua's own `%.14g` in front of it — the two bindings are
/// byte-identical in the reference (`0x798690` / `0x7984c0`), so the numeric verb does no numeric
/// work of its own. Decision 1831; the expected strings are the reference's executed output.
#[test]
fn set_number_formats_like_the_references_printf() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumBox", UIParent)"#)
.unwrap();
for (input, want) in [
("3", "3"),
("0.8", "0.8"),
("-0.5", "-0.5"),
("0.1 + 0.2", "0.3"),
("1/3", "0.33333333333333"),
("1e13", "10000000000000"),
// The three-digit exponent, which is MSVC's `%g` and not C99's. `1e+020`, never `1e+20`.
("1e14", "1e+014"),
("1e20", "1e+020"),
("1e-5", "1e-005"),
// Just inside the fixed-notation window.
("1e-4", "0.0001"),
// `%g` drops the sign on negative zero.
("-0.0", "0"),
] {
s.run(&format!("box:SetNumber({input})")).unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
want,
"SetNumber({input})"
);
}
// A STRING is accepted and passed through verbatim — the gate is `lua_isstring`, so the
// argument is never parsed as a number.
s.run(r#"box:SetNumber("abc")"#).unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "abc");
// Everything else raises, including an ABSENT argument. `luaL_error` does not return.
for bad in [
"box:SetNumber()",
"box:SetNumber(nil)",
"box:SetNumber(true)",
] {
assert!(s.run(bad).is_err(), "{bad} must raise");
}
}
/// A `numeric` box takes the reference's wholesale abort: the clear-all has already run when the
/// insert fails the digit test, so the box is left EMPTY rather than partly filled. The money
/// boxes (`MoneyInputFrame.xml`) are numeric, which is why this matters — though the money path
/// itself only ever passes non-negative integers.
#[test]
fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumOnly", UIParent) box:SetNumeric(true)"#)
.unwrap();
s.run("box:SetNumber(1234)").unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "1234");
// The minus sign fails the digit test, and the abort is wholesale.
s.run("box:SetNumber(-5)").unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
"",
"a sign empties a numeric box rather than partly filling it"
);
}
-20
View File
@@ -146,12 +146,6 @@ impl super::UiScript {
pub fn take_inspect_clear(&mut self) -> bool {
std::mem::take(&mut self.model_mut().inspect_clear)
}
/// The inspect model pane's bake yaw in radians — the twin of
/// [`Self::paperdoll_yaw`], read by the app onto the `"inspect"` booth slot each frame.
pub fn inspect_yaw(&self) -> f32 {
self.model_ref().inspect_yaw
}
}
/// What the app resolved for `token`, or `None` when it resolved to no live unit at all — the
@@ -271,19 +265,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaInspectModel_SetFacing(radians) — the inspect pane's own bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` (decision 0208 §5's rotate-adjusts-the-bake). Two scalars
// rather than one shared: the two windows can be open at different facings, and the ref's own
// `InspectModelFrame` carries its own `rotation` independent of the character pane's.
g.set(
"BenillaInspectModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.inspect_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
+13 -3
View File
@@ -288,6 +288,18 @@ pub(crate) struct Model {
/// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble
/// (decision 1634's `[layout-pre] watched=`).
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// Edit boxes whose text changed and whose `OnTextChanged` has **not fired yet** — the
/// reference's `textChanged` dirty bit (`[E+0x31c]` bit 0), which `SetText`/`Insert` raise and
/// only the dirty-word drain `0x77d3e0` clears (decision 1831).
///
/// The fire is deferred, not immediate: `SetText` returns to Lua before any handler runs, and N
/// changes to one box between two drains produce exactly ONE fire carrying the final text. The
/// reference's own FrameXML depends on both halves — `MoneyInputFrame_SetCopper` sets three
/// boxes while counting the fires it expects back, and errors if one arrives mid-loop.
///
/// A hidden box stays on this list: `Hide` splices it out of the chain the update walk follows,
/// so its pending fire waits until it is shown again (or a key/mouse-down reaches it).
pub(crate) dirty_editboxes: Vec<FrameHandle>,
/// `event name → frames registered for it` (RegisterEvent), in **registration order** — an
/// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener
/// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line
@@ -1330,8 +1342,6 @@ pub(crate) struct Model {
/// The inspect model pane's bake yaw — the last of the benilla-named pane scalars beside
/// [`Self::dressup_yaw`]. A migrated window's pane carries its own facing in `ModelState`
/// (`UiScript::model_pane_facing`, decision 1751); these two remain because their windows are
/// still ours, and each retires with its file.
pub(crate) inspect_yaw: f32,
/// The dressing room's queued intents (decision 1060) — `BenillaDressUpModel_Dress/TryOn/Close`,
/// drained by the app in order (see [`super::dressup`] on why order matters).
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
@@ -1574,6 +1584,7 @@ impl Model {
scripts: HashMap::new(),
on_update_frames: Vec::new(),
on_size_changed_frames: Vec::new(),
dirty_editboxes: Vec::new(),
event_to_frames: HashMap::new(),
frame_events: HashMap::new(),
focused_editbox: None,
@@ -1865,7 +1876,6 @@ impl Model {
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_yaw: 0.0,
unit_reach: HashMap::new(),
+12 -1
View File
@@ -117,7 +117,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// the bake replaces them. A later `SetTexture`/`SetPortraitToTexture` clears the binding.
lua.globals().set(
"SetPortraitTexture",
lua.create_function(|lua, (region, unit): (Table, String)| {
lua.create_function(|lua, (region, unit): (Table, Value)| {
// `0x519ef0` gates its unit argument at `0x519fb4` with `lua_isstring` and fails into
// `luaL_error` with this exact string (`0x8513b8`); `luaL_error` does not return, so
// nil/absent/boolean/table abandon the caller's statement. Stock
// `Blizzard_InspectUI.lua:49` reaches it on a re-open, which is how it was found.
// A token that RESOLVES to nothing is a different, quiet case — it blanks the portrait
// and returns no values. Decision 1834.
let unit = crate::script::binding_abi::string_arg(
lua,
unit,
r#"Usage: SetPortraitTexture(texture, "unit")"#,
)?;
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
+5
View File
@@ -107,6 +107,11 @@ impl UiScript {
}
// The focused edit box's caret blink (`0x77a790` runs on the client's frame tick).
editbox::tick_blink(&self.lua, elapsed);
// …and, from inside that same `0x77a790` (`0x77a7a1`), the dirty-word drain that fires the
// `OnTextChanged`s an edit only marked (decision 1831). It runs BEFORE this tick's
// OnUpdate sweep, matching the reference's order: the box's own OnUpdate override drains
// before the FrameXML handlers that read the box get their turn.
editbox::drain_text_changed(&self.lua);
// Events queued by Lua bindings last tick (`Model::pending_events` — e.g. `SetMapZoom` →
// `WORLD_MAP_UPDATE`; the cursor arc's `CURSOR_UPDATE`/`ITEM_LOCK_CHANGED`/
// `DELETE_ITEM_CONFIRM`, decision 0216) fire first, so handlers see them before this
+217 -30
View File
@@ -72,8 +72,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `lua_isstring` gate admits tag 3 and hands the resolver `"5"`). `check_unit_token` inside
// `with_unit` is the second of those; the first is the `?` on the argument type below.
// The gate is the BINDING's, not `with_unit`'s: `check_unit_token` lets a nil through by
// design, because that is right for `UnitExists`, `UnitIsVisible` and most of this family. It
// is wrong for exactly these two, so the shape-A test happens here, before the shared path.
// design, because that is right for `UnitExists`, `UnitIsVisible` and eleven others. **"and
// most of this family" is what this comment used to say, and it was the wrong way round** —
// wow-re has since censused all 83 entries of the table at `0x850438` and 53 of them gate and
// raise, with only 13 unit-token bindings quiet (decision 1834). These two were never the
// exception; they were an early instance of the rule.
for (name, usage, by_player) in [
("UnitIsTapped", r#"Usage: UnitIsTapped("unit")"#, false),
(
@@ -151,7 +154,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitName",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitName("unit")"#,
)?);
let name = with_unit(lua, &token, None, |u| u.name.clone())?;
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
@@ -162,14 +170,24 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitHealth",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHealth("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.health))
})?,
)?;
g.set(
"UnitHealthMax",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHealthMax("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.max_health))
})?,
)?;
@@ -183,7 +201,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `b` INFERRED and 0 in normal play — it can never drive the value ≤ 0 anyway).
g.set(
"UnitLevel",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitLevel("unit")"#,
)?);
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else {
@@ -223,7 +246,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// (`UnitCanAttack("player", "target")`).
g.set(
"UnitCanAttack",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
Ok(if with_unit(lua, &token, false, |u| u.can_attack)? {
Value::Integer(1)
@@ -247,7 +282,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsDead",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsDead("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead)
})?,
)?;
@@ -256,13 +296,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// so IsDead is false for it and the popup flow branches on all three.
g.set(
"UnitIsGhost",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.ghost)
})?,
)?;
g.set(
"UnitIsDeadOrGhost",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsDeadOrGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead || u.ghost)
})?,
)?;
@@ -274,7 +324,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// frame paints its name plate blue then, exactly like the reference.
g.set(
"UnitReaction",
lua.create_function(|lua, (token, _other): (Option<String>, Option<String>)| {
lua.create_function(|lua, (token, _other): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitReaction("unit", "otherUnit")"#,
)?);
let _other = Some(crate::script::binding_abi::string_arg(
lua,
_other,
r#"Usage: UnitReaction("unit", "otherUnit")"#,
)?);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r == 0 {
Value::Nil
@@ -294,7 +356,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// reaction (0) → nil for both, the API's "can't tell". `1`/nil returns, era-style.
g.set(
"UnitIsEnemy",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if (1..=2).contains(&r) {
@@ -306,7 +380,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitIsFriend",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r >= 5 {
@@ -335,7 +421,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// target plate disagree about the same mob.
g.set(
"UnitIsCivilian",
lua.create_function(|lua, unit: Option<String>| {
lua.create_function(|lua, unit: Value| {
let unit = Some(crate::script::binding_abi::string_arg(
lua,
unit,
r#"Usage: UnitIsCivilian("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let player_level = model.player_req.level;
Ok(match unit.and_then(|u| model.unit(&u)) {
@@ -374,7 +465,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// is trivially the same unit; otherwise both snapshots must carry a real (nonzero) guid.
g.set(
"UnitIsUnit",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
)?);
// BOTH arguments go through the resolver, so either being unrecognised raises.
check_unit_token(&a)?;
check_unit_token(&b)?;
@@ -494,7 +597,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// whisper a same-faction player), without the faction machinery the engine doesn't carry.
g.set(
"UnitCanCooperate",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let ok = with_unit(lua, &token, false, |u| {
u.exists && u.is_player && u.reaction >= 5
@@ -507,7 +622,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// fed per token).
g.set(
"GetRaidTargetIndex",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
// Gated (`0x4bb4be` → `0x4bb4cd`), and the usage string names the
// argument unquoted — the reference's own spelling. Decision 1836.
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
"Usage: GetRaidTargetIndex(unit)",
)?);
let idx = with_unit(lua, &token, 0u8, |u| u.raid_target)?;
Ok(if idx > 0 {
Value::Integer(i64::from(idx))
@@ -522,7 +644,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// stated v1 shortcut, module doc), these are new and follow the era shape from the start.
g.set(
"UnitIsConnected",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsConnected("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_connected)? {
Value::Integer(1)
} else {
@@ -554,7 +681,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// two callers) — see the field doc.
g.set(
"UnitIsPVP",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsPVP("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.pvp)? {
Value::Integer(1)
} else {
@@ -564,7 +696,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitIsPVPFreeForAll",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsPVPFreeForAll("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_pvp_ffa)? {
Value::Integer(1)
} else {
@@ -588,7 +725,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// files are on our chain.
g.set(
"UnitFactionGroup",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitFactionGroup("unit")"#,
)?);
let pair = with_unit(lua, &token, (None, None), |u| {
(u.faction_group.clone(), u.faction_group_localized.clone())
})?;
@@ -615,7 +757,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// reports nil, nil — the live API's shape for an absent unit.
g.set(
"UnitRace",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitRace("unit")"#,
)?);
let pair = with_unit(lua, &token, None, |u| {
u.race.clone().zip(u.race_file.clone())
})?;
@@ -630,7 +777,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitClass",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitClass("unit")"#,
)?);
let pair = with_unit(lua, &token, None, |u| {
u.class.clone().zip(u.class_file.clone())
})?;
@@ -667,7 +819,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// The truthy leg is the NUMBER 1, never a boolean; the false leg is nil, never `false`.
g.set(
"UnitHasRelicSlot",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHasRelicSlot("unit")"#,
)?);
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
Ok(if has { Value::Integer(1) } else { Value::Nil })
})?,
@@ -677,7 +834,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
g.set(
"UnitSex",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitSex("unit")"#,
)?);
let sex = with_unit(lua, &token, 0u8, |u| u.sex)?;
Ok(if sex == 0 {
Value::Nil
@@ -763,7 +925,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// alt-power color components; addons that read those handle nil).
g.set(
"UnitPowerType",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitPowerType("unit")"#,
)?);
let ty = with_unit(lua, &token, 0u8, |u| u.power_type)?;
Ok((i64::from(ty), power_token(ty).to_string()))
})?,
@@ -782,13 +949,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// have" is spelled `UnitPowerType(unit)` first, which we already provide and which IS 1.12.
g.set(
"UnitMana",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitMana("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.power))
})?,
)?;
g.set(
"UnitManaMax",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitManaMax("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.max_power))
})?,
)?;
@@ -800,7 +977,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let is_player = |token: &Option<String>| token.as_deref() == Some("player");
g.set(
"UnitXP",
lua.create_function(move |lua, token: Option<String>| {
lua.create_function(move |lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitXP("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if is_player(&token) {
i64::from(model.player_xp)
@@ -811,7 +993,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitXPMax",
lua.create_function(move |lua, token: Option<String>| {
lua.create_function(move |lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitXPMax("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if is_player(&token) {
i64::from(model.player_next_level_xp)
+8 -3
View File
@@ -644,9 +644,14 @@ pub(crate) fn token_recognised(token: &str) -> bool {
/// 1203's shape pointed the other way: a failure the client reports, silently swallowed.
///
/// The split is THREE-way, not two, and the two quiet legs are as carved:
/// * **absent argument** — quiet nil (the per-binding argument gates are NOT uniform; `UnitName`
/// has its own `lua_isstring` gate and `UnitExists` has none at all, and only those two poles
/// are verified, so this does not invent a gate for the other ~102);
/// * **absent argument** — quiet nil *here*, because this helper is only the resolver's half.
/// Whether a nil ever reaches it is the BINDING's question, and it is settled per binding:
/// wow-re's census of all 83 entries at `0x850438` found 53 that gate the token position with
/// `lua_isstring` and raise `Usage:`, against 13 unit-token bindings with no gate at all
/// (decision 1834). The gated ones call `binding_abi::string_arg` before they get here, so a
/// nil never arrives; the quiet 13 pass it straight through. This comment used to say the
/// gates were "NOT uniform … only those two poles are verified" and decline to guess, which
/// was the right call at the time — the table now exists, so it is applied rather than feared;
/// * **`""`** — quiet nil;
/// * a **recognised** token naming nothing (`"party5"` solo, `"playerfoo"`) — quiet nil.
pub(crate) fn check_unit_token(token: &Option<String>) -> mlua::Result<()> {
+80 -4
View File
@@ -984,11 +984,32 @@ fn an_unrecognised_unit_token_raises_and_a_recognised_empty_one_does_not() {
implementation that raises on \"did not resolve\" gets wrong."
);
}
// QUIET NIL, leg 3: the empty string and an absent argument. The per-binding argument gates are
// NOT uniform in the client and only two poles are verified, so this asserts the resolver's
// behaviour and does not invent a gate.
// QUIET NIL, leg 3: the empty string — which passes the `lua_isstring` gate (it IS a string)
// and dies quietly in the resolver's own NULL/empty guard.
assert!(s.run(r#"UnitName("")"#).is_ok());
assert!(s.run("UnitName()").is_ok());
// …but an ABSENT or nil argument is NOT leg 3, and this line used to assert that it was.
// `UnitName 0x517020` gates its token position at `0x517048` and raises
// `Usage: UnitName("unit")` (`0x850ee0`); `luaL_error` does not return. The comment this
// replaces said the per-binding gates were "not uniform … only two poles verified", which was
// true when it was written — wow-re has since censused all 83 entries of the table at
// `0x850438`: 53 gate and raise, and only 13 unit-token bindings are quiet. Decision 1834.
assert!(s.run("UnitName()").is_err(), "absent argument raises");
assert!(s.run("UnitName(nil)").is_err(), "nil argument raises");
// A NUMBER passes the gate — `lua_isstring` admits tag 3 — and is handed to the resolver as
// "5", which then raises the family's OTHER message rather than the `Usage:` one.
assert!(
s.run("UnitName(5)").is_err(),
"a number reaches the resolver"
);
// The thirteen that stay quiet, three of them here: no gate at all, so nil is fine.
for quiet in ["UnitExists", "UnitIsVisible", "UnitClassification"] {
assert!(
s.run(&format!("{quiet}()")).is_ok(),
"{quiet} is one of the 13 with no gate — nil must stay quiet"
);
}
// A two-unit call gates BOTH arguments.
assert!(s.run(r#"UnitIsUnit("player", "bogus")"#).is_err());
@@ -1371,3 +1392,58 @@ fn unit_has_relic_slot_answers_one_or_nil() {
"function"
);
}
/// **The two-token predicates gate BOTH positions** — decision 1836, closing the half 1834 left
/// open on purpose.
///
/// 1834 applied the `lua_isstring` gate only where wow-re's partial list named a single-token
/// binding, because a census that finds "a gate somewhere in this body" cannot say *which*
/// argument carries it. The complete 83-row table (`nil-unit-token-arg-law.md` §10) resolves it:
/// these seven each carry **two** `lua_isstring` sites, so either argument being nil raises.
#[test]
fn a_two_token_predicate_raises_on_either_nil_argument() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(player()));
for verb in [
"UnitIsUnit",
"UnitIsEnemy",
"UnitIsFriend",
"UnitCanCooperate",
"UnitCanAttack",
"UnitReaction",
] {
assert!(
s.run(&format!(r#"{verb}("player", "target")"#)).is_ok(),
"{verb} with both tokens is fine"
);
assert!(
s.run(&format!(r#"{verb}("player")"#)).is_err(),
"{verb} with the SECOND argument absent must raise"
);
assert!(
s.run(&format!(r#"{verb}(nil, "target")"#)).is_err(),
"{verb} with the FIRST argument nil must raise"
);
assert!(s.run(&format!("{verb}()")).is_err(), "{verb} with neither");
}
// `GetRaidTargetIndex` is the one of six previously-unclassified names that takes a token, and
// it is gated. Its usage string names the argument UNQUOTED — the reference's own spelling.
assert!(s.run(r#"GetRaidTargetIndex("player")"#).is_ok());
assert!(s.run("GetRaidTargetIndex()").is_err());
// …while the other five take no argument at all and have no gate. `GetTimeToWellRested` is a
// three-instruction stub in the reference that always pushes nil; computing a value there
// would be divergence, not a feature.
for none in [
"GetQuestGreenRange",
"GetRestState",
"GetXPExhaustion",
"GetTimeToWellRested",
"GetBillingTimeRested",
] {
assert!(s.run(&format!("{none}()")).is_ok(), "{none} takes no token");
}
}