- every dialog is the reference's own: `StaticPopup.xml` + `StaticPopup.lua` load off the player's chain, and 0308's engine with its 37 dialogs leaves `UiPanels.xml`; all 76 of the reference's dialogs are the stock table's, 28 of which no file of ours had ever registered (the resurrect family among them); the nine hidden listener frames that existed only because benilla once had no `UIParent_OnEvent` come home to it verbatim from UIParent.lua, and the seven files that were nothing but a dialog and its driver are deleted; what the swap surfaced: the reference capitalises the countdown's unit word ("5 Minutes until release") where ours had lowercased it, and ESC in the typed delete confirm's box is swallowed — the entry names no escape handler; arms for events this engine never fires (PLAYER_SKINNED, TRADE_REQUEST) wait on their feeds rather than being armed blind (1960)
- the social window and the unit menus are the reference's own files: `FriendsFrame.xml` loads off the chain and our transcription is deleted — its twelve dialogs re-homed, `FRIENDLIST_SHOW` fired as the answer to an explicit `ShowFriends()`, and `Get`/`SetLookingForGroup` a client-side flag until their bodies are carved; `UnitPopup.xml` loads off the chain, our row catalogue and its driver frame go, and nine verbs are built for it — `HasFullControl` off the player-control flag the reference reads at `0x51a158`, `UnitPlayerOrPetInParty`/`InRaid` over a new unit owner, and the six pet one-shots, each pressing the bar's own reaction or command slot word through the same latch and `CMSG_PET_ACTION`, confirmed by wow-re's carve; the Guild Leave/Promote confirms 1958 believed nothing registered were our social file's all along, and the duplicate went with it (1958, 1959)
- the trainer loads on demand: our `TrainerFrame.xml` is deleted and no row replaces it — `Blizzard_TrainerUI` loads off the player's own chain through the reference's `ClassTrainerFrame_LoadUI`, and the LoadOnDemand `Blizzard_*` addons are registry rows read off the chain; the timing IS the mechanism: the list builder writes the state-filter mask to 3 on every `SMSG_TRAINER_LIST` (`0x4d75d9`) and the stock addon pushes its saved filters exactly once, on its own ADDON_LOADED — which fires on the session's first trainer, after that packet, so the saved filters win that open and an eager load would never have applied them; the loader holds an anchor to a sibling not built yet and applies it once the subtree exists; and `UNIT_FLAGS` fires only on a change between two snapshots — wow-re's trio found the per-field watch bridge runs no notify pass on the create leg, correcting 1953 (1957)
- the additive window, built and withdrawn the same hour: under `BLEND_ADD` every additive particle draw commutes with every other, so same-key additive draws can be regrouped exactly — built in the effect lane's merge walk and measured on the crowd rig (Stormwind, forty partybots): the adjacency merge already folds about half the effect items and the regroup found two more folds in a frame of ninety-three, so the pre-regroup walk is what runs; what stays: an `fx=` items/merged census on the FPS probe line, a `DRESS_PART` line per drawn part of the self body on the dress census, and the regression the A/B uncovered: 1948's stock chat migration dropped the drain's plain-line sender, so every `WOW_PROBE_CHAT` rig — the crowd rig's forty `.partybot add`s among them — had gone silent on the server; a probe's slash-less line reaches the wire as SAY again (1955)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1480 lines
72 KiB
Rust
1480 lines
72 KiB
Rust
//! 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.
|
||
|
||
use mlua::{Lua, Value};
|
||
|
||
use super::super::Model;
|
||
use super::{
|
||
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
|
||
with_unit, SelectionRequest,
|
||
};
|
||
|
||
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
|
||
/// the class byte `[[player+0x110]+0x79]` against. That byte is `UNIT_FIELD_BYTES_0` byte 1, the
|
||
/// same value [`PlayerReqState::class_id`](super::super::PlayerReqState) carries; `UnitClass`'s own
|
||
/// binding (`0x518350`) reads it through the identical `[obj+0x110]+0x79` chain, which is what
|
||
/// identifies it. The *names* are the conventional vanilla ids — the client's class table is
|
||
/// heap-built, so nothing in the file maps 4→Rogue by itself (decision 0875).
|
||
const CLASS_ROGUE: u32 = 4;
|
||
const CLASS_DRUID: u32 = 11;
|
||
|
||
/// 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).
|
||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let g = lua.globals();
|
||
|
||
g.set(
|
||
"UnitExists",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
with_unit(lua, &token, false, |u| u.exists)
|
||
})?,
|
||
)?;
|
||
|
||
// `UnitIsVisible(unit)` — `0x516030`, and it is **object presence, nothing else**:
|
||
// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. 57 bytes, one branch, no field
|
||
// read and no comparison beyond `test eax,eax`.
|
||
//
|
||
// **Not a synonym for `UnitExists`, and neither implies the other.** An out-of-range party
|
||
// member has a roster entry and no object: `UnitExists` = 1 through its GUID fallback,
|
||
// `UnitIsVisible` = nil. That pair is the branch pfUI takes seven times — most visibly
|
||
// `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)`, which chooses between a 3D
|
||
// portrait and a flat one.
|
||
//
|
||
// **The return is the NUMBER 1, never a boolean** — `0x6f3810` writes tag 3 with the operands
|
||
// of `1.0` on the true leg, `lua_pushnil` on the false one. `UnitExists` above answers a Rust
|
||
// `bool` and so hands Lua `true`/`false`; that is its own pre-existing question, and matching
|
||
// it here would have been the wrong kind of consistency.
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// `UnitIsTapped(unit)` / `UnitIsTappedByPlayer(unit)` — `0x519c90` / `0x519d00`, a masked-byte
|
||
// pair (108 bytes each; only the mask and the `Usage:` string differ). Each is
|
||
// `object present && (UNIT_DYNAMIC_FLAGS & mask)` and nothing else: no ownership, no GUID
|
||
// compare, no party/raid or health conjunct anywhere in either body.
|
||
//
|
||
// **Shape A, unlike `UnitIsVisible` directly above** — these two carry an `lua_isstring` gate
|
||
// and a `Usage:` `luaL_error`, where their neighbour has none. Two adjacent verbs in one
|
||
// family with opposite argument shapes is exactly why 1717's taxonomy is settled per binding;
|
||
// inheriting the sibling's shape here would have been wrong in the quiet direction.
|
||
//
|
||
// The reference raises TWO different messages — `Usage:` for a non-string/non-number, and the
|
||
// resolver's `"Unknown unit name: %s"` for a bad token (and for any NUMBER, since the
|
||
// `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 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),
|
||
(
|
||
"UnitIsTappedByPlayer",
|
||
r#"Usage: UnitIsTappedByPlayer("unit")"#,
|
||
true,
|
||
),
|
||
] {
|
||
g.set(
|
||
name,
|
||
lua.create_function(move |lua, token: Value| {
|
||
// Shape A: a string OR a number passes (`lua_isstring` admits tag 3, and the
|
||
// client hands the resolver `"5"` — which then raises `Unknown unit name: 5`,
|
||
// the family's SECOND message). Everything else — nil, absent, boolean, table,
|
||
// function — is the `Usage:` raise.
|
||
let token = Some(crate::script::binding_abi::string_arg(lua, token, usage)?);
|
||
let hit = with_unit(lua, &token, false, |u| {
|
||
if by_player {
|
||
u.tapped_by_player
|
||
} else {
|
||
u.tapped
|
||
}
|
||
})?;
|
||
// 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 })
|
||
})?,
|
||
)?;
|
||
}
|
||
|
||
// `UnitIsPartyLeader(unit)` — `0x516210`. **Two legs, ORed, covering disjoint failures:**
|
||
//
|
||
// o = ObjPtr(resolve(token), TYPEMASK_PLAYER) -- 0x10, not this family's 8
|
||
// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(token) == g_groupLeaderGuid
|
||
//
|
||
// The descriptor leg answers for any held player — including a stranger who leads their OWN
|
||
// party, which a comparison against our group's leader can never express. The GUID leg answers
|
||
// for a group member whose object the client does not hold (an out-of-range `party3`, any
|
||
// `raidN`), where there is no descriptor to read. Neither is sufficient alone, which is why
|
||
// this is not derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however it is
|
||
// arranged (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED).
|
||
//
|
||
// **No zero guard, and that is deliberate.** `IsPartyLeader 0x4e9130` short-circuits on a
|
||
// `0:0` cached leader; this one does not. An unresolvable-but-non-raising token resolves to
|
||
// `0:0`, which equals the zeroed leader while ungrouped — so `UnitIsPartyLeader(nil)` answers
|
||
// **1 solo**. It reads like a bug and it is the behaviour; answering nil there would be the
|
||
// divergence.
|
||
//
|
||
// Shape C with a shape-A tail: no `lua_isstring` gate and no `Usage:` of its own, but a bad
|
||
// token — and any Lua NUMBER, which the resolver stringifies first — raises
|
||
// `"Unknown unit name: %s"` from `check_unit_token`. One value on both legs, the number 1 or
|
||
// nil, never a boolean.
|
||
g.set(
|
||
"UnitIsPartyLeader",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
check_unit_token(&token)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let by_flag = token
|
||
.as_ref()
|
||
.and_then(|t| model.unit(t))
|
||
.is_some_and(|u| u.group_leader);
|
||
// The GUID leg. A token with no snapshot resolves to GUID 0 — the reference's `0:0` —
|
||
// and 0 == the zeroed leader is exactly the solo case above.
|
||
let guid = token
|
||
.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
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"UnitName",
|
||
lua.create_function(|lua, token: Value| {
|
||
let token = Some(crate::script::binding_abi::string_arg(
|
||
lua,
|
||
token,
|
||
r#"Usage: UnitName("unit")"#,
|
||
)?);
|
||
// TWO values on every live path (`eax = 2` at `0x5170ae` and `0x517289`): the name and
|
||
// the **realm**. Slot 2 is nil for a same-realm player, and *structurally* nil for a
|
||
// pet, creature, game object or item — `0x609210` writes the out-parameter only on its
|
||
// PLAYER branch. benilla is single-realm, so the second is always nil here; the day a
|
||
// cross-realm name arrives it is `UnitState`'s to carry, and `UnitPopup.lua:106`'s
|
||
// `name.."-"..server` join is what will read it. Decision 1840.
|
||
//
|
||
// Not to be confused with `showServerName`: that is the parameter of FrameXML's own
|
||
// `GetUnitName(unit, showServerName)` wrapper, which calls this binding with ONE
|
||
// argument. The engine's real second argument is a strict `LUA_TBOOLEAN` and is not
|
||
// modelled — no consumer passes it.
|
||
let name = with_unit(lua, &token, None, |u| u.name.clone())?;
|
||
let name = match name {
|
||
Some(n) => Value::String(lua.create_string(&n)?),
|
||
None => Value::Nil,
|
||
};
|
||
Ok((name, Value::Nil))
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"UnitHealth",
|
||
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: 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))
|
||
})?,
|
||
)?;
|
||
|
||
// UnitLevel (`0x517fc0`, §5-VERIFIED 2026-07-17): the raw UNIT_FIELD_LEVEL — a raw ≤ 0
|
||
// VERBATIM (never −1) — or **−1** iff world-boss rank 3 (unconditional) / hostile
|
||
// (reaction ≤ 1 internal) AND ≥ 10 levels above the player (inclusive). The FrameXML
|
||
// target frame branches its skull on `<= 0` (`TargetFrame_CheckLevel`), so a level-0
|
||
// (unstreamed) unit skulls through the verbatim 0, exactly as the reference does. Not
|
||
// carried: the dormant attackable-decay override (`max(1, raw − round(min(b,100)·0.05))`,
|
||
// `b` INFERRED and 0 in normal play — it can never drive the value ≤ 0 anyway).
|
||
g.set(
|
||
"UnitLevel",
|
||
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 {
|
||
return Ok(0i64);
|
||
};
|
||
Ok(if u.level == 0 {
|
||
0
|
||
} else if level_reads_unknown(u, model.player_req.level) {
|
||
-1
|
||
} else {
|
||
i64::from(u.level)
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitIsCorpse (`0x5161c0`, §5-VERIFIED 2026-07-17) → 1/nil: a pure OBJECT-TYPE check —
|
||
// the token resolves to a live TYPEID_CORPSE world object (a released player's remains).
|
||
// NO health test: a dead mob or dead player is NOT a corpse (the ref target frame shows a
|
||
// dead mob's level number, not the skull). Reads [`UnitState::corpse_object`], which no
|
||
// feed sets yet — corpse objects aren't selectable in benilla — so this returns nil today,
|
||
// faithfully.
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitCanAttack (`0x516c50`, §5-VERIFIED 2026-07-17) → 1/nil: pure delegation to the
|
||
// `CanAttack 0x606980` predicate (decision 0172), read from the non-player token's app-fed
|
||
// snapshot ([`UnitState::can_attack`]). Directional in the live API; our snapshot carries
|
||
// the player→unit direction, the only order the shipped FrameXML calls
|
||
// (`UnitCanAttack("player", "target")`).
|
||
g.set(
|
||
"UnitCanAttack",
|
||
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)
|
||
} else {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// GetQuestGreenRange (`0x4e17d0`, §5-VERIFIED 2026-07-17) — the green→grey boundary the
|
||
// FrameXML `GetDifficultyColor` buckets by (ref QuestLogFrame.lua l.593):
|
||
// `GRAYBAND[min(playerLevel/5, 19)]` off the binary's `0x8076c0` table, byte-identical to
|
||
// the `0x81dda8`/`0x80ae98` twins [`grey_band`] transcribes. No args; 0 with no player.
|
||
g.set(
|
||
"GetQuestGreenRange",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(i64::from(grey_band(model.player_req.level)))
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"UnitIsDead",
|
||
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)
|
||
})?,
|
||
)?;
|
||
|
||
// The other two of the client's death trio (decision 0308 §1): a released ghost has health 1,
|
||
// so IsDead is false for it and the popup flow branches on all three.
|
||
g.set(
|
||
"UnitIsGhost",
|
||
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: 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)
|
||
})?,
|
||
)?;
|
||
|
||
// UnitReaction(unit, other) → 1..8 (hated..exalted) or nil. The live API is directional (unit's
|
||
// reaction toward `other`); our feed only resolves it for the "target" token toward the player,
|
||
// which is the sole caller (`TargetFrame_CheckFaction`), so the `other` arg is accepted and
|
||
// unused. `0` (unknown / not yet streamed) reports as nil, the API's "can't tell" — the target
|
||
// frame paints its name plate blue then, exactly like the reference.
|
||
g.set(
|
||
"UnitReaction",
|
||
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
|
||
} else {
|
||
Value::Integer(i64::from(r))
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitIsEnemy / UnitIsFriend — the reaction-thresholded pair (the ref target-select sound
|
||
// branch, TargetFrame_OnShow). v1 derives both from the same snapshot as `UnitReaction`:
|
||
// enemy = reaction ≤ 2 (hated/hostile), friend = reaction ≥ 5 (friendly+); the live API's
|
||
// extra PvP inputs (duels, flagged players, sanctuaries) are deferred with the rest of the
|
||
// PvP wire. The pair is directional in the live API but our snapshot only carries the
|
||
// target↔player reaction, so the binding reads whichever arg isn't "player" (the ref calls
|
||
// both orders: UnitIsEnemy("target","player"), UnitIsFriend("player","target")). Unknown
|
||
// 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): (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) {
|
||
Value::Integer(1)
|
||
} else {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"UnitIsFriend",
|
||
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 {
|
||
Value::Integer(1)
|
||
} else {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitIsPlayer(unit) → 1 if the unit is a player character, else nil. Reads the snapshot's
|
||
// guid-family flag (the same one the unit tooltip's "(Player)" line keys on). The target frame's
|
||
// faction tint branches on it (`TargetFrame_CheckFaction`): a player-controlled unit takes the
|
||
// red/blue player legs, an NPC the reaction swatch. (The live gate is `UnitPlayerControlled`,
|
||
// which also covers pets/charmed creatures; we resolve only the player half of it — the extra
|
||
// reach needs a player-controlled flag we don't carry, and a player's own alt is the case here.)
|
||
// UnitIsCivilian(unit) → 1 if killing this unit would be a DISHONORABLE kill, else nil. This is
|
||
// `0x612550` itself handed to Lua — the same four-term predicate (`pvp` bit AND creature-query
|
||
// civilian flag AND hostile AND the kill would be grey) that the unit tooltip's green CIVILIAN
|
||
// line and `UnitPVPName`'s civilian arm already run through `is_civilian_kill`. That function's
|
||
// doc called itself "ONE home, two callers"; the stock target frame is the third, and it is the
|
||
// one that names the predicate — `TargetFrame_CheckDishonorableKill` (TargetFrame.lua:230) is
|
||
// its only FrameXML caller and the comment inside it reads "Is a dishonorable kill".
|
||
//
|
||
// Sharing the function is the point: a second copy would let the tooltip, the name and the
|
||
// target plate disagree about the same mob.
|
||
g.set(
|
||
"UnitIsCivilian",
|
||
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)) {
|
||
Some(u) if super::is_civilian_kill(u, player_level) => Value::Integer(1),
|
||
_ => Value::Nil,
|
||
})
|
||
})?,
|
||
)?;
|
||
// UnitPlayerControlled(unit) → 1 if a PLAYER is driving this unit, else nil. `UNIT_FIELD_FLAGS`
|
||
// bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`), which is wider than `UnitIsPlayer` below: a player's
|
||
// pet and a charmed creature answer 1 here and nil there. Stock `UnitFrame_OnEnter`
|
||
// (UnitFrame.lua:58) is the caller that made this a blocker rather than a nicety — it gates the
|
||
// player-options newbie tip on it, so a missing binding raised on the first unit-frame hover.
|
||
g.set(
|
||
"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,
|
||
})
|
||
})?,
|
||
)?;
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// The identity predicates (decision 0434 §5 — the unit popup's menu pick + gating). Same-token
|
||
// is trivially the same unit; otherwise both snapshots must carry a real (nonzero) guid.
|
||
g.set(
|
||
"UnitIsUnit",
|
||
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)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let (Some(a), Some(b)) = (a, b) else {
|
||
return Ok(Value::Nil);
|
||
};
|
||
let (Some(ua), Some(ub)) = (model.unit(&a), model.unit(&b)) else {
|
||
return Ok(Value::Nil);
|
||
};
|
||
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)
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// UnitAffectingCombat(unit) → the number 1 in combat, else nil (`0x517e10`).
|
||
//
|
||
// The unusual half is the miss shape: **false and "no such unit" are the SAME arm**
|
||
// (`0x517e48 je 0x517e73` joins `0x517e5c je 0x517e73`), so an unresolvable token is
|
||
// indistinguishable from a peaceful one. Reproduced deliberately — an addon cannot use this
|
||
// binding to probe whether a unit exists, and ours must not let it either.
|
||
//
|
||
// A **bad or missing argument raises**, unlike its `UnitInRaid` neighbour: the guard here is
|
||
// `0x6f3510` (number-or-string, and NULL for an absent argument) with the usage literal at
|
||
// `0x851070` behind `0x6f4940`, which does not return. A *number* is accepted and stringified
|
||
// — `UnitAffectingCombat(5)` resolves the token `"5"`, finds nothing, and answers nil.
|
||
g.set(
|
||
"UnitAffectingCombat",
|
||
lua.create_function(|lua, token: Value| {
|
||
let token = super::super::binding_abi::string_arg(
|
||
lua,
|
||
token,
|
||
"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 })
|
||
})?,
|
||
)?;
|
||
|
||
// UnitInRaid(unit) → **the constant number 1** on a hit, nil on a miss. NOT an index, and
|
||
// never a raise (`0x516350`, wow-re `raid-roster-bindings.md` §1, §5-cross-checked).
|
||
//
|
||
// Three things about this binding are the opposite of the obvious guess, and all three are
|
||
// read off its 83 bytes:
|
||
//
|
||
// * **The hit value is a hard-coded `1.0`** — `0x51637e push 0x3ff00000; push 0` into the
|
||
// push-number helper. The membership helper underneath (`0x4baee0`) only ever returns
|
||
// `mov eax,1` / `xor eax,eax`; it never exposes its loop counter, so there is no index to
|
||
// be 0- or 1-based about. An addon doing `local i = UnitInRaid(u)` and then
|
||
// `GetRaidRosterInfo(i)` reads member 1 on the real client too.
|
||
// * **It never raises.** `0x6f3690` hands back NULL for a missing or uncoercible argument
|
||
// instead of erroring, `0x515970` maps NULL/empty to GUID `0:0`, and `0x4baee0`
|
||
// short-circuits `0:0` to false at its entry (`or eax,esi; je`). So a missing, wrong-typed
|
||
// or unresolvable unit answers `nil`, not an error — hence `Option<String>` and no usage
|
||
// string, unlike its `GetRaidRosterInfo` sibling.
|
||
// * **The player is in the roster array**, so no `t == "player"` special case is needed here
|
||
// — unlike `UnitInParty` below, whose roster excludes the recipient.
|
||
// HasFullControl() → 1 | nil: the reference's `[0xb4b3e4]` read at `0x51a158` (wow-re
|
||
// `control-loss-and-restore.md`) — the control flag `SMSG_CLIENT_CONTROL_UPDATE` writes for
|
||
// the local player, which the stock unit menu greys its follow/trade rows on (1958).
|
||
g.set(
|
||
"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
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitPlayerOrPetInParty(unit) / UnitPlayerOrPetInRaid(unit) → 1 | nil: the unit is a
|
||
// member of the group, or a member's pet — its owner (`UNIT_FIELD_SUMMONEDBY`, else the
|
||
// charmer, else the creator: `UnitState::owner`) is. The bindings are registered
|
||
// (`0x5162f0` / `0x5163b0`) and delegate to a C++ predicate wow-re has not carved; the
|
||
// owner reading is this file's, flagged in 1958.
|
||
for (name, raid) in [
|
||
("UnitPlayerOrPetInParty", false),
|
||
("UnitPlayerOrPetInRaid", true),
|
||
] {
|
||
g.set(
|
||
name,
|
||
lua.create_function(move |lua, token: Option<String>| {
|
||
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 {
|
||
return Ok(Value::Nil);
|
||
};
|
||
if !u.exists || u.guid == 0 {
|
||
return Ok(Value::Nil);
|
||
}
|
||
let me = model.unit("player").map(|p| p.guid).unwrap_or(0);
|
||
let in_group = |guid: u64| {
|
||
guid != 0
|
||
&& if raid {
|
||
model.party.raid.iter().any(|m| m.guid == guid)
|
||
} else {
|
||
guid == me || model.party.members.iter().any(|m| m.guid == guid)
|
||
}
|
||
};
|
||
let grouped = if raid {
|
||
!model.party.raid.is_empty()
|
||
} else {
|
||
!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 })
|
||
})?,
|
||
)?;
|
||
}
|
||
|
||
g.set(
|
||
"UnitInRaid",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
check_unit_token(&token)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let hit = token
|
||
.as_ref()
|
||
.and_then(|t| model.unit(t))
|
||
.filter(|u| u.exists && u.guid != 0)
|
||
.is_some_and(|u| {
|
||
model
|
||
.party
|
||
.raid
|
||
.iter()
|
||
.any(|m| m.guid != 0 && m.guid == u.guid)
|
||
});
|
||
Ok(if hit { Value::Integer(1) } else { Value::Nil })
|
||
})?,
|
||
)?;
|
||
|
||
// UnitInParty(unit) → 1 when the unit is the player-in-a-group or one of the party1..4
|
||
// members (a party token directly, or any token whose guid matches the roster's).
|
||
g.set(
|
||
"UnitInParty",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
check_unit_token(&token)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(t) = token else {
|
||
return Ok(Value::Nil);
|
||
};
|
||
let Some(u) = model.unit(&t) else {
|
||
return Ok(Value::Nil);
|
||
};
|
||
let grouped = !model.party.members.is_empty();
|
||
if !u.exists || !grouped {
|
||
return Ok(Value::Nil);
|
||
}
|
||
let hit = (t.starts_with("party") && !t.starts_with("partypet"))
|
||
|| t.eq_ignore_ascii_case("player")
|
||
|| (u.guid != 0
|
||
&& (model.unit("player").is_some_and(|p| p.guid == u.guid)
|
||
|| model
|
||
.party
|
||
.members
|
||
.iter()
|
||
.any(|m| m.guid != 0 && m.guid == u.guid)));
|
||
Ok(if hit { Value::Integer(1) } else { Value::Nil })
|
||
})?,
|
||
)?;
|
||
|
||
// UnitCanCooperate(a, b) → 1 for a friendly PLAYER unit. DEVIATION, stated: the client
|
||
// resolves faction-template cooperation masks; this reads the snapshot's is_player +
|
||
// UnitIsFriend's reaction>=5 — the same verdict for every case the popup gates on (invite/
|
||
// whisper a same-faction player), without the faction machinery the engine doesn't carry.
|
||
g.set(
|
||
"UnitCanCooperate",
|
||
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
|
||
})?;
|
||
Ok(if ok { Value::Integer(1) } else { Value::Nil })
|
||
})?,
|
||
)?;
|
||
|
||
// GetRaidTargetIndex(unit) → the mark slot 1..8, or nil unmarked (decision 0434 §6's board,
|
||
// fed per token).
|
||
g.set(
|
||
"GetRaidTargetIndex",
|
||
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))
|
||
} else {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// 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.
|
||
g.set(
|
||
"UnitIsConnected",
|
||
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 {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
// UnitIsPVP reads the same `pvp` field the unit tooltip's "PvP" line already does (one flag,
|
||
// two callers) — see the field doc.
|
||
g.set(
|
||
"UnitIsPVP",
|
||
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 {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"UnitIsPVPFreeForAll",
|
||
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 {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
// UnitFactionGroup(unit) → (englishGroup, localizedName), the pair the PvP icon law reads:
|
||
// the first names the `UI-PVP-<group>` texture, the second titles the player frame's hit-area
|
||
// tooltip. nil, nil for a unit with no side (Monster/neutral templates) or no snapshot — the
|
||
// ref's icon branches gate on exactly that (decision 0646 §1/§3).
|
||
//
|
||
// **The two are genuinely different strings and this used to push one twice.** They are
|
||
// FactionGroup.dbc's `InternalName` (field 2) and `Name0` (field 3), and only on enUS are they
|
||
// the same word — which is why the duplication survived, and why the old doc on
|
||
// `UnitState::faction_group` said so as if it settled the matter. Every stock consumer of the
|
||
// FIRST return concatenates it into a path: `"…\UI-PVP-"..factionGroup` at `PlayerFrame.lua:68`,
|
||
// `TargetFrame.lua:198` and `PartyMemberFrame.lua:125`, `"…\Battleground-"..` at
|
||
// `BattlefieldFrame.lua:195`, and `HonorFrame.lua:68` compares it to the literal "Alliance".
|
||
// On any localized client the old shape named a texture that does not exist. All five of those
|
||
// files are on our chain.
|
||
g.set(
|
||
"UnitFactionGroup",
|
||
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())
|
||
})?;
|
||
match pair {
|
||
(Some(english), localized) => {
|
||
let a = Value::String(lua.create_string(&english)?);
|
||
// The localized half falls back to the English one rather than to nil: a unit
|
||
// with a side always has an `InternalName`, and `Name0` is empty for the
|
||
// Player/Monster rows, so nil here would blank a tooltip the reference fills.
|
||
let b = match localized {
|
||
Some(l) if !l.is_empty() => Value::String(lua.create_string(&l)?),
|
||
_ => a.clone(),
|
||
};
|
||
Ok((a, b))
|
||
}
|
||
(None, _) => Ok((Value::Nil, Value::Nil)),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// UnitRace(unit) → (localized, raceFile) or nil, nil; UnitClass(unit) → (localized,
|
||
// classFileName) — the paper doll's "Level %d %s %s" line + the CLASSFILENAME-keyed
|
||
// stat-tooltip lookups (decision 0208 §3). Unknown (feed not landed / a raceless creature)
|
||
// reports nil, nil — the live API's shape for an absent unit.
|
||
g.set(
|
||
"UnitRace",
|
||
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())
|
||
})?;
|
||
match pair {
|
||
Some((loc, file)) => Ok((
|
||
Value::String(lua.create_string(&loc)?),
|
||
Value::String(lua.create_string(&file)?),
|
||
)),
|
||
None => Ok((Value::Nil, Value::Nil)),
|
||
}
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"UnitClass",
|
||
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())
|
||
})?;
|
||
match pair {
|
||
Some((loc, file)) => Ok((
|
||
Value::String(lua.create_string(&loc)?),
|
||
Value::String(lua.create_string(&file)?),
|
||
)),
|
||
None => Ok((Value::Nil, Value::Nil)),
|
||
}
|
||
})?,
|
||
)?;
|
||
// UnitHasRelicSlot(unit) → **the number 1, or nil** (`0x519e50`). Whether this unit's
|
||
// INVSLOT 17 holds a Libram/Totem/Idol instead of a bow.
|
||
//
|
||
// The whole body is: typemask bit 4 (PLAYER) → `UNIT_FIELD_BYTES_0` byte 1 (the class) →
|
||
// bounds against `ds:0xc0def8` → `ds:0xc0def4`[class] → `[row+0x40]`, `ChrClasses.dbc` field
|
||
// 16. There is **no `cmp` against a class id anywhere in the function** — 1.12 is entirely
|
||
// data-driven here, and `benilla_formats::ChrClasses` is that data. The app resolves it and
|
||
// hands us the bit, exactly as it does for `class_file`.
|
||
//
|
||
// **This shipped absent for a long time, on a claim that was simply false** — that the relic
|
||
// slot post-dates 1.12 and `UnitHasRelicSlot` is a later-era verb. The base `dbc.MPQ` copy of
|
||
// `ChrClasses.dbc` really does have no field 16; the `patch.MPQ` copy the client actually
|
||
// reads has it, set for Paladin, Shaman and Druid. Decision 1796. Stock `PaperDollFrame.lua`
|
||
// calls this **unconditionally** at l.429 and l.580 — so while it was missing, the character
|
||
// sheet raised `attempt to call global 'UnitHasRelicSlot' (a nil value)` for *every* class,
|
||
// not just the three.
|
||
//
|
||
// No `"player"` fast path (unlike `UnitClass 0x5183b0`, which has one), so this answers for
|
||
// any token — `UnitHasRelicSlot("target")` on an enemy druid is 1, and the stock inspect path
|
||
// at `PaperDollFrame.lua:429` depends on exactly that.
|
||
//
|
||
// 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: 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 })
|
||
})?,
|
||
)?;
|
||
|
||
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
|
||
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
|
||
g.set(
|
||
"UnitSex",
|
||
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
|
||
} else {
|
||
Value::Integer(i64::from(sex))
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// UnitCreatureType(unit) → **1 string, or nil** (`0x51a280`), the sibling of
|
||
// `UnitCreatureFamily 0x51a310`. Adjacent addresses and the same shape — but a DIFFERENT
|
||
// column: this reads `CreatureType.dbc` col `1 + locale`, the family reads col `8 + locale`.
|
||
// "Adjacent so probably identical" was right about the shape and wrong about the map.
|
||
//
|
||
// **A three-stage resolver** (`0x605570`), with no class or typemask gate anywhere — its only
|
||
// three tests are bounds checks:
|
||
// 1. **shapeshift override** — `SpellShapeshiftForm.dbc` col 12, keyed by field 138
|
||
// `UNIT_FIELD_BYTES_1` byte 2, taken only if signed `> 0` (`0x60559a jg`), so `0` **and
|
||
// the `-1` sentinel** fall through;
|
||
// 2. **the cached creature record** `[unit+0xb30]+0x18`, returned **unvalidated** — this is
|
||
// what [`UnitState::creature_type_name`] carries;
|
||
// 3. **race fallback** — `ChrRaces.dbc` col 9, keyed by field 36 `UNIT_FIELD_BYTES_0` byte 0.
|
||
//
|
||
// Stage 3 is why **a PLAYER answers `"Humanoid"`, not nil**: `ChrRaces` col 9 is **7 for all
|
||
// nine shipped race rows** and `CreatureType[7]` is `"Humanoid"`, while `[player+0xb30]` is
|
||
// never populated (§5-verified by a 4-hit writer census — the ctor zeroes it, two writers
|
||
// early-out on `key==0`, and the third is hard-gated on `OBJECT_FIELD_TYPE == 0x9`, an
|
||
// equality a player's `0x19` fails structurally). A creature-record-only reading answers nil
|
||
// there, which would have been wrong for every `UnitCreatureType("player")` and every
|
||
// `("target")` aimed at a player.
|
||
//
|
||
// **Stage 1 is NOT modelled, and it is the one divergence to know about.** A druid in Cat,
|
||
// Bear, Dire Bear, Travel or Aquatic form — and a shaman in Ghost Wolf — answers `"Beast"` on
|
||
// the reference and `"Humanoid"` here. Tree Form, Moonkin, Battle Stance, Shadowform and
|
||
// Spirit of Redemption carry col 12 = `-1` and fall through to race anyway, so those already
|
||
// agree. We hold the active form's spell id and name ([`super::super::ShapeshiftFormView`])
|
||
// but **not the form INDEX the DBC is keyed by**, and matching on a localised form name would
|
||
// be inventing a mechanism the client does not use.
|
||
//
|
||
// The argument gate is real, and differs from the two boolean siblings: arg1 goes through
|
||
// `lua_isstring` (`0x6f3510`) and a miss calls `luaL_error 0x6f4940`, which **longjmps** — so a
|
||
// non-string, including a MISSING argument, abandons the caller's statement. An unresolved
|
||
// *token* is a different thing entirely and answers nil (`0x51a2b8`).
|
||
//
|
||
// Demand: 6 distinct corpus addons over 11 files.
|
||
g.set(
|
||
"UnitCreatureType",
|
||
lua.create_function(|lua, token: Value| {
|
||
let token = match &token {
|
||
Value::String(s) => Some(s.to_str()?.to_string()),
|
||
// `lua_isstring` coerces a number, so a numeric token is accepted and simply
|
||
// resolves to nothing.
|
||
Value::Number(_) | Value::Integer(_) => Some(String::new()),
|
||
_ => return Err(mlua::Error::runtime("Usage: UnitCreatureType(\"unit\")")),
|
||
};
|
||
let word = with_unit(lua, &token, None, |u| {
|
||
u.creature_type_name
|
||
.clone()
|
||
// Stage 3, collapsed: every player race maps to CreatureType 7.
|
||
.or_else(|| u.is_player.then(|| "Humanoid".to_string()))
|
||
})?;
|
||
match word {
|
||
Some(w) => Ok(Value::String(lua.create_string(&w)?)),
|
||
None => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// UnitClassification(unit) → "normal" | "elite" | "rareelite" | "worldboss" | "rare" (decision
|
||
// 0782, byte-verified `0x516d90`): a plain table index by the gated rank, and it answers a
|
||
// STRING for every input — never nil. An absent snapshot deliberately reports "normal" rather
|
||
// than nil because the binary does: its unresolved-token path loads table index 0 and pushes
|
||
// that, so a frame reading it gets the plain border art instead of a nil comparison.
|
||
g.set(
|
||
"UnitClassification",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
let rank = with_unit(lua, &token, 0u32, |u| u.rank)?;
|
||
Ok(classification_word(rank).to_string())
|
||
})?,
|
||
)?;
|
||
|
||
// UnitPowerType(unit) → (index, token): (0, "MANA"), (1, "RAGE"), … (the live API also returns
|
||
// alt-power color components; addons that read those handle nil).
|
||
g.set(
|
||
"UnitPowerType",
|
||
lua.create_function(|lua, token: Value| {
|
||
let token = Some(crate::script::binding_abi::string_arg(
|
||
lua,
|
||
token,
|
||
r#"Usage: UnitPowerType("unit")"#,
|
||
)?);
|
||
// ONE value, not the Era pair. `0x517940` has four live `ret`s and `eax = 1` at every
|
||
// one, four `lua_pushnumber` sites and zero `lua_pushstring` — the `(type, "MANA")`
|
||
// tuple does not exist in 5875 (decision 1840). `power_token` stays: 1819's per-resource
|
||
// event names are its real caller.
|
||
//
|
||
// The miss default is the NUMBER 0, the same value as Mana and never nil, which is
|
||
// load-bearing rather than tidy: `UnitFrame.lua:122` indexes `ManaBarColor[...]` with it.
|
||
let ty = with_unit(lua, &token, 0u8, |u| u.power_type)?;
|
||
Ok(i64::from(ty))
|
||
})?,
|
||
)?;
|
||
|
||
// **`UnitMana`/`UnitManaMax(unit)`, not `UnitPower`** (1188 phase 5, decision 1190's list).
|
||
// These carried Era's names — `UnitPower`/`UnitPowerMax` do not exist in 1.12 at all, which
|
||
// `reference/1.12-globals.tsv` says outright — and an addon feature-detecting `if UnitPower`
|
||
// would have taken a path this client cannot honour.
|
||
//
|
||
// The rename is exact, not approximate: 1.12's verbs take **one argument** and return the
|
||
// current/max of whatever power the unit actually uses (`UnitFrame.lua`: `local currValue =
|
||
// UnitMana(unit)`), which is precisely what the Era pair did when called without a type — and
|
||
// every one of our own call sites called it that way. The type-filtering second argument goes
|
||
// with the name: 1.12 has no such parameter, and asking "how much *mana* does a rage user
|
||
// have" is spelled `UnitPowerType(unit)` first, which we already provide and which IS 1.12.
|
||
g.set(
|
||
"UnitMana",
|
||
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: 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))
|
||
})?,
|
||
)?;
|
||
|
||
// UnitXP/UnitXPMax(unit) → the player's XP within the level / the level's requirement. Player-
|
||
// level values (PLAYER_XP is PRIVATE, only our own avatar's), but the live API is unit-tokened:
|
||
// it returns the values only for the "player" token and 0 for any other unit — faithfully, no
|
||
// creature/other player exposes XP. `0` until the app's feed lands (SetMinMaxValues clamps).
|
||
let is_player = |token: &Option<String>| token.as_deref() == Some("player");
|
||
g.set(
|
||
"UnitXP",
|
||
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)
|
||
} else {
|
||
0
|
||
})
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"UnitXPMax",
|
||
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)
|
||
} else {
|
||
0
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// 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>| {
|
||
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(
|
||
"GetMoney",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(model.money as i64)
|
||
})?,
|
||
)?;
|
||
|
||
// GetComboPoints() → the player's banked combo points, 0..5 (a player-level global, not a unit
|
||
// token — the 1.12 binding takes no arguments). `ComboFrame` shows/hides on it, and the
|
||
// combat-text COMBO_POINTS arm reads it.
|
||
//
|
||
// TWO gates before the byte, transcribed from `0x51a190` (§5 byte-read, decision 0875) — the
|
||
// reference `ComboFrame.lua` carries no class check at all, so BOTH of them live here or
|
||
// nowhere:
|
||
//
|
||
// 51a1c3 mov eax,[esi+0x110]; mov al,[eax+0x79] ; the class byte
|
||
// 51a1cc cmp al,4 / cmp al,0xb → jne push 0.0 ; ROGUE or DRUID, nothing else
|
||
// 51a1f3 edx=[esi+0xe68]; eax=[edx+0x838] …0x83c ; PLAYER_FIELD_COMBO_TARGET
|
||
// 51a205 cmp against [0xb4e2d8]/[0xb4e2dc] ; == the CURRENT target, or push 0.0
|
||
// 51a234 mov al,[ecx+0x1029] ; only now, PLAYER_FIELD_BYTES byte 1
|
||
//
|
||
// The class gate is why a *warrior* never sees a dot even though the server really does bank a
|
||
// point for them on a victim's dodge: that byte reaches the usable walk's leg 5 (which has no
|
||
// class test, and is what greys Overpower — 0869) but stops here, before any UI can see it.
|
||
// The target gate is why combo points read as "per target": re-targeting empties the dots
|
||
// without the count moving, and selecting the banked unit again refills them.
|
||
//
|
||
// Both comparisons are the binary's own plain equality — no null special case. With nothing
|
||
// banked and no target both GUIDs are 0, which passes the target gate and reads a byte that is
|
||
// 0 anyway; the server writes and clears the pair together.
|
||
g.set(
|
||
"GetComboPoints",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let class = model.player_req.class_id;
|
||
if class != CLASS_ROGUE && class != CLASS_DRUID {
|
||
return Ok(0_i64);
|
||
}
|
||
let target = model.unit("target").map_or(0, |u| u.guid);
|
||
if model.combo_target != target {
|
||
return Ok(0_i64);
|
||
}
|
||
Ok(i64::from(model.combo_points))
|
||
})?,
|
||
)?;
|
||
|
||
// The rest-state trio (decisions 1082/1087) — player-level globals over the app's rest feed
|
||
// ([`UiScript::set_rest_state`]), the MainMenuBar exhaustion tick's and the player frame's
|
||
// whole wire. Byte-VERIFIED, wow-re `system/ui/scratch/rested-xp-bindings.md` (a §5 pair +
|
||
// orchestrator arbitration): the surface is Exhaustion.dbc DATA, not client constants — the
|
||
// rows live in the model ([`UiScript::set_exhaustion_rows`]; shipped-table fallback).
|
||
//
|
||
// GetRestState() → (stateID, stateName, multiplier) — `0x48d350`: the raw `PLAYER_BYTES_2`
|
||
// byte 3 indexes Exhaustion.dbc DIRECTLY (the `[0xc0dd78]` ID→row array) and the triple is
|
||
// `(row.ID, row.name[locale], row.factor)`: 1 → (1, "Rested", 2.0), 2 → (2, "Normal", 1.0),
|
||
// and FrameXML's dead 3..5 branches map to the real beta rows (XXXTired 1.0/0.5,
|
||
// XXXExhausted 0.25). Every failure — byte 0 (pre-feed), byte past the table, no row —
|
||
// returns (nil, nil, nil), the binary's own fail path. The multiplier is what
|
||
// `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
|
||
g.set(
|
||
"GetRestState",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(match model.exhaustion.get(&model.rest_state) {
|
||
Some((name, factor)) => (
|
||
Value::Number(f64::from(model.rest_state)),
|
||
Value::String(lua.create_string(name)?),
|
||
Value::Number(*factor),
|
||
),
|
||
None => (Value::Nil, Value::Nil, Value::Nil),
|
||
})
|
||
})?,
|
||
)?;
|
||
// GetXPExhaustion() → the rested span in BAR-XP units — `0x48d3f0`: the u32 pool × the f32
|
||
// factor of **Exhaustion.dbc row ID 1, hard-coded** (`[[0xc0dd78]+4]`, whatever the current
|
||
// state) — 2.0 in the shipped data, which is the whole "rested XP is double" law: the server
|
||
// drains the pool 1:1 against BASE kill XP while granting +100% (vmangos `GetXPRestBonus`),
|
||
// and the client scales by exactly this row's factor. **nil is decided by the rest-state
|
||
// byte, never by the pool** (`0x48d43b dec/jne`): byte ≠ 1 → nil whatever the pool holds
|
||
// (vmangos's 0 < bonus ≤ 10 hysteresis window sends byte 2 with a nonzero pool — nil there),
|
||
// and a rested byte with pool 0 → the NUMBER 0. The tick parks at `currXP + this`
|
||
// (`ExhaustionTick_Update`).
|
||
g.set(
|
||
"GetXPExhaustion",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(if model.rest_state != 1 {
|
||
Value::Nil
|
||
} else {
|
||
let factor = model.exhaustion.get(&1).map_or(2.0, |(_, f)| *f);
|
||
Value::Number(f64::from(model.rest_pool) * factor)
|
||
})
|
||
})?,
|
||
)?;
|
||
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `0x516ea0`, byte-VERIFIED:
|
||
// PLAYER_FLAGS `shr 5; test 1` = bit 0x20, pushed as the NUMBER 1.0 or nil (the Lua-vanilla
|
||
// predicate shape, not a boolean). The player frame's flashing zzz reads exactly this.
|
||
g.set(
|
||
"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
|
||
})
|
||
})?,
|
||
)?;
|
||
// PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read
|
||
// straight off `PLAYER_FLAGS` — `0x48eb70` is `shr eax,0xc; and al,1` (bit 12) and `0x48ebe0`
|
||
// its bit-13 neighbour (decision 1746, which carved both while establishing that bit 0x1000 is
|
||
// PARTIAL_PLAY_TIME on 5875 rather than the pre-1.6.1 CAN_SELF_RESURRECT). Same Lua-vanilla
|
||
// predicate shape as `IsResting` above: the NUMBER 1 or nil, never a boolean. Stock
|
||
// `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) is the only FrameXML caller of either.
|
||
g.set(
|
||
"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
|
||
})
|
||
})?,
|
||
)?;
|
||
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
|
||
})
|
||
})?,
|
||
)?;
|
||
// GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always
|
||
// (decision 1820; binding `0x48ec50`, byte-VERIFIED). The body has no `jcc` at all and ends in
|
||
// an unconditional `mov eax,1`, and both callees are branchless and call-free — so it can never
|
||
// return nil and never return zero values, which is why this is not the `Value::Nil` shape its
|
||
// neighbours use. The engine applies NO conversion: `0x48ec5e` is a bare unsigned u32→double
|
||
// push, so "minutes" is the server's convention, corroborated by stock `PlayerFrame.lua:246`
|
||
// dividing by 60 against `REQUIRED_REST_HOURS = 5`.
|
||
//
|
||
// Against vmangos this reads 0, because the server hardcodes `uint32(0)` for the field
|
||
// (`World.cpp:331`). It is fed from the wire rather than pinned to a constant so a server that
|
||
// populates it is reported honestly. Note the reference only ever reaches this call from
|
||
// INSIDE `if PartialPlayTime() ... elseif NoPlayTime()`, so with those bits clear stock
|
||
// FrameXML never calls it — it has to exist for the file to load, and is off the live path.
|
||
g.set(
|
||
"GetBillingTimeRested",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(Value::Number(f64::from(model.billing_time_rested)))
|
||
})?,
|
||
)?;
|
||
// GetTimeToWellRested() → nil, always — `0x48d4b0`, byte-VERIFIED: the whole binding is 11
|
||
// bytes, `pushnil; return 1`. FrameXML's EXHAUST_TOOLTIP4 countdown branch is dead in 5875.
|
||
g.set(
|
||
"GetTimeToWellRested",
|
||
lua.create_function(|_, ()| Ok(Value::Nil))?,
|
||
)?;
|
||
|
||
// TargetUnit(unit) — select a unit by token (the reference's `TargetUnit` Lua shim → SetSelection;
|
||
// the caller here is `PlayerFrame_OnClick`'s left-click branch → `TargetUnit("player")`, and the
|
||
// TARGETSELF binding). Queues the raw token; the app resolves it to a streamed entity and commits
|
||
// the selection. A nil/absent unit is ignored, as is any token the app can't resolve — the real
|
||
// client no-ops `TargetUnit` on a unit that doesn't exist.
|
||
g.set(
|
||
"TargetUnit",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
if let Some(token) = token {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.selection_requests.push(SelectionRequest::Unit(token));
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// AssistUnit(unit) — select the *token's own* target (`0x489b80`; wow-re
|
||
// `object-layer/scratch/targeting-by-name.md` PART C, §5-cross-checked). The ASSISTTARGET
|
||
// binding's body is `AssistUnit("target")`, and `/assist`'s bare form is the same call.
|
||
//
|
||
// The shared assist tail (`0x489bb2`–`0x489c07`, byte-identical to `AssistByName`'s) is three
|
||
// steps and no more: read `UNIT_FIELD_TARGET` off the basis (`[[obj+0x110]+0x28]`), bail
|
||
// **silently** if it is zero, then hand the guid to the select-if-resolves helper `0x489a40`.
|
||
// Four things it deliberately is not:
|
||
//
|
||
// * **not gated by `CanAssist`** — a whole-binary census of the 25 `call 0x6066f0` sites puts
|
||
// none of them on this path (VERIFIED negative), so `AssistUnit("target")` on a hostile
|
||
// creature assists it, and that is the common case in play.
|
||
// * **not players-only** — unlike `AssistByName`'s typemask `0x10`, the token resolver takes
|
||
// whatever the token names.
|
||
// * **not a deselect on failure** — an unresolvable token, a basis with no target, and a
|
||
// target that is not streamed all leave the current selection exactly where it was
|
||
// (`0x489a40`'s arm 3 is a bare `ret`).
|
||
// * **not an attack** — the tail's swing leg is gated on the `assistAttack` CVar, whose
|
||
// registered default is `"0"` (VERIFIED at the registration bytes `0x48fc50`). Stock assist
|
||
// selects and does not swing; the leg becomes reachable only if benilla grows the CVar.
|
||
//
|
||
// A nil/absent unit is ignored here rather than queued, like `TargetUnit`'s: the reference
|
||
// emits game-message `0xb8` for the token it cannot parse, and that id→string table is
|
||
// runtime-populated BSS wow-re could not statically recover — the same known deviation
|
||
// `TargetByName` already carries, and silence is better than an invented line.
|
||
g.set(
|
||
"AssistUnit",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
if let Some(token) = token {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model
|
||
.selection_requests
|
||
.push(SelectionRequest::Assist(token));
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// TargetLastEnemy() — re-select the last **attackable** unit that was targeted (`0x489b45`
|
||
// reads the pair `[0xb4e2e8]/[0xb4e2ec]`, which `SetSelection 0x493540` stamps at `0x49377d`
|
||
// alongside the plain last-target pair `TargetLastTarget` reads). The TARGETLASTHOSTILE
|
||
// binding (default `G`) is its only shipped caller.
|
||
//
|
||
// Takes no argument and routes through the same select-if-resolves helper, so a remembered
|
||
// guid whose unit has since streamed out is a silent no-op rather than a deselect. The app
|
||
// owns the memory itself (`crate::target::scan`'s `LastEnemy`) — the VM holds no guids.
|
||
g.set(
|
||
"TargetLastEnemy",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.selection_requests.push(SelectionRequest::LastEnemy);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// TargetNearestFriend([reverse]) — the friendly half of the TAB scan. `0x489aa0` is
|
||
// byte-for-byte `TargetNearestEnemy`'s shim with one changed immediate: both fetch the
|
||
// optional Lua arg #1 as the reverse flag (`0x6f1c10`, default 0) and call the one cycler
|
||
// `0x493f60(ecx = reverse, edx = mode)` — `edx = 1` for enemy, **`2` for friend** (3 and 4 are
|
||
// the party and raid siblings). Everything downstream is the same code; only the
|
||
// per-candidate filter `0x493e40` forks on the mode, and mode 2's arm (`0x493eca`) is
|
||
// `CanAssist(player, cand)` then `UNIT_FIELD_HEALTH > 0`.
|
||
//
|
||
// TARGETNEARESTFRIEND (`CTRL-TAB`) calls it bare; TARGETPREVIOUSFRIEND (`CTRL-SHIFT-TAB`)
|
||
// calls `TargetNearestFriend(1)` — 1.12's own `Bindings.xml` comments the argument
|
||
// `-- 1 (or "true") means reverse!`, so the truthiness test below takes a number OR a boolean.
|
||
// A numeric 0 is forward, matching `0x6f1c10`'s "absent == 0" reading.
|
||
g.set(
|
||
"TargetNearestFriend",
|
||
lua.create_function(|lua, reverse: Option<Value>| {
|
||
let reverse = match reverse {
|
||
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
|
||
Some(Value::Integer(n)) => n != 0,
|
||
Some(Value::Number(n)) => n != 0.0,
|
||
Some(_) => true,
|
||
};
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.target_nearest_friend_requests.push(reverse);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// TargetByName(name [, exactMatch]) — select by NAME through the shared resolver `0x493aa0`
|
||
// (`0x489d60`; wow-re `object-layer/scratch/targeting-by-name.md`, a §5 trio + two pairs).
|
||
// The app already owns that resolver for `/target` (decision 0886, `crate::target::by_name`);
|
||
// this is the binding half, which 11 corpus addons call and the slash command bypassed.
|
||
//
|
||
// The four things this signature is not:
|
||
//
|
||
// * **not exact by default** — tier 1 is a whole-string case-insensitive compare, tier 2 is
|
||
// the longest common PREFIX (anchored at 0, never a substring), and tier 2 is live
|
||
// whenever Lua arg #2 is absent or 0. `TargetByName("Rag")` selects Ragnaros.
|
||
// * **not nearest** — a tier-1 hit returns 0 from the walk callback and *terminates the
|
||
// enumeration*, so among whole-string matches the winner is the first in enumeration
|
||
// order, not the closest. Our resolver ranks exact-then-longest-prefix-then-nearest
|
||
// instead; the deviation and its cost are argued at length in `by_name`'s module header,
|
||
// which is the one place to change if that judgement is ever reversed.
|
||
// * **not players-only** — typemask 8 is UNIT, i.e. creatures *and* players (contrast
|
||
// `AssistByName`/`FollowByName`, which pass `0x10`). And with filter mode 0 there is no
|
||
// dead, reaction, attackability, range, cone or scene-attach gate, and **no
|
||
// self-exclusion**: `TargetByName(UnitName("player"))` self-targets.
|
||
// * **not silent on a miss** — the reference emits game-message `0x127` (named, nothing
|
||
// matched) or `0xb8` (null/empty name) and leaves the current target untouched. We keep
|
||
// the target untouched and say nothing: the id→string table is runtime-populated BSS that
|
||
// wow-re could not statically recover, and inventing a line is worse than omitting one.
|
||
// Same known deviation the slash path already carries.
|
||
//
|
||
// A missing or wrong-typed first argument RAISES (`0x489d69 call 0x6f3510` → `0x489de1` →
|
||
// `0x6f4940`, usage literal `0x842698`); a number is accepted and stringified.
|
||
g.set(
|
||
"TargetByName",
|
||
lua.create_function(|lua, (name, exact): (Value, Option<Value>)| {
|
||
let name =
|
||
super::super::binding_abi::string_arg(lua, name, "Usage: TargetByName(\"name\")")?;
|
||
// Arg #2 is fetched with `0x6f1c10(idx=2, default 0)` — it never raises, and a
|
||
// numeric 0 is the same as absent. Matches `FollowByName`'s reading of its own
|
||
// arg #2, which reaches the identical `ctx+0x0c` slot.
|
||
let exact = match exact {
|
||
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
|
||
Some(Value::Integer(n)) => n != 0,
|
||
Some(Value::Number(n)) => n != 0.0,
|
||
Some(_) => true,
|
||
};
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.target_by_name_requests.push((name, exact));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// DropItemOnUnit(unit) — drop the cursor's held item onto a unit (`0x48d960`). Two legs in the
|
||
// reference: the PET leg feeds the pet, the PLAYER leg opens a trade. Queues the raw token and
|
||
// nothing else — every gate reads state this VM does not hold, so the app owns all of them
|
||
// (`ui_action::targeting::drop_item_on_unit`).
|
||
//
|
||
// This binding **existed in our shipped `PetFrame_OnClick` before it existed here**: the
|
||
// handler transcribed the reference's three legs faithfully, and the middle one called a nil
|
||
// global, so the whole handler errored out the moment you clicked your pet holding anything.
|
||
// That is B208's "dropping food onto the pet doesn't feed" — the reported bug was a missing
|
||
// registration, not a missing mechanism.
|
||
g.set(
|
||
"DropItemOnUnit",
|
||
lua.create_function(|lua, token: Option<String>| {
|
||
if let Some(token) = token {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.drop_item_on_unit.push(token);
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// SpellTargetUnit(unit) — bind a unit to the spell awaiting its click. The other leg of
|
||
// `PetFrame_OnClick`, tested BEFORE `CursorHasItem()` (ref `PetFrame.lua:114-129`), and dead
|
||
// in our VM for the same reason `DropItemOnUnit` was.
|
||
//
|
||
// It is registered as an accepted **no-op**, deliberately, and that is faithful for every word
|
||
// benilla can currently arm: the targeting cursor models the location / item / gameobject
|
||
// seams (0792/0923/0939), and a *unit* cannot satisfy any of them — the reference's
|
||
// `BindTarget 0x6e5b40` would reject it at the same three mask tests our seams ask. The word
|
||
// that would make this do something is the residual unit-word machine that `cast_target`'s
|
||
// header names as still deferred (a unit-target spell never enters targeting mode here at all;
|
||
// it resolves to `CastWireTarget::Unit` or refuses). So: present, silent, and honest — what it
|
||
// must NOT be is absent, which is what took the handler down with it.
|
||
g.set(
|
||
"SpellTargetUnit",
|
||
lua.create_function(|_, _token: Option<String>| Ok(()))?,
|
||
)?;
|
||
|
||
// ClearTarget() — drop the current selection (the reference API returns 1 when it cleared,
|
||
// nil when there was nothing to clear; `ToggleGameMenu`'s ESC chain depends on the nil to
|
||
// fall through to opening the menu). Reads the same per-token store `UnitExists("target")`
|
||
// answers from; the app commits the actual deselect (SetSelection guid 0 + the engaged
|
||
// attack-stop) from the drained trigger.
|
||
g.set(
|
||
"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) {
|
||
model.target_clear = true;
|
||
Ok(Value::Integer(1))
|
||
} else {
|
||
Ok(Value::Nil)
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
Ok(())
|
||
}
|