Files
benilla-octo/crates/benilla-ui/src/script/char_stats.rs
T
gromandClaude Opus 5 3bbd99ebaf The slam macro, end to end through the path the game takes
He re-tested and it still never casts Slam, so I drove the whole thing rather
than the conditional: `UseAction` on a macro slot → `run_macro` fires
`EXECUTE_CHAT_LINE` per line (`0x4f14e0`) → the stock ChatFrame sends it through
its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
`SlashCmdList["CAST"]` → CleveRoids' hook → `GetParsedMsg` → `DoWithConditionals`
→ `Keywords.noslamclip` → `ValidateNoSlamClip` → `CastSpellByName`. On his own
interface, with his own 2.70 s weapon and Battle Stance, the line casts **Slam**
at the top of a swing and **Heroic Strike** 1.6 s in — which is the assertion the
old test never made, and the reason "the conditional answers true" meant nothing.

Three things came out of the drive.

**The load-time latch is worse than I wrote this morning, and it is what he is
seeing.** `GetSlamWindowPercent` reads SP_SwingTimer's `st_timerMax` cache, not
`UnitAttackSpeed`, so the `ADDON_LOADED` latch decides the window for the whole
first fight: the stand-in gives (2.0 - 1.5)/2.0 = **25%** against his weapon's
44%, while `st_timer` is still 0 so the swing reads **100% elapsed** — and
`[noslamclip]` is false with nothing said about it. Before the stand-in it was
false *and* printed "requires SP_SwingTimer" once; the fix traded a visible
failure for a silent one. Both states are pinned in the test now.

So the sheet is **seeded before the addon walk**, beside the roster record and
unit snapshot that are already seeded there for the same reason
(`ui_char::seed_combat_stats`). A `/reload` — and anyone mid-fight — now latches
the real weapon. A fresh login still has no self store (the create block has not
arrived, which is why the record comes off the roster), so the stand-in stands
there and heals on the first swing.

**And the stand-in now says so in the log.** One line per VM per token, through
the channel `/errors` lists: a combat-stats verb answering for "player" before
anything pushed that unit's sheet. One such read is expected at load; what the
line is for is the case where it never stops, which no test of the verb can see
because every one of them seats the sheet first. `UnitAttackSpeed` reaching Lua
with the store's own `UNIT_FIELD_BASEATTACKTIME` is now asserted through the real
`feed_char`, which is the test that was missing underneath all of this.

The dual-wield road is pinned too, because it is the one shape that can strand
the main-hand timer: `CheckDamageSource` returns "MAIN" on line one for a
two-hander and never runs its arithmetic, but dual-wielding it weighs the hit
against `UnitDamage`'s ranges scaled by the target's armour — and
`UnitArmor("target")` is 0 on any 1.12 client, ours included, because another
unit's resistances are not public. Every hit then reads UNKNOWN and falls to
`ClosestSwing == true or HeroicStrikeQueued()`, whose first half compares against
a *function* and whose second is true whenever this very macro has queued a
Heroic Strike — and `ResetTimer(true)` refills the **off-hand** timer while
`st_timer` runs to 0, where the elapsed read is 100 for good. That loop is the
addon's own and reproduces on any client; what we must not do is enter it when
the player is two-handed, which is `has_offhand` alone, and his combat log says
two-handed: 2.70, 2.71, 2.72, 2.68 between consecutive hits, with no 0.2 s
off-hand pairing anywhere.

189 failures, unchanged, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-29 17:33:31 -06:00

1940 lines
80 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 paper doll's data feed: the stat and equipment-slot globals, read from the combat-stats
//! snapshots the app pushes for `"player"` and `"pet"` and from its inventory-slot snapshot.
//!
//! As in the reference, the pet sheet calls the same `PaperDollFrame_Set*` helpers with `"pet"`
//! (`PetPaperDollFrame.lua:75-81`). Any other token, or a snapshot not yet pushed, serves the
//! absent shape: zeros, `percent` 1.0. A pet has no PLAYER block, so its buff splits read 0.
//!
//! For a third unit, zeros are right for `UnitStat` (`0x518600`): `UNIT_FIELD_STAT*` is PRIVATE +
//! OWNER_ONLY, so the reference holds zeros too. Not built: vmangos also sends a beast's
//! `UNIT_FIELD_RESISTANCES` to the caster of its Beast Lore (`Object.cpp:1065-1067`,
//! `Player.cpp:2603-2610`), and the reference's `UnitResistance` and `UnitArmor` then answer them.
//!
//! vmangos narrows the four buff-split arrays with `uint32(float)` (`Object.cpp:759-763`), so a
//! negative delta arrives as 0 from an arm64 server and intact from x86.
use mlua::{Lua, Value};
use super::binding_abi::flag;
use super::{binding_abi, Model};
/// The `SkillLine.dbc` id behind weapon subclass `0..=20` (vmangos `Item.cpp:700-707`), `None`
/// for the obsolete, exotic and misc rows; with no weapon the skill is [`SKILL_UNARMED`]
/// (`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 use 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)
}
/// The melee skill with no weapon equipped (vmangos `SharedDefines.h:987`, `Player.cpp:20184`).
pub const SKILL_UNARMED: u32 = 162;
/// The `SkillLine.dbc` Defense row `UnitDefense` reports (vmangos `SharedDefines.h:961`).
pub const SKILL_DEFENSE: u32 = 95;
/// One unit's combat stats behind a paper doll, from its descriptor. Arrays are in field order:
/// stats Str/Agi/Sta/Int/Spi, schools with `[0]` armor. A pet leaves the PLAYER-block fields (buff
/// splits, damage-done mods, skill pairs) at their defaults.
#[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,
/// Damage-done bonuses per school (`PLAYER_FIELD_MOD_DAMAGE_DONE_POS` / `_NEG`, neg ≤ 0).
///
/// `[0]` is physical, the pair `UnitDamage` reports. `[1..7]` are the spell schools: a
/// vanilla server folds item spell power into this same field (`_ApplyItemBonuses` →
/// `ApplySpellDamageBonus`), so it is where a school's spell power actually lives and what
/// `GetSpellBonusDamage` reads.
pub damage_bonus_pos: [i32; 7],
pub damage_bonus_neg: [i32; 7],
/// The physical damage multiplier (`PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[0]`), 1.0 until streamed.
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 off-hand weapon is equipped, which gates `UnitAttackSpeed`'s second return.
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 main hand's skill line (picked by [`weapon_subclass_skill`]) as `(value + perm, temp)`,
/// the split the reference's skill reader `0x5ea460` makes for all four skill pairs.
pub main_weapon_skill: (i32, i32),
/// The off hand's pair, read the same way; an empty or non-weapon off hand is Unarmed.
pub offhand_weapon_skill: (i32, i32),
/// The ranged weapon's pair, the same split. Player only: `UnitRangedAttack` (`0x518b90`)
/// has no creature fallback, so a pet answers `(0, 0)` where `UnitDefense` answers level * 5.
pub ranged_weapon_skill: (i32, i32),
/// The [`SKILL_DEFENSE`] pair, the same split. The player's only: `UnitDefense` gives a
/// non-player level * 5 instead (`cgunit_skill`), so the pet feed leaves it unset.
pub defense_skill: (i32, i32),
/// Whether a wand is equipped (`HasWandEquipped`).
pub has_wand: bool,
/// Avoidance chances, already percents on the wire (`PLAYER_DODGE_PERCENTAGE` and siblings).
pub dodge_percent: f32,
pub parry_percent: f32,
pub block_percent: f32,
}
/// The engine's own base attack time, in ms: what a unit with no weapon swings at, and the
/// stand-in for a field that has not streamed. `UnitAttackSpeed` never answers 0 in the
/// reference, and nothing that divides by a swing time survives one.
pub const VANILLA_SWING_MS: u32 = 2000;
impl Default for UnitCombatStats {
/// All zeros but `damage_percent` 1.0, which stock divides by (`PaperDollFrame.lua:298`), and
/// the three attack times, which are the vanilla 2000 and never zero.
///
/// This default **is** the answer every stats verb gives between a VM's birth and the first
/// `feed_char` push (`with_unit_stats` falls back to it), and that window always contains the
/// addon walk: `load_ingame_ui_on_world_entry` mints the VM, seeds the player's roster record
/// and runs every addon's file scope and `ADDON_LOADED` before any Bevy feed system gets a
/// turn — and on a fresh login the player's descriptor has not even streamed yet, which is
/// why the record is seeded from the roster at all. A swing time of 0 in that window is not
/// what the reference can answer: the player's `UNIT_FIELD_BASEATTACKTIME` is in the create
/// block the real client has before `UI_Init`, and a unit with no weapon still carries the
/// engine's 2000.
///
/// A 0 is read by addons as a real number, not as "not yet":
///
/// * SP_SwingTimer latches `st_timerMax = UnitAttackSpeed("player")` in its `ADDON_LOADED`
/// arm behind `if not st_timerMax`, and `0` is truthy in Lua, so the guard never re-fires.
/// Everything downstream divides by it: `CleveRoids.GetSwingPercentElapsed` answers `nil`
/// (its own `attackSpeed > 0` gate), `GetSlamWindowPercent` answers 0, and every
/// `[noslamclip]`/`[swingtimer]` macro silently takes its other branch for the whole
/// session unless an auto-attack lands and re-assigns it.
/// * BetterCharacterStats' `damage / speed` DPS rows (`BetterCharacterStats.lua:599-609`)
/// read `inf`.
///
/// 2000 is the same stand-in `ui_char::base_swing` already puts in front of an unstreamed
/// field for exactly this reason, so the pre-push answer and the post-push answer agree.
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,
damage_bonus_pos: [0; 7],
damage_bonus_neg: [0; 7],
damage_percent: 1.0,
main_attack_time_ms: VANILLA_SWING_MS,
offhand_attack_time_ms: VANILLA_SWING_MS,
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: VANILLA_SWING_MS,
ranged_min_damage: 0.0,
ranged_max_damage: 0.0,
main_weapon_skill: (0, 0),
offhand_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
dodge_percent: 0.0,
parry_percent: 0.0,
block_percent: 0.0,
}
}
}
/// One equipment or ammo slot as the `GetInventoryItem*` family reads it, resolved by the app like
/// [`super::container::ContainerSlot`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InvSlotView {
/// The item's template entry (`GetInventoryItemID`).
pub item_id: u32,
/// Whether the item may go on an action bar, by the same filter as a bag slot's.
pub bar_placeable: bool,
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
pub icon: Option<String>,
/// `ITEM_FIELD_STACK_COUNT`, or for the ammo slot the bag-summed carried total.
pub count: u32,
/// For a container (`TYPEMASK_CONTAINER`, `0x4c87a6`), what `GetInventoryItemCount` pushes:
/// its contents' summed stacks when its `ItemSubClass.dbc` row has `DisplayFlags & 0x4` (Soul
/// Bag and quivers, `0x4c881a`), else 0; past 0-based slot `0x16` the binding answers 0 first.
pub contents_count: Option<u32>,
/// Item quality 0..6 (`GetInventoryItemQuality`).
pub quality: i32,
/// The item's name, once known.
pub name: Option<String>,
/// Live durability `(current, max)`, the tooltip's "Durability X / Y" line.
pub durability: Option<(u32, u32)>,
/// What repairing this item costs in copper right now, the number `0x4faf30` computes from
/// `DurabilityCosts.dbc`/`DurabilityQuality.dbc` over the points lost — already discounted by
/// the open merchant's price discount (`0x4faf8a`), full price with no merchant open. `0` is
/// the only answer for an item that needs no repair, carries no durability, or is neither
/// weapon nor armor, exactly as the reference's own helper answers. Read by
/// `GameTooltip:SetInventoryItem`'s third return and by `GetInventoryItemRepairCost`.
pub repair_cost: u32,
/// `ITEM_FIELD_FLAGS`; the alert recompute `0x4c7ee0` reads two bits: `0x10` force-red
/// (status 4) and `0x08` wrapped (no durability alert).
pub flags: u32,
/// Bound at runtime (`0x5da2c0`): `ITEM_FIELD_FLAGS & 1`, or an enchant whose
/// `SpellItemEnchantment` row binds. The tooltip's bind line then says Soulbound.
pub already_bound: bool,
/// An `|Hitem:…|h[Name]|h` link once the name is known.
pub link: Option<String>,
/// Whether a pending item operation covers this slot (`IsInventoryItemLocked`).
pub locked: bool,
/// The 1-based live-API slots this item could be equipped into; empty if none.
pub equip_slots: Vec<u8>,
/// The resolved `ITEM_FIELD_CREATOR` name, the tooltip's "<Made by %s>" line.
pub creator: Option<String>,
/// The resolved enchant slots in slot order. An inspected player's record has all 7 too, but
/// vmangos fills only PERM and TEMP (`Player.cpp:10594-10595`).
pub enchants: Vec<super::EnchantView>,
/// The instance's remaining lifetime in ms; `None` without a timer.
pub duration_ms: Option<u64>,
/// The raw descriptor numbers nampower's equipment getters report ([`ItemRawFields`]).
pub raw: ItemRawFields,
/// The slot's use cooldown, `(start_ms on the GetTime clock, duration_ms, enabled)`; `None`
/// is cold. Backs `GetInventoryItemCooldown` — a **stock 1.12 verb** that answered `(0, 0, 1)`
/// unconditionally until this was fed, so the paper doll never drew a trinket's swirl.
pub cooldown: Option<(i64, u32, bool)>,
/// The same query's three legs, for nampower's `GetTrinketCooldown`.
pub cooldown_legs: super::CooldownLegsView,
/// The template's on-use spell id, 0 for none: `GetTrinketCooldown`'s `itemActiveSpellId`,
/// and the spell the legs above were keyed on.
pub use_spell_id: u32,
}
/// The item-descriptor numbers nampower's `GetEquippedItem`/`GetEquippedItems` report, kept apart
/// from the display fields above because they are **raw**: `enchants` is the tooltip's resolved and
/// filtered view (a `SpellItemEnchantment` row with no name, or one flagged `0x2` to hide it, never
/// reaches that list), where an addon asking "is this slot enchanted" wants the id either way.
///
/// Fields `nampower.dll` reports that are not here: `duration`, which is the descriptor's
/// `ITEM_FIELD_DURATION` in seconds, where this struct's sibling [`InvSlotView::duration_ms`] is
/// the display countdown — not the same number, and nothing installed reads it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ItemRawFields {
/// `ITEM_FIELD_ENCHANTMENT_*_1` by slot: 0 permanent, 1 temporary, 2..=6 the random suffix.
/// Negative ids are kept signed, as the descriptor carries them.
pub enchant_ids: [i32; 7],
/// The temporary slot's charges, `ITEM_FIELD_ENCHANTMENT_2_3`.
pub temp_enchant_charges: u32,
/// Ms left on the temporary enchant, from `SMSG_ITEM_ENCHANT_TIME_UPDATE` — the live countdown
/// nampower names `tempEnchantmentTimeLeftMs`, not the descriptor's duration word.
pub temp_enchant_remaining_ms: Option<u64>,
/// `ITEM_FIELD_RANDOM_PROPERTIES_ID`.
pub random_properties_id: u32,
/// `ITEM_FIELD_SPELL_CHARGES` slot 0, the charges a **Use:** item has left.
pub spell_charges: i32,
}
/// The doll snapshot: 0 ammo, 1..=19 equipment, 20..=23 the equipped bags, as
/// `GetInventorySlotInfo` numbers them; `None` is an empty slot.
pub const INVENTORY_SLOT_COUNT: usize = 24;
pub type InventorySlots = [Option<InvSlotView>; INVENTORY_SLOT_COUNT];
/// The six bank-bag slots in button order, live-API ids 64..=69; `None` is empty or unbought.
/// They are player-descriptor slots, present whether or not the bank window is open.
pub const BANK_BAG_SLOT_COUNT: usize = 6;
pub type BankBagSlots = [Option<InvSlotView>; BANK_BAG_SLOT_COUNT];
/// `PaperDollItemFrame.dbc`'s 36 rows as `(SlotName, SlotID, art suffix)`. The texture column
/// shares strings between rows, so `BackSlot` shows the Chest art and `AmmoSlot` the Ranged.
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 bank-bag band is 64..69; all sixteen bag rows share one texture string.
("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 client's 12 durability-alert regions (`0x806eb8`) as live slot ids: stock's 11
/// `INVENTORY_ALERT_STATUS_SLOTS` (`DurabilityFrame.lua:2-12`), then low ammo, which the client
/// table holds as slot -1 and we as the ammo view, slot 0.
const ALERT_SLOTS: [usize; 12] = [1, 3, 5, 6, 7, 8, 9, 10, 16, 17, 18, 0];
/// Broken for the doll's red tint, from `0x4c7ee0`'s bits: never when wrapped (`0x08`), else when
/// force-red (`0x10`) or at durability 0 of a nonzero max.
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` (`0x4c7ee0`): 4 when force-red or broken, 3 with 1..=5
/// durability points left (an absolute count, not a percentage), else 0. Statuses 1 and 2, the
/// temporary-enchant alerts, are not fed; stock gives them no color (`DurabilityFrame.lua:18-19`).
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 low-ammo region (`0x4c7ee0`'s slot -1 arm): 3 when the carried ammo is 20 or fewer, else 0.
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.
pub fn set_player_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().player_combat_stats = stats;
}
/// The pet's snapshot; `None` without a pet, so `Unit*("pet")` reads the absent shape.
pub fn set_pet_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().pet_combat_stats = stats;
}
/// Push the doll snapshot, recompute the 12 alert statuses and fire `UPDATE_INVENTORY_ALERTS`.
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;
}
// Every recompute fires it, never diffed against the last (`0x4c7ee0`); the app pushes
// only on a real change.
self.fire_event("UPDATE_INVENTORY_ALERTS", vec![]);
}
/// Push the six bank-bag slots. The caller fires `PLAYERBANKSLOTS_CHANGED` for a changed item:
/// the stock bank buttons repaint only on it and `BANKFRAME_OPENED` (`BankFrame.lua:206-212`).
pub fn set_bank_bag_slots(&mut self, slots: BankBagSlots) {
self.model_mut().bank_bag_slots = slots;
}
/// Drain the slot ids `UseInventoryItem` queued; the app sends each as `CMSG_USE_ITEM`, bag
/// 255 and the 0-based slot.
pub fn take_inventory_uses(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().inventory_uses)
}
/// Drain equipped slots clicked in repair mode, for `CMSG_REPAIR_ITEM`.
pub fn take_inventory_repairs(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().inventory_repairs)
}
}
fn with_unit_stats<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&UnitCombatStats) -> T,
) -> T {
let absent = UnitCombatStats::default();
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
if let Some(stats) = pushed {
return f(stats);
}
}
// No sheet for this token: the answer is the stand-in, and for `"player"` that is worth one
// line in the log.
//
// Every addon that reads a swing speed, a damage range or an armour value divides by it, and
// several latch it: SP_SwingTimer caches `UnitAttackSpeed("player")` into `st_timerMax` at
// `ADDON_LOADED` behind an `if not st_timerMax` that a number satisfies, and CleveRoids
// computes its whole `[noslamclip]` window from that cache. A stand-in read there is wrong by
// the ratio of the real weapon to the vanilla 2000 — (2.0 - 1.5)/2.0 = 25% where the player's
// 2.70 s weapon gives 44% — and nothing anywhere raises. The window between a VM's birth and
// the first `feed_char` push always contains the addon walk, so one such read is *expected*;
// what this line is here to catch is the case where it never stops, which no test of the verb
// can see because every one of them seats the sheet first.
//
// Once per VM per token, through the same channel `/errors` lists: a per-call warning on a
// verb pfUI polls four times a second would be the log's whole content.
if let Some(token) = token.as_deref().filter(|t| *t == "player" || *t == "pet") {
let first = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.stats_stand_in_warned.insert(token.to_string())
};
if first {
crate::script::diagnostics::record_warning(
lua,
&format!(
"a combat-stats verb answered for \"{token}\" before anything pushed that \
unit's sheet, so it answered the stand-in ({} ms swing, zero damage). \
Expected once at load; if it is the last word for the session, `feed_char` \
never pushed and every addon dividing by a swing speed is wrong.",
VANILLA_SWING_MS
),
);
}
}
f(&absent)
}
/// The clamp `UnitDefense` (`0x519298`) and each hand of `UnitAttackBothHands` (`0x5188a7`) apply:
/// a modifier below `-base` becomes `-base`, so the total never reads negative.
fn skill_clamped((base, modifier): (i32, i32)) -> (i64, i64) {
let base = i64::from(base);
let modifier = i64::from(modifier);
(base, if modifier + base < 0 { -base } else { modifier })
}
/// A non-player's `UnitDefense` and `UnitAttackBothHands` answer: `CGUnit_C`'s vtable bodies
/// (`0x613680`, `0x6136b0`) return level * 5 with a 0 modifier; a creature has no skill block.
fn cgunit_skill(model: &Model, token: &str) -> i64 {
model.unit(token).map_or(0, |u| i64::from(u.level)) * 5
}
/// `token`'s inventory slot, unit-keyed as in the reference so the inspect doll reuses these
/// bindings: `"player"` reads our own items, the inspected unit its public
/// `PLAYER_VISIBLE_ITEM_*` entries, which carry no 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)
}
/// The bank's live-API ids, `BankButtonIDToInvSlotID`'s two bands: 24 vault slots, 6 bag slots.
const BANK_INV_SLOTS: std::ops::RangeInclusive<usize> = 40..=63;
const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive<usize> = 64..=69;
/// The vault's container id; must match `ui_items::BANK_CONTAINER` in the app.
const BANK_CONTAINER: i64 = -1;
impl Model {
/// The item `token` exposes at live-API id `slot`, the one routing the `GetInventoryItem*`
/// getters and `GameTooltip:SetInventoryItem` share. Stock paints the bank through this API
/// (`BankFrame.lua:35`), so the bank band is answered from the container snapshot.
pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option<InvSlotView> {
if token.eq_ignore_ascii_case("player") {
if let Some(view) = self.bank_inv_slot(slot) {
return Some(view);
}
return self.inventory_slots.get(slot)?.clone();
}
self.inspect
.as_ref()
.filter(|v| v.unit == token)?
.slots
.get(slot)?
.clone()
}
fn bank_inv_slot(&self, slot: usize) -> Option<InvSlotView> {
if BANK_INV_SLOTS.contains(&slot) {
let vault = self.containers.get(&BANK_CONTAINER)?;
let n = (slot - BANK_INV_SLOTS.start() + 1) as u32;
return vault.slots.get(&n).map(InvSlotView::from_container_slot);
}
if BANK_BAG_INV_SLOTS.contains(&slot) {
// A bank bag is a container, not a slot in one, so this band is its own store; stock
// picks it up and describes it through this API.
let i = slot - BANK_BAG_INV_SLOTS.start();
return self.bank_bag_slots.get(i)?.clone();
}
None
}
}
/// The inventory-slot reader's whitelist (`0x4c8520`) on its 0-based slot (`0x4c8546`); outside
/// it the binding raises "Invalid inventory slot in …". The backpack's item slots (23..=38) and
/// buyback (69..=80) are container-API slots, not in it.
fn inventory_slot_reader_accepts(slot0: i32) -> bool {
slot0 == -1 // Lua 0 the ammo leg
|| (0x00..=0x16).contains(&slot0) // Lua 1..=23 the doll + the four equipped bags
|| (0x27..=0x3e).contains(&slot0) // Lua 40..=63 the bank vault
|| (0x3f..=0x44).contains(&slot0) // Lua 64..=69 the bank bag slots
|| (0x51..=0x70).contains(&slot0) // Lua 82..=113 the keyring
}
fn link_item_name(link: &str) -> Option<String> {
let (_, rest) = link.split_once("|h[")?;
let (name, _) = rest.split_once("]|h")?;
Some(name.to_string())
}
impl InvSlotView {
/// A container slot seen through the inventory API, for `Model::inv_slot`'s bank band.
fn from_container_slot(slot: &super::container::ContainerSlot) -> Self {
InvSlotView {
item_id: slot.item_id,
bar_placeable: slot.bar_placeable,
icon: slot.texture.clone(),
count: slot.count,
quality: slot.quality.map_or(0, |q| q as i32),
// A container slot has no name, but its link is built from it.
name: slot.link.as_deref().and_then(link_item_name),
durability: slot.durability,
already_bound: slot.already_bound,
link: slot.link.clone(),
locked: slot.locked,
creator: slot.creator.clone(),
enchants: slot.enchants.clone(),
// Slot 20 (`Bag0Slot`) among `equip_slots` marks a container (`INVTYPE_BAG`). The bank
// vault is past the `0x16` short-circuit, so only `Some` versus `None` matters.
contents_count: slot.equip_slots.contains(&20).then_some(0),
..Default::default()
}
}
}
/// The reference's verbatim index-arm strings, pushed at `0x5187d6` (`0x85117c`) and `0x5185cb`
/// (`0x85112c`): no `Usage:` prefix, the index not interpolated. The pool interleaves the two
/// bindings' strings, so each is fixed by its `push`, never by adjacency.
const STAT_INDEX_ERROR: &str = "Invalid stat index in UnitStat";
const RESISTANCE_INDEX_ERROR: &str = "Invalid resistance index in UnitResistance";
/// The argument-type arm, pushed at `0x5187ed` (`0x851158`) and `0x5185e2` (`0x8510fc`), for a
/// value neither a number nor a numeric string ([`binding_abi`]).
const STAT_USAGE: &str = "Usage: UnitStat(\"unit\", statIndex)";
const RESISTANCE_USAGE: &str = "Usage: UnitResistance(\"unit\", resistanceIndex)";
/// Register the paper-doll stat/slot globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitStat(unit, 1..=5) → (stat, effectiveStat, posBuff, negBuff): `UNIT_FIELD_STAT0+i` raw
// (`0x518689`), then clamped at zero (`0x5186a3`-`0x5186b7`), then `POSSTAT0+i` and
// `NEGSTAT0+i` behind a SELF gate (`0x518712`, `0x518772`). Unlike `UnitResistance` it is not
// decomposed: stock subtracts the buffs itself (`PaperDollFrame.lua:152`). A bad index raises.
g.set(
"UnitStat",
lua.create_function(|lua, (token, i): (Value, Value)| {
let token = binding_abi::string_arg(lua, token, STAT_USAGE)?;
let idx = binding_abi::number_arg(lua, i, STAT_USAGE)? - 1;
if !(0..5).contains(&idx) {
return Err(mlua::Error::RuntimeError(STAT_INDEX_ERROR.into()));
}
let idx = idx as usize;
Ok(with_unit_stats(lua, &Some(token), |s| {
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(unit, 0..=6) → (base, resistance, positive, negative) through `0x5efcd0`,
// school 0 armor: `base = raw - pos - neg` before the clamp, `resistance = max(raw, 0)`.
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Value, Value)| {
let token = binding_abi::string_arg(lua, token, RESISTANCE_USAGE)?;
let school = binding_abi::number_arg(lua, school, RESISTANCE_USAGE)?;
if !(0..7).contains(&school) {
return Err(mlua::Error::RuntimeError(RESISTANCE_INDEX_ERROR.into()));
}
let idx = school as usize;
Ok(with_unit_stats(lua, &Some(token), |s| {
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(unit) → (base, effectiveArmor, armor, posBuff, negBuff): school 0 through
// `0x5efcd0`, effectiveArmor and armor both its clamped total.
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(unit) → (minDamage, maxDamage, minOffHandDamage, maxOffHandDamage,
// physicalBonusPos, physicalBonusNeg, percent): the damage fields verbatim, then school 0's
// `MOD_DAMAGE_DONE` split, which is inferred, not traced in the reference.
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.damage_bonus_pos[0]),
i64::from(s.damage_bonus_neg[0]),
f64::from(s.damage_percent),
)
}))
})?,
)?;
// GetSpellBonusDamage(school) → that school's spell power, ClassicAPI's backport of a
// later-client global. It reads the same `PLAYER_FIELD_MOD_DAMAGE_DONE_POS`/`_NEG` pair
// `UnitDamage` reports for physical, one index along: a vanilla server folds item spell
// power into that field (`_ApplyItemBonuses` → `ApplySpellDamageBonus`), so `[1..7]` already
// carries the number without needing a stat the wire does not have.
//
// The school is 1-based as everywhere else in the API — 1 physical, 2 holy, 3 fire, 4
// nature, 5 frost, 6 shadow, 7 arcane — and anything off that range answers 0 rather than
// raising, which is what pfUI's `GetSpellBonusDamage(i) or 0` already assumes
// (`modules/player.lua:92`, polled from a 0.25s OnUpdate on every class).
g.set(
"GetSpellBonusDamage",
lua.create_function(|lua, school: Option<i64>| {
let slot = school
.and_then(|s| usize::try_from(s - 1).ok())
.filter(|&i| i < 7);
let Some(i) = slot else { return Ok(0i64) };
Ok(with_unit_stats(lua, &Some("player".to_string()), |s| {
i64::from(s.damage_bonus_pos[i] + s.damage_bonus_neg[i])
}))
})?,
)?;
// UnitAttackSpeed(unit) → (mainSpeed, offhandSpeed) in seconds; nil offhand without a weapon.
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(unit) → (base, posBuff, negBuff): `UNIT_FIELD_ATTACK_POWER` and the signed
// halves of `_MODS` (vmangos `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(unit) → (base, posBuff, negBuff), from the ranged fields.
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(unit) → (mainBase, mainMod, offBase, offMod): `0x518810` calls
// `[vtbl+0xb0]` for hand 0 then hand 1. Stock reads only the first pair
// (`PaperDollFrame.lua:398-399`). A pet's `CGUnit_C` body (`0x6136b0`) ignores the hand, so
// both pairs are `(level * 5, 0)`.
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, 0, 0), |s| {
let (mb, mm) = skill_clamped(s.main_weapon_skill);
let (ob, om) = skill_clamped(s.offhand_weapon_skill);
(mb, mm, ob, om)
}),
Some("pet") => {
let base = cgunit_skill(&model, "pet");
(base, 0, base, 0)
}
_ => (0, 0, 0, 0),
})
})?,
)?;
// UnitRangedAttack(unit) → (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); stock splits the modifier's sign itself
// (`PaperDollFrame.lua:259-271`). The gate is SELF or `UNIT_FIELD_SUMMONEDBY` = us, then
// `[vtbl+0xac]`: `CGPlayer_C` `0x5eda20` reads the Defense skill (`0x6de040`), `CGUnit_C`
// `0x613680` answers level * 5 and 0. Any other token gets the gate's 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| skill_clamped(s.defense_skill)),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
// GetDodgeChance(), GetParryChance(), GetBlockChance() → one number each, no unit argument
// (`0x516f00`, `0x516fc0`, `0x516f60`); 0 before the field streams.
for (name, pick) in [
("GetDodgeChance", 0usize),
("GetParryChance", 1),
("GetBlockChance", 2),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.player_combat_stats
.as_ref()
.map_or(0.0, |s| match pick {
0 => f64::from(s.dodge_percent),
1 => f64::from(s.parry_percent),
_ => f64::from(s.block_percent),
}))
})?,
)?;
}
// UnitRangedDamage(unit) → (speed, minDamage, maxDamage, physicalBonusPos, physicalBonusNeg,
// percent), with `UnitDamage`'s school-0 mods.
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.damage_bonus_pos[0]),
i64::from(s.damage_bonus_neg[0]),
f64::from(s.damage_percent),
)
}))
})?,
)?;
// HasWandEquipped() → 1 or nil, for the player.
g.set(
"HasWandEquipped",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(
model
.player_combat_stats
.as_ref()
.is_some_and(|s| s.has_wand),
))
})?,
)?;
// GetInventoryItemID(unit, slot) → itemId or nil.
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(unit, slot) → icon path, or nil until the item's template arrives.
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) → `|cff…|Hitem:…|h[Name]|h|r`, or nil until the template
// arrives with the name and quality.
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),
}
})?,
)?;
// The reference's verbatim strings: `0x8489b4` when arg 1 is neither number nor string
// (`0x6f3510`), `0x848984` when arg 2 is not a number or not a whitelisted slot.
const USAGE_GET_INVENTORY_ITEM_COUNT: &str = "Usage: GetInventoryItemCount(unit, slot)";
const INVALID_SLOT_GET_INVENTORY_ITEM_COUNT: &str =
"Invalid inventory slot in GetInventoryItemCount";
// GetInventoryItemCount(unit, slot) (`0x4c8680`): an empty slot pushes 1 (`0x4c8797`), a
// non-container its stack count, a container past 0-based slot `0x16` 0 before any lookup
// (`0x4c87af`, so a bank bag shows no digit), and an equipped bag its `contents_count`.
g.set(
"GetInventoryItemCount",
lua.create_function(|lua, (unit, slot): (Value, Value)| {
let token = super::binding_abi::string_arg(lua, unit, USAGE_GET_INVENTORY_ITEM_COUNT)?;
let slot0 =
super::binding_abi::number_arg(lua, slot, INVALID_SLOT_GET_INVENTORY_ITEM_COUNT)?;
let slot0 = slot0.wrapping_sub(1);
if !inventory_slot_reader_accepts(slot0) {
return Err(mlua::Error::RuntimeError(
INVALID_SLOT_GET_INVENTORY_ITEM_COUNT.into(),
));
}
let Some(v) = player_inv_slot(lua, &Some(token), i64::from(slot0) + 1) else {
return Ok(1i64);
};
Ok(match v.contents_count {
None => i64::from(v.count),
Some(_) if slot0 > 0x16 => 0,
Some(n) => i64::from(n),
})
})?,
)?;
// GetInventoryItemQuality(unit, slot) → 0..6, or nil for an empty slot (the wiki's shape).
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(unit, slot) → 1 or nil, per `slot_is_broken`.
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),
}
})?,
)?;
// GetInventoryItemCooldown(unit, slot) → (start, duration, enable), the triple stock calls on
// every slot update (`PaperDollFrame.lua:691`) to drive a worn item's cooldown swirl.
//
// **This answered `(0, 0, 1)` unconditionally until the doll feed carried a cooldown.** The
// shape was right and the number was always "cold", so nothing errored and no trinket ever
// swirled on the character pane — the quietest kind of wrong. The feed is
// `benilla-app`'s `ui_char::slot_view`, keyed on the (use spell, item entry) pair exactly as
// the bag feed's is, so one trinket reads the same cooldown from the doll and from a bag.
//
// A cold or absent slot is still `(0, 0, 1)`: the caller does arithmetic on these and stock
// `CooldownFrame_SetTimer` reads a 0 duration as "hide", so nil would be the wrong shape.
// Another unit's slot is cold too — an inspected player's cooldowns are not on the wire.
g.set(
"GetInventoryItemCooldown",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
let cooldown = player_inv_slot(lua, &token, slot).and_then(|v| v.cooldown);
Ok(match cooldown {
Some((start_ms, duration_ms, enabled)) => (
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
i64::from(enabled),
),
None => (0.0, 0.0, 1),
})
})?,
)?;
// GetInventoryAlertStatus(index) → the status of region 1..=12: 0 none, 3 damaged or low ammo,
// 4 broken.
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 or nil: whether the off hand holds a weapon (item class 2).
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) from `SLOT_INFO`.
// `checkRelic` is a property of the slot; stock pairs it with `UnitHasRelicSlot("player")`
// (`PaperDollFrame.lua:680`, `:744`).
g.set(
"GetInventorySlotInfo",
lua.create_function(|lua, name: String| {
// A full-string ASCII case-insensitive match: `0x4c8215` calls `_strnicmp` (`0x64a4c0`
// -> `0x414310`) with no length limit. The 36 names stay distinct after folding.
let Some((_, id, art)) = SLOT_INFO
.iter()
.find(|(n, _, _)| n.eq_ignore_ascii_case(&name))
else {
// An unknown name raises, never nil; the string is `0x848894`'s, verbatim.
return Err(mlua::Error::runtime(
"Invalid inventory slot in GetInventorySlotInfo",
));
};
Ok((
*id,
// The DBC's bytes verbatim, unnormalised (`0x4c825b`): a lowercase directory and
// the `.blp` extension, visible to any Lua that compares the string.
Value::String(
lua.create_string(format!(
"interface\\paperdoll\\UI-PaperDoll-Slot-{art}.blp"
))?,
),
// `checkRelic`: the number 1 for `RangedSlot` only (`0x4c8263`), not a boolean.
if *id == 18 {
Value::Integer(1)
} else {
Value::Nil
},
))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
weapon_subclass_skill, RESISTANCE_INDEX_ERROR, SKILL_UNARMED, STAT_INDEX_ERROR, STAT_USAGE,
};
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,
// [0] physical, then holy, fire, nature, frost, shadow, arcane.
damage_bonus_pos: [25, 0, 60, 0, 0, 94, 7],
damage_bonus_neg: [-3, 0, 0, 0, 0, -14, 0],
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),
offhand_weapon_skill: (20, 0),
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
dodge_percent: 0.0,
parry_percent: 0.0,
block_percent: 0.0,
}
}
/// A pet's snapshot: the UNIT fields filled, the PLAYER-block ones at their defaults.
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
for sub in [9u32, 11, 12, 14, 21, 100] {
assert_eq!(weapon_subclass_skill(sub), None, "subclass {sub}");
}
}
/// The values are exact in `f32`, so the assertions compare equal.
#[test]
fn the_avoidance_verbs_are_player_implicit_and_answer_one_number() {
let mut s = UiScript::new().unwrap();
// Before anything streams: 0, not nil.
assert_eq!(s.eval::<f64>("return GetDodgeChance()").unwrap(), 0.0);
s.set_player_combat_stats(Some(UnitCombatStats {
dodge_percent: 5.25,
parry_percent: 3.5,
block_percent: 2.75,
..stats()
}));
assert_eq!(s.eval::<f64>("return GetDodgeChance()").unwrap(), 5.25);
assert_eq!(s.eval::<f64>("return GetParryChance()").unwrap(), 3.5);
assert_eq!(s.eval::<f64>("return GetBlockChance()").unwrap(), 2.75);
// One return, and a unit argument is ignored.
assert_eq!(
s.eval::<i64>("local a, b = GetDodgeChance() return b == nil and 1 or 2")
.unwrap(),
1
);
assert_eq!(
s.eval::<f64>(r#"return GetDodgeChance("target")"#).unwrap(),
5.25
);
}
#[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: raw 25 in both first slots; stock subtracts the +4 itself.
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.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 2)"#)
.unwrap(),
(20, 20, 0, -2)
);
// Only the second return is clamped.
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()));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
}
/// The index truncates toward zero (`_ftol`) ahead of the range test (`0x51865a`,
/// `0x518663`): 1.9 is stat 1, while 0.5 and any negative raise.
#[test]
fn an_out_of_range_stat_index_raises_the_references_own_string() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
for arg in ["6", "0", "-3", "0.5", "-0.5"] {
let err = s
.eval::<(i64, i64, i64, i64)>(&format!(r#"return UnitStat("player", {arg})"#))
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(STAT_INDEX_ERROR),
"UnitStat(\"player\", {arg}) must raise {STAT_INDEX_ERROR:?}, got {msg:?}"
);
assert!(!msg.contains("Usage:"), "no Usage: prefix on the index arm");
}
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1.9)"#)
.unwrap(),
(25, 25, 4, 0)
);
// A numeric string is a number.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", "2")"#)
.unwrap(),
(20, 20, 0, -2)
);
// Anything else takes the Usage arm.
for arg in ["nil", "{}", "true", r#""abc""#] {
let msg = format!(
"{}",
s.eval::<(i64, i64, i64, i64)>(&format!(r#"return UnitStat("player", {arg})"#))
.unwrap_err()
);
assert!(
msg.contains(STAT_USAGE) && !msg.contains(STAT_INDEX_ERROR),
"UnitStat(\"player\", {arg}) must take the Usage arm, got {msg:?}"
);
}
// A missing unit fails the same way: `0x6f3510` sees neither number nor string.
assert!(format!(
"{}",
s.eval::<i64>(r#"return UnitStat(nil, 1)"#).unwrap_err()
)
.contains(STAT_USAGE));
}
#[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)
);
// Arcane cursed to −5: the displayed total clamps to 0, `base` keeps the pre-clamp split.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 6)"#)
.unwrap(),
(0, 0, 0, -5)
);
// UnitArmor is school 0, effectiveArmor = armor = the total.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("player")"#)
.unwrap(),
(130, 150, 150, 30, -10)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
// Out of range raises `UnitResistance`'s own string, not the adjacent `UnitStat` one.
for arg in ["7", "-1"] {
let msg = format!(
"{}",
s.eval::<(i64, i64, i64, i64)>(&format!(
r#"return UnitResistance("player", {arg})"#
))
.unwrap_err()
);
assert!(
msg.contains(RESISTANCE_INDEX_ERROR) && !msg.contains("UnitStat"),
"UnitResistance(\"player\", {arg}) must raise {RESISTANCE_INDEX_ERROR:?}, got {msg:?}"
);
}
// School 0 is in range, unlike `UnitStat`'s index 0.
assert!(s
.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 0)"#)
.is_ok());
}
/// pfUI's player module polls every school each 0.25s tick and takes the best, so the whole
/// 1..7 range has to answer, and a school with no gear has to answer 0 rather than nil.
#[test]
fn get_spell_bonus_damage_reads_each_school() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
assert_eq!(
s.eval::<Vec<i64>>(
"local t = {} for i = 1, 7 do t[i] = GetSpellBonusDamage(i) end return t"
)
.unwrap(),
// Physical 25-3, fire 60, shadow 94-14, arcane 7; the rest unstreamed.
vec![22, 0, 60, 0, 0, 80, 7],
"each school is its own POS+NEG pair, and NEG is already signed"
);
}
/// The shape pfUI relies on: `GetSpellBonusDamage(i) or 0` never has to catch a raise, and
/// a school off the end is 0, not an error and not the next school along.
#[test]
fn get_spell_bonus_damage_answers_zero_outside_the_schools() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
assert_eq!(
s.eval::<Vec<i64>>(
"return {GetSpellBonusDamage(0), GetSpellBonusDamage(8), \
GetSpellBonusDamage(-1), GetSpellBonusDamage()}"
)
.unwrap(),
vec![0, 0, 0, 0]
);
}
/// Before the first snapshot there is nothing to read, and it still must not raise: this is
/// the call that was erroring 433 times a session on a fresh warlock login.
#[test]
fn get_spell_bonus_damage_is_zero_without_a_snapshot() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return GetSpellBonusDamage(6)").unwrap(),
0,
"no snapshot is 0 spell power, not a nil the caller has to guard"
);
}
#[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 off-hand weapon: the second return is nil.
assert!(s
.eval::<bool>(r#"local m, o = UnitAttackSpeed("player") return m == 2.9 and o == nil"#)
.unwrap());
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)
);
// Another token, or no snapshot: zeros and percent 1.0.
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, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(25, 2, 20, 0),
"FOUR values, one pair per hand"
);
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))
);
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("target")"#)
.unwrap(),
(0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("target")"#)
.unwrap(),
(0, 0, 0, 0)
);
}
/// `UnitDefense` (`0x519200`) and `UnitAttackBothHands` (`0x518810`) fork through the unit's
/// vtable to a creature body; `UnitRangedAttack` (`0x518b90`) is a direct call gated on the
/// PLAYER typemask (`0x612b40`, `0x5edae0`, `0x5ea460` are in no vtable), so a pet gets zeros.
#[test]
fn ranged_attack_gives_a_pet_zeros_where_defense_gives_it_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("pet")"#)
.unwrap(),
(300, 0),
"the vtable fork: level * 5"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("pet")"#)
.unwrap(),
(0, 0),
"no fork, no fallback body — a pet fails the PLAYER typemask and gets zeros"
);
// Another player gets zeros too.
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("target")"#)
.unwrap(),
(0, 0)
);
}
/// `0x518810` pushes `(base0, mod0, base1, mod1)`, one pair per hand.
#[test]
fn attack_both_hands_answers_a_pair_per_hand() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(UnitCombatStats {
main_weapon_skill: (300, 5),
offhand_weapon_skill: (275, -12),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(300, 5, 275, -12),
"each hand's own skill line, in hand order"
);
// The per-hand clamp (`0x5188a7`), which `UnitDefense` shares.
s.set_player_combat_stats(Some(UnitCombatStats {
main_weapon_skill: (40, -100),
offhand_weapon_skill: (40, -40),
defense_skill: (30, -90),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(40, -40, 40, -40),
"a debuff deeper than the skill reads as reduced-to-0, never a negative total"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(30, -30),
"the same clamp, the same law"
);
}
#[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: the pet's 68, the player's 22.
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 player carries a buff the pet must not inherit.
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)
);
// Fire resistance: the pet's 15, the player's 20 (+25/−5).
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 routes too; the pet's `percent` stays 1.0.
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)
);
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)
);
// 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)
);
}
/// The vtable fork (`0x5eda20`, `0x613680`): the player's skill pair, a pet's level * 5 with a
/// 0 modifier; `UnitAttackBothHands` takes the same fork.
#[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, i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
.unwrap(),
(300, 0, 300, 0),
"the Attack row takes the same fork — and ignores its hand index, so both pairs match"
);
// The pet's snapshot carries neither pair; the numbers above come from its level.
assert_eq!(pet_stats().defense_skill, (0, 0));
assert_eq!(pet_stats().main_weapon_skill, (0, 0));
// A pet with no level fed yet reads 0.
s.set_unit("pet", None);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(0, 0)
);
// A token failing the SELF-or-SUMMONEDBY gate gets zeros, not its 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; stock paints it 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());
}
/// `0x4c8680`'s container fork: an equipped quiver counts its arrows, a plain bag 0, and any
/// container in a bank bag slot 0 before the DBC is read.
#[test]
fn get_inventory_item_count_forks_on_the_container_bit_and_the_slot() {
let mut s = UiScript::new().unwrap();
let bag = |contents: Option<u32>| {
Some(InvSlotView {
item_id: 4496,
count: 1,
contents_count: contents,
..Default::default()
})
};
let mut slots: crate::script::InventorySlots = Default::default();
slots[20] = bag(Some(162)); // a quiver: the gate is set, the sum is its arrows
slots[21] = bag(Some(0)); // a plain bag: the gate is clear
slots[22] = Some(InvSlotView {
item_id: 2263,
count: 7,
..Default::default()
}); // not a container at all
s.set_inventory_slots(slots);
let mut bank: crate::script::BankBagSlots = Default::default();
bank[0] = bag(Some(162)); // the very same quiver, in a bank bag slot
s.set_bank_bag_slots(bank);
let count = |slot: i64| {
s.eval::<i64>(&format!(
r#"return GetInventoryItemCount("player", {slot})"#
))
.unwrap()
};
assert_eq!(count(20), 162, "a quiver counts what is inside it");
assert_eq!(
count(21),
0,
"a plain bag counts nothing — not its own stack"
);
assert_eq!(count(22), 7, "an ordinary item is still its stack count");
assert_eq!(
count(64),
0,
"0-based 63 is past 0x16: every container short-circuits, quiver or not"
);
// Both arms raise. The backpack's item slots (Lua 24..=39) are outside the whitelist.
let err = |code: &str| s.run(code).unwrap_err().to_string();
assert!(
err(r#"GetInventoryItemCount("player", 30)"#)
.contains("Invalid inventory slot in GetInventoryItemCount"),
"the backpack band raises"
);
assert!(
err(r#"GetInventoryItemCount("player", 200)"#)
.contains("Invalid inventory slot in GetInventoryItemCount"),
"and so does anything past the keyring"
);
assert!(
err(r#"GetInventoryItemCount("player", {})"#)
.contains("Invalid inventory slot in GetInventoryItemCount"),
"a non-number slot takes the SAME string, not the Usage one"
);
assert!(
err("GetInventoryItemCount({}, 1)")
.contains("Usage: GetInventoryItemCount(unit, slot)"),
"…and only a bad unit takes the Usage string"
);
assert_eq!(
count(0),
1,
"the ammo pseudo-slot (0-based -1) is in the whitelist, so it ANSWERS — and with no \
ammo seated the answer is the empty one, which is 1"
);
}
#[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 and quality, and count 1 (`0x4c8797`).
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(),
1
);
assert!(s
.eval::<bool>(r#"return GetInventoryItemQuality("player", 5) == nil"#)
.unwrap());
// A token with no items behind it: the empty shape.
assert!(s
.eval::<bool>(r#"return GetInventoryItemID("target", 1) == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return GetInventoryItemCount("target", 1)"#)
.unwrap(),
1,
"no item behind the token is the empty answer, and the empty answer is 1"
);
// 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, 20..23 and 64..75, share one texture string.
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()
)
);
assert!(s
.eval::<i64>(r#"return GetInventorySlotInfo("NoSuchSlot")"#)
.is_err());
}
/// Each paper doll's facing is its pane's own `SetRotation` state, so turning one leaves the
/// other alone, as stock `Model_RotateLeft(model)` expects.
#[test]
fn each_model_pane_carries_its_own_facing_for_the_app_to_sample() {
let s = UiScript::new().unwrap();
assert_eq!(
s.model_pane_facing("CharacterModelFrame"),
0.0,
"a pane whose file has not loaded"
);
s.run(
r#"
CreateFrame("PlayerModel", "CharacterModelFrame")
CreateFrame("PlayerModel", "PetModelFrame")
"#,
)
.unwrap();
// Stock's default, as `Model_OnLoad` writes it (`UIParent.lua:1422`).
s.run("CharacterModelFrame:SetRotation(0.61)").unwrap();
assert_eq!(s.model_pane_facing("CharacterModelFrame"), 0.61);
// Persistent, not a drain.
assert_eq!(s.model_pane_facing("CharacterModelFrame"), 0.61);
assert_eq!(
s.model_pane_facing("PetModelFrame"),
0.0,
"the pet pane did not move"
);
s.run("PetModelFrame:SetRotation(1.2)").unwrap();
assert_eq!(s.model_pane_facing("PetModelFrame"), 1.2);
assert_eq!(
s.model_pane_facing("CharacterModelFrame"),
0.61,
"…and neither did the character pane"
);
s.run("CharacterModelFrame:SetRotation(-0.5)").unwrap();
assert_eq!(s.model_pane_facing("CharacterModelFrame"), -0.5);
// An undeclared name reads 0.0: the app samples this before the window loads.
assert_eq!(s.model_pane_facing("NoSuchModelFrame"), 0.0);
// A declared frame that is not a model pane has no pane.
s.run(r#"CreateFrame("Frame", "NotAPane")"#).unwrap();
assert!(s.model_pane("NotAPane").is_none());
}
}