- 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>
1560 lines
70 KiB
Rust
1560 lines
70 KiB
Rust
//! 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);
|
||
}
|
||
}
|