- 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>
758 lines
46 KiB
Rust
758 lines
46 KiB
Rust
//! The game-state `Unit*` bindings (decision 0068 §3) — the first slice of the addon-facing API that
|
||
//! reads *live game state*, and the seam that keeps this crate engine-free while doing so.
|
||
//!
|
||
//! The engine must not touch the ECS/net (decisions 0006/0061: the codec is stateless, the ECS owns
|
||
//! state). So instead of reaching outward, the app pushes a per-frame **unit snapshot** into the VM
|
||
//! via [`UiScript::set_unit`], and the `Unit*` globals here read that plain data. A frame's
|
||
//! `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 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` 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); `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).
|
||
|
||
use mlua::Lua;
|
||
|
||
use super::Model;
|
||
|
||
/// One queued **selection ask** from Lua, in call order — the app resolves it to a guid and
|
||
/// commits it through the one SetSelection tail.
|
||
///
|
||
/// The three verbs share one queue because the reference shares one *function*: `TargetUnit`
|
||
/// (`0x4899d0`), `AssistUnit` (`0x489b80`), `TargetLastEnemy` and `TargetLastTarget` all reach
|
||
/// selection through the "select if it resolves" helper `0x489a40`, whose three arms are the same
|
||
/// for every caller — resolve the guid and commit, else fall back to the group roster, else a
|
||
/// **silent return** (wow-re `object-layer/scratch/selection-attack-seam.md` §1). Splitting them
|
||
/// into a queue each would also lose their relative order, which a macro can observe
|
||
/// (`TargetUnit("party1"); AssistUnit("target")` is not the same as the reverse).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum SelectionRequest {
|
||
/// `TargetUnit(unit)` — select the unit the token names.
|
||
Unit(String),
|
||
/// `AssistUnit(unit)` — select the token's own `UNIT_FIELD_TARGET`; a basis with no target is
|
||
/// a silent no-op (the reference's shared assist tail bails before any send).
|
||
Assist(String),
|
||
/// `AssistByName(name)` (`0x489c40`) — the `/assist Bob` handler's verb: select what the
|
||
/// *named player* is targeting. A name, not a unit token, so it is the app's name→unit
|
||
/// resolution (`AssistRequest`) rather than `Assist`'s token walk.
|
||
AssistByName(String),
|
||
/// `TargetLastEnemy()` — select the last *attackable* unit that was committed.
|
||
LastEnemy,
|
||
}
|
||
|
||
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
|
||
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
|
||
/// draws between the app's net/ECS feed and the Lua API.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct UnitState {
|
||
/// Whether the unit exists (`UnitExists`). A token with no snapshot is treated as `false` too.
|
||
///
|
||
/// **Stays TRUE for an out-of-range party member** — that is the whole point of the roster
|
||
/// fallback, and it is what separates this field from [`Self::has_object`].
|
||
///
|
||
/// *Named gap, second order:* 1.12's `UnitExists 0x515fb0` is a **conjunction** — the object
|
||
/// lookup AND a virtual `[edx+0x58]` that resolves to `IsSelectable 0x60be60`
|
||
/// (`!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == you`) — falling back to the
|
||
/// GUID-only roster test on a miss. We model the fallback and not the conjunct, so a
|
||
/// not-selectable unit created by someone else reads `exists` here and `nil` there. No corpus
|
||
/// caller reaches it; recorded rather than folded in (wow-re
|
||
/// `ui/scratch/unitisvisible-object-presence.md`).
|
||
pub exists: bool,
|
||
/// **`UnitIsVisible` — the client holds a live object for this token, and nothing else.**
|
||
///
|
||
/// The reference is 57 bytes with one branch (`0x516030`):
|
||
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. Its body reads **zero** object
|
||
/// fields, makes **zero** comparisons beyond `test eax,eax`, and contains **zero** float
|
||
/// opcodes — so there is no distance term, no radius, no alpha/fade/interpolation flag and no
|
||
/// "known to the client" bit in it. Addons read it as a range gate because **the range test is
|
||
/// the server's**: `SMSG_DESTROY_OBJECT` and the OUT_OF_RANGE demotion unlink the object from
|
||
/// the manager index this query searches, so the client answers with the presence of the
|
||
/// object the server chose to send.
|
||
///
|
||
/// Distinct from [`Self::exists`], and neither implies the other. The one that matters here:
|
||
/// an out-of-range party member is `exists == true`, `has_object == false` — which is exactly
|
||
/// pfUI's `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)` portrait branch.
|
||
///
|
||
/// The app already computed this and did not publish it: `ui_party::feed`'s
|
||
/// `member_unit_state` takes `store: Option<&ObjectStore>` and its own comment calls that
|
||
/// argument "the reference's `0x468460`". Every snapshot built from a live store sets it
|
||
/// (`ui_unit::snapshot`); the roster-record leg and a token with no snapshot leave it `false`.
|
||
///
|
||
/// **One presence field serves three verbs** — `UnitIsVisible`, and the object-presence
|
||
/// conjunct of both tapped predicates below.
|
||
pub has_object: bool,
|
||
/// `UnitIsTapped` — `UNIT_DYNAMIC_FLAGS` (UpdateField **143**) bit `0x4`: this unit is
|
||
/// someone's kill credit. `0x519c90` is `test BYTE PTR [eax+0x224],0x4` behind the same
|
||
/// object-presence check [`Self::has_object`] carries, and **nothing else** — no ownership, no
|
||
/// GUID compare, no party/raid or health conjunct anywhere in the body (wow-re
|
||
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
|
||
///
|
||
/// Set only on the live-descriptor leg, so an out-of-range unit reads `false` — which is the
|
||
/// object-presence conjunct, for free, by the same route `has_object` gets it.
|
||
pub tapped: bool,
|
||
/// **`UnitIsPartyLeader`'s FIRST leg** — the server's `PLAYER_FLAGS & 0x1` on this unit's own
|
||
/// descriptor, restricted to players (`TYPEMASK_PLAYER`, `0x10` — **not** this family's usual
|
||
/// `8`, which is the one difference the predicate makes to the object lookup).
|
||
///
|
||
/// Not derived client-side, and not the same question as "leads MY group": it is true for a
|
||
/// stranger who leads their own party, which no comparison against our group's leader GUID can
|
||
/// express. The second leg is that comparison, in `script::party` — the two cover disjoint
|
||
/// failures and the reference ORs them (wow-re
|
||
/// `ui/scratch/party-leader-and-nameplate-verbs.md`).
|
||
pub group_leader: bool,
|
||
/// `UnitIsTappedByPlayer` — the same field's bit `0x8` (`0x519d00`, a masked-byte clone of its
|
||
/// sibling: 108 bytes each, differing only in the mask and the `Usage:` string).
|
||
///
|
||
/// Read as a **pair**: `tapped && !tapped_by_player` is "someone else's kill", which is the
|
||
/// grey-bar condition unit-frame addons draw (pfUI `api/unitframes.lua:2012`).
|
||
pub tapped_by_player: bool,
|
||
/// The unit's name (`UnitName` → the string, or nil). `None` while the app's name-query cache
|
||
/// hasn't answered (or the server doesn't know the unit).
|
||
pub name: Option<String>,
|
||
/// Current health (`UnitHealth`).
|
||
pub health: u32,
|
||
/// Maximum health (`UnitHealthMax`).
|
||
pub max_health: u32,
|
||
/// Level (`UnitLevel`).
|
||
pub level: u32,
|
||
/// The active power type (`UnitPowerType`): `0` mana, `1` rage, `2` focus, `3` energy,
|
||
/// `4` happiness — the descriptor's `UNIT_FIELD_BYTES_0` byte 3.
|
||
pub power_type: u8,
|
||
/// Current power of the active type (`UnitMana`).
|
||
pub power: u32,
|
||
/// Maximum power of the active type (`UnitManaMax`).
|
||
pub max_power: u32,
|
||
/// Whether the unit is dead (`UnitIsDead`). NB a released ghost is NOT dead by this
|
||
/// predicate — its wire health is 1 (decision 0308 §1); the trio is dead / [`Self::ghost`] /
|
||
/// dead-or-ghost, the real client's three tests.
|
||
pub dead: bool,
|
||
/// Whether somebody is charming this unit (`UnitIsCharmed`).
|
||
///
|
||
/// **`UNIT_FIELD_CHARMEDBY != 0`, and the asymmetry is the fact worth keeping.** The binding
|
||
/// `0x516cf0` reads fields 10/11 as one 64-bit value and tests it non-zero — not a
|
||
/// `UNIT_FIELD_FLAGS` bit, and not a comparison against the player (its body contains no `cmp`
|
||
/// at all). The field means *"who charms me"*; its mirror `UNIT_FIELD_CHARM` (*"whom I charm"*)
|
||
/// is never read here. So a **charmer** answers nil, a **charmed** pet answers 1, and an
|
||
/// ordinary **summoned** pet answers nil — `"pet"` is itself charm-else-summon in the resolver.
|
||
/// (wow-re `system/ui/scratch/unit-verbs-controlled-charmed-creaturetype.md`.)
|
||
pub charmed: bool,
|
||
/// Whether the unit is a released ghost (`UnitIsGhost` — `PLAYER_FLAGS` bit 0x10, decision
|
||
/// 0308 §1). Only meaningful for player tokens; creatures stay `false`.
|
||
pub ghost: bool,
|
||
/// The unit's reaction toward the player on the `UnitReaction` scale (`1` hated … `4` neutral …
|
||
/// `8` exalted; `0` = unknown → `UnitReaction` returns nil). The app resolves it from the
|
||
/// faction-template / reputation decode (the same one the selection ring uses) for the `"target"`
|
||
/// token; other tokens leave it `0`. Read by `UnitReaction`, which the target frame tints its
|
||
/// name plate by (`TargetFrame_CheckFaction`).
|
||
pub reaction: u8,
|
||
/// The unit's localized race display name (`UnitRace`'s first return, e.g. "Night Elf").
|
||
/// `None` = unknown (the race byte hasn't streamed, or a raceless creature) → `UnitRace`
|
||
/// returns nil, nil. The app resolves it from `UNIT_FIELD_BYTES_0` byte 0 (decision 0208 §3).
|
||
pub race: Option<String>,
|
||
/// The race's file/token name (`UnitRace`'s second return, e.g. "NightElf" — 1.12's 8 races),
|
||
/// the shape texture-path splices key on.
|
||
pub race_file: Option<String>,
|
||
/// The unit's localized class display name (`UnitClass`'s first return, e.g. "Warrior").
|
||
pub class: Option<String>,
|
||
/// The class's file name (`UnitClass`'s second return): the **uppercase** token ("WARRIOR")
|
||
/// the ref's stat-tooltip lookups key on (`getglobal(strupper(class).."_STAT_TOOLTIP")` —
|
||
/// the ref uppercases it again defensively; we store it already uppercase).
|
||
pub class_file: Option<String>,
|
||
/// This unit's INVSLOT 17 is a **relic** slot rather than a ranged-weapon slot —
|
||
/// `UnitHasRelicSlot`'s whole answer (`0x519e50`).
|
||
///
|
||
/// Resolved app-side off `ChrClasses.dbc` field 16, for the same reason [`Self::class_file`]
|
||
/// is resolved app-side: it is a static per-class column of a table this crate cannot read.
|
||
/// The reference gates on TYPEMASK_PLAYER before it looks at the class byte, so the feed sets
|
||
/// this only for a player — a creature, a pet and an unstreamed unit all leave it `false`,
|
||
/// which is the nil leg.
|
||
///
|
||
/// True for exactly Paladin, Shaman and Druid in 1.12 (Libram, Totem, Idol). It was believed
|
||
/// here for a long time that no 1.12 class had one; see decision 1796 for how that survived.
|
||
pub has_relic_slot: bool,
|
||
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
|
||
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
|
||
/// The app maps `UNIT_FIELD_BYTES_0` byte 2 (0 male / 1 female) onto this shape.
|
||
pub sex: u8,
|
||
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
|
||
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
|
||
pub is_player: bool,
|
||
/// This token is **player-CONTROLLED** — `UNIT_FIELD_FLAGS` bit 3
|
||
/// (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, behaviourally "player-controlled"; the same bit
|
||
/// `target::relations::ring_reaction`'s duel leg selects on). Wider than
|
||
/// [`Self::is_player`]: a player's pet and a charmed creature are player-controlled without
|
||
/// being players, which is exactly the distinction stock `UnitFrame_OnEnter`
|
||
/// (UnitFrame.lua:58) needs when it decides whether the hovered plate gets the
|
||
/// player-options newbie tip. `UnitIsPlayer`'s note used to call this reach a gap we did not
|
||
/// carry; the unit-frame migration is what closed it.
|
||
pub player_controlled: bool,
|
||
/// `UNIT_FIELD_FLAGS`, raw — what `UNIT_FLAGS` fires on (the app's `fire_transitions`); the
|
||
/// three readings above are bits of it.
|
||
pub flags: u32,
|
||
/// The unit's owner — `UNIT_FIELD_SUMMONEDBY`, else its charmer, else its creator; `0` for
|
||
/// nobody's. What `UnitPlayerOrPetInParty`/`InRaid` read for the "or pet" half (1958).
|
||
pub owner: u64,
|
||
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
|
||
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
|
||
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see
|
||
/// [`super::guild::UnitGuild`].
|
||
pub guild: Option<super::guild::UnitGuild>,
|
||
/// The NPC subtitle line ("Stable Master") — the creature template's subname; the unit
|
||
/// tooltip's second line. `None` for players/untitled creatures.
|
||
pub subtitle: Option<String>,
|
||
/// The creature TYPE display word ("Beast", "Humanoid", …) — app-resolved from
|
||
/// `CreatureType.dbc`; the level line's class slot for hostile/neutral creatures.
|
||
pub creature_type_name: Option<String>,
|
||
/// Elite rank 0..4 — the creature classification every rank reader in the client shares, via
|
||
/// the one getter `0x605620`: the level line's rank word `{"", Elite, Elite, Boss, ""}` (0276),
|
||
/// `UnitLevel`'s world-boss −1 ([`level_reads_unknown`]), and `UnitClassification`
|
||
/// ([`classification_word`], decision 0782). **Already gated** by the app when it fills this:
|
||
/// the getter answers `0` unless the unit has a cached creature template *and* a zero
|
||
/// `UNIT_FIELD_PETNUMBER`, so a player, an un-queried creature and an enslaved elite all read
|
||
/// `0` here — the gate belongs at the one write, not in each of the three readers.
|
||
pub rank: u32,
|
||
/// The creature civilian flag — feeds the tooltip's green CIVILIAN line (shown only for a
|
||
/// PvP-flagged HOSTILE unit that is also GREY/trivial to the player — the dishonorable-kill
|
||
/// warning, the client's `0x612550` gate; a friendly civilian never shows it).
|
||
pub civilian: bool,
|
||
/// The creature racial-leader flag — the tooltip's white LEADER line (`0x6125c0`, gated on
|
||
/// the same PvP bit as CIVILIAN).
|
||
pub racial_leader: bool,
|
||
/// PvP-flagged (`UNIT_FIELD_FLAGS` PvP bit) — the tooltip's "PvP" line, and also `UnitIsPVP`
|
||
/// (decision 0434 §2: the party-frame PVP icon reads the same flag the tooltip line does, so
|
||
/// there's one field, not two).
|
||
pub pvp: bool,
|
||
/// Skinnable (`UNIT_FIELD_FLAGS` skinnable bit) — the tooltip's RED "Skinnable" line.
|
||
pub skinnable: bool,
|
||
/// The unit's faction display name ("Stormwind") — the tooltip's white line between the
|
||
/// level line and "PvP". App-resolved (Faction.dbc through the unit's faction template,
|
||
/// every gate applied: reputation faction, race/class slot, hidden flag, the creature
|
||
/// HIDE_FACTION_TOOLTIP type flag); `None` = no line.
|
||
pub faction_name: Option<String>,
|
||
/// Connection to the server is live (`UnitIsConnected`) — an offline party member's portrait
|
||
/// desaturates (decision 0434 §2/§3: the `GROUP_LIST` status byte's `0x01` online bit).
|
||
/// **The zero value is `false`**, so a snapshot built via `..Default::default()` reports a unit
|
||
/// DISCONNECTED — which matters more than it reads: stock `UnitFrameManaBar_Update`
|
||
/// (UnitFrame.lua:213) greys a disconnected unit's bar and never reaches the power colour at
|
||
/// all. A synthetic unit that means to be live must say so.
|
||
///
|
||
/// The app's own feed does (`ui_unit::snapshot`, "the stated `is_connected` gap, closed"), and
|
||
/// the party feed reads the roster status byte's `0x01` for its tokens. This doc used to call
|
||
/// that "a pending app-side change, not yet wired"; it has been wired since, and the note
|
||
/// outlived it. A token absent from the store (`UnitExists` false) still reports nil
|
||
/// regardless, via the same "no snapshot" path every other predicate here uses.
|
||
pub is_connected: bool,
|
||
/// Away-from-keyboard (`UnitIsAFK` — decision 0434 §2's status-byte `0x40` bit). The party
|
||
/// frame's name line and chat both key off it. App-resolved; `false` until the feed lands.
|
||
pub is_afk: bool,
|
||
/// Do-not-disturb (`UnitIsDND` — decision 0434 §2's status-byte `0x80` bit), the AFK flag's
|
||
/// sibling.
|
||
pub is_dnd: bool,
|
||
/// Free-for-all PvP-flagged (`UnitIsPVPFreeForAll` — decision 0434 §2's status-byte `0x10`
|
||
/// bit) — distinct from [`Self::pvp`]'s ordinary faction PvP flag; a unit can carry either, both,
|
||
/// or neither independently (an FFA zone flags this without the ordinary flag ever setting).
|
||
pub is_pvp_ffa: bool,
|
||
/// The unit's **current** PvP rank on the internal `0..=18` scale, `0` = no rank
|
||
/// (`UnitPVPRank`; decision 1512). `PLAYER_BYTES_3` **byte 3** — a **PUBLIC** descriptor field,
|
||
/// which is the whole reason a foreign player's rank is knowable and why the reference's
|
||
/// inspect pane can call `UnitPVPRank("target")`. Not to be confused with the HIGHEST LIFETIME
|
||
/// rank (`PLAYER_FIELD_BYTES` byte 3), which is private, rides
|
||
/// [`HonorState`](super::HonorState) instead, and is what `GetPVPLifetimeStats` reports.
|
||
///
|
||
/// Internal, not visual: the badge texture and `GetPVPRankInfo`'s second return are the
|
||
/// `-4..=14` visual scale, converted in [`super::pvp`] and nowhere else.
|
||
pub pvp_rank: u8,
|
||
/// The unit's **city-protector title** index — `PLAYER_BYTES_3` **byte 2**, the byte next door
|
||
/// to [`Self::pvp_rank`] (vmangos `PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`), PUBLIC like
|
||
/// it. `0` = none. Read by exactly one thing: `UnitPVPName`'s medal line, which appends
|
||
/// `"\n"` + the `PVP_MEDAL<n>` GlobalString when it is non-zero (`0x6093ef`).
|
||
///
|
||
/// **v1 feed gap, stated not hidden:** no app-side feed fills this yet — it rides the same
|
||
/// dword the rank byte is decoded from, so wiring it is one line there, and until that line
|
||
/// exists every unit reads `0` and `UnitPVPName` renders no medal. The binding leg is built
|
||
/// against the field rather than left out, so the seam is a feed line rather than a missing
|
||
/// arm (same shape as [`Self::is_connected`]'s stated gap).
|
||
pub pvp_medal: u8,
|
||
/// The unit's PvP faction group — `"Alliance"` or `"Horde"`, else `None` (`UnitFactionGroup`;
|
||
/// decision 0646 §1). App-resolved from `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s
|
||
/// group mask, with the Player bit skipped: every playable race's template carries
|
||
/// Player|<side> (mask 3 or 5), and so do the PvP-flagged city guards, so the side bit is the
|
||
/// only one that can name a shipped `UI-PVP-<group>` texture. `None` for Monster/neutral
|
||
/// templates is a state the icon callers explicitly handle (`if ( factionGroup and … )`).
|
||
///
|
||
/// This is the **English** half — FactionGroup.dbc's `InternalName` — because that is what
|
||
/// `UnitFactionGroup`'s first return must carry: every stock consumer concatenates it into a
|
||
/// texture path (`"…\UI-PVP-"..factionGroup` at `PlayerFrame.lua:68`, `TargetFrame.lua:198`,
|
||
/// `PartyMemberFrame.lua:125`; `"…\Battleground-"..` at `BattlefieldFrame.lua:195`), and
|
||
/// `HonorFrame.lua:68` compares it against the literal `"Alliance"`.
|
||
///
|
||
/// This field's doc used to say the binding "returns it twice … enUS ships the same word for
|
||
/// both". That is true of enUS and of nothing else: `Name0` is localized, so on any other
|
||
/// client the texture path would name a file that does not exist. The localized half lives in
|
||
/// [`Self::faction_group_localized`] now.
|
||
pub faction_group: Option<String>,
|
||
/// The **localized** group name — FactionGroup.dbc's `Name0`, and `UnitFactionGroup`'s SECOND
|
||
/// return, which stock uses as display text (`PlayerFrame.lua`'s PvP hit-area tooltip title).
|
||
/// Never interchangeable with the English half above.
|
||
pub faction_group_localized: Option<String>,
|
||
/// The unit's GUID (`OBJECT_FIELD_GUID`) — the identity the cross-token predicates compare
|
||
/// (`UnitIsUnit`, `UnitInParty`; decision 0434 §5's popup gating). `0` = the app's feed didn't
|
||
/// resolve one; two zero guids never compare equal.
|
||
pub guid: u64,
|
||
/// The raid-target mark on this unit (`GetRaidTargetIndex`): `0` none, `1..=8` the slot.
|
||
/// App-fed from the `GroupState` raid-target board (decision 0434 §6's verified 8-slot table
|
||
/// law) — the phase-5 submenu reads it; the phase-6 world renders draw from the same board.
|
||
pub raid_target: u8,
|
||
/// The player can attack this unit (`UnitCanAttack("player", unit)`) — app-fed from the
|
||
/// byte-confirmed `CanAttack 0x606980` predicate (decision 0172; the flag disqualifiers +
|
||
/// reaction ≤ neutral legs TAB and the combat flash share; the Lua binding `0x516c50` is
|
||
/// pure delegation to it, §5-VERIFIED). Like [`Self::reaction`], the feed resolves it for
|
||
/// the `"target"` token; other tokens leave the default `false` (→ nil), which is right for
|
||
/// every current caller (`TargetFrame_CheckLevel`'s difficulty-color gate).
|
||
pub can_attack: bool,
|
||
/// This token resolves to a **TYPEID_CORPSE world object** — `UnitIsCorpse`'s whole
|
||
/// predicate (`0x5161c0`, §5-VERIFIED: a pure object-type check, no health test; a dead
|
||
/// mob/player is NOT a corpse). No feed sets it yet — corpse objects (a released player's
|
||
/// remains) aren't selectable in benilla — a stated gap, not an oversight.
|
||
pub corpse_object: bool,
|
||
/// In combat (`UnitAffectingCombat`) — `UNIT_FIELD_FLAGS` (descriptor index 46) **bit 19**,
|
||
/// mask `0x00080000` (`0x517e10`, wow-re `bag-language-combat-action-bindings.md` §3,
|
||
/// §5-cross-checked: `mov ecx,[eax+0xa0]; shr ecx,0x13; test cl,1`).
|
||
///
|
||
/// **There is no player-specific combat latch**, stated as the explicit negative because it
|
||
/// is precisely what a client is tempted to invent: a whole-image census of the
|
||
/// `shr reg,0x13` + `test rl,1` idiom returns 7 hits, six of them this same field+bit — and
|
||
/// two of those six are hardcoded *local-player* readers taking no token at all. `"player"`
|
||
/// takes a fast path to the cached local-player GUID inside `0x515970` and then reads the
|
||
/// identical bit. One wire flag answers this for every token, which is what this one field
|
||
/// is; `benilla::ui_action::usable` already reads the same bit off the same descriptor.
|
||
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
|
||
/// legs), the `GetQuestGreenRange` binding (the FrameXML `GetDifficultyColor` green→grey
|
||
/// boundary), and — through both — every level con the UI shows.
|
||
const GREY_BAND: [u32; 20] = [
|
||
4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12,
|
||
];
|
||
|
||
/// The band value for a player level — `GREY_BAND[min(playerLevel/5, 19)]` (the
|
||
/// `GetQuestGreenRange` return, §5-VERIFIED 2026-07-17: the binding `0x4e17d0` reads the
|
||
/// byte-identical `0x8076c0` twin with exactly this index clamp).
|
||
pub fn grey_band(player_level: u32) -> u32 {
|
||
GREY_BAND
|
||
.get((player_level / 5) as usize)
|
||
.copied()
|
||
.unwrap_or(12)
|
||
}
|
||
|
||
/// The trivial/GREY check (`0x5f0700`, §5-VERIFIED 2026-07-17 — was INFERRED): a unit enough
|
||
/// levels *below* the player that it cons grey — `playerLevel − unitLevel >
|
||
/// GREY_BAND[playerLevel/5]`, **strict >** (diff == band is still green). The binary's full
|
||
/// predicate also requires not-player-controlled; every consumer here applies this to creatures.
|
||
pub fn unit_is_grey(player_level: u32, unit_level: u32) -> bool {
|
||
player_level > unit_level && player_level - unit_level > grey_band(player_level)
|
||
}
|
||
|
||
/// The **CIVILIAN predicate `0x612550`** — "killing this would be a dishonorable kill": the unit's
|
||
/// PvP bit **and** the creature-query civilian flag **and** the unit is HOSTILE to the player
|
||
/// (`UnitReaction` ≤ 2) **and** the kill would be GREY/trivial ([`unit_is_grey`]). A friendly
|
||
/// civilian, or one that still cons, is not one.
|
||
///
|
||
/// **ONE home, two callers**, which is the whole reason it is a function: the unit tooltip's green
|
||
/// CIVILIAN line ([`super::tooltip_unit`], decision 0276) and `UnitPVPName`'s civilian arm
|
||
/// ([`super::pvp`] — `0x609370` leg B, which prefixes `PVP_RANK_CIVILIAN` onto a non-player's
|
||
/// name). The engine gates both on the same call; a second copy here would let the tooltip and the
|
||
/// name disagree about whether the same mob is a civilian.
|
||
///
|
||
/// **Scope of the verification.** wow-re's honor carve leaves `0x612550` itself uncarved (its §12,
|
||
/// "named by its use here"); the four terms are decision 0276's tooltip-line law, which is where
|
||
/// this composition was verified. So this is the tooltip's gate reused at the second call site the
|
||
/// carve proves shares it — not an independently byte-checked predicate.
|
||
pub fn is_civilian_kill(u: &UnitState, player_level: u32) -> bool {
|
||
u.civilian && u.pvp && u.reaction != 0 && u.reaction <= 2 && unit_is_grey(player_level, u.level)
|
||
}
|
||
|
||
/// The "level reads ??" gate — the engine tooltip's byte-pinned leg set (`0x529fe0` §2-LEVEL):
|
||
/// never players; level 0 (unstreamed), world-boss rank 3, or a much-higher hostile (reaction
|
||
/// ≤ 2 on the 1..8 API scale, unit ≥ player + 10 — **inclusive**, byte-confirmed). The Lua
|
||
/// `UnitLevel`'s −1 law was §5-VERIFIED against this (wow-re `ui/scratch/
|
||
/// level-assess-bindings.md`, 2026-07-17): its two −1 legs are exactly the boss + hostile-≥10
|
||
/// legs here (no trivial-gray term exists in `0x517fc0`, and none is possible at ≥ +10), with
|
||
/// ONE divergence the binding handles itself — a raw level ≤ 0 returns VERBATIM, not −1
|
||
/// (same skull outcome in FrameXML; the tooltip's "??" keeps the level-0 leg per its own law).
|
||
pub fn level_reads_unknown(u: &UnitState, player_level: u32) -> bool {
|
||
let much_higher_hostile =
|
||
u.reaction != 0 && u.reaction <= 2 && u.level >= player_level.saturating_add(10);
|
||
!u.is_player && (u.level == 0 || u.rank == 3 || much_higher_hostile)
|
||
}
|
||
|
||
/// `UnitClassification`'s return — the classification-word table, **byte-verified** at `0x850424`
|
||
/// (decision 0782): the binding `0x516d90` is nothing but `TABLE[rank(unit)]`, indexed by the same
|
||
/// gated rank [`UnitState::rank`] carries. The table is exactly five entries long (the sixth dword
|
||
/// at `0x850438` is an unrelated `"UnitExists"` literal), and rank comes pre-clamped to 0..4 by
|
||
/// `CreatureInfo+0x20`, so an out-of-range value can only be our own bug — it reads `"normal"`,
|
||
/// which is also what the binding's unresolved-token path pushes (`0x516dc4` loads index 0).
|
||
///
|
||
/// Note `"rareelite"` is a real return here even though 1.12 has no rare-elite *art* and no
|
||
/// rare-elite tooltip *word* — the reference's `TargetFrame_CheckClassification` sends it to the
|
||
/// same Elite border as `"elite"`, and the tooltip's `{"", Elite, Elite, Boss, ""}` prints ELITE for
|
||
/// it. The distinction exists in the API and nowhere in the pixels.
|
||
pub fn classification_word(rank: u32) -> &'static str {
|
||
match rank {
|
||
1 => "elite",
|
||
2 => "rareelite",
|
||
3 => "worldboss",
|
||
4 => "rare",
|
||
_ => "normal",
|
||
}
|
||
}
|
||
|
||
/// The Era power-token string for a power-type index (`UnitPowerType`'s second return,
|
||
/// `UNIT_POWER_UPDATE`'s arg2). Unknown indices report as mana, the descriptor default.
|
||
pub fn power_token(ty: u8) -> &'static str {
|
||
match ty {
|
||
1 => "RAGE",
|
||
2 => "FOCUS",
|
||
3 => "ENERGY",
|
||
4 => "HAPPINESS",
|
||
_ => "MANA",
|
||
}
|
||
}
|
||
|
||
impl super::UiScript {
|
||
/// Push (or clear) a unit token's snapshot. `Some(state)` stores it under `token`; `None` removes
|
||
/// it (so `UnitExists(token)` reports false and the numeric getters return `0`). The app's
|
||
/// per-frame feed (decision 0068 §3) calls this for `"player"`/`"target"`/… before the VM's
|
||
/// event dispatch, so a frame's `OnEvent` sees the current values.
|
||
pub fn set_unit(&mut self, token: &str, state: Option<UnitState>) {
|
||
{
|
||
let mut model = self.model_mut();
|
||
match state {
|
||
// Folded on the way IN as well as on the way out — the map's key type is the
|
||
// canonical lowercase token, so a feed that ever pushed `"Target"` could not create
|
||
// a second, shadowing entry.
|
||
Some(s) => {
|
||
model.units_by_lower.insert(token.to_ascii_lowercase(), s);
|
||
}
|
||
None => {
|
||
model.units_by_lower.remove(&token.to_ascii_lowercase());
|
||
}
|
||
}
|
||
}
|
||
// A push for a tooltip's LIVE unit token re-drives its health bar (the byte law's
|
||
// HEALTH watcher — decision 0276; no line rebuild).
|
||
super::tooltip_unit::on_unit_push(&self.lua, token);
|
||
}
|
||
|
||
/// Push the player's purse in copper (`PLAYER_FIELD_COINAGE`), read by the `GetMoney` global. A
|
||
/// player-level snapshot (like the unit tokens) — the app calls this each frame the coinage
|
||
/// field changes (decision 0081 phase 4).
|
||
pub fn set_money(&mut self, copper: u64) {
|
||
self.model_mut().money = copper;
|
||
}
|
||
|
||
/// Push the player's experience within the level (`PLAYER_XP`) and the level's requirement
|
||
/// (`PLAYER_NEXT_LEVEL_XP`), read by the `UnitXP`/`UnitXPMax` globals. A player-level pair (like
|
||
/// [`Self::set_money`]) — the app calls this each frame the XP fields change (the MainMenuBar XP
|
||
/// bar feed). Both PRIVATE descriptor fields, so this only ever carries our own avatar's values.
|
||
pub fn set_player_xp(&mut self, xp: u32, next_level_xp: u32) {
|
||
let mut model = self.model_mut();
|
||
model.player_xp = xp;
|
||
model.player_next_level_xp = next_level_xp;
|
||
}
|
||
|
||
/// Push the player's rest snapshot — the `PLAYER_BYTES_2` byte 3 rest state, the
|
||
/// `PLAYER_REST_STATE_EXPERIENCE` pool (raw wire value, base kill-XP units) and the
|
||
/// `PLAYER_FLAGS_RESTING` bit — taken together so the `GetRestState`/`GetXPExhaustion`/
|
||
/// `IsResting` trio can never read a half-updated rest picture. Player-level fields, same
|
||
/// shape as [`Self::set_money`]; the app calls this each frame any of the three moves
|
||
/// (decision 1082).
|
||
pub fn set_rest_state(&mut self, state: u8, pool: u32, resting: bool) {
|
||
let mut model = self.model_mut();
|
||
model.rest_state = state;
|
||
model.rest_pool = pool;
|
||
model.resting = resting;
|
||
}
|
||
|
||
/// Push the two **play-time** bits of `PLAYER_FLAGS` — 12 (`PartialPlayTime`) and 13
|
||
/// (`NoPlayTime`), decision 1746. Taken together for the same reason the rest trio is:
|
||
/// stock `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) tests them as an if/elseif pair
|
||
/// and a half-updated pair would paint the wrong one of the two icons.
|
||
pub fn set_play_time(&mut self, partial: bool, none: bool) {
|
||
let mut model = self.model_mut();
|
||
model.partial_play_time = partial;
|
||
model.no_play_time = none;
|
||
}
|
||
|
||
/// Push the account's **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that admitted
|
||
/// the session — what `GetBillingTimeRested()` returns (decision 1820). Set once at login: the
|
||
/// client parks it in a process-lifetime global and nothing else on the wire ever writes it.
|
||
pub fn set_billing_time_rested(&mut self, minutes: u32) {
|
||
self.model_mut().billing_time_rested = minutes;
|
||
}
|
||
|
||
/// Enter the **UI-load sound-suppression scope** — the reference's `0x458f50`, an `inc` on the
|
||
/// counted depth every name-keyed `PlaySound` reads. Counted, not a flag, exactly as the
|
||
/// client has it: nesting is legal and only the outermost exit re-enables sound.
|
||
pub fn push_sound_suppression(&self) {
|
||
let mut model = self.model_mut();
|
||
model.sound_suppression = model.sound_suppression.saturating_add(1);
|
||
}
|
||
|
||
/// Leave it — `0x458f60`'s `dec`. Saturating on the way down too: an unbalanced pop is a bug
|
||
/// in the caller, and silently wrapping to a permanently-muted UI would be the worse failure.
|
||
pub fn pop_sound_suppression(&self) {
|
||
let mut model = self.model_mut();
|
||
model.sound_suppression = model.sound_suppression.saturating_sub(1);
|
||
}
|
||
|
||
/// Push whether a cinematic is playing — what `InCinematic()` answers.
|
||
pub fn set_in_cinematic(&mut self, playing: bool) {
|
||
self.model_mut().in_cinematic = playing;
|
||
}
|
||
|
||
/// Push Exhaustion.dbc — `(rest-state byte, localized name, factor)` rows for the
|
||
/// `GetRestState`/`GetXPExhaustion` bindings (they read the table exactly as `0x48d350` /
|
||
/// `0x48d3f0` read the client's own copy; decision 1087). Called once at startup off the
|
||
/// patch chain; an empty push is ignored so a failed DBC read keeps the shipped-table
|
||
/// fallback the model seeds.
|
||
pub fn set_exhaustion_rows(&mut self, rows: Vec<(u8, String, f64)>) {
|
||
if rows.is_empty() {
|
||
return;
|
||
}
|
||
let mut model = self.model_mut();
|
||
model.exhaustion = rows.into_iter().map(|(id, n, f)| (id, (n, f))).collect();
|
||
}
|
||
|
||
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
|
||
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
|
||
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as
|
||
/// [`Self::set_money`]; the app calls this each frame either moves, including the drop back to
|
||
/// zero (decisions 0869, 0875).
|
||
///
|
||
/// **Raw wire values** — `GetComboPoints`'s class and current-target gates live in the binding,
|
||
/// where the binary puts them.
|
||
pub fn set_combo_points(&mut self, points: u8, target: u64) {
|
||
let mut model = self.model_mut();
|
||
model.combo_points = points;
|
||
model.combo_target = target;
|
||
}
|
||
|
||
/// Drain the selection asks queued since the last call — the app resolves each to a guid and
|
||
/// commits it through the one SetSelection tail. See [`SelectionRequest`] for why the three
|
||
/// verbs share one ordered queue.
|
||
pub fn take_selection_requests(&mut self) -> Vec<SelectionRequest> {
|
||
std::mem::take(&mut self.model_mut().selection_requests)
|
||
}
|
||
|
||
/// Drain the `TargetNearestFriend([reverse])` presses queued since the last call — one `bool`
|
||
/// per call, the cycle direction (`false` forward, `true` backward). Its own queue rather than
|
||
/// a [`SelectionRequest`] variant because it names no unit: the reference runs it through the
|
||
/// TAB cycler `0x493f60(reverse, mode 2)` and straight into `SetSelection`, never through the
|
||
/// select-if-resolves helper the other three share.
|
||
pub fn take_target_nearest_friend_requests(&mut self) -> Vec<bool> {
|
||
std::mem::take(&mut self.model_mut().target_nearest_friend_requests)
|
||
}
|
||
|
||
/// Drain the `(name, exactMatch)` pairs `TargetByName` queued since the last call — the app
|
||
/// runs the shared by-name resolver (decision 0886) and commits the selection.
|
||
pub fn take_target_by_name_requests(&mut self) -> Vec<(String, bool)> {
|
||
std::mem::take(&mut self.model_mut().target_by_name_requests)
|
||
}
|
||
|
||
/// Drain the `ClearTarget()` trigger: `true` if it fired with a live target since the last
|
||
/// call — the ESC chain's last leg; the app commits the deselect (SetSelection guid 0).
|
||
pub fn take_target_clear(&mut self) -> bool {
|
||
std::mem::take(&mut self.model_mut().target_clear)
|
||
}
|
||
|
||
/// Drain the unit tokens `DropItemOnUnit` queued since the last call — the app runs every gate
|
||
/// and, on the pet leg, casts the learned Feed Pet spell at the held item (`0x48d960`).
|
||
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
|
||
std::mem::take(&mut self.model_mut().drop_item_on_unit)
|
||
}
|
||
}
|
||
|
||
/// Pick the interesting token from a directional two-unit call (`UnitIsEnemy(a, b)`): whichever
|
||
/// arg isn't `"player"` — our snapshot stores the relationship on the non-player unit (target),
|
||
/// and the ref calls both argument orders.
|
||
///
|
||
/// **The `"player"` test folds case**, like every other token comparison here and like the client's
|
||
/// own resolver. It is the kind of site the map's fold does not cover on its own: an addon passing
|
||
/// `UnitIsEnemy("Player", "target")` would otherwise be read as naming a non-player first arg, and
|
||
/// this would answer about the wrong unit — a wrong ANSWER rather than a missing one, which is the
|
||
/// worse failure of the two.
|
||
fn pick_unit_token(a: &Option<String>, b: &Option<String>) -> Option<String> {
|
||
match (a, b) {
|
||
(Some(x), _) if !x.eq_ignore_ascii_case("player") => Some(x.clone()),
|
||
(_, Some(y)) => Some(y.clone()),
|
||
(x, _) => x.clone(),
|
||
}
|
||
}
|
||
|
||
/// The token prefixes 1.12's resolver `0x515970` recognises, in its own order.
|
||
///
|
||
/// The order is the binary's and is preserved even though our recogniser is order-insensitive:
|
||
/// `partypet` must be tested before `party` and `raidpet` before `raid` for a *resolver* to pick the
|
||
/// right unit, and the day this grows from a boolean into a resolver, the list is already right.
|
||
///
|
||
/// **These are PREFIX tests.** The compare length is the literal's, and `_strnicmp` stops at either
|
||
/// string's NUL, so `"playerfoo"` matches `player` — which is why it is a quiet nil rather than a
|
||
/// raise. `npc` is the sole FULL-STRING compare in the resolver, so `"npctarget"` matches nothing
|
||
/// and raises; it is deliberately absent from this list and handled separately below.
|
||
const UNIT_TOKEN_PREFIXES: [&str; 8] = [
|
||
"player",
|
||
"pet",
|
||
"target",
|
||
"partypet",
|
||
"party",
|
||
"raidpet",
|
||
"raid",
|
||
"mouseover",
|
||
];
|
||
|
||
/// Does 1.12's resolver RECOGNISE this token — whether or not it names a live unit?
|
||
///
|
||
/// This is the whole raise/nil distinction, so it is worth being exact about what it is not: it does
|
||
/// not ask whether a unit exists. `"party5"` on a solo character, `"pet"` with no pet, and
|
||
/// `"playerfoo"` are all *recognised* and answer nil; only a token the resolver's nine compares
|
||
/// never match raises `Unknown unit name`.
|
||
pub(crate) fn token_recognised(token: &str) -> bool {
|
||
// `npc` full-string, everything else a prefix — and folded, like every compare in the resolver
|
||
// (`SStrCmpI` -> `_strnicmp`, ASCII only; 1247).
|
||
token.eq_ignore_ascii_case("npc")
|
||
|| UNIT_TOKEN_PREFIXES
|
||
.iter()
|
||
// BYTES, not a string slice. `token[..p.len()]` panics when the token is multibyte
|
||
// UTF-8 and the prefix length lands mid-character ("byte index N is not a char
|
||
// boundary") — an addon passing a non-ASCII token would take the client down. The
|
||
// client's own compare is `_strnicmp` over bytes with an ASCII-only fold, so byte
|
||
// comparison is both the safe form and the faithful one.
|
||
.any(|p| {
|
||
token.len() >= p.len()
|
||
&& token.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
|
||
})
|
||
}
|
||
|
||
/// The gate every `Unit*` binding puts an addon-supplied token through.
|
||
///
|
||
/// **An unrecognised token RAISES and does not return** — `0x515970` falls off the end of its nine
|
||
/// compares into `luaL_error(L, "Unknown unit name: %s")`, which longjmps (wow-re
|
||
/// `system/ui/scratch/unit-token-grammar.md`). Ours answered nil for everything unknown, which is
|
||
/// 1203's shape pointed the other way: a failure the client reports, silently swallowed.
|
||
///
|
||
/// The split is THREE-way, not two, and the two quiet legs are as carved:
|
||
/// * **absent argument** — quiet nil *here*, because this helper is only the resolver's half.
|
||
/// Whether a nil ever reaches it is the BINDING's question, and it is settled per binding:
|
||
/// wow-re's census of all 83 entries at `0x850438` found 53 that gate the token position with
|
||
/// `lua_isstring` and raise `Usage:`, against 13 unit-token bindings with no gate at all
|
||
/// (decision 1834). The gated ones call `binding_abi::string_arg` before they get here, so a
|
||
/// nil never arrives; the quiet 13 pass it straight through. This comment used to say the
|
||
/// gates were "NOT uniform … only those two poles are verified" and decline to guess, which
|
||
/// was the right call at the time — the table now exists, so it is applied rather than feared;
|
||
/// * **`""`** — quiet nil;
|
||
/// * a **recognised** token naming nothing (`"party5"` solo, `"playerfoo"`) — quiet nil.
|
||
pub(crate) fn check_unit_token(token: &Option<String>) -> mlua::Result<()> {
|
||
match token {
|
||
Some(t) if !t.is_empty() && !token_recognised(t) => {
|
||
Err(mlua::Error::runtime(format!("Unknown unit name: {t}")))
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// Read a unit token's snapshot under a short model borrow, mapping it through `f`; `default` when the
|
||
/// token is absent (the "unit doesn't exist" path).
|
||
///
|
||
/// Raises for an unrecognised token ([`check_unit_token`]) before it looks anything up.
|
||
fn with_unit<T>(
|
||
lua: &Lua,
|
||
token: &Option<String>,
|
||
default: T,
|
||
f: impl FnOnce(&UnitState) -> T,
|
||
) -> mlua::Result<T> {
|
||
check_unit_token(token)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(match token.as_ref().and_then(|t| model.unit(t)) {
|
||
Some(u) => f(u),
|
||
None => default,
|
||
})
|
||
}
|
||
|
||
/// 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;
|
||
#[cfg(test)]
|
||
mod tests;
|
||
|
||
pub(super) use bindings::install;
|