loot and pet refusals become message ids, and waterfalls flow again

- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:39:11 +07:00
co-authored by Claude Fable 5.1
parent 4040166138
commit 8f0bece3be
41 changed files with 3488 additions and 547 deletions
+59 -132
View File
@@ -1,13 +1,20 @@
//! The `Unit*` global registrations (see the parent module's doc for the seam and the return
//! shapes): every binding reads the per-token [`UnitState`](super::UnitState) snapshot store
//! through the parent's `with_unit`/`pick_unit_token` helpers.
//!
//! **Every predicate here returns through one function** — [`super::unit_predicate`] when it reads
//! a snapshot field, [`predicate`] when it computes its own bool. Neither ever hands mlua a Rust
//! `bool`: 1.12 pushes the number `1` or `nil` and has no boolean on its binding surface at all
//! (decisions 1830, 2043). A new predicate that open-codes `Value::Integer(1)`/`Value::Nil`, or
//! returns a `bool`, is the drift those two records exist to stop.
use mlua::{Lua, Value};
use super::super::binding_abi::predicate;
use super::super::Model;
use super::{
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
with_unit, SelectionRequest,
unit_predicate, unknownobject, with_unit, SelectionRequest,
};
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
@@ -19,19 +26,6 @@ use super::{
const CLASS_ROGUE: u32 = 4;
const CLASS_DRUID: u32 = 11;
/// `FrameScript_GetText("UNKNOWNOBJECT")` as the name resolvers call it (`0x517220`, `0x609324`):
/// the VM's own GlobalString — read out of `_G` exactly as `0x703bf0` reads it, so a translated
/// `GlobalStrings.lua` translates this too (enUS: `"Unknown"`, `GlobalStrings.lua:4444`) — and,
/// when the global is missing or empty, the binary's own literal `"Unknown Being"` (`0x860fa4`,
/// the bytes at that address in `WoW.exe`). Always a string: this is the "name not yet known"
/// state, and it is never nil. Decision 2002.
fn unknownobject(lua: &Lua) -> mlua::Result<Value> {
match lua.globals().get::<Value>("UNKNOWNOBJECT") {
Ok(Value::String(s)) if !s.as_bytes().is_empty() => Ok(Value::String(s)),
_ => Ok(Value::String(lua.create_string("Unknown Being")?)),
}
}
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
/// surface).
@@ -41,7 +35,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitExists",
lua.create_function(|lua, token: Option<String>| {
with_unit(lua, &token, false, |u| u.exists)
unit_predicate(lua, &token, |u| u.exists)
})?,
)?;
@@ -62,11 +56,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsVisible",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.has_object)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.has_object)
})?,
)?;
@@ -115,7 +105,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?;
// One value, and it is the NUMBER 1 or nil — never a boolean, the same shape the
// rest of this family answers in.
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
}
@@ -157,11 +147,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.as_ref()
.and_then(|t| model.unit(t))
.map_or(0, |u| u.guid);
Ok(if by_flag || guid == model.party.leader_guid {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(by_flag || guid == model.party.leader_guid))
})?,
)?;
@@ -213,7 +199,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
{
Value::Nil
}
Some(None) => unknownobject(lua)?,
Some(None) => Value::String(unknownobject(lua)?),
};
Ok((name, Value::Nil))
})?,
@@ -282,11 +268,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsCorpse",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.corpse_object)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.corpse_object)
})?,
)?;
@@ -311,11 +293,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.can_attack)
})?,
)?;
@@ -339,7 +317,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsDead("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead)
unit_predicate(lua, &token, |u| u.dead)
})?,
)?;
@@ -353,7 +331,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.ghost)
unit_predicate(lua, &token, |u| u.ghost)
})?,
)?;
g.set(
@@ -364,7 +342,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsDeadOrGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead || u.ghost)
unit_predicate(lua, &token, |u| u.dead || u.ghost)
})?,
)?;
@@ -422,11 +400,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if (1..=2).contains(&r) {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate((1..=2).contains(&r)))
})?,
)?;
g.set(
@@ -446,11 +420,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r >= 5 {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(r >= 5))
})?,
)?;
@@ -480,10 +450,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
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)) {
Some(u) if super::is_civilian_kill(u, player_level) => Value::Integer(1),
_ => Value::Nil,
})
Ok(predicate(
unit.and_then(|u| model.unit(&u))
.is_some_and(|u| super::is_civilian_kill(u, player_level)),
))
})?,
)?;
// UnitPlayerControlled(unit) → 1 if a PLAYER is driving this unit, else nil. `UNIT_FIELD_FLAGS`
@@ -495,20 +465,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"UnitPlayerControlled",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if u.player_controlled => Value::Integer(1),
_ => Value::Nil,
})
Ok(predicate(
unit.and_then(|u| model.unit(&u))
.is_some_and(|u| u.player_controlled),
))
})?,
)?;
g.set(
"UnitIsPlayer",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_player)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_player)
})?,
)?;
@@ -542,11 +508,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
if !ua.exists || !ub.exists {
return Ok(Value::Nil);
}
if a == b || (ua.guid != 0 && ua.guid == ub.guid) {
Ok(Value::Integer(1))
} else {
Ok(Value::Nil)
}
Ok(predicate(a == b || (ua.guid != 0 && ua.guid == ub.guid)))
})?,
)?;
@@ -570,7 +532,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"Usage: UnitAffectingCombat(\"unit\")",
)?;
let hot = with_unit(lua, &Some(token), false, |u| u.exists && u.in_combat)?;
Ok(if hot { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hot))
})?,
)?;
@@ -599,11 +561,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"HasFullControl",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.player_control {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.player_control))
})?,
)?;
@@ -642,7 +600,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
!model.party.members.is_empty()
};
let hit = grouped && (in_group(u.guid) || in_group(u.owner));
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
}
@@ -663,7 +621,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.iter()
.any(|m| m.guid != 0 && m.guid == u.guid)
});
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
@@ -693,7 +651,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.members
.iter()
.any(|m| m.guid != 0 && m.guid == u.guid)));
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
@@ -720,7 +678,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let ok = with_unit(lua, &token, false, |u| {
u.exists && u.is_player && u.reaction >= 5
})?;
Ok(if ok { Value::Integer(1) } else { Value::Nil })
Ok(predicate(ok))
})?,
)?;
@@ -746,8 +704,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// The party-frame status predicates (decision 0434 §2/§3): connection, AFK/DND, and the two PvP
// flags. All 1/nil, the live API's own shape for these — unlike UnitIsDead/UnitIsGhost above (a
// stated v1 shortcut, module doc), these are new and follow the era shape from the start.
// flags. All 1/nil, through the family's one push site (2043).
//
// **`UnitIsAFK`/`UnitIsDND` are OURS, not the reference's** — this comment used to claim they
// "follow the era shape from the start", which named the wrong authority. wow-re's raw byte
// census over the whole image finds ZERO occurrences of `UnitIsAFK`, `UnitIsDND`, `IsAFK` or
// `IsDND`, as bindings or as strings (positive control: `UnitIsPVP\0` and `UnitIsGhost\0`
// each return exactly 1) — `ui/scratch/nil-unit-token-arg-law.md` §11. 1.12 surfaces a
// member's AFK/DND state through `GetGuildRosterInfo` and the `CHAT_FLAG_AFK`/`CHAT_FLAG_DND`
// GlobalStrings; there is no unit predicate for it. They wear the family's shape because that
// is the right shape for an invention of ours to wear, not because a binding was read.
g.set(
"UnitIsConnected",
lua.create_function(|lua, token: Value| {
@@ -756,31 +722,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsConnected("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_connected)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_connected)
})?,
)?;
g.set(
"UnitIsAFK",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_afk)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_afk)
})?,
)?;
g.set(
"UnitIsDND",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_dnd)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_dnd)
})?,
)?;
// UnitIsPVP reads the same `pvp` field the unit tooltip's "PvP" line already does (one flag,
@@ -793,11 +747,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsPVP("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.pvp)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.pvp)
})?,
)?;
g.set(
@@ -808,11 +758,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsPVPFreeForAll("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_pvp_ffa)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_pvp_ffa)
})?,
)?;
// UnitFactionGroup(unit) → (englishGroup, localizedName), the pair the PvP icon law reads:
@@ -932,7 +878,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
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 })
Ok(predicate(has))
})?,
)?;
@@ -1137,13 +1083,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsCharmed",
lua.create_function(|lua, token: Option<String>| {
with_unit(lua, &token, Value::Nil, |u| {
if u.charmed {
Value::Number(1.0)
} else {
Value::Nil
}
})
unit_predicate(lua, &token, |u| u.charmed)
})?,
)?;
@@ -1251,11 +1191,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"IsResting",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.resting {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.resting))
})?,
)?;
// PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read
@@ -1268,22 +1204,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"PartialPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.partial_play_time {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.partial_play_time))
})?,
)?;
g.set(
"NoPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.no_play_time {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.no_play_time))
})?,
)?;
// GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always
@@ -1505,12 +1433,11 @@ 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.unit("target").is_some_and(|u| u.exists) {
let had = model.unit("target").is_some_and(|u| u.exists);
if had {
model.target_clear = true;
Ok(Value::Integer(1))
} else {
Ok(Value::Nil)
}
Ok(predicate(had))
})?,
)?;
+61 -4
View File
@@ -7,16 +7,20 @@
//! `UnitHealth("player")` therefore resolves against a [`UnitState`] the Bevy side deposited that
//! frame — the same shape a real addon sees, with none of the coupling.
//!
//! Return shapes follow the live API (warcraft.wiki.gg): `UnitName` returns the name *or nil*;
//! Return shapes follow the reference: `UnitName` returns the name *or nil*;
//! `UnitHealth`/`UnitHealthMax`/`UnitLevel` return numbers (`UnitLevel` returns **−1** for a
//! unit whose level "can't be told" — [`level_reads_unknown`], the target frame's skull gate);
//! a unit that doesn't exist reports `UnitExists` false and the numbers `0` (nil for the name),
//! a unit that doesn't exist reports `UnitExists` nil and the numbers `0` (nil for the name),
//! exactly as the client does for an absent unit token. Tokens (`"player"`, `"target"`, …) are opaque strings the host never
//! interprets — the app decides what each maps to.
//!
//! **Every predicate in this family answers the number `1` or `nil`, never a Lua boolean**, and it
//! answers it through the one helper — [`unit_predicate`], over
//! [`super::binding_abi::predicate`]. Decision 2043; the law is 1830's, which settled the same
//! question for the widget predicates a family earlier.
//!
//! v1 gaps, stated not hidden: the snapshot carries only the *active* power slot (see the
//! `UnitMana` note below); `UnitIsDead` returns a Lua boolean rather than the client's `1`/nil
//! (truthy either way — the shape a caller branches on is identical); `UnitIsConnected`'s backing
//! `UnitMana` note below); `UnitIsConnected`'s backing
//! field defaults `false`, and no app-side feed sets it `true` for live units yet (decision 0434
//! §2's party-frame predicates land the binding first, the feed follows — see the field doc).
@@ -338,6 +342,34 @@ pub struct UnitState {
pub in_combat: bool,
}
/// `FrameScript_GetText("UNKNOWNOBJECT")` as the client's name resolvers call it — the VM's own
/// GlobalString, read out of `_G` exactly as `0x703bf0` reads it (so a translated
/// `GlobalStrings.lua` translates this too; enUS `"Unknown"`, `GlobalStrings.lua:4444`), falling
/// back to the binary's own literal `"Unknown Being"` (`0x860fa4`) when the global is missing or
/// empty. Always a string — this is the "name not yet known" state, never a nil.
///
/// **ONE home, two callers**, the same reason [`is_civilian_kill`] is a function. `UnitName`
/// `0x517020` reaches it at `0x517220`, and `CGUnit_C::GetUnitName 0x609210` — which the UNIT
/// TOOLTIP builder `0x529fe0` calls for its title line (`0x52a187`) — reaches the *same* tail at
/// `0x609324`, from every one of its misses:
///
/// ```text
/// 609353 je 0x609324 ; creature: [CGUnit+0xb30] (creaturecache.wdb) still null
/// 6092ce je 0x609324 ; pet: petnamecache.wdb row absent, or its stamp stale
/// 609265 je 0x609324 ; player: namecache.wdb has not answered
/// 609324 push 0x0 / or edx,-1 / mov ecx,0x850ed0 ("UNKNOWNOBJECT") / call 0x703bf0
/// 60933c mov eax,0x860fa4 ; the global missing or empty -> "Unknown Being"
/// ```
///
/// One resolver, so the verb and the plate can never disagree about what a unit whose name is
/// still in flight is called (decisions 2002, 2040).
pub fn unknownobject(lua: &Lua) -> mlua::Result<mlua::String> {
match lua.globals().get::<mlua::Value>("UNKNOWNOBJECT") {
Ok(mlua::Value::String(s)) if !s.as_bytes().is_empty() => Ok(s),
_ => lua.create_string("Unknown Being"),
}
}
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
/// grey level colour), indexed by `playerLevel / 5` and clamped to 12 past the table. ONE home
/// for its three consumers: [`unit_is_grey`] (tooltip CIVILIAN gate + the plate's skull/grey
@@ -691,6 +723,31 @@ fn with_unit<T>(
})
}
/// A unit **predicate** binding's whole body: resolve the token, map its snapshot to a bool, and
/// push the family's one return shape — the number `1` or `nil`, never a Lua boolean.
///
/// [`with_unit`] answers whatever `f` answers, so a predicate written on it *directly* hands mlua a
/// Rust `bool`, and mlua pushes tag 1. That is the one shape 1.12 cannot produce: `UnitExists`
/// (`0x515fb0`) pushes `lua_pushnumber` (`0x6f3810`, tag 3, the double `1.0`) or `lua_pushnil`
/// (`0x6f37f0`, tag 0) and nothing else, and so does every other predicate in the family. So the
/// whole family goes through [`super::binding_abi::predicate`] — 1830's widget law, which is the
/// *same* law, finally applied to the unit surface (decision 2043). A predicate that hand-computes
/// its own bool calls that helper directly; one that reads a snapshot field calls this. Both end at
/// the one push site, which is what stops the family drifting apart again.
///
/// **Not every unit binding is a predicate.** `UnitReaction` answers the reaction *number*,
/// `UnitLevel` a level (with `-1` for "can't be told"), `UnitPowerType` a power index whose miss is
/// the number `0` and never nil. Those keep their own shapes and must not be routed here.
fn unit_predicate(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&UnitState) -> bool,
) -> mlua::Result<mlua::Value> {
Ok(super::binding_abi::predicate(with_unit(
lua, token, false, f,
)?))
}
/// The `Unit*`/`GetQuestGreenRange` Lua binding registrations — split from this module's
/// state+laws half purely for size (same seam as the other script modules' install fns).
mod bindings;
+390 -15
View File
@@ -342,7 +342,7 @@ fn set_unit_is_read_by_the_bindings() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("player")"#).unwrap(), 1);
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Benilla"
@@ -353,13 +353,19 @@ fn set_unit_is_read_by_the_bindings() {
100
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 12);
assert!(!s.eval::<bool>(r#"return UnitIsDead("player")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsDead("player") == nil"#)
.unwrap());
}
#[test]
fn absent_token_reports_not_existing_with_zero_numbers() {
let s = UiScript::new().unwrap();
assert!(!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
// **nil, not `false`** — the family's false leg (decision 2043). `== nil` is the comparison a
// boolean would invert, so it is the one worth asserting.
assert!(s
.eval::<bool>(r#"return UnitExists("target") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
assert_eq!(
s.eval::<i64>(r#"return UnitHealthMax("target")"#).unwrap(),
@@ -369,7 +375,9 @@ fn absent_token_reports_not_existing_with_zero_numbers() {
assert!(s
.eval::<bool>(r#"return UnitName("target") == nil"#)
.unwrap());
assert!(!s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsDead("target") == nil"#)
.unwrap());
}
#[test]
@@ -387,8 +395,8 @@ fn a_dead_unit_reports_dead_and_zero_health() {
..Default::default()
}),
);
assert!(s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
assert!(s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("target")"#).unwrap(), 1);
assert_eq!(s.eval::<i64>(r#"return UnitIsDead("target")"#).unwrap(), 1);
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
// Name unknown (no name-query yet) → `UNKNOWNOBJECT`, never nil: the unit RESOLVED, and the
// reference pushes nil for a zero GUID only (decision 2002; the shape is pinned below). A bare
@@ -534,9 +542,11 @@ fn unit_xp_reads_the_player_globals_only_for_the_player_token() {
fn set_unit_none_removes_the_token() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("player")"#).unwrap(), 1);
s.set_unit("player", None);
assert!(!s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitExists("player") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 0);
}
@@ -945,9 +955,11 @@ fn a_unit_token_resolves_whatever_its_case() {
.unwrap(),
12
);
assert!(s
.eval::<bool>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap());
assert_eq!(
s.eval::<i64>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap(),
1
);
}
for spelling in ["target", "Target", "TARGET"] {
assert_eq!(
@@ -998,7 +1010,8 @@ fn seating_a_token_folds_its_key_too() {
// …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(),
s.eval::<bool>(r#"return UnitExists("target") == nil"#)
.unwrap(),
"removal folds too — no shadowed entry survives"
);
}
@@ -1182,9 +1195,9 @@ fn unit_is_visible_is_object_presence_not_existence() {
None,
"no object -> nil, even though the token still exists"
);
assert!(
s.eval::<bool>(r#"return UnitExists("party1") and true or false"#)
.unwrap(),
assert_eq!(
s.eval::<i64>(r#"return UnitExists("party1")"#).unwrap(),
1,
"...and UnitExists must NOT have moved with it — the roster fallback is the difference"
);
@@ -1525,3 +1538,365 @@ fn a_two_token_predicate_raises_on_either_nil_argument() {
assert!(s.run(&format!("{none}()")).is_ok(), "{none} takes no token");
}
}
// ── The family's return SHAPE, pinned per binding ───────────────────────────────────────────────
/// **Every `Unit*` predicate answers the number `1` or `nil` — never a Lua boolean** (decision
/// 2043; the law is 1830's, made for the widget predicates a family earlier).
///
/// The reference cannot produce a boolean here. Its pushes are `lua_pushnumber 0x6f3810` (tag 3,
/// the double `1.0`) and `lua_pushnil 0x6f37f0` (tag 0); `lua_pushboolean 0x6f39f0` has seven call
/// sites image-wide and not one is a registered binding body, so **tag 1 is never written**
/// (wow-re `ui/scratch/binding-shape-arity-law.md`, `ui/scratch/button-enabled-state.md`, restated
/// `ui.md` l.4700 and l.7348; the per-binding carves for `UnitExists 0x515fb0`,
/// `UnitIsVisible 0x516030`, `UnitIsTapped 0x519c90`, `UnitAffectingCombat 0x517e10`,
/// `UnitPlayerControlled 0x516410`, `UnitInRaid 0x516350`, `UnitIsCharmed 0x516cf0` and
/// `UnitHasRelicSlot 0x519e50` each state it outright).
///
/// **Truthiness is exactly why this needs a test rather than a reading.** `if UnitIsDead(u)` and
/// `if not UnitIsDead(u)` are identical under either shape, and mlua's `bool` conversion maps
/// `Integer(1)` to `true` as happily as it maps `Boolean(true)` — so a test that reads the value
/// as a Rust `bool` passes under the bug. Only `type(...)`, `== 1` and `== nil` separate them, and
/// `== nil` is the one that *inverts*: a boolean `false` is not `nil`, so a caller comparing
/// against `nil` concludes the opposite of the truth.
///
/// The table is the whole registered family, so a new predicate added outside the seam fails here
/// rather than shipping its own shape.
#[test]
fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() {
// (binding, the call as an addon writes it, a UnitState that makes it TRUE)
let table: Vec<(&str, &str, UnitState)> = vec![
(
"UnitExists",
r#"UnitExists("target")"#,
UnitState {
exists: true,
..Default::default()
},
),
(
"UnitIsVisible",
r#"UnitIsVisible("target")"#,
UnitState {
exists: true,
has_object: true,
..Default::default()
},
),
(
"UnitIsDead",
r#"UnitIsDead("target")"#,
UnitState {
exists: true,
dead: true,
..Default::default()
},
),
(
"UnitIsGhost",
r#"UnitIsGhost("target")"#,
UnitState {
exists: true,
ghost: true,
..Default::default()
},
),
(
"UnitIsDeadOrGhost",
r#"UnitIsDeadOrGhost("target")"#,
UnitState {
exists: true,
dead: true,
..Default::default()
},
),
(
"UnitIsCorpse",
r#"UnitIsCorpse("target")"#,
UnitState {
exists: true,
corpse_object: true,
..Default::default()
},
),
(
"UnitIsPlayer",
r#"UnitIsPlayer("target")"#,
UnitState {
exists: true,
is_player: true,
..Default::default()
},
),
(
"UnitPlayerControlled",
r#"UnitPlayerControlled("target")"#,
UnitState {
exists: true,
player_controlled: true,
..Default::default()
},
),
(
"UnitIsCharmed",
r#"UnitIsCharmed("target")"#,
UnitState {
exists: true,
charmed: true,
..Default::default()
},
),
(
"UnitIsTapped",
r#"UnitIsTapped("target")"#,
UnitState {
exists: true,
tapped: true,
..Default::default()
},
),
(
"UnitIsTappedByPlayer",
r#"UnitIsTappedByPlayer("target")"#,
UnitState {
exists: true,
tapped_by_player: true,
..Default::default()
},
),
(
"UnitAffectingCombat",
r#"UnitAffectingCombat("target")"#,
UnitState {
exists: true,
in_combat: true,
..Default::default()
},
),
(
"UnitIsConnected",
r#"UnitIsConnected("target")"#,
UnitState {
exists: true,
is_connected: true,
..Default::default()
},
),
(
"UnitIsAFK",
r#"UnitIsAFK("target")"#,
UnitState {
exists: true,
is_afk: true,
..Default::default()
},
),
(
"UnitIsDND",
r#"UnitIsDND("target")"#,
UnitState {
exists: true,
is_dnd: true,
..Default::default()
},
),
(
"UnitIsPVP",
r#"UnitIsPVP("target")"#,
UnitState {
exists: true,
pvp: true,
..Default::default()
},
),
(
"UnitIsPVPFreeForAll",
r#"UnitIsPVPFreeForAll("target")"#,
UnitState {
exists: true,
is_pvp_ffa: true,
..Default::default()
},
),
(
"UnitHasRelicSlot",
r#"UnitHasRelicSlot("target")"#,
UnitState {
exists: true,
has_relic_slot: true,
..Default::default()
},
),
(
"UnitCanAttack",
r#"UnitCanAttack("player", "target")"#,
UnitState {
exists: true,
can_attack: true,
..Default::default()
},
),
(
"UnitIsEnemy",
r#"UnitIsEnemy("player", "target")"#,
UnitState {
exists: true,
reaction: 1,
..Default::default()
},
),
(
"UnitIsFriend",
r#"UnitIsFriend("player", "target")"#,
UnitState {
exists: true,
reaction: 5,
..Default::default()
},
),
(
"UnitCanCooperate",
r#"UnitCanCooperate("player", "target")"#,
UnitState {
exists: true,
is_player: true,
reaction: 5,
..Default::default()
},
),
(
"UnitIsUnit",
r#"UnitIsUnit("target", "target")"#,
UnitState {
exists: true,
guid: 42,
..Default::default()
},
),
];
for (name, call, live) in table {
// ---- the TRUE leg: the number 1, one value, and NOT a boolean ----
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(live));
assert_eq!(
s.eval::<String>(&format!("return type({call})")).unwrap(),
"number",
"{name} pushes a boolean where the reference pushes tag 3 (lua_pushnumber)"
);
assert_eq!(
s.eval::<i64>(&format!("return {call}")).unwrap(),
1,
"{name}'s true leg is the number 1"
);
assert!(
s.eval::<bool>(&format!("return {call} == 1")).unwrap(),
"{name}: the corpus idiom `== 1` must match on the true leg"
);
assert!(
!s.eval::<bool>(&format!("return {call} == true")).unwrap(),
"{name}: `== true` must NEVER match — the reference cannot push tag 1"
);
assert_eq!(
s.eval::<i64>(&format!("return select('#', {call})"))
.unwrap(),
1,
"{name} returns exactly one value on the true leg"
);
// ---- the FALSE leg: nil, one value, and NOT `false` ----
// The token is cleared rather than re-flagged, so this also covers the "no such unit" arm
// the reference shares with a false predicate.
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", None);
assert!(
s.eval::<bool>(&format!("return {call} == nil")).unwrap(),
"{name}'s false leg is nil — a boolean `false` is not nil, and a caller comparing \
against nil would read the opposite of the truth"
);
assert!(
!s.eval::<bool>(&format!("return {call} == false")).unwrap(),
"{name}: `== false` must never match"
);
assert_eq!(
s.eval::<i64>(&format!("return select('#', {call})"))
.unwrap(),
1,
"{name} returns exactly one value on the false leg too — nil, never zero values"
);
}
}
/// The corpus idiom, spelled the way an addon spells it, on a live token and a dead one.
///
/// This is the shape a `bool` breaks and truthiness hides: `if UnitExists("target") == 1` is
/// simply never true under a boolean, and `if UnitExists("target") == nil` is never true either —
/// the branch silently picks the wrong arm in both directions at once.
#[test]
fn the_equals_one_idiom_branches_correctly_on_a_live_and_a_dead_token() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(player()));
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == 1 then return "live" else return "dead" end"#
)
.unwrap(),
"live"
);
s.set_unit("target", None);
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == 1 then return "live" else return "dead" end"#
)
.unwrap(),
"dead"
);
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == nil then return "gone" else return "here" end"#
)
.unwrap(),
"gone",
"the `== nil` half — the comparison a boolean INVERTS"
);
}
/// `UnitOnTaxi` (`0x517a40`) is the family's one predicate registered outside `unit/`, over the
/// taxi module's own ride flag — which is how it stayed a `bool` when its 22 siblings were fixed.
/// Same shape, and the same `Usage:` raise its gate at `0x517a48` carries.
#[test]
fn unit_on_taxi_is_one_or_nil_and_raises_the_reference_usage() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s
.eval::<bool>(r#"return UnitOnTaxi("player") == nil"#)
.unwrap());
s.set_on_taxi(true);
assert_eq!(
s.eval::<String>(r#"return type(UnitOnTaxi("player"))"#)
.unwrap(),
"number"
);
assert_eq!(s.eval::<i64>(r#"return UnitOnTaxi("player")"#).unwrap(), 1);
assert!(!s
.eval::<bool>(r#"return UnitOnTaxi("player") == true"#)
.unwrap());
// Any other token is nil, not false — only our own player is tracked.
assert!(s
.eval::<bool>(r#"return UnitOnTaxi("target") == nil"#)
.unwrap());
let err = s.eval::<mlua::Value>("return UnitOnTaxi()").unwrap_err();
assert!(
err.to_string().contains(r#"Usage: UnitOnTaxi("unit")"#),
"the gate's own message, not mlua's type error: {err}"
);
}