Files
benilla-octo/crates/benilla-ui/src/script/char_stats.rs
T
SamandClaude Fable 5 c887170b37 the social window, the escape menu, and items that work from the bar
- The social window: friends, ignore and `/who`, in the reference's own two-level
  arrangement with its inner tabs and per-row right-click menus. The lists ride the
  real wire — guid-keyed with no names, resolved through the name cache, and capped
  at 50 friends / 25 ignores because those are the client's own caps — and the
  ignore list does its actual job: an ignored speaker's chat is dropped silently, a
  whisper additionally tells the sender, and a duel challenge is auto-declined.
  Guild ships as a disabled tab, which is what a guildless character sees.
- The escape menu: ESC's last rung finally opens something. The game menu lands at
  the reference's own size and ladder, and leaving works — logout and quit with the
  server owning the clock (instant at an inn, refused in combat, or the 20-second
  countdown), narrated by the real camp/quit dialogs. While the menu holds the
  center nothing else opens, and the bag bar greys out under it.
- Items on the action bar act like items. An equipped trinket was a dead button;
  the reference's use law is two stages — not equippable means use, equippable and
  already worn means use in place — over its exact search order (equipment first,
  then the bags, then the backpack, then the keyring; never the bank). What can go
  on the bar is now the reference's own two filters, byte-read: an item with an
  on-use spell or one you can equip, and no passive spells — and dropping a passive
  there speaks the real client's own line, "You can't put a passive ability in the
  action bar."
- A quest-starter item opens its quest instead of "The item was not found." The
  question goes to the item's own guid, never through item-use — which the server
  refuses for every one of the 215 quest-starter items — and refusing the quest
  says what the client says, then closes the window.
- For the toolbox: a pixel pick that reports everything under the cursor all the
  way back, a lens that says which render phase a draw landed in, and switches that
  separate "discarded" from "lost depth" from "never drawn" — built while cornering
  a flickering tent, whose fix ships in the next snapshot.

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

1124 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The character-window stats + equipment seam (decision 0208 §3) — the paper doll's data feed.
//!
//! Same engine-free shape as [`super::unit`]: the app pushes a **player combat-stats snapshot**
//! ([`super::UiScript::set_player_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. Every stat binding is **player-token-only, faithfully**: the 1.12
//! fields behind them (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY — no other unit ever streams them — so any non-`"player"` token serves the
//! absent shape (zeros / nils), exactly like `UnitXP` (decision 0208 §3).
//!
//! 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;
/// The player's combat-stats snapshot behind the paper doll's stat pane — plain data the app
/// derives from the 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).
#[derive(Clone, Debug, PartialEq)]
pub struct PlayerCombatStats {
/// 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),
/// 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 PlayerCombatStats {
/// 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 {
PlayerCombatStats {
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),
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 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); 24] = [
("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 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<PlayerCombatStats>) {
self.model_mut().player_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
}
}
/// Read the player combat-stats snapshot under a short model borrow — but only for the
/// `"player"` token; any other token (and a not-yet-pushed snapshot) reads the default (the
/// absent shape: zeros, `damage_percent` 1.0). The faithful player-only gate (module doc).
fn with_player_stats<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&PlayerCombatStats) -> T,
) -> T {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let absent = PlayerCombatStats::default();
let stats = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().unwrap_or(&absent),
_ => &absent,
};
f(stats)
}
/// 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): effective is the
// UNIT_FIELD_STAT value, pos/neg the POSSTAT/NEGSTAT deltas (neg ≤ 0), and stat (base) their
// inverse — the ref computes base = effective − pos − neg and branches on negBuff < 0.
// 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_player_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 (eff, pos, neg) = (s.stats[idx], s.stat_pos[idx], s.stat_neg[idx]);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(pos),
i64::from(neg),
)
}))
})?,
)?;
// UnitResistance("player", school /*0..=6*/) → (base, resistance, positive, negative) — the
// same inverse math per school ([0] = armor).
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Option<String>, i64)| {
Ok(with_player_stats(lua, &token, |s| {
let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else {
return (0, 0, 0, 0);
};
let (eff, pos, neg) = (
s.resistances[idx],
s.resistance_pos[idx],
s.resistance_neg[idx],
);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(pos),
i64::from(neg),
)
}))
})?,
)?;
// UnitArmor("player") → (base, effectiveArmor, armor, posBuff, negBuff) — school 0 of the
// resistance block; the ref reads effectiveArmor and armor equivalently (both the total).
g.set(
"UnitArmor",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
let (eff, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(eff),
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_player_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_player_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_player_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_player_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).
g.set(
"UnitAttackBothHands",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}))
})?,
)?;
// UnitRangedAttack("player") → (base, modifier) — the ranged weapon's skill pair.
g.set(
"UnitRangedAttack",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
(
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
)
}))
})?,
)?;
// 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_player_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),
}
})?,
)?;
// 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| {
let Some((_, id, art)) = SLOT_INFO.iter().find(|(n, _, _)| *n == name) else {
return Err(mlua::Error::runtime(format!(
"GetInventorySlotInfo: unknown slot name '{name}'"
)));
};
Ok((
*id,
Value::String(
lua.create_string(format!("Interface\\Paperdoll\\UI-PaperDoll-Slot-{art}"))?,
),
false,
))
})?,
)?;
// 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(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{weapon_subclass_skill, SKILL_UNARMED};
use crate::script::{InvSlotView, PlayerCombatStats, UiScript};
/// A filled snapshot exercising every field the bindings read.
fn stats() -> PlayerCombatStats {
PlayerCombatStats {
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),
has_wand: false,
}
}
#[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_effective_and_the_derived_base() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
// Str: effective 25, pos 4, neg 0 → base 21.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
);
// Agi: effective 20, pos 0, neg −2 → base 22 (the ref tests negBuff < 0).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 2)"#)
.unwrap(),
(22, 20, 0, -2)
);
// 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 cursed school can read negative (arcane: −5 total, all from the debuff).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 6)"#)
.unwrap(),
(0, -5, 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(PlayerCombatStats {
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)
);
}
#[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(PlayerCombatStats {
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, bool)>(r#"return GetInventorySlotInfo("HeadSlot")"#)
.unwrap(),
(
1,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Head".into(),
false
)
);
// The DBC's oddballs: BackSlot shows the Chest art, AmmoSlot the Ranged art.
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("BackSlot")"#)
.unwrap(),
(
15,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Chest".into(),
false
)
);
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("AmmoSlot")"#)
.unwrap(),
(
0,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Ranged".into(),
false
)
);
// The four equipped-bag icons (decision 0216 slice 2's bag bar): ids 20..23, re-verified
// this session against the real PaperDollItemFrame.dbc — every one shares the same empty-
// slot art.
for (name, id) in [
("Bag0Slot", 20),
("Bag1Slot", 21),
("Bag2Slot", 22),
("Bag3Slot", 23),
] {
assert_eq!(
s.eval::<(i64, String, bool)>(&format!(r#"return GetInventorySlotInfo("{name}")"#))
.unwrap(),
(
id,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-Bag".into(),
false
),
"{name}"
);
}
assert_eq!(
s.eval::<(i64, String, bool)>(r#"return GetInventorySlotInfo("SecondaryHandSlot")"#)
.unwrap(),
(
17,
"Interface\\Paperdoll\\UI-PaperDoll-Slot-SecondaryHand".into(),
false
)
);
// 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);
}
}