Files
benilla-octo/crates/benilla-ui/src/script/char_stats.rs
T
SamandClaude Fable 5 d3c1a501de buffed stats go green, and the bear stops stacking its roar
- gear and buff bonuses finally render green in the character sheet, tooltips naming the unbuffed base — the stat-split fields are int on the wire and were decoded as float, which rounded every bonus to zero
- the bear pet's aggro roar played 63 times in two minutes and stacked into a phasing growl; it now plays through the reference's per-unit voice channel, where a live bark drops the next
- the chat bubble stops jittering: its anchor lagged the body by a frame, and it now snaps to the device pixel grid like the nameplate under it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:06:24 +07:00

1560 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The character-window stats + equipment seam (decision 0208 §3) — the paper doll's data feed.
//!
//! Same engine-free shape as [`super::unit`]: the app pushes a **combat-stats snapshot** per unit
//! that has one ([`super::UiScript::set_player_combat_stats`] /
//! [`super::UiScript::set_pet_combat_stats`]) and an **inventory-slot snapshot**
//! ([`super::UiScript::set_inventory_slots`]) each frame they change, and the stat/slot globals
//! here read that plain data.
//!
//! **The stat family is unit-parameterised, as the reference's is** (decision 1057): every
//! `PaperDollFrame_Set*(unit, prefix)` helper the character sheet calls with `"player"`, the pet
//! sheet calls with `"pet"` (ref `PetPaperDollFrame.lua:73-81`), through these very bindings. So
//! `UnitStat`/`UnitResistance`/`UnitArmor`/… route on the token — `"player"` and `"pet"` each read
//! their own pushed snapshot, and **every other token (and a snapshot the app has not pushed yet)
//! serves the absent shape**: zeros, `percent` 1.0. That last part *is* faithful — the 1.12 fields
//! behind these (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY, so no third unit ever streams them. A pet's descriptor carries the UNIT
//! half and no PLAYER block at all, which is why its buff decompositions read `0` and the ref's
//! own pet sheet shows plain white numbers.
//!
//! (Until 1057 the router was a hard `token == "player"` test documented as "the faithful
//! player-only gate". It was neither: it was "no consumer yet". The reference passes `"pet"` into
//! the same bindings, and the gate was simply never exercised.)
//!
//! Return shapes are the ref Lua's own inverse math (ref-PaperDollFrame): the descriptor carries
//! the *effective* value plus the split positive/negative buff deltas; `base = effective − pos −
//! neg`. The negative deltas arrive **negative-or-zero** (vmangos `Player.h:1505`
//! `ApplyStatBuffMod` routes a debuff's already-negative amount into `NEGSTAT`;
//! `StatSystem.cpp:335-336` writes the AP mods' negative half the same way), matching the ref's
//! `negBuff < 0` tests.
use mlua::{Lua, Value};
use super::Model;
/// The 1.12 weapon-subclass → `SkillLine.dbc` id table, transcribed from vmangos
/// `ItemPrototype::GetProficiencySkill`'s `item_weapon_skills` (`Objects/Item.cpp:700-707`;
/// subclass ids `Objects/ItemPrototype.h:190-213`, skill ids `SharedDefines.h:951-1038`) — item
/// class 2 (weapon) subclasses `0..=20`. `None` = no proficiency skill backs the subclass (the
/// obsolete/exotic/misc rows) or out of range. The app resolves the paper doll's weapon-skill
/// line ("Both Hands"/"Ranged") through this, falling back to [`SKILL_UNARMED`] with no weapon
/// (vmangos `Player::GetBaseWeaponSkillValue`, `Objects/Player.cpp:20175-20186`).
pub fn weapon_subclass_skill(subclass: u32) -> Option<u32> {
const TABLE: [u32; 21] = [
44, // 0 axe → SKILL_AXES
172, // 1 two-hand axe → SKILL_2H_AXES
45, // 2 bow → SKILL_BOWS
46, // 3 gun → SKILL_GUNS
54, // 4 mace → SKILL_MACES
160, // 5 two-hand mace → SKILL_2H_MACES
229, // 6 polearm → SKILL_POLEARMS
43, // 7 sword → SKILL_SWORDS
55, // 8 two-hand sword → SKILL_2H_SWORDS
0, // 9 obsolete
136, // 10 staff → SKILL_STAVES
0, // 11 exotic
0, // 12 exotic2
162, // 13 fist weapon → SKILL_UNARMED (vmangos's own row — fists ride the unarmed skill)
0, // 14 misc
173, // 15 dagger → SKILL_DAGGERS
176, // 16 thrown → SKILL_THROWN
253, // 17 spear → SKILL_ASSASSINATION (vmangos's own row)
226, // 18 crossbow → SKILL_CROSSBOWS
228, // 19 wand → SKILL_WANDS
356, // 20 fishing pole → SKILL_FISHING
];
TABLE.get(subclass as usize).copied().filter(|&s| s != 0)
}
/// `SKILL_UNARMED` (162, vmangos `SharedDefines.h:987`) — the melee weapon-skill line's fallback
/// when no weapon is equipped (`Player::GetBaseWeaponSkillValue`, `Objects/Player.cpp:20184`).
pub const SKILL_UNARMED: u32 = 162;
/// `SKILL_DEFENSE` — the Defense skill line `UnitDefense` reports. **Read out of the shipped
/// 1.12.1 `SkillLine.dbc` this session**, not remembered: the file's 123 records carry exactly one
/// row whose enUS `displayName` (column 3, the `SkillLinefmt` layout in
/// `benilla_formats::skill_lines`) is `"Defense"`, and it is id **95**, category 6 (Weapon Skills).
/// Corroborated by vmangos `SharedDefines.h:961` (`SKILL_DEFENSE = 95`).
pub const SKILL_DEFENSE: u32 = 95;
/// One unit's combat-stats snapshot behind a paper doll's stat pane — plain data the app derives
/// from that unit's descriptor accessors ([`UnitStat`]/[`UnitResistance`]/… read it). Arrays are
/// in field order: stats 0..4 = Str/Agi/Stam/Int/Spi (`UNIT_FIELD_STAT0..4`), schools 0..6 with
/// `[0]` = armor/physical. The `*_neg` values are **negative-or-zero** (the stored wire sign; see
/// the module doc).
///
/// Two units carry one: the player and (decision 1057) the pet. A **creature's** descriptor has no
/// PLAYER block, so every field sourced from one — the stat/resistance buff splits, the
/// damage-done mods, the skill pairs — keeps its default for a pet, which is exactly the ref pet
/// sheet's plain white numbers with no buff decomposition in the tooltip.
#[derive(Clone, Debug, PartialEq)]
pub struct UnitCombatStats {
/// Effective (post-buff) primary stats (`UNIT_FIELD_STAT0..4`).
pub stats: [i32; 5],
/// Positive stat buff deltas (`PLAYER_FIELD_POSSTAT0..4`, ≥ 0).
pub stat_pos: [i32; 5],
/// Negative stat buff deltas (`PLAYER_FIELD_NEGSTAT0..4`, ≤ 0).
pub stat_neg: [i32; 5],
/// Effective resistances (`UNIT_FIELD_RESISTANCES`, `[0]` = armor).
pub resistances: [i32; 7],
/// Positive resistance buffs (`PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE`, ≥ 0).
pub resistance_pos: [i32; 7],
/// Negative resistance buffs (`PLAYER_FIELD_RESISTANCEBUFFMODSNEGATIVE`, ≤ 0).
pub resistance_neg: [i32; 7],
/// Mainhand damage range (`UNIT_FIELD_MINDAMAGE`/`MAXDAMAGE`).
pub min_damage: f32,
pub max_damage: f32,
/// Offhand damage range (`UNIT_FIELD_MINOFFHANDDAMAGE`/`MAXOFFHANDDAMAGE`).
pub min_offhand_damage: f32,
pub max_offhand_damage: f32,
/// Physical damage-done bonuses (`PLAYER_FIELD_MOD_DAMAGE_DONE_POS[0]` / `_NEG[0]`, neg ≤ 0).
pub physical_bonus_pos: i32,
pub physical_bonus_neg: i32,
/// The physical damage-done multiplier (`PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[0]`, a true float —
/// the app fills `1.0` while the field hasn't streamed; the default here is `1.0` too).
pub damage_percent: f32,
/// Attack speeds in ms (`UNIT_FIELD_BASEATTACKTIME[0..2]`).
pub main_attack_time_ms: u32,
pub offhand_attack_time_ms: u32,
/// Whether an offhand **weapon** is equipped — gates `UnitAttackSpeed`'s second return (the
/// app decides from the inv slots; the offhand fields stream regardless).
pub has_offhand: bool,
/// Melee AP + its split mods (`UNIT_FIELD_ATTACK_POWER` / `_MODS`, neg ≤ 0).
pub attack_power: i32,
pub attack_power_pos: i32,
pub attack_power_neg: i32,
/// Ranged AP + its split mods.
pub ranged_attack_power: i32,
pub ranged_attack_power_pos: i32,
pub ranged_attack_power_neg: i32,
/// Ranged attack speed in ms (`UNIT_FIELD_RANGEDATTACKTIME`).
pub ranged_attack_time_ms: u32,
/// Ranged damage range (`UNIT_FIELD_MINRANGEDDAMAGE`/`MAXRANGEDDAMAGE`).
pub ranged_min_damage: f32,
pub ranged_max_damage: f32,
/// The equipped main-hand weapon's skill line as `(value, temp+perm bonus)` — the app resolves
/// WHICH skill via [`weapon_subclass_skill`] (unarmed = [`SKILL_UNARMED`]) and reads the pair
/// from `PLAYER_SKILL_INFO`; `UnitAttackBothHands` serves it verbatim.
pub main_weapon_skill: (u32, i32),
/// The ranged weapon's skill pair (`UnitRangedAttack`).
pub ranged_weapon_skill: (u32, i32),
/// The [`SKILL_DEFENSE`] line's `(value, temp+perm bonus)` pair, read out of `PLAYER_SKILL_INFO`
/// like the two weapon pairs above; `UnitDefense` serves it verbatim.
///
/// **The player's only.** A creature has no skill block, and the client does not read one for
/// it: `UnitDefense` forks on the resolved unit's vtable and gives a non-player
/// `UNIT_FIELD_LEVEL * 5` instead ([`cgunit_skill`]), so the pet feed never fills this and the
/// binding never reads it for a pet.
pub defense_skill: (u32, i32),
/// Whether a wand is equipped (`HasWandEquipped` — the ref swaps the ranged-attack action for
/// wand Shoot on it).
pub has_wand: bool,
}
impl Default for UnitCombatStats {
/// All-zeros except `damage_percent` = `1.0` — the multiplicative identity, so the absent
/// shape never feeds the ref's `bonus / percent` math a division by zero.
fn default() -> Self {
UnitCombatStats {
stats: [0; 5],
stat_pos: [0; 5],
stat_neg: [0; 5],
resistances: [0; 7],
resistance_pos: [0; 7],
resistance_neg: [0; 7],
min_damage: 0.0,
max_damage: 0.0,
min_offhand_damage: 0.0,
max_offhand_damage: 0.0,
physical_bonus_pos: 0,
physical_bonus_neg: 0,
damage_percent: 1.0,
main_attack_time_ms: 0,
offhand_attack_time_ms: 0,
has_offhand: false,
attack_power: 0,
attack_power_pos: 0,
attack_power_neg: 0,
ranged_attack_power: 0,
ranged_attack_power_pos: 0,
ranged_attack_power_neg: 0,
ranged_attack_time_ms: 0,
ranged_min_damage: 0.0,
ranged_max_damage: 0.0,
main_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
}
}
}
/// One resolved equipment/ammo slot view (the `GetInventoryItem*` family reads it), resolved by
/// the app like [`super::container::ContainerSlot`]: icon from the display catalog, count/quality
/// from the item object + template. Plain data.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InvSlotView {
/// The item's template entry (`GetInventoryItemID`).
pub item_id: u32,
/// The doll twin of [`super::container::ContainerSlot::bar_placeable`] — an equipped item
/// dragged straight to the bar goes through the same filter.
pub bar_placeable: bool,
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
pub icon: Option<String>,
/// Stack count (`GetInventoryItemCount` — 1 for equipment, the bag-summed count for ammo).
pub count: u32,
/// Item quality 0..6 (`GetInventoryItemQuality`).
pub quality: i32,
/// The item's name, when known (tooltip/link consumers).
pub name: Option<String>,
/// The instance's live durability `(current, max)` — the equipped-item tooltip's
/// "Durability X / Y" line (see [`super::container::ContainerSlot::durability`]).
pub durability: Option<(u32, u32)>,
/// `ITEM_FIELD_FLAGS` (wire field 21) — the alert/broken laws read two bits (VERIFIED wow-re
/// inventory-alert-law `0x4c7ee0`): `0x08` wrapped (a gift — never alerts, never broken),
/// `0x10` force-red (alert status 4 regardless of durability).
pub flags: u32,
/// An `|Hitem:…|h[Name]|h` link once the name is known — the doll twin of
/// `ContainerSlot::link`; carried onto the cursor payload so a world-drop `DELETE_ITEM_CONFIRM`
/// off an equipped item can report its name (decision 0208 phase 1b).
pub link: Option<String>,
/// Whether an outstanding pending op covers `(EQUIPMENT_BAG, this slot)` — the app's
/// `PendingItemOps` feed (decision 0216 §4/0218 §3), fed by `ui_char.rs::feed_char` the same
/// way `ContainerSlot::locked` is. `IsInventoryItemLocked` ORs this with the payload-held-here
/// check.
pub locked: bool,
/// The 1-based live-API inventory slot ids this item could be EQUIPPED into (empty = not
/// equippable; an equipped ring's own two finger slots, say) — decision 0208 phase 1b's "the
/// fit rule", resolved app-side via `ui_items::find_equip_slot`.
pub equip_slots: Vec<u8>,
/// The RESOLVED `ITEM_FIELD_CREATOR` name — the equipped-item tooltip's green
/// "<Made by %s>" line (see [`super::container::ContainerSlot::creator`], the bag twin).
pub creator: Option<String>,
/// The instance's resolved enchant slots, in slot order — the doll twin of
/// [`super::container::ContainerSlot::enchants`] (decisions 0915/0920). Our own equipped item
/// reads all 7 slots off its streamed item object; an INSPECTED player's record carries 7 too
/// and the reference renders all 7 from it — but a 1.12 server fills only PERM and TEMP, so in
/// practice that is what an inspect hover shows.
pub enchants: Vec<super::EnchantView>,
}
/// The inventory-slot snapshot: index 0 = ammo, 1..=19 the equipment slots, 20..=23 the four
/// equipped-bag icons (all the client's `GetInventorySlotInfo` slot ids — decision 0216 slice 2's
/// bag bar). `None` = an empty slot.
pub const INVENTORY_SLOT_COUNT: usize = 24;
pub type InventorySlots = [Option<InvSlotView>; INVENTORY_SLOT_COUNT];
/// The client's own `PaperDollItemFrame.dbc` rows (re-verified against the 1.12.1 (build 5875)
/// MPQ this session, byte-exact — 36 records, 3 `u32` fields each: `SlotName` offset,
/// `SlotTexture` offset, `SlotID`): `(slotName, slotId, empty-slot art suffix)` for the 24 rows
/// `GetInventorySlotInfo` actually names — the 20 equipment+ammo rows plus `Bag0Slot`..`Bag3Slot`
/// (ids **20..23**, confirmed this session — every one points at the same
/// `interface\paperdoll\UI-PaperDoll-Slot-Bag.blp`, matching the reference `CharacterBag0Slot`
/// bag-bar buttons' `GetID()`s). The DBC's remaining 12 rows (`"Bag1".."Bag12"`, ids 64..75) are a
/// different, unrelated numbering — not `GetInventorySlotInfo` names, out of scope here — left
/// untranscribed. The oddballs among the 24: `BackSlot` shows the **Chest** art and `AmmoSlot` the
/// **Ranged** art (both confirmed by their `SlotTexture` offset pointing at that other row's
/// string, not a fresh one).
const SLOT_INFO: [(&str, i64, &str); 36] = [
("AmmoSlot", 0, "Ranged"),
("HeadSlot", 1, "Head"),
("NeckSlot", 2, "Neck"),
("ShoulderSlot", 3, "Shoulder"),
("ShirtSlot", 4, "Shirt"),
("ChestSlot", 5, "Chest"),
("WaistSlot", 6, "Waist"),
("LegsSlot", 7, "Legs"),
("FeetSlot", 8, "Feet"),
("WristSlot", 9, "Wrists"),
("HandsSlot", 10, "Hands"),
("Finger0Slot", 11, "Finger"),
("Finger1Slot", 12, "Finger"),
("Trinket0Slot", 13, "Trinket"),
("Trinket1Slot", 14, "Trinket"),
("BackSlot", 15, "Chest"),
("MainHandSlot", 16, "MainHand"),
("SecondaryHandSlot", 17, "SecondaryHand"),
("RangedSlot", 18, "Ranged"),
("TabardSlot", 19, "Tabard"),
("Bag0Slot", 20, "Bag"),
("Bag1Slot", 21, "Bag"),
("Bag2Slot", 22, "Bag"),
("Bag3Slot", 23, "Bag"),
// The twelve rows this table was short of. The shipped DBC has **36 records, not 24**, and
// `Bag1`..`Bag12` at SlotNumbers 64..75 are in the very table this binding scans — 64..69 is
// exactly the bank-bag band `ContainerIDToInventoryID` produces. This file used to call them
// "a different, unrelated numbering — not `GetInventorySlotInfo` names, out of scope"; that is
// refuted, and the scan reaches them like any other row.
//
// All twelve share ONE string-block offset with `Bag0Slot`..`Bag3Slot` — sixteen rows, one
// string (`+1001`). Read at the offset rather than resolved from the row name, because that is
// how this column works: 36 rows carry only 17 distinct offsets, which is why `BackSlot` shows
// the Chest art and `AmmoSlot` the Ranged art rather than art of their own.
("Bag1", 64, "Bag"),
("Bag2", 65, "Bag"),
("Bag3", 66, "Bag"),
("Bag4", 67, "Bag"),
("Bag5", 68, "Bag"),
("Bag6", 69, "Bag"),
("Bag7", 70, "Bag"),
("Bag8", 71, "Bag"),
("Bag9", 72, "Bag"),
("Bag10", 73, "Bag"),
("Bag11", 74, "Bag"),
("Bag12", 75, "Bag"),
];
/// The durability-alert regions in the client's own slot table (`0x806eb8`, VERIFIED wow-re
/// inventory-alert-law — 12 entries): alert index 1..=11 → the live-id equipment slot (Head,
/// Shoulders, Chest, Waist, Legs, Feet, Wrists, Hands, Weapon, Shield, Ranged — the ref
/// FrameXML's `INVENTORY_ALERT_STATUS_SLOTS` order), index 12 → the client's low-ammo region
/// (slot -1 in its table; FrameXML never reads it, the binding answers it). Our live-id 0 IS
/// the ammo view, so the 12th entry maps there.
const ALERT_SLOTS: [usize; 12] = [1, 3, 5, 6, 7, 8, 9, 10, 16, 17, 18, 0];
/// The broken classification the doll tint (`GetInventoryItemBroken`) and alert status 4 share
/// (the recompute `0x4c7ee0`, byte-verified): a wrapped item (`flags & 0x08`) is never broken;
/// `flags & 0x10` forces broken regardless of durability; else broken ⇔ tracks durability
/// (max > 0) at 0.
fn slot_is_broken(v: &InvSlotView) -> bool {
if v.item_id == 0 || v.flags & 0x08 != 0 {
return false;
}
v.flags & 0x10 != 0 || matches!(v.durability, Some((0, max)) if max > 0)
}
/// One region's `GetInventoryAlertStatus` value — the recompute `0x4c7ee0`'s per-item
/// classification (VERIFIED wow-re inventory-alert-law, §5-cross-checked; the enum's own name
/// is `INV_ALERT_STATUS`): `4` (red) = broken per [`slot_is_broken`]; `3` (yellow) = tracks
/// durability and **`1..=5` points left — an ABSOLUTE count, no percentage, maxDurability is
/// only the tracks-durability gate** (`cmp [D+0xa0],5`); `0` otherwise. Statuses 1/2 are the
/// temp-weapon-enchant alerts (present/expiring-in-30s) — no enchant feed yet, and the 1.12
/// FrameXML disables their colors anyway; a named deferral.
fn alert_status(slot: &Option<InvSlotView>) -> u8 {
let Some(v) = slot.as_ref().filter(|v| v.item_id != 0) else {
return 0;
};
if v.flags & 0x10 != 0 {
return 4;
}
if v.flags & 0x08 != 0 {
return 0;
}
match v.durability {
Some((0, max)) if max > 0 => 4,
Some((1..=5, max)) if max > 0 => 3,
_ => 0,
}
}
/// The 12th region's low-ammo status (`0x4c7ee0`'s slot −1 arm): an equipped ammo item whose
/// carried count is `<= 20` reads `3`; no ammo (or plenty) reads `0`. The ammo view's count is
/// already the bag-summed carried total.
fn ammo_alert_status(slot: &Option<InvSlotView>) -> u8 {
match slot.as_ref().filter(|v| v.item_id != 0) {
Some(v) if v.count <= 20 => 3,
_ => 0,
}
}
impl super::UiScript {
/// Push (or clear, with `None`) the player's combat-stats snapshot — the app calls this each
/// frame any of the backing descriptor fields changed (decision 0208 §3).
pub fn set_player_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().player_combat_stats = stats;
}
/// The `"pet"` twin (decision 1057) — `None` whenever there is no pet, which is what makes
/// every `Unit*("pet")` fall back to the absent shape the moment one is dismissed.
pub fn set_pet_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().pet_combat_stats = stats;
}
/// Push the equipment/ammo slot snapshot (index 0 = ammo, 1..=19 equipment). Recomputes the
/// 11 durability-alert region statuses off the live pairs; a change fires
/// `UPDATE_INVENTORY_ALERTS` — the DurabilityFrame's own repaint signal (the real engine
/// computes these statuses itself and fires the same event).
pub fn set_inventory_slots(&mut self, slots: InventorySlots) {
{
let mut model = self.model_mut();
let alerts: [u8; 12] = std::array::from_fn(|i| {
if i == 11 {
ammo_alert_status(&slots[ALERT_SLOTS[i]])
} else {
alert_status(&slots[ALERT_SLOTS[i]])
}
});
model.inventory_slots = slots;
model.inventory_alerts = alerts;
}
// Unconditionally, the client's own shape (VERIFIED `0x4c7ee0`: the event fires at the
// tail of EVERY recompute, never diffed against prior state) — SetAlerts re-derives the
// whole frame from the statuses each time, so glyph choices (the off-hand
// shield/off-weapon swap) can never go stale. The app pushes only on real slot-view
// change, so this stays quiet between changes.
self.fire_event("UPDATE_INVENTORY_ALERTS", vec![]);
}
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b) — the app
/// resolves each to the equipped item's guid and sends `CMSG_USE_ITEM` (bag 255 + the
/// 0-based wire slot).
pub fn take_inventory_uses(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().inventory_uses)
}
/// The paper-doll model pane's yaw in radians (`BenillaPaperDollModel_SetFacing` wrote it) —
/// a persistent value the app samples each frame to pose the booth's body bake (decision 0208
/// §5), not a drain. `0.0` until Lua sets it (the ref's own default, 0.61, is authored in the
/// window's OnLoad).
pub fn paperdoll_yaw(&self) -> f32 {
self.model_ref().paperdoll_yaw
}
/// The **pet** paper doll pane's yaw (`BenillaPetPaperDollModel_SetFacing` wrote it) — the
/// exact twin of [`Self::paperdoll_yaw`] and of `UiScript::inspect_yaw`, sampled each frame by
/// `crate::ui_pet_doll` onto its booth (decision 1057).
pub fn pet_paperdoll_yaw(&self) -> f32 {
self.model_ref().pet_paperdoll_yaw
}
}
/// Read a unit's combat-stats snapshot under a short model borrow, routed by token: `"player"` and
/// `"pet"` each read their own pushed snapshot, anything else — and a snapshot the app has not
/// pushed yet — reads the default (the absent shape: zeros, `damage_percent` 1.0). See the module
/// doc for why exactly these two tokens and no more.
fn with_unit_stats<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&UnitCombatStats) -> T,
) -> T {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let absent = UnitCombatStats::default();
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
f(pushed.unwrap_or(&absent))
}
/// A **non-player** unit's answer to both skill-shaped questions — `UnitDefense` and
/// `UnitAttackBothHands` — which is `UNIT_FIELD_LEVEL * 5`, flat.
///
/// It is one helper because it is one function in the client: both bindings dispatch through the
/// resolved unit's vtable, and a `CGUnit_C` lands on a three-line body that multiplies the level by
/// five and writes 0 to the modifier (`0x613680` / `0x6136b0`, wow-re
/// `ui/scratch/pet-paperdoll-stat-api.md`). Nothing here is skill data — a creature has no skill
/// block at all, which is exactly why the client substitutes a formula.
fn cgunit_skill(model: &Model, token: &str) -> i64 {
model.unit(token).map_or(0, |u| i64::from(u.level)) * 5
}
/// Read inventory slot `slot` under a short borrow, cloned out so the caller holds no borrow.
/// `None` = empty / out of range / a token with no equipment source.
///
/// **Unit-keyed, and that is the reference's own shape** (decision 0631): the engine answers
/// `GetInventoryItemTexture(unit, slot)` for any unit whose item data it holds, which is why the
/// inspect paper doll reuses the very same bindings rather than a parallel `GetInspectItem*`
/// family. Two sources, by token:
///
/// - `"player"` → the self feed ([`Model::inventory_slots`]), resolved from our PRIVATE
/// `PLAYER_FIELD_INV_SLOT_*` guids → item objects → templates. Carries counts, durability,
/// locks — everything an owned item object knows.
/// - the **inspected** token → [`Model::inspect`], resolved from the target's PUBLIC
/// `PLAYER_VISIBLE_ITEM_*` entries. No item objects exist for a foreign player (their inventory
/// guids are server-private), so those views carry entry/icon/name/quality and nothing else —
/// which is exactly why the reference's inspect window shows no stack counts or durability.
fn player_inv_slot(lua: &Lua, token: &Option<String>, slot: i64) -> Option<InvSlotView> {
let token = token.as_deref()?;
let idx = usize::try_from(slot).ok()?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.inv_slot(token, idx).cloned()
}
impl Model {
/// The equipment slot `token` exposes at live-API id `slot`, or `None`. The one place the
/// two-source routing above is decided — shared by the `GetInventoryItem*` getters and
/// `GameTooltip:SetInventoryItem`, so a tooltip can never disagree with the icon under it.
pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option<&InvSlotView> {
let slots = if token.eq_ignore_ascii_case("player") {
&self.inventory_slots
} else {
&self.inspect.as_ref().filter(|v| v.unit == token)?.slots
};
slots.get(slot)?.as_ref()
}
}
/// Register the paper-doll stat/slot globals (decision 0208 §3).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitStat("player", i /*1..=5*/) → (stat, effectiveStat, posBuff, negBuff).
//
// **The first two returns are the same field, UNDECOMPOSED** — `UNIT_FIELD_STAT0+i` raw
// (`fild`, `0x518689`), then that same value clamped at zero (`0x5186a3`–`0x5186b7`:
// `setl cl; dec ecx; and ecx,eax`). Slots 3/4 are `PLAYER_FIELD_POSSTAT0+i` (`0x518712`) and
// `NEGSTAT0+i` (`0x518772`), each behind a SELF gate. VERIFIED, wow-re
// `ui/scratch/pet-paperdoll-stat-api.md` §4.
//
// This served `effective − pos − neg` as the first return until decision 1397. Subtracting is
// the ref Lua's *own* job — `PaperDollFrame_SetStats` writes the tooltip's base as
// `(stat - posBuff - negBuff)` — so a pre-subtracted `stat` deducts the buff twice. It was
// invisible only because pos/neg were stuck at 0 by the field-decode bug 1397 fixes; the two
// are one repair.
//
// **Note the contrast with the two resistance bindings below**, whose first return really *is*
// the decomposed base (`0x5efcd0`: `*arg2 = raw - pos - neg`). The reference reads the two
// families differently, and the ref Lua's own asymmetry — `SetStats` subtracts, `SetResistances`
// and `PaperDollFormatStat` do not — is the tell.
//
// Out-of-range i (the ref never passes one) serves the absent zeros.
g.set(
"UnitStat",
lua.create_function(|lua, (token, i): (Option<String>, i64)| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = i.checked_sub(1).and_then(|v| usize::try_from(v).ok()) else {
return (0, 0, 0, 0);
};
if idx >= 5 {
return (0, 0, 0, 0);
}
let (raw, pos, neg) = (s.stats[idx], s.stat_pos[idx], s.stat_neg[idx]);
(
i64::from(raw),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
}))
})?,
)?;
// UnitResistance("player", school /*0..=6*/) → (base, resistance, positive, negative) — the
// decomposition helper `0x5efcd0` per school ([0] = armor): `base = raw − pos − neg`, computed
// BEFORE the clamp, and `resistance = max(raw, 0)`. A school cursed below zero therefore reads
// a *displayed* 0 with the real (negative) total still folded out of `base`.
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Option<String>, i64)| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else {
return (0, 0, 0, 0);
};
let (raw, pos, neg) = (
s.resistances[idx],
s.resistance_pos[idx],
s.resistance_neg[idx],
);
(
i64::from(raw - pos - neg),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
}))
})?,
)?;
// UnitArmor("player") → (base, effectiveArmor, armor, posBuff, negBuff) — school 0 through the
// same `0x5efcd0`, whose 3rd and 4th out-params are both the clamped total (the ref reads
// effectiveArmor and armor equivalently, and both are zeroed together when raw < 0).
g.set(
"UnitArmor",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
let (raw, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
(
i64::from(raw - pos - neg),
i64::from(raw.max(0)),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
}))
})?,
)?;
// UnitDamage("player") → (minDamage, maxDamage, minOffHandDamage, maxOffHandDamage,
// physicalBonusPos, physicalBonusNeg, percent) — the damage fields verbatim plus the
// school-0 MOD_DAMAGE_DONE decomposition (percent 1.0 when absent; decision 0208 "to
// confirm" flags the buffed decomposition as inferred).
g.set(
"UnitDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.min_damage),
f64::from(s.max_damage),
f64::from(s.min_offhand_damage),
f64::from(s.max_offhand_damage),
i64::from(s.physical_bonus_pos),
i64::from(s.physical_bonus_neg),
f64::from(s.damage_percent),
)
}))
})?,
)?;
// UnitAttackSpeed("player") → (mainSpeed, offhandSpeed|nil) in seconds (BASEATTACKTIME /
// 1000). Offhand is nil unless an offhand weapon is equipped — the snapshot's has_offhand,
// the app's call (the field itself streams regardless).
g.set(
"UnitAttackSpeed",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.main_attack_time_ms) / 1000.0,
s.has_offhand
.then_some(f64::from(s.offhand_attack_time_ms) / 1000.0),
)
}))
})?,
)?;
// UnitAttackPower("player") → (base, posBuff, negBuff): base = UNIT_FIELD_ATTACK_POWER, the
// buffs the MODS field's split signed halves (neg ≤ 0 — StatSystem.cpp:335-336).
g.set(
"UnitAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.attack_power),
i64::from(s.attack_power_pos),
i64::from(s.attack_power_neg),
)
}))
})?,
)?;
// UnitRangedAttackPower("player") → (base, posBuff, negBuff) — the ranged twin.
g.set(
"UnitRangedAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_attack_power),
i64::from(s.ranged_attack_power_pos),
i64::from(s.ranged_attack_power_neg),
)
}))
})?,
)?;
// UnitAttackBothHands("player") → (base, modifier) — the main-hand weapon-skill line: skill
// value + its temp+perm bonus, resolved app-side via the weapon_subclass_skill table (unarmed
// = SKILL_UNARMED when nothing's equipped).
//
// A non-player unit takes the same virtual fork `UnitDefense` does (`0x6136b0`, wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`) and lands on the identical `level * 5` — and that
// one **ignores its hand index outright**, so both hands answer the same pair. The pet sheet's
// Attack row therefore reads 300 at level 60, exactly like its Defense row.
g.set(
"UnitAttackBothHands",
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
// UnitRangedAttack("player") → (base, modifier) — the ranged weapon's skill pair.
g.set(
"UnitRangedAttack",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
)
}))
})?,
)?;
// UnitDefense(unit) → (base, modifier). The ref reads exactly two numbers and folds the
// modifier's sign itself (PaperDollFrame.lua:259-271: modifier > 0 → a green posBuff, < 0 → a
// red negBuff).
//
// **The fork is a VIRTUAL CALL on the resolved unit, not a token test** (wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`, the §5 that corrected decision 1057's INTERIM):
// `[vtbl+0xac]` is either `CGPlayer_C 0x5eda20` — resolve the Defense SkillLine (`0x6de040`)
// and read `PLAYER_SKILL_INFO` — or `CGUnit_C 0x613680`, which is three lines:
// `*out1 = UNIT_FIELD_LEVEL * 5; *out2 = 0`. **A level-60 pet shows 300**, not the 0 that
// 1057 shipped while the dispatch was out. The outer gate is SELF **or**
// `UNIT_FIELD_SUMMONEDBY == my guid`, which our pet passes and no other token we serve does —
// so `"player"` takes the skill leg, `"pet"` the level leg (a warlock's minion included: it is
// summoned by us too), and everything else the gate-failure zeros.
g.set(
"UnitDefense",
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(i64::from(s.defense_skill.0), i64::from(s.defense_skill.1))
}),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
// UnitRangedDamage("player") → (speed, minDamage, maxDamage, physicalBonusPos,
// physicalBonusNeg, percent) — RANGEDATTACKTIME/1000 + the ranged damage range + the same
// school-0 mods as UnitDamage.
g.set(
"UnitRangedDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.ranged_attack_time_ms) / 1000.0,
f64::from(s.ranged_min_damage),
f64::from(s.ranged_max_damage),
i64::from(s.physical_bonus_pos),
i64::from(s.physical_bonus_neg),
f64::from(s.damage_percent),
)
}))
})?,
)?;
// HasWandEquipped() → boolean (the ref's ranged block swaps "Shoot" in on it). No unit arg —
// the live global is player-implicit.
g.set(
"HasWandEquipped",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.player_combat_stats
.as_ref()
.is_some_and(|s| s.has_wand))
})?,
)?;
// GetInventoryItemID("player", slot) → itemId | nil (slot per GetInventorySlotInfo: 0 ammo,
// 1..=19 equipment). Player-token-only like the stat block — the INV_SLOT guids are PRIVATE.
g.set(
"GetInventoryItemID",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot) {
Some(v) if v.item_id != 0 => Ok(Value::Integer(i64::from(v.item_id))),
_ => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemTexture("player", slot) → icon path | nil (nil while the template answer is
// in flight — the button shows the empty-slot art until the push lands).
g.set(
"GetInventoryItemTexture",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot).and_then(|v| v.icon) {
Some(icon) => Ok(Value::String(lua.create_string(&icon)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemLink(unit, slot) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil (nil
// while the template answer is in flight — the link is built from the item's name + quality,
// and neither is known until it lands). Unit-keyed like its siblings, so the reference's paper
// doll and its inspect twin both reach it from one binding:
// `DressUpItemLink(GetInventoryItemLink("player", this:GetID()))` (PaperDollFrame.lua:650) and
// the shift-click `ChatFrameEditBox:Insert(...)` beside it (l.653). Decision 1059.
g.set(
"GetInventoryItemLink",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot).and_then(|v| v.link) {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemCount("player", slot) → the stack count; **0 for an empty slot** (the
// wiki's documented shape — warcraft.wiki.gg: "1 for most items", 0 when nothing's there;
// the ammo slot carries the bag-summed count the app resolves).
g.set(
"GetInventoryItemCount",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
Ok(player_inv_slot(lua, &token, slot).map_or(0i64, |v| i64::from(v.count)))
})?,
)?;
// GetInventoryItemQuality("player", slot) → 0..6 | nil (nil for an empty slot — the wiki
// shape; the ref keys quality borders on it).
g.set(
"GetInventoryItemQuality",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot) {
Some(v) if v.item_id != 0 => Ok(Value::Integer(i64::from(v.quality))),
_ => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemBroken("player", slot) → 1 | nil — the shared broken classification
// ([`slot_is_broken`]: durability 0 with a max, or the force-red flag bit; wrapped never).
// The ref's PaperDollItemSlotButton_Update keys the red slot tint on it
// (PaperDollFrame.lua:670-676).
g.set(
"GetInventoryItemBroken",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot) {
Some(v) if slot_is_broken(&v) => Ok(Value::Integer(1)),
_ => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryAlertStatus(index) → the region's alert status (`INV_ALERT_STATUS`: 0 none,
// 3 damaged/low-ammo, 4 broken; 1/2 = temp-enchant alerts, unfed) — DurabilityFrame's
// armor-guy read. Valid 1..=12 (the client's own 12-entry table; index 12 = low ammo, which
// the 1.12 FrameXML never reads); out-of-range reads 0.
g.set(
"GetInventoryAlertStatus",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(if (1..=12).contains(&index) {
i64::from(model.inventory_alerts[index - 1])
} else {
0
})
})?,
)?;
// OffhandHasWeapon() → 1 | nil — whether the off-hand slot holds a WEAPON (item class 2)
// rather than a shield/held-in-off-hand; DurabilityFrame.lua swaps the shield glyph for the
// off-weapon glyph on it. Read off the equipped slot view + the template store.
g.set(
"OffhandHasWeapon",
lua.create_function(|lua, (): ()| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let is_weapon = model.inventory_slots[17]
.as_ref()
.filter(|v| v.item_id != 0)
.and_then(|v| model.item_templates.get(&v.item_id))
.is_some_and(|t| t.class == 2);
Ok(is_weapon.then_some(1i64))
})?,
)?;
// GetInventorySlotInfo(slotName) → (slotId, textureName, checkRelic) — the client's own
// PaperDollItemFrame.dbc rows (SLOT_INFO above). checkRelic is always false in vanilla
// (UnitHasRelicSlot is a later-era concept; the 0208 deferral). An unknown name is a Lua
// error, the client's own behavior.
g.set(
"GetInventorySlotInfo",
lua.create_function(|lua, name: String| {
// **The name match is CASE-INSENSITIVE, and that was the whole bug.** `0x4c8215` calls
// `0x64a4c0` -> `0x414310`, the CRT `_strnicmp`, whose comparison folds BOTH operands
// (`0x414352 add ah,dh` / `0x41435c add al,dh`, with `dh = 0x20` and the A-Z bounds in
// `bh`/`bl`) before the byte compare — ASCII folding, which is exactly
// `eq_ignore_ascii_case`. Its locale-aware arm folds too, so the verdict does not
// depend on one. `maxlen` is `0x7fffffff` and the loop stops at the first NUL on either
// side, so it is a FULL-STRING compare, not a prefix. The non-circular control is that
// the image also ships a byte-identical case-SENSITIVE wrapper (`0x64a480` ->
// `0x40de80`, `rep cmpsb`, no fold) and this binding does not call it.
//
// Two 1.12-era corpus addons died at session start on this and both worked on the real
// client: `FuBar_AmmoFu` passes `"ammoSlot"` and `FuBar_PoisonFu` `"MAINHANDSLOT"`.
// The 36 shipped names stay pairwise distinct after folding, so first-match-wins cannot
// become ambiguous.
let Some((_, id, art)) = SLOT_INFO
.iter()
.find(|(n, _, _)| n.eq_ignore_ascii_case(&name))
else {
// The raise is faithful — there is no nil path, and `0x4c823c xor eax,eax; ret` is
// dead code after `luaL_error` longjmps. The message is the reference's own
// (`.rdata 0x848894`): no `Usage:` prefix, and the offending name is NOT
// interpolated. The `Usage:` literals either side of it in the string pool belong
// to `KeyRingButtonIDToInvSlotID` and `GetInventoryItemTexture` — the adjacency
// trap.
return Err(mlua::Error::runtime(
"Invalid inventory slot in GetInventorySlotInfo",
));
};
Ok((
*id,
// The DBC string, VERBATIM. `0x4c825b` pushes `[esi+4]` straight through
// (`0x6f3890`, `repne scasb` for the length) with no normalisation anywhere, and
// the stored bytes are `interface\paperdoll\UI-PaperDoll-Slot-Bag.blp` — a
// LOWERCASE directory, and the `.blp` extension present. Only the
// `UI-PaperDoll-Slot-` leaf is mixed case. Texture *loading* would not care (the
// asset VFS folds case), but this is a Lua-visible string: anything that compares
// it, keys a table by it or concatenates it sees these bytes.
Value::String(
lua.create_string(format!(
"interface\\paperdoll\\UI-PaperDoll-Slot-{art}.blp"
))?,
),
// `checkRelic` — the NUMBER 1, never a boolean, and only for `RangedSlot`
// (`0x4c8263 dec ecx; cmp ecx,0x11`, i.e. SlotNumber 18); nil for every other slot.
// It shipped as a constant `false` here, which is falsey like nil but is the wrong
// type for a caller that compares it to 1, and wrong outright for the ranged slot.
if *id == 18 {
Value::Integer(1)
} else {
Value::Nil
},
))
})?,
)?;
// BenillaPaperDollModel_SetFacing(radians) — the model pane's rotate buttons write the bake
// yaw here (persistent; the app samples UiScript::paperdoll_yaw each frame — decision 0208
// §5's rotate-adjusts-the-bake). Benilla-named: the real client's Model:SetFacing is a widget
// method on a live 3D pane; our doctrine-consistent still carries one scalar.
g.set(
"BenillaPaperDollModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.paperdoll_yaw = radians;
Ok(())
})?,
)?;
// BenillaPetPaperDollModel_SetFacing(radians) — the PET pane's own bake yaw (decision 1057),
// the exact twin of `BenillaInspectModel_SetFacing`: a third scalar, because tab 1 and tab 2
// are two panes that can sit at two different facings.
g.set(
"BenillaPetPaperDollModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.pet_paperdoll_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{weapon_subclass_skill, SKILL_UNARMED};
use crate::script::{InvSlotView, UiScript, UnitCombatStats};
/// A filled snapshot exercising every field the bindings read.
fn stats() -> UnitCombatStats {
UnitCombatStats {
stats: [25, 20, 22, 10, 11],
stat_pos: [4, 0, 0, 0, 0],
stat_neg: [0, -2, 0, 0, 0],
resistances: [150, 0, 20, 0, 0, 0, -5],
resistance_pos: [30, 0, 25, 0, 0, 0, 0],
resistance_neg: [-10, 0, -5, 0, 0, 0, -5],
min_damage: 12.5,
max_damage: 19.5,
min_offhand_damage: 5.0,
max_offhand_damage: 9.0,
physical_bonus_pos: 25,
physical_bonus_neg: -3,
damage_percent: 1.1,
main_attack_time_ms: 2900,
offhand_attack_time_ms: 1500,
has_offhand: false,
attack_power: 78,
attack_power_pos: 30,
attack_power_neg: -10,
ranged_attack_power: 52,
ranged_attack_power_pos: 0,
ranged_attack_power_neg: -3,
ranged_attack_time_ms: 2800,
ranged_min_damage: 31.0,
ranged_max_damage: 47.0,
main_weapon_skill: (25, 2),
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
}
}
/// A pet's snapshot: the UNIT half filled, every PLAYER-block-sourced field at its default —
/// what `ui_char::unit_combat_stats` really produces over a creature's descriptor.
fn pet_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [63, 45, 68, 32, 42],
resistances: [1810, 0, 15, 0, 0, 0, 0],
min_damage: 30.5,
max_damage: 44.5,
main_attack_time_ms: 2000,
attack_power: 178,
attack_power_pos: 12,
attack_power_neg: -4,
..Default::default()
}
}
#[test]
fn weapon_skill_table_matches_vmangos_item_cpp() {
// The populated rows (Item.cpp:700-707).
assert_eq!(weapon_subclass_skill(0), Some(44)); // axe
assert_eq!(weapon_subclass_skill(1), Some(172)); // 2h axe
assert_eq!(weapon_subclass_skill(2), Some(45)); // bow
assert_eq!(weapon_subclass_skill(3), Some(46)); // gun
assert_eq!(weapon_subclass_skill(4), Some(54)); // mace
assert_eq!(weapon_subclass_skill(5), Some(160)); // 2h mace
assert_eq!(weapon_subclass_skill(6), Some(229)); // polearm
assert_eq!(weapon_subclass_skill(7), Some(43)); // sword
assert_eq!(weapon_subclass_skill(8), Some(55)); // 2h sword
assert_eq!(weapon_subclass_skill(10), Some(136)); // staff
assert_eq!(weapon_subclass_skill(13), Some(SKILL_UNARMED)); // fist
assert_eq!(weapon_subclass_skill(15), Some(173)); // dagger
assert_eq!(weapon_subclass_skill(16), Some(176)); // thrown
assert_eq!(weapon_subclass_skill(17), Some(253)); // spear
assert_eq!(weapon_subclass_skill(18), Some(226)); // crossbow
assert_eq!(weapon_subclass_skill(19), Some(228)); // wand
assert_eq!(weapon_subclass_skill(20), Some(356)); // fishing pole
// The skill-less rows + out of range.
for sub in [9u32, 11, 12, 14, 21, 100] {
assert_eq!(weapon_subclass_skill(sub), None, "subclass {sub}");
}
}
#[test]
fn unit_stat_serves_the_raw_field_twice_and_the_buff_split() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
// Str: the raw field 25 in BOTH of the first two slots, then the +4/0 split. The ref Lua
// does the subtraction itself (`stat - posBuff - negBuff` = 21 for the tooltip's base) —
// this binding must not pre-subtract, or the buff is deducted twice.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(25, 25, 4, 0)
);
// Agi: raw 20 with a −2 debuff (the ref tests negBuff < 0 to pick red over green).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 2)"#)
.unwrap(),
(20, 20, 0, -2)
);
// Only the SECOND return is clamped: a stat driven below zero keeps its sign in slot 1.
s.set_player_combat_stats(Some(UnitCombatStats {
stats: [-3, 20, 22, 10, 11],
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(-3, 0, 4, 0)
);
s.set_player_combat_stats(Some(stats()));
// A non-player token serves the absent zeros (no other unit streams these fields).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
// Out-of-range index: zeros, no error.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 6)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 0)"#)
.unwrap(),
(0, 0, 0, 0)
);
}
#[test]
fn unit_resistance_and_armor_decompose_school_zero() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
// Armor (school 0): 150 total, +30/−10 → base 130.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 0)"#)
.unwrap(),
(130, 150, 30, -10)
);
// Fire (school 2): 20 total, +25/−5 → base 0.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 2)"#)
.unwrap(),
(0, 20, 25, -5)
);
// A school cursed below zero (arcane: −5 total, all from the debuff): the DISPLAYED total
// is clamped to 0 (`0x5efcd0`'s tail), while `base` keeps the pre-clamp decomposition.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 6)"#)
.unwrap(),
(0, 0, 0, -5)
);
// UnitArmor is school 0 with the five-return shape (effectiveArmor = armor = the total).
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("player")"#)
.unwrap(),
(130, 150, 150, 30, -10)
);
// Out of range / non-player: zeros.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 7)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
}
#[test]
fn unit_damage_and_attack_speed_read_the_snapshot() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("player")"#)
.unwrap(),
(12.5, 19.5, 5.0, 9.0, 25, -3, f64::from(1.1f32))
);
// No offhand equipped → second return nil.
assert!(s
.eval::<bool>(r#"local m, o = UnitAttackSpeed("player") return m == 2.9 and o == nil"#)
.unwrap());
// With an offhand: both speeds, in seconds.
s.set_player_combat_stats(Some(UnitCombatStats {
has_offhand: true,
..stats()
}));
assert_eq!(
s.eval::<(f64, f64)>(r#"return UnitAttackSpeed("player")"#)
.unwrap(),
(2.9, 1.5)
);
// Absent snapshot / non-player token: zeros, percent 1.0 (the div-safe identity).
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("target")"#)
.unwrap(),
(0.0, 0.0, 0.0, 0.0, 0, 0, 1.0)
);
let fresh = UiScript::new().unwrap();
assert_eq!(
fresh
.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("player")"#)
.unwrap(),
(0.0, 0.0, 0.0, 0.0, 0, 0, 1.0)
);
}
#[test]
fn attack_power_and_weapon_skill_bindings_serve_the_pairs() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("player")"#)
.unwrap(),
(78, 30, -10)
);
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitRangedAttackPower("player")"#)
.unwrap(),
(52, 0, -3)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(25, 2)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("player")"#)
.unwrap(),
(18, 0)
);
assert_eq!(
s.eval::<(f64, f64, f64, i64, i64, f64)>(r#"return UnitRangedDamage("player")"#)
.unwrap(),
(2.8, 31.0, 47.0, 25, -3, f64::from(1.1f32))
);
// Non-player: the absent zeros throughout.
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("target")"#)
.unwrap(),
(0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("target")"#)
.unwrap(),
(0, 0)
);
}
/// **The pet routing, end to end** (decision 1057) — the thing no build gate can catch: a
/// pushed pet snapshot really is what `Unit*("pet")` answers, while `"player"` still answers
/// the player's and a third token still answers the absent shape. The failure this guards is
/// silent: a mis-routed reader shows a pet sheet full of the *player's* numbers, or of zeros,
/// and nothing errors.
#[test]
fn the_pet_token_reads_the_pet_snapshot_and_only_it() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
// Stamina (index 3): the pet's 68 with no buff split, the player's 22 with its own.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
.unwrap(),
(68, 68, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 3)"#)
.unwrap(),
(22, 22, 0, 0)
);
// Str, where the two genuinely differ AND the player carries a buff: the pet must not
// inherit either number.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(63, 63, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(25, 25, 4, 0)
);
// Resistances: fire (school 2) — 15 for the pet, 20 (+25/−5) for the player.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
.unwrap(),
(15, 15, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 2)"#)
.unwrap(),
(0, 20, 25, -5)
);
// UnitArmor (school 0): the pet's 1810 undecomposed, the player's 150 split.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(1810, 1810, 1810, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("player")"#)
.unwrap(),
(130, 150, 150, 30, -10)
);
// The rest of the family routes too — and `percent` stays the divide-safe 1.0 for the pet
// (the ref Lua divides the damage range by it).
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(30.5, 44.5, 0.0, 0.0, 0, 0, 1.0)
);
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("pet")"#)
.unwrap(),
(178, 12, -4)
);
// No offhand ⇒ the second return is nil, the same gate as the player's.
assert!(s
.eval::<bool>(r#"local m, o = UnitAttackSpeed("pet") return m == 2.0 and o == nil"#)
.unwrap());
// A third token is still the absent shape…
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
// …and so is `"pet"` once the pet is dismissed (the feed pushes None).
s.set_pet_combat_stats(None);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(0.0, 0.0, 0.0, 0.0, 0, 0, 1.0)
);
// The player's is untouched by any of it.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(25, 25, 4, 0)
);
}
/// `UnitDefense` always answers two numbers (the ref reads `local base, modifier`), and **the
/// two legs are different functions, not one function with missing data**: the player's is the
/// skill pair, a pet's is `level * 5` with a flat 0 modifier (the vtable fork,
/// `0x5eda20`/`0x613680`). `UnitAttackBothHands` takes the same fork and must agree with it —
/// that agreement is the point, since a mismatch is exactly what a snapshot-only
/// implementation would produce.
#[test]
fn unit_defense_forks_the_player_skill_from_a_pets_level_times_five() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
s.set_unit(
"pet",
Some(super::super::UnitState {
exists: true,
level: 60,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(55, 4)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(300, 0),
"a level-60 pet: level * 5, modifier flat 0"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
.unwrap(),
(300, 0),
"the Attack row takes the same fork — and ignores its hand index"
);
// The pet's snapshot carries neither pair, which is the whole point: the numbers above
// cannot have come from it.
assert_eq!(pet_stats().defense_skill, (0, 0));
assert_eq!(pet_stats().main_weapon_skill, (0, 0));
// A pet the level feed has not reached yet reads 0 rather than a stale or invented number.
s.set_unit("pet", None);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(0, 0)
);
// A token that fails the client's SELF-or-SUMMONEDBY gate gets the failure zeros, not
// some other unit's level.
s.set_unit(
"target",
Some(super::super::UnitState {
exists: true,
level: 63,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("target")"#)
.unwrap(),
(0, 0)
);
// A negative modifier survives the round trip — the ref branches on `modifier < 0` to
// paint the number red.
s.set_player_combat_stats(Some(UnitCombatStats {
defense_skill: (300, -25),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(300, -25)
);
}
#[test]
fn has_wand_equipped_reads_the_flag() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return HasWandEquipped()").unwrap());
s.set_player_combat_stats(Some(UnitCombatStats {
has_wand: true,
..stats()
}));
assert!(s.eval::<bool>("return HasWandEquipped()").unwrap());
}
#[test]
fn inventory_item_bindings_serve_occupied_empty_and_absent_shapes() {
let mut s = UiScript::new().unwrap();
let mut slots: crate::script::InventorySlots = Default::default();
// Head (slot 1) occupied; ammo (slot 0) with a bag-summed count; the rest empty.
slots[1] = Some(InvSlotView {
item_id: 2263,
icon: Some("Interface\\Icons\\INV_Misc_Bandana_01".into()),
count: 1,
quality: 2,
name: Some("Brawler's Harness".into()),
..Default::default()
});
slots[0] = Some(InvSlotView {
item_id: 2512,
icon: Some("Interface\\Icons\\INV_Ammo_Arrow_01".into()),
count: 200,
quality: 1,
name: Some("Rough Arrow".into()),
..Default::default()
});
s.set_inventory_slots(slots);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemID("player", 1)"#)
.unwrap(),
2263
);
assert_eq!(
s.eval::<String>(r#"return GetInventoryItemTexture("player", 1)"#)
.unwrap(),
"Interface\\Icons\\INV_Misc_Bandana_01"
);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("player", 1)"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemQuality("player", 1)"#)
.unwrap(),
2
);
// The ammo slot (0) reads through the same family.
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemID("player", 0)"#)
.unwrap(),
2512
);
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("player", 0)"#)
.unwrap(),
200
);
// An empty slot: nil id/texture/quality, count 0 (the wiki's documented empty shape).
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", 5) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return GetInventoryItemTexture("player", 5) == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("player", 5)"#)
.unwrap(),
0
);
assert!(s
.eval::<bool>(r#"return GetInventoryItemQuality("player", 5) == nil"#)
.unwrap());
// A non-player token: the empty shape (the INV_SLOT fields are PRIVATE — player only).
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("target", 1) == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("target", 1)"#)
.unwrap(),
0
);
// Out-of-range slots: the empty shape, no error.
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", 25) == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("player", -1) == nil"#)
.unwrap());
}
#[test]
fn get_inventory_slot_info_serves_the_dbc_rows() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("HeadSlot")"#)
.unwrap(),
(1, "interface\\paperdoll\\UI-PaperDoll-Slot-Head.blp".into())
);
// The DBC's oddballs: BackSlot shows the Chest art, AmmoSlot the Ranged art.
assert_eq!(
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("BackSlot")"#)
.unwrap(),
(
15,
"interface\\paperdoll\\UI-PaperDoll-Slot-Chest".to_string() + ".blp"
)
);
assert_eq!(
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("AmmoSlot")"#)
.unwrap(),
(
0,
"interface\\paperdoll\\UI-PaperDoll-Slot-Ranged.blp".into()
)
);
// The bag rows: the four equipped-bag icons at 20..23 AND `Bag1`..`Bag12` at 64..75, which
// this table was short of until the DBC was re-read (36 records, not 24). All SIXTEEN share
// one string-block offset, which is why they answer the same art.
const BAG_ART: &str = "interface\\paperdoll\\UI-PaperDoll-Slot-Bag.blp";
for (name, id) in [
("Bag0Slot", 20),
("Bag1Slot", 21),
("Bag2Slot", 22),
("Bag3Slot", 23),
] {
assert_eq!(
s.eval::<(i64, String)>(&format!(r#"return GetInventorySlotInfo("{name}")"#))
.unwrap(),
(id, BAG_ART.into()),
"{name}"
);
}
for n in 1..=12i64 {
assert_eq!(
s.eval::<(i64, String)>(&format!(r#"return GetInventorySlotInfo("Bag{n}")"#))
.unwrap(),
(63 + n, BAG_ART.into()),
"Bag{n}"
);
}
assert_eq!(
s.eval::<(i64, String)>(r#"return GetInventorySlotInfo("SecondaryHandSlot")"#)
.unwrap(),
(
17,
"interface\\paperdoll\\UI-PaperDoll-Slot-SecondaryHand.blp".into()
)
);
// An unknown slot name is a Lua error (the client's own behavior).
assert!(s
.eval::<i64>(r#"return GetInventorySlotInfo("NoSuchSlot")"#)
.is_err());
}
#[test]
fn paperdoll_facing_persists_for_the_app_to_sample() {
let s = UiScript::new().unwrap();
assert_eq!(s.paperdoll_yaw(), 0.0, "unset default");
s.run("BenillaPaperDollModel_SetFacing(0.61)").unwrap();
assert_eq!(s.paperdoll_yaw(), 0.61);
// Persistent, not a drain — two reads see the same value; a later write replaces it.
assert_eq!(s.paperdoll_yaw(), 0.61);
s.run("BenillaPaperDollModel_SetFacing(-0.5)").unwrap();
assert_eq!(s.paperdoll_yaw(), -0.5);
}
/// The pet pane's yaw is a THIRD scalar, not a share of the character pane's: the two tabs can
/// sit at different facings, so a write to either must leave the other alone.
#[test]
fn pet_paperdoll_facing_is_independent_of_the_character_panes() {
let s = UiScript::new().unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 0.0, "unset default");
s.run("BenillaPetPaperDollModel_SetFacing(1.2)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2);
assert_eq!(s.paperdoll_yaw(), 0.0, "the character pane did not move");
s.run("BenillaPaperDollModel_SetFacing(0.61)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2, "…and neither did the pet pane");
assert_eq!(s.paperdoll_yaw(), 0.61);
}
}