the dressing room opens, and the pet gets its paper doll

- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:21 +07:00
co-authored by Claude Fable 5
parent 31c224548c
commit d99ad8bc7d
109 changed files with 11304 additions and 2532 deletions
+379 -42
View File
@@ -1,12 +1,25 @@
//! 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**
//! Same engine-free shape as [`super::unit`]: the app pushes a **combat-stats snapshot** per unit
//! that has one ([`super::UiScript::set_player_combat_stats`] /
//! [`super::UiScript::set_pet_combat_stats`]) and an **inventory-slot snapshot**
//! ([`super::UiScript::set_inventory_slots`]) each frame they change, and the stat/slot globals
//! here read that plain data. 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).
//! here read that plain data.
//!
//! **The stat family is unit-parameterised, as the reference's is** (decision 1057): every
//! `PaperDollFrame_Set*(unit, prefix)` helper the character sheet calls with `"player"`, the pet
//! sheet calls with `"pet"` (ref `PetPaperDollFrame.lua:73-81`), through these very bindings. So
//! `UnitStat`/`UnitResistance`/`UnitArmor`/… route on the token — `"player"` and `"pet"` each read
//! their own pushed snapshot, and **every other token (and a snapshot the app has not pushed yet)
//! serves the absent shape**: zeros, `percent` 1.0. That last part *is* faithful — the 1.12 fields
//! behind these (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY, so no third unit ever streams them. A pet's descriptor carries the UNIT
//! half and no PLAYER block at all, which is why its buff decompositions read `0` and the ref's
//! own pet sheet shows plain white numbers.
//!
//! (Until 1057 the router was a hard `token == "player"` test documented as "the faithful
//! player-only gate". It was neither: it was "no consumer yet". The reference passes `"pet"` into
//! the same bindings, and the gate was simply never exercised.)
//!
//! Return shapes are the ref Lua's own inverse math (ref-PaperDollFrame): the descriptor carries
//! the *effective* value plus the split positive/negative buff deltas; `base = effective − pos −
@@ -57,13 +70,25 @@ pub fn weapon_subclass_skill(subclass: u32) -> Option<u32> {
/// 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
/// `SKILL_DEFENSE` — the Defense skill line `UnitDefense` reports. **Read out of the shipped
/// 1.12.1 `SkillLine.dbc` this session**, not remembered: the file's 123 records carry exactly one
/// row whose enUS `displayName` (column 3, the `SkillLinefmt` layout in
/// `benilla_formats::skill_lines`) is `"Defense"`, and it is id **95**, category 6 (Weapon Skills).
/// Corroborated by vmangos `SharedDefines.h:961` (`SKILL_DEFENSE = 95`).
pub const SKILL_DEFENSE: u32 = 95;
/// One unit's combat-stats snapshot behind a paper doll's stat pane — plain data the app derives
/// from that unit's descriptor accessors ([`UnitStat`]/[`UnitResistance`]/… read it). Arrays are
/// in field order: stats 0..4 = Str/Agi/Stam/Int/Spi (`UNIT_FIELD_STAT0..4`), schools 0..6 with
/// `[0]` = armor/physical. The `*_neg` values are **negative-or-zero** (the stored wire sign; see
/// the module doc).
///
/// Two units carry one: the player and (decision 1057) the pet. A **creature's** descriptor has no
/// PLAYER block, so every field sourced from one — the stat/resistance buff splits, the
/// damage-done mods, the skill pairs — keeps its default for a pet, which is exactly the ref pet
/// sheet's plain white numbers with no buff decomposition in the tooltip.
#[derive(Clone, Debug, PartialEq)]
pub struct PlayerCombatStats {
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).
@@ -113,16 +138,24 @@ pub struct PlayerCombatStats {
pub main_weapon_skill: (u32, i32),
/// The ranged weapon's skill pair (`UnitRangedAttack`).
pub ranged_weapon_skill: (u32, i32),
/// The [`SKILL_DEFENSE`] line's `(value, temp+perm bonus)` pair, read out of `PLAYER_SKILL_INFO`
/// like the two weapon pairs above; `UnitDefense` serves it verbatim.
///
/// **The player's only.** A creature has no skill block, and the client does not read one for
/// it: `UnitDefense` forks on the resolved unit's vtable and gives a non-player
/// `UNIT_FIELD_LEVEL * 5` instead ([`cgunit_skill`]), so the pet feed never fills this and the
/// binding never reads it for a pet.
pub defense_skill: (u32, i32),
/// Whether a wand is equipped (`HasWandEquipped` — the ref swaps the ranged-attack action for
/// wand Shoot on it).
pub has_wand: bool,
}
impl Default for PlayerCombatStats {
impl Default for UnitCombatStats {
/// All-zeros except `damage_percent` = `1.0` — the multiplicative identity, so the absent
/// shape never feeds the ref's `bonus / percent` math a division by zero.
fn default() -> Self {
PlayerCombatStats {
UnitCombatStats {
stats: [0; 5],
stat_pos: [0; 5],
stat_neg: [0; 5],
@@ -150,6 +183,7 @@ impl Default for PlayerCombatStats {
ranged_max_damage: 0.0,
main_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
}
}
@@ -304,10 +338,16 @@ fn ammo_alert_status(slot: &Option<InvSlotView>) -> u8 {
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>) {
pub fn set_player_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().player_combat_stats = stats;
}
/// The `"pet"` twin (decision 1057) — `None` whenever there is no pet, which is what makes
/// every `Unit*("pet")` fall back to the absent shape the moment one is dismissed.
pub fn set_pet_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().pet_combat_stats = stats;
}
/// Push the equipment/ammo slot snapshot (index 0 = ammo, 1..=19 equipment). Recomputes the
/// 11 durability-alert region statuses off the live pairs; a change fires
/// `UPDATE_INVENTORY_ALERTS` — the DurabilityFrame's own repaint signal (the real engine
@@ -347,23 +387,44 @@ impl super::UiScript {
pub fn paperdoll_yaw(&self) -> f32 {
self.model_ref().paperdoll_yaw
}
/// The **pet** paper doll pane's yaw (`BenillaPetPaperDollModel_SetFacing` wrote it) — the
/// exact twin of [`Self::paperdoll_yaw`] and of `UiScript::inspect_yaw`, sampled each frame by
/// `crate::ui_pet_doll` onto its booth (decision 1057).
pub fn pet_paperdoll_yaw(&self) -> f32 {
self.model_ref().pet_paperdoll_yaw
}
}
/// Read 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>(
/// Read a unit's combat-stats snapshot under a short model borrow, routed by token: `"player"` and
/// `"pet"` each read their own pushed snapshot, anything else — and a snapshot the app has not
/// pushed yet — reads the default (the absent shape: zeros, `damage_percent` 1.0). See the module
/// doc for why exactly these two tokens and no more.
fn with_unit_stats<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&PlayerCombatStats) -> T,
f: impl FnOnce(&UnitCombatStats) -> 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,
let absent = UnitCombatStats::default();
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
f(stats)
f(pushed.unwrap_or(&absent))
}
/// A **non-player** unit's answer to both skill-shaped questions — `UnitDefense` and
/// `UnitAttackBothHands` — which is `UNIT_FIELD_LEVEL * 5`, flat.
///
/// It is one helper because it is one function in the client: both bindings dispatch through the
/// resolved unit's vtable, and a `CGUnit_C` lands on a three-line body that multiplies the level by
/// five and writes 0 to the modifier (`0x613680` / `0x6136b0`, wow-re
/// `ui/scratch/pet-paperdoll-stat-api.md`). Nothing here is skill data — a creature has no skill
/// block at all, which is exactly why the client substitutes a formula.
fn cgunit_skill(model: &Model, token: &str) -> i64 {
model.units.get(token).map_or(0, |u| i64::from(u.level)) * 5
}
/// Read inventory slot `slot` under a short borrow, cloned out so the caller holds no borrow.
@@ -413,7 +474,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitStat",
lua.create_function(|lua, (token, i): (Option<String>, i64)| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = i.checked_sub(1).and_then(|v| usize::try_from(v).ok()) else {
return (0, 0, 0, 0);
};
@@ -436,7 +497,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Option<String>, i64)| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else {
return (0, 0, 0, 0);
};
@@ -460,7 +521,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitArmor",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let (eff, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
(
i64::from(eff - pos - neg),
@@ -480,7 +541,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.min_damage),
f64::from(s.max_damage),
@@ -500,7 +561,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitAttackSpeed",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.main_attack_time_ms) / 1000.0,
s.has_offhand
@@ -515,7 +576,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.attack_power),
i64::from(s.attack_power_pos),
@@ -529,7 +590,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitRangedAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_attack_power),
i64::from(s.ranged_attack_power_pos),
@@ -542,15 +603,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// UnitAttackBothHands("player") → (base, modifier) — the main-hand weapon-skill line: skill
// value + its temp+perm bonus, resolved app-side via the weapon_subclass_skill table (unarmed
// = SKILL_UNARMED when nothing's equipped).
//
// A non-player unit takes the same virtual fork `UnitDefense` does (`0x6136b0`, wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`) and lands on the identical `level * 5` — and that
// one **ignores its hand index outright**, so both hands answer the same pair. The pet sheet's
// Attack row therefore reads 300 at level 60, exactly like its Defense row.
g.set(
"UnitAttackBothHands",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}))
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
@@ -558,7 +629,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitRangedAttack",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
@@ -567,13 +638,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitDefense(unit) → (base, modifier). The ref reads exactly two numbers and folds the
// modifier's sign itself (PaperDollFrame.lua:259-271: modifier > 0 → a green posBuff, < 0 → a
// red negBuff).
//
// **The fork is a VIRTUAL CALL on the resolved unit, not a token test** (wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`, the §5 that corrected decision 1057's INTERIM):
// `[vtbl+0xac]` is either `CGPlayer_C 0x5eda20` — resolve the Defense SkillLine (`0x6de040`)
// and read `PLAYER_SKILL_INFO` — or `CGUnit_C 0x613680`, which is three lines:
// `*out1 = UNIT_FIELD_LEVEL * 5; *out2 = 0`. **A level-60 pet shows 300**, not the 0 that
// 1057 shipped while the dispatch was out. The outer gate is SELF **or**
// `UNIT_FIELD_SUMMONEDBY == my guid`, which our pet passes and no other token we serve does —
// so `"player"` takes the skill leg, `"pet"` the level leg (a warlock's minion included: it is
// summoned by us too), and everything else the gate-failure zeros.
g.set(
"UnitDefense",
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(i64::from(s.defense_skill.0), i64::from(s.defense_skill.1))
}),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
// UnitRangedDamage("player") → (speed, minDamage, maxDamage, physicalBonusPos,
// physicalBonusNeg, percent) — RANGEDATTACKTIME/1000 + the ranged damage range + the same
// school-0 mods as UnitDamage.
g.set(
"UnitRangedDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.ranged_attack_time_ms) / 1000.0,
f64::from(s.ranged_min_damage),
@@ -623,6 +721,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInventoryItemLink(unit, slot) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil (nil
// while the template answer is in flight — the link is built from the item's name + quality,
// and neither is known until it lands). Unit-keyed like its siblings, so the reference's paper
// doll and its inspect twin both reach it from one binding:
// `DressUpItemLink(GetInventoryItemLink("player", this:GetID()))` (PaperDollFrame.lua:650) and
// the shift-click `ChatFrameEditBox:Insert(...)` beside it (l.653). Decision 1059.
g.set(
"GetInventoryItemLink",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot).and_then(|v| v.link) {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemCount("player", slot) → the stack count; **0 for an empty slot** (the
// wiki's documented shape — warcraft.wiki.gg: "1 for most items", 0 when nothing's there;
// the ammo slot carries the bag-summed count the app resolves).
@@ -727,17 +841,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaPetPaperDollModel_SetFacing(radians) — the PET pane's own bake yaw (decision 1057),
// the exact twin of `BenillaInspectModel_SetFacing`: a third scalar, because tab 1 and tab 2
// are two panes that can sit at two different facings.
g.set(
"BenillaPetPaperDollModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.pet_paperdoll_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{weapon_subclass_skill, SKILL_UNARMED};
use crate::script::{InvSlotView, PlayerCombatStats, UiScript};
use crate::script::{InvSlotView, UiScript, UnitCombatStats};
/// A filled snapshot exercising every field the bindings read.
fn stats() -> PlayerCombatStats {
PlayerCombatStats {
fn stats() -> UnitCombatStats {
UnitCombatStats {
stats: [25, 20, 22, 10, 11],
stat_pos: [4, 0, 0, 0, 0],
stat_neg: [0, -2, 0, 0, 0],
@@ -765,10 +892,27 @@ mod tests {
ranged_max_damage: 47.0,
main_weapon_skill: (25, 2),
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
}
}
/// A pet's snapshot: the UNIT half filled, every PLAYER-block-sourced field at its default —
/// what `ui_char::unit_combat_stats` really produces over a creature's descriptor.
fn pet_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [63, 45, 68, 32, 42],
resistances: [1810, 0, 15, 0, 0, 0, 0],
min_damage: 30.5,
max_damage: 44.5,
main_attack_time_ms: 2000,
attack_power: 178,
attack_power_pos: 12,
attack_power_neg: -4,
..Default::default()
}
}
#[test]
fn weapon_skill_table_matches_vmangos_item_cpp() {
// The populated rows (Item.cpp:700-707).
@@ -885,7 +1029,7 @@ mod tests {
.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 {
s.set_player_combat_stats(Some(UnitCombatStats {
has_offhand: true,
..stats()
}));
@@ -951,11 +1095,190 @@ mod tests {
);
}
/// **The pet routing, end to end** (decision 1057) — the thing no build gate can catch: a
/// pushed pet snapshot really is what `Unit*("pet")` answers, while `"player"` still answers
/// the player's and a third token still answers the absent shape. The failure this guards is
/// silent: a mis-routed reader shows a pet sheet full of the *player's* numbers, or of zeros,
/// and nothing errors.
#[test]
fn the_pet_token_reads_the_pet_snapshot_and_only_it() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
// Stamina (index 3): the pet's 68 with no buff split, the player's 22 with its own.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
.unwrap(),
(68, 68, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 3)"#)
.unwrap(),
(22, 22, 0, 0)
);
// Str, where the two genuinely differ AND the player carries a buff: the pet must not
// inherit either number.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(63, 63, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
);
// Resistances: fire (school 2) — 15 for the pet, 20 (+25/−5) for the player.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
.unwrap(),
(15, 15, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 2)"#)
.unwrap(),
(0, 20, 25, -5)
);
// UnitArmor (school 0): the pet's 1810 undecomposed, the player's 150 split.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(1810, 1810, 1810, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("player")"#)
.unwrap(),
(130, 150, 150, 30, -10)
);
// The rest of the family routes too — and `percent` stays the divide-safe 1.0 for the pet
// (the ref Lua divides the damage range by it).
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(30.5, 44.5, 0.0, 0.0, 0, 0, 1.0)
);
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("pet")"#)
.unwrap(),
(178, 12, -4)
);
// No offhand ⇒ the second return is nil, the same gate as the player's.
assert!(s
.eval::<bool>(r#"local m, o = UnitAttackSpeed("pet") return m == 2.0 and o == nil"#)
.unwrap());
// A third token is still the absent shape…
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
// …and so is `"pet"` once the pet is dismissed (the feed pushes None).
s.set_pet_combat_stats(None);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(0.0, 0.0, 0.0, 0.0, 0, 0, 1.0)
);
// The player's is untouched by any of it.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
);
}
/// `UnitDefense` always answers two numbers (the ref reads `local base, modifier`), and **the
/// two legs are different functions, not one function with missing data**: the player's is the
/// skill pair, a pet's is `level * 5` with a flat 0 modifier (the vtable fork,
/// `0x5eda20`/`0x613680`). `UnitAttackBothHands` takes the same fork and must agree with it —
/// that agreement is the point, since a mismatch is exactly what a snapshot-only
/// implementation would produce.
#[test]
fn unit_defense_forks_the_player_skill_from_a_pets_level_times_five() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
s.set_unit(
"pet",
Some(super::super::UnitState {
exists: true,
level: 60,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(55, 4)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(300, 0),
"a level-60 pet: level * 5, modifier flat 0"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
.unwrap(),
(300, 0),
"the Attack row takes the same fork — and ignores its hand index"
);
// The pet's snapshot carries neither pair, which is the whole point: the numbers above
// cannot have come from it.
assert_eq!(pet_stats().defense_skill, (0, 0));
assert_eq!(pet_stats().main_weapon_skill, (0, 0));
// A pet the level feed has not reached yet reads 0 rather than a stale or invented number.
s.set_unit("pet", None);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(0, 0)
);
// A token that fails the client's SELF-or-SUMMONEDBY gate gets the failure zeros, not
// some other unit's level.
s.set_unit(
"target",
Some(super::super::UnitState {
exists: true,
level: 63,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("target")"#)
.unwrap(),
(0, 0)
);
// A negative modifier survives the round trip — the ref branches on `modifier < 0` to
// paint the number red.
s.set_player_combat_stats(Some(UnitCombatStats {
defense_skill: (300, -25),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(300, -25)
);
}
#[test]
fn has_wand_equipped_reads_the_flag() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return HasWandEquipped()").unwrap());
s.set_player_combat_stats(Some(PlayerCombatStats {
s.set_player_combat_stats(Some(UnitCombatStats {
has_wand: true,
..stats()
}));
@@ -1126,4 +1449,18 @@ mod tests {
s.run("BenillaPaperDollModel_SetFacing(-0.5)").unwrap();
assert_eq!(s.paperdoll_yaw(), -0.5);
}
/// The pet pane's yaw is a THIRD scalar, not a share of the character pane's: the two tabs can
/// sit at different facings, so a write to either must leave the other alone.
#[test]
fn pet_paperdoll_facing_is_independent_of_the_character_panes() {
let s = UiScript::new().unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 0.0, "unset default");
s.run("BenillaPetPaperDollModel_SetFacing(1.2)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2);
assert_eq!(s.paperdoll_yaw(), 0.0, "the character pane did not move");
s.run("BenillaPaperDollModel_SetFacing(0.61)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2, "…and neither did the pet pane");
assert_eq!(s.paperdoll_yaw(), 0.61);
}
}
+67 -11
View File
@@ -180,14 +180,26 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().container_destroys)
}
/// The Lua-set **displayed-cursor override** (`Show*SellCursor`/`ShowInspectCursor`/
/// `ResetCursor`): `None` = show the base (world-classifier) mode, else the mode the FrameXML
/// cursor family armed. The app overlays it over the world classifier's Point — the 5875
/// mechanism (wow-re cursor-system.md §7: the displayed mode swaps only when the base mode is
/// Point; `ResetCursor` restores displayed = base).
/// The mode the last FrameXML cursor call armed — `None` after a `ResetCursor`. This is the
/// *value* of the most recent write; whether a write happened at all is
/// [`Self::take_cursor_write`], and that is what the app acts on (decision 1061).
pub fn ui_cursor(&self) -> Option<UiCursorMode> {
self.model_ref().ui_cursor
}
/// Drain the pending cursor write: `Some(mode)` = a `Show*Cursor` armed `mode`, `Some(None)` =
/// a `ResetCursor` asked for the base mode, `None` = **no FrameXML cursor call happened**, so
/// the sticky mode stands untouched.
///
/// The three-state return is the point. A UI element with no cursor handler must leave the
/// cursor exactly as it was — that is how an armed spell keeps its cast cursor while the mouse
/// crosses a spellbook button, and reading a two-state latch instead is what made it snap to
/// Point (decision 1061).
#[allow(clippy::option_option)]
pub fn take_cursor_write(&mut self) -> Option<Option<UiCursorMode>> {
let mut model = self.model_mut();
std::mem::take(&mut model.ui_cursor_dirty).then_some(model.ui_cursor)
}
}
/// A **displayed-cursor override** the FrameXML cursor family arms (wow-re cursor-system.md §7): the
@@ -204,6 +216,14 @@ pub enum UiCursorMode {
UnableBuy,
/// Inspect(7) — the magnifier the Ctrl-hover shows over an item (`ShowInspectCursor`).
Inspect,
/// Point(1) — `SetCursor("POINT_CURSOR")`, and what a `ResetCursor` resolves to.
Point,
/// Cast(2) — `SetCursor("CAST_CURSOR")`: the lit cast cursor a unit frame shows while a spell
/// that CAN bind that unit is armed (`UnitFrame_OnEnter`).
Cast,
/// UnableCast(22) — `SetCursor("CAST_ERROR_CURSOR")`: the greyed twin, for a unit the armed
/// spell cannot bind, and for `UnitFrame_OnLeave` while still targeting.
CastError,
}
/// The pick/place/swap gesture behind `PickupContainerItem` (decision 0216 §2, amended by 0218;
@@ -512,8 +532,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ShowContainerSellCursor(bag, slot) — arm the pouch cursor for a sellable hover (5875
// `0x4fa460`, wow-re cursor-system.md §7: Buy(3) only when the slot actually holds an item —
// an empty slot leaves the cursor unchanged; no Unable twin, no SellPrice check — selling
// never grays). The spell-targeting and base-mode-Point gates live app-side where those states
// are known.
// never grays), and the reference's own `IsTargeting` bail at its first instruction.
lua.globals().set(
"ShowContainerSellCursor",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
@@ -523,8 +542,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.is_some();
if occupied {
// `IsTargeting` bails at the function's FIRST instruction — an armed spell suppresses
// the sell cursor outright (wow-re `item-target-cursor-and-dropitemonunit.md`). Now
// that the mode is sticky, this gate has to be here rather than app-side: a write of
// Buy would otherwise stamp over the cast cursor and there would be no per-frame world
// write to put it back.
if occupied && !model.spell_targeting {
model.ui_cursor = Some(UiCursorMode::Buy);
model.ui_cursor_dirty = true;
}
Ok(())
})?,
@@ -536,9 +561,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"ShowInspectCursor",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.ui_cursor = Some(UiCursorMode::Inspect);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = Some(UiCursorMode::Inspect);
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
@@ -558,6 +583,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
// SetCursor(name) — the reference's `0x489490`: a name->mode table (POINT/CAST/BUY/ATTACK =
// 1..4, each with an `*_ERROR` twin at +20) into `CursorSetMode`, falling back to a custom
// bitmap for an unknown name. Only the modes FrameXML actually names are mapped here; anything
// else is ignored rather than guessed, and a **no-arg call is a `ResetCursor`**, which is the
// reference's own documented shape.
//
// Its one shipped caller is the unit-frame hover pair (`UnitFrame_OnEnter`/`OnLeave`), which is
// the ONLY lit/grey cursor split over a UI element in 1.12 — and it is authored in Lua, not in
// C++ (wow-re `item-target-cursor-and-dropitemonunit.md` §4.3).
lua.globals().set(
"SetCursor",
lua.create_function(|lua, name: Option<String>| {
let mode = match name.as_deref() {
None => None, // no-arg == ResetCursor
Some("POINT_CURSOR") => Some(UiCursorMode::Point),
Some("CAST_CURSOR") => Some(UiCursorMode::Cast),
Some("CAST_ERROR_CURSOR") => Some(UiCursorMode::CastError),
Some("BUY_CURSOR") => Some(UiCursorMode::Buy),
Some("BUY_ERROR_CURSOR") => Some(UiCursorMode::UnableBuy),
// An unmapped name: the reference would load a custom bitmap. We have no such art,
// and silently painting the wrong stock cursor would be worse than leaving the
// sticky mode alone, so this is a no-op rather than a guess.
Some(_) => return Ok(()),
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = mode;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
// ResetCursor — displayed mode back to the base (the world classifier's) mode (5875
// `0x48ac70` → `0x523d30`): clear the whole override, whichever `Show*Cursor` armed it.
lua.globals().set(
@@ -565,6 +620,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = None;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
+6
View File
@@ -426,6 +426,12 @@ impl super::UiScript {
self.model_mut().item_pick_armed = armed;
}
/// Push what `SpellCanTargetUnit` answers — whether the standing targeting word could bind a
/// unit at all (see [`crate::script::Model::spell_can_target_unit`]).
pub fn set_spell_can_target_unit(&mut self, can: bool) {
self.model_mut().spell_can_target_unit = can;
}
/// Drain the `(bag, slot)` clicks the armed item half consumed since the last call. A doll
/// click reports as [`EQUIPMENT_BAG`] + its 1-based inventory slot — the ONE bag space, so
/// the app resolves both seams with one lookup.
+113
View File
@@ -0,0 +1,113 @@
//! The **dressing room** surface (decision 1060) — the intent queue behind the ref's
//! `DressUpModel:SetUnit`/`:TryOn`/`:Dress`, plus the pane's bake yaw.
//!
//! ## Why these are benilla-named, not the live API's
//!
//! In 1.12 the dressing room's model is a `<DressUpModel>` **widget** and the verbs are *methods on
//! it* (`DressUpFrame.lua:2-16`: `DressUpModel:SetUnit("player")` then `DressUpModel:TryOn(item)`;
//! the Reset button calls `DressUpModel:Dress()`). benilla has no live 3D model widget — every
//! model pane is a booth bake sampled by a plain `<Frame>` (the settled doctrine, 0105/0118, and
//! what `BenillaSetBoothTexture` exists for) — so the *widget methods* have no home. They become
//! four benilla globals over this queue, exactly as the paper doll's rotation did
//! (`BenillaPaperDollModel_SetFacing`). The window's own Lua keeps the reference's `DressUpItem` /
//! `DressUpItemLink` entry points, which is what every ctrl-click site actually calls.
//!
//! ## The seam
//!
//! - **Intents:** an ordered queue, not a set of flags — `DressUpItem` resets *then* tries on in
//! one breath when the window was closed (ref `DressUpFrame.lua:3-7`), and applying those two out
//! of order would show the player's own gear instead of the item they clicked.
//! - **State lives app-side:** the VM holds no item ids and no equipment. The app owns the
//! substitution set, resolves each item id to a display through the ask-once template cache, and
//! composes the booth's look ([`crate::script::UiScript::take_dressup_intents`]).
//! - **Yaw:** [`UiScript::dressup_yaw`], the exact twin of `paperdoll_yaw` / `inspect_yaw`.
use mlua::Lua;
use super::Model;
/// One queued dressing-room intent (see the module doc on ordering).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DressUpIntent {
/// `DressUpModel:SetUnit("player")` (the open) and `DressUpModel:Dress()` (the Reset button) —
/// both mean "wear what the player is actually wearing", dropping every substitution. One verb
/// because they are one effect here: our model is always the player's.
Dress,
/// `DressUpModel:TryOn(item)` — substitute this item id into whichever slot its
/// `InventoryType` belongs to.
TryOn(u32),
/// The window was hidden — there is nothing to show, so the booth empties (and stops
/// rendering). The reference's widget keeps its state while hidden, but its next `DressUpItem`
/// re-issues `SetUnit("player")` precisely *because* the frame was not visible, so the state it
/// kept is never observable — dropping it here is behaviour-identical and saves the bake.
Close,
}
impl super::UiScript {
/// Drain the dressing room's queued intents, oldest first — the app applies them in order.
pub fn take_dressup_intents(&mut self) -> Vec<DressUpIntent> {
std::mem::take(&mut self.model_mut().dressup_intents)
}
/// The dressing-room pane's bake yaw in radians (the ref's `Model:SetRotation`), read by the
/// app onto the `"dressup"` booth slot each frame — the twin of [`Self::paperdoll_yaw`].
pub fn dressup_yaw(&self) -> f32 {
self.model_ref().dressup_yaw
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
let queue = |lua: &Lua, intent: DressUpIntent| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.dressup_intents
.push(intent);
};
// BenillaDressUpModel_Dress() — the ref's `DressUpModel:SetUnit("player")` (DressUpFrame.lua:5)
// and `:Dress()` (the Reset button, DressUpFrame.xml:180).
g.set(
"BenillaDressUpModel_Dress",
lua.create_function(move |lua, ()| {
queue(lua, DressUpIntent::Dress);
Ok(())
})?,
)?;
// BenillaDressUpModel_TryOn(itemId) — the ref's `DressUpModel:TryOn(item)` (DressUpFrame.lua:8),
// whose argument is the bare item id `DressUpItemLink` pulled out of the `|Hitem:<id>:…` link.
g.set(
"BenillaDressUpModel_TryOn",
lua.create_function(move |lua, item: Option<u32>| {
if let Some(item) = item.filter(|i| *i != 0) {
queue(lua, DressUpIntent::TryOn(item));
}
Ok(())
})?,
)?;
// BenillaDressUpModel_Close() — the window's OnHide (see [`DressUpIntent::Close`]).
g.set(
"BenillaDressUpModel_Close",
lua.create_function(move |lua, ()| {
queue(lua, DressUpIntent::Close);
Ok(())
})?,
)?;
// BenillaDressUpModel_SetFacing(radians) — the pane's bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` / `BenillaInspectModel_SetFacing`.
g.set(
"BenillaDressUpModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.dressup_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
+53 -5
View File
@@ -11,9 +11,11 @@
//! 1.12's loot API is a flat set of globals the FrameXML `LootFrame.lua` drives (VERIFIED against the
//! extracted `LootFrame.lua`, scratchpad): `GetNumLootItems()`, `GetLootSlotInfo(slot)` →
//! `texture, item, quantity, quality` (`LootFrame.lua:81`), `LootSlotIsItem(slot)` /
//! `LootSlotIsCoin(slot)` (`LootFrame.lua:80`), `LootSlot(slot)` (the C `LootButton` behaviour, the
//! action a row click performs — `LootFrame.lua:94`), and `CloseLoot()` (fired from `OnHide`,
//! `LootFrame.lua:143-145`). `slot` is **1-based**; an out-of-range slot answers `nil`.
//! `LootSlotIsCoin(slot)` (`LootFrame.lua:80`), `GetLootSlotLink(slot)` → the row's item link (what
//! the row click's ctrl/shift arms read, `LootFrame.lua:149`/`:152` — decision 1059),
//! `LootSlot(slot)` (the C `LootButton` behaviour, the action a row click performs —
//! `LootFrame.lua:94`), and `CloseLoot()` (fired from `OnHide`, `LootFrame.lua:143-145`).
//! `slot` is **1-based**; an out-of-range slot answers `nil`.
//!
//! The **coin pile is a synthesized client-side row** (first in the list when the loot carries gold):
//! `LootSlotIsCoin` is true for it, its `item` text is the formatted money amount, and `LootSlot(1)`
@@ -48,6 +50,14 @@ pub struct LootRow {
/// row. A benilla extension riding as a TRAILING return of `GetLootSlotInfo` (the era 4-tuple
/// never carried it; tooltip content was C++'s alone), same idiom as the quest item getters.
pub item_id: u32,
/// The row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetLootSlotLink`, decision 1059).
/// `None` for the synthesized coin row (there is no item to link) and while the ask-once
/// template answer is in flight — the link embeds the name, so it cannot exist before `name`
/// does; the same `Option` shape [`super::char_stats::InvSlotView::link`] carries. Both arms of
/// the row click take a nil in stride: `DressUpItemLink` returns on one (its own guard,
/// `DressUpFrame.lua:10-16`) and the shift arm goes through `BenillaChatEdit_InsertLink`, which
/// drops it — our `EditBox:Insert` binding is typed `String` and would raise.
pub link: Option<String>,
}
/// One open loot window: its rows (coin first when present). Pushed whole by the app; `None` means no
@@ -129,6 +139,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetLootSlotLink(slot) → the row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link | nil. 1-based
// like GetLootSlotInfo beside it; nil out of range, nil for the coin row, and nil while the
// item-template query is in flight (the link embeds the name). The reference's row click reads
// it for BOTH modifier arms — `DressUpItemLink(GetLootSlotLink(this.slot))` (`LootFrame.lua:149`)
// and `ChatFrameEditBox:Insert(GetLootSlotLink(this.slot))` (`:152`); ours routes the second
// through `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer.
// Decision 1059.
g.set(
"GetLootSlotLink",
lua.create_function(|lua, slot: usize| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.loot
.as_ref()
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
.and_then(|r| r.link.clone())
};
match link {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// LootSlotIsItem(slot) → true for a real item row (in range, not the coin pile).
g.set(
"LootSlotIsItem",
@@ -186,7 +221,7 @@ mod tests {
fn loot() -> LootState {
LootState {
rows: vec![
// The coin pile, always first.
// The coin pile, always first. No link: there is no item to link (decision 1059).
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
@@ -194,8 +229,9 @@ mod tests {
quantity: 1,
quality: Some(1),
is_coin: true,
link: None,
},
// A resolved item.
// A resolved item — name, quality AND link all landed together (one template answer).
LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
@@ -203,6 +239,7 @@ mod tests {
quantity: 3,
quality: Some(1),
is_coin: false,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r".into()),
},
// An in-flight item: the loot arrived, the item-template answer hasn't.
LootRow {
@@ -212,6 +249,7 @@ mod tests {
quantity: 1,
quality: None,
is_coin: false,
link: None,
},
],
}
@@ -253,8 +291,18 @@ mod tests {
)
.unwrap());
// GetLootSlotLink: the resolved item's link, nil for the coin row and for the in-flight one
// (both arms of the reference's row click hand this straight on — decision 1059).
assert_eq!(
s.eval::<String>("return GetLootSlotLink(2)").unwrap(),
"|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r"
);
assert!(s.eval::<bool>("return GetLootSlotLink(1) == nil").unwrap());
assert!(s.eval::<bool>("return GetLootSlotLink(3) == nil").unwrap());
// Out of range → nil.
assert!(s.eval::<bool>("return GetLootSlotInfo(9) == nil").unwrap());
assert!(s.eval::<bool>("return GetLootSlotLink(9) == nil").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsItem(9)").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsCoin(9)").unwrap());
}
+44 -1
View File
@@ -17,6 +17,8 @@
//! (`LootFrame.lua:261`) — `bindOnPickUp` swaps the frame to the gold BoP backdrop.
//! - `GetLootRollTimeLeft(rollID)` → the milliseconds left, polled from the Timer's `OnUpdate`
//! (`LootFrame.lua:287-296`).
//! - `GetLootRollItemLink(rollID)` → the rolled item's link, read by the icon button's ctrl/shift
//! arms (`LootFrame.xml:353-361` — decision 1059).
//! - `RollOnLoot(rollID, rollType)` — `0` Pass, `1` Need, `2` Greed, wired to the frame's
//! PassButton/RollButton/GreedButton `OnClick` (`LootFrame.xml:375`/`:398`/`:425`).
//! - `CANCEL_LOOT_ROLL` fires with `arg1 = rollID`; the matching frame hides (`LootFrame.lua:279-285`).
@@ -58,6 +60,13 @@ pub struct LootRollEntry {
/// extension riding as a TRAILING return of `GetLootRollItemInfo`, the same idiom
/// [`super::loot::LootRow::item_id`] uses on `GetLootSlotInfo`.
pub item_id: u32,
/// The rolled item's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetLootRollItemLink`,
/// decision 1059) — what the icon button's ctrl/shift arms hand to `DressUpItemLink` /
/// `ChatFrameEditBox:Insert` (`LootFrame.xml:353-361`). `None` while the item-template query is
/// in flight: the link embeds the name, so it lands with `name`/`quality`, not before. A roll
/// popup shows the instant `START_LOOT_ROLL` fires, so this nil is the common case for the first
/// frames of every roll — both click arms drop it rather than posting an empty link.
pub link: Option<String>,
}
/// Every group loot roll currently open, in the order the app opened them. Pushed whole each frame.
@@ -117,6 +126,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetLootRollItemLink(rollID) → the rolled item's full escaped link | nil. Unknown id → nil, and
// nil while the item template is in flight (the link embeds the name). The reference's icon
// button reads it for both modifier arms — `DressUpItemLink(GetLootRollItemLink(...))` and
// `ChatFrameEditBox:Insert(...)`, `LootFrame.xml:353-361`; ours routes the second through
// `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer. Decision 1059.
g.set(
"GetLootRollItemLink",
lua.create_function(|lua, roll_id: u32| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
find(&model, roll_id).and_then(|r| r.link.clone())
};
match link {
Some(link) => Ok(mlua::Value::String(lua.create_string(&link)?)),
None => Ok(mlua::Value::Nil),
}
})?,
)?;
// GetLootRollTimeLeft(rollID) → milliseconds remaining; 0 for an unknown id (the reference
// OnUpdate clamps anything outside the bar's range to its minimum anyway, LootFrame.lua:290-293).
g.set(
@@ -182,7 +210,7 @@ mod tests {
fn rolls() -> LootRollsState {
LootRollsState {
rolls: vec![
// A resolved BoP item.
// A resolved BoP item — name, quality and link land together (one template answer).
LootRollEntry {
roll_id: 7,
name: Some("Staff of Jordan".into()),
@@ -192,6 +220,7 @@ mod tests {
bind_on_pickup: true,
time_left_ms: 42_000,
item_id: 17182,
link: Some("|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r".into()),
},
// An in-flight item: the roll opened, the item-template answer hasn't landed.
LootRollEntry {
@@ -203,6 +232,7 @@ mod tests {
bind_on_pickup: false,
time_left_ms: 60_000,
item_id: 4306,
link: None,
},
],
}
@@ -245,10 +275,23 @@ mod tests {
)
.unwrap());
// GetLootRollItemLink: the resolved roll's link; nil while the template is in flight (the
// icon button's ctrl/shift arms hand this straight on — decision 1059).
assert_eq!(
s.eval::<String>("return GetLootRollItemLink(7)").unwrap(),
"|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r"
);
assert!(s
.eval::<bool>("return GetLootRollItemLink(8) == nil")
.unwrap());
// An unknown id → nil / 0, never an error.
assert!(s
.eval::<bool>("return GetLootRollItemInfo(99) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetLootRollItemLink(99) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetLootRollTimeLeft(99)").unwrap(), 0);
}
+53
View File
@@ -52,6 +52,13 @@ pub struct MerchantItem {
/// (`None` while it's in flight — the tooltip shows nothing it can't know yet). Still plain
/// data: the engine renders whatever numbers the app resolved, it holds no item model.
pub stats: Option<ItemStatsHead>,
/// The row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetMerchantItemLink`, decision
/// 1059) — what the row click's ctrl/shift arms hand to `DressUpItemLink` /
/// `ChatFrameEditBox:Insert` (`MerchantFrame.lua:303`/`:306`). `None` while the template answer
/// is in flight (the link embeds the name), and `None` on a **buyback** row: 1.12 has no
/// `GetBuybackItemLink` — the reference's buyback arm is an unmodified `BuybackItem(this:GetID())`
/// with no ctrl/shift branch at all (`MerchantFrame.lua:358-361`), so nothing would read it.
pub link: Option<String>,
}
/// An item template's tooltip stat head (the app's `ItemInfo` view, resolved per row): what the
@@ -227,6 +234,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetMerchantItemLink(index) → the row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link | nil.
// 1-based like GetMerchantItemInfo; nil out of range and nil while the row's template answer is
// in flight (the link embeds the name). The reference's row click reads it for both LEFT-button
// modifier arms — `DressUpItemLink(GetMerchantItemLink(this:GetID()))` (`MerchantFrame.lua:303`)
// and `ChatFrameEditBox:Insert(...)` (`:306`); ours routes the second through
// `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer. Merchant rows
// only: see [`MerchantItem::link`] for why a buyback row carries none. Decision 1059.
g.set(
"GetMerchantItemLink",
lua.create_function(|lua, index: usize| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.merchant
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
.and_then(|it| it.link.clone())
};
match link {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// BenillaGetMerchantItemStats(index) → quality, invType, class, subclass, dmgMin, dmgMax,
// dmgType, delayMs, armor, block — the tooltip stat head ([`ItemStatsHead`]), or nil while
// the row's template answer is in flight / the index is out of range. Benilla-named: 1.12's Lua
@@ -479,6 +511,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
/// without a `CursorSetMode` (wow-re cursor-system.md §7).
fn arm_vendor_cursor(lua: &Lua, price_of: impl FnOnce(&MerchantState) -> Option<u32>) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The sell-cursor family bails on `IsTargeting` at its first instruction (wow-re
// `item-target-cursor-and-dropitemonunit.md`) — an armed spell keeps its cast cursor.
if model.spell_targeting {
return;
}
let Some(price) = model.merchant.as_ref().and_then(price_of) else {
return;
};
@@ -487,6 +524,7 @@ fn arm_vendor_cursor(lua: &Lua, price_of: impl FnOnce(&MerchantState) -> Option<
} else {
UiCursorMode::UnableBuy
});
model.ui_cursor_dirty = true;
}
#[cfg(test)]
@@ -509,6 +547,7 @@ mod tests {
quality: 1,
..Default::default()
}),
link: Some("|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r".into()),
},
// An in-flight row: the vendor list arrived, the item-template answer hasn't.
MerchantItem {
@@ -519,6 +558,7 @@ mod tests {
num_available: 3,
item_id: 4540,
stats: None,
link: None,
},
],
..Default::default()
@@ -560,6 +600,19 @@ mod tests {
return n == nil and t == nil and p == 0 and q == 1 and a == 0 and u == 1",
)
.unwrap());
// GetMerchantItemLink: the resolved row's link; nil while the template is in flight and nil
// out of range (the row click's ctrl/shift arms hand this straight on — decision 1059).
assert_eq!(
s.eval::<String>("return GetMerchantItemLink(1)").unwrap(),
"|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r"
);
assert!(s
.eval::<bool>("return GetMerchantItemLink(2) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetMerchantItemLink(9) == nil")
.unwrap());
}
/// The isUsable leg end-to-end: the gate reads the shared template store + the player req
+5 -2
View File
@@ -51,6 +51,7 @@ mod death;
mod editbox;
pub(crate) use editbox::adopt_text_region;
mod cvars;
mod dressup;
mod duel;
mod event;
mod extract;
@@ -108,8 +109,8 @@ pub use aura::{AuraState, TrackingState};
pub use backdrop::{pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use char_stats::{
weapon_subclass_skill, InvSlotView, InventorySlots, PlayerCombatStats, INVENTORY_SLOT_COUNT,
SKILL_UNARMED,
weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState};
@@ -118,6 +119,7 @@ pub use cursor::{
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
@@ -296,6 +298,7 @@ impl UiScript {
taxi::install(&lua)?;
trade::install(&lua)?;
inspect::install(&lua)?;
dressup::install(&lua)?;
tradeskill::install(&lua)?;
craft::install(&lua)?;
skills::install(&lua)?;
+63 -3
View File
@@ -155,6 +155,13 @@ pub(crate) struct Model {
/// (`ToggleGameMenu`'s order: the clear runs only when nothing earlier ate the press).
/// Drained by [`super::UiScript::take_target_clear`]; the app commits the deselect ([`unit`]).
pub(crate) target_clear: bool,
/// Unit tokens `DropItemOnUnit` queued since the app's last
/// [`UiScript::take_drop_item_on_unit`] drain — the cursor's held item being dropped onto a
/// unit (`0x48d960`). The binding queues the token only; **every gate is the app's**, because
/// all of them read state the VM does not hold (the pet's owner fields, the learned Feed-Pet
/// spell, the held item's guid). A token the app refuses is silent and keeps the payload,
/// which is the reference's own behaviour ([`super::cursor`]).
pub(crate) drop_item_on_unit: Vec<String>,
/// The party/raid roster snapshot the app pushes (`GroupState`'s merged view, decision 0434
/// §2) — `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/… read it ([`party`]).
@@ -285,6 +292,14 @@ pub(crate) struct Model {
/// `SpellStopTargeting()`, whose 1/nil return the ESC chain's rung (`UIParent.lua:1490`)
/// falls through on, exactly like [`Self::casting`]'s.
pub(crate) spell_targeting: bool,
/// Whether the standing targeting word could bind a UNIT — what `SpellCanTargetUnit` answers
/// (`0x6e6d00` → `0x6e6460`'s unit leg). Pushed by the app beside `spell_targeting`.
///
/// Always `false` today, and *derived* rather than hardcoded so it stops being false the moment
/// that stops being true: benilla's targeting cursor models the location / item / gameobject
/// words (0792/0923/0939), and no unit satisfies any of them — a unit-target spell never enters
/// targeting mode at all, it resolves to `CastWireTarget::Unit` or refuses.
pub(crate) spell_can_target_unit: bool,
/// Set when `SpellStopTargeting()` fired while [`Self::spell_targeting`] — the ESC-chain
/// targeting cancel, drained by [`super::UiScript::take_stop_targeting`] ([`spellbook`]).
pub(crate) spell_stop_targeting: bool,
@@ -323,6 +338,15 @@ pub(crate) struct Model {
/// two-pair form **by body size**, so a relocation and its write must travel together and must
/// not be flattened into a stream of singles.
pub(crate) pet_set_actions: Vec<Vec<(u32, u32)>>,
/// `PetAbandon()` and `PetDismiss()` calls queued — two counts, not one, even though both menu
/// rows end at the same opcode (decision 1066). They are two *bindings*, with two Lua names and
/// two menu rows the reference shows to different classes, so the seam keeps them apart and
/// lets the app decide each one's wire; folding them together here would bake a wire fact into
/// an engine that is supposed to hold none.
pub(crate) pet_abandons: u32,
pub(crate) pet_dismisses: u32,
/// Names `PetRename(name)` queued, in order — the `PETRENAMECONFIRM` popup's payload.
pub(crate) pet_renames: Vec<String>,
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
@@ -392,6 +416,17 @@ pub(crate) struct Model {
/// [`merchant`]) — the single "displayed mode" the real client keeps at `0xbe2c2c`, restored to
/// the base (world-classifier) mode by `ResetCursor`. `None` = no override (show the base mode).
pub(crate) ui_cursor: Option<container::UiCursorMode>,
/// Set by every FrameXML cursor call — `Show*SellCursor` / `ShowInspectCursor` / `SetCursor` /
/// `ResetCursor` — and drained by the app each frame ([`UiScript::take_cursor_write`]).
///
/// **The cursor mode is a WRITE, not a level** (decision 1061), and that distinction is the
/// whole of B208's regression. The reference keeps one sticky global (`0xbe2c2c`): the world
/// classifier writes it while the pointer is over the world, FrameXML writes it from a hover
/// handler, and in between **nothing** writes it — so the last value simply stands. Reading
/// `ui_cursor` as a level made "no override" mean "show the base", which turned every UI
/// element with no cursor handler at all (a spellbook button, a panel) into a forced Point and
/// killed the armed cast cursor the moment the mouse left the world.
pub(crate) ui_cursor_dirty: bool,
/// `(bag, slot)` sources queued by `AutoEquipCursorItem` (decision 0208 phase 1b, `cursor`'s
/// `doll` submodule) — drained by the app into `CMSG_AUTOEQUIP_ITEM`.
pub(crate) container_autoequips: Vec<(i64, u32)>,
@@ -666,9 +701,15 @@ pub(crate) struct Model {
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
/// seam ([`char_stats`], decision 0208 §3). All player-only data: the backing descriptor
/// fields are PRIVATE/OWNER_ONLY.
pub(crate) player_combat_stats: Option<char_stats::PlayerCombatStats>,
/// seam ([`char_stats`], decision 0208 §3).
pub(crate) player_combat_stats: Option<char_stats::UnitCombatStats>,
/// The **pet's** combat-stats snapshot (decision 1057) — the same shape under the `"pet"`
/// token, because the reference's own pet sheet calls the very same `UnitStat`/`UnitResistance`/
/// `PaperDollFrame_Set*(unit, prefix)` family with `unit = "pet"` (ref
/// `PetPaperDollFrame.lua:73-81`). A second slot rather than a token map: exactly two units
/// ever have one, they are fed by two different systems on two different clocks, and every
/// binding's route is a fork between them (the [`Self::pet_book`] precedent).
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
pub(crate) inventory_slots: char_stats::InventorySlots,
/// The 12 alert-region statuses (`GetInventoryAlertStatus`, DurabilityFrame's armor-guy
/// feed) in the client's own `0x806eb8` table order (11 equipment regions + the low-ammo
@@ -691,6 +732,15 @@ pub(crate) struct Model {
pub(crate) inspect_clear: bool,
/// The inspect model pane's bake yaw, the twin of [`Self::paperdoll_yaw`].
pub(crate) inspect_yaw: f32,
/// The **pet** paper doll's model-pane bake yaw (decision 1057) — a third scalar for the same
/// reason the inspect pane got a second: character tab 1 and tab 2 are two panes that can sit
/// at two different facings, and the ref carries a `rotation` per `<PlayerModel>`.
pub(crate) pet_paperdoll_yaw: f32,
/// The dressing room's queued intents (decision 1060) — `BenillaDressUpModel_Dress/TryOn/Close`,
/// drained by the app in order (see [`super::dressup`] on why order matters).
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
/// The dressing-room pane's bake yaw, the fourth of those scalars.
pub(crate) dressup_yaw: f32,
/// Unit token → squared distance from the player, for every popup token the app resolved to a
/// live inspectable player this frame — the input to both verified range predicates
/// (`CanInspect`, `CheckInteractDistance`). Absent token = in range (see
@@ -779,6 +829,7 @@ impl Model {
cancel_aura_requests: Vec::new(),
tracking: None,
target_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
party: party::PartyState::default(),
party_requests: Vec::new(),
@@ -812,6 +863,7 @@ impl Model {
casting: false,
spell_stop: false,
spell_targeting: false,
spell_can_target_unit: false,
spell_stop_targeting: false,
talents: super::talent::TalentUiState::default(),
talent_learns: Vec::new(),
@@ -822,6 +874,9 @@ impl Model {
pet_autocast_toggles: Vec::new(),
pet_stop_attacks: 0,
pet_set_actions: Vec::new(),
pet_abandons: 0,
pet_dismisses: 0,
pet_renames: Vec::new(),
containers: HashMap::new(),
container_uses: Vec::new(),
container_cooldowns: HashMap::new(),
@@ -837,6 +892,7 @@ impl Model {
enchant_confirms: Vec::new(),
container_destroys: Vec::new(),
ui_cursor: None,
ui_cursor_dirty: false,
container_autoequips: Vec::new(),
drag_registered: HashMap::new(),
drag: None,
@@ -938,6 +994,7 @@ impl Model {
combo_points: 0,
combo_target: 0,
player_combat_stats: None,
pet_combat_stats: None,
inventory_slots: Default::default(),
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
@@ -946,6 +1003,9 @@ impl Model {
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
pet_paperdoll_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_yaw: 0.0,
inspect_reach: HashMap::new(),
chat_input: Vec::new(),
skills: skills::SkillsState::default(),
+428 -32
View File
@@ -15,6 +15,11 @@
//! Return conventions are the 1.12 API's own, matching [`super::action`]: 1/nil booleans, and the
//! cooldown as `(start_s on the GetTime clock, duration_s, enable)` with the same
//! elapsed-goes-cold rule `GetActionCooldown` uses.
//!
//! Two later families joined the bar's eight through the same two-way seam, because they are about
//! the same unit and move on the same push: the **hunter stat block** ([`PetStats`], decision 1005)
//! that the pet paper doll and the happiness icon read, and the **right-click menu**
//! (`PetCanBeAbandoned`/`PetCanBeRenamed`/`PetAbandon`/`PetDismiss`/`PetRename`, decision 1066).
use mlua::{Lua, MultiValue, Value};
@@ -79,13 +84,19 @@ pub(crate) struct StoredPetAction {
}
/// The hunter-pet stat block behind `GetPetHappiness`/`GetPetLoyalty`/`GetPetTrainingPoints`/
/// `GetPetExperience` and `HasPetUI`'s second return (decision 1005; wow-re §11b).
/// `GetPetExperience` and `HasPetUI`'s second return (decision 1005; wow-re §11b), plus the two
/// **family**-derived answers `UnitCreatureFamily("pet")` and `GetPetFoodTypes()` (decision 1062).
///
/// **All four bindings share one gate** — `0x6116e0(pet)`, "is this a hunter's pet" — which is why
/// they share one pushed struct: a warlock's imp resolves perfectly well and still answers nothing,
/// because happiness, loyalty and training points are hunter machinery.
/// **The four stat bindings share one gate** — `0x6116e0(pet)`, "is this a hunter's pet" — which is
/// why they share one pushed struct: a warlock's imp resolves perfectly well and still answers
/// nothing, because happiness, loyalty and training points are hunter machinery.
///
/// **Their failure conventions differ, and the difference is the API.** `GetPetLoyalty` fails to
/// **The two family fields sit on OPPOSITE sides of that gate, and the split is carved.**
/// [`Self::family`] is outside it — `UnitCreatureFamily 0x51a310` has no class test, so a warlock
/// minion shows "Imp" and gating it would blank a line the reference fills. [`Self::food_types`] is
/// inside it — `GetPetFoodTypes 0x4bea10` shares `0x6116e0` with the four stats.
///
/// **The stat failure conventions differ, and the difference is the API.** `GetPetLoyalty` fails to
/// **nil**; the two pairs fail to **`(0, 0)` — numbers, not nil**; `GetPetHappiness` fails to
/// **`(nil, 100.0, 0.0)`**, nil in the first slot and numbers in the other two. `PetFrame.lua`
/// hides the happiness icon on `not happiness` alone, so collapsing any of these into a uniform
@@ -109,6 +120,27 @@ pub struct PetStats {
pub training_points: (u16, u16),
/// `GetPetExperience` → `(currXP, nextXP)`.
pub experience: (u32, u32),
/// `UnitCreatureFamily("pet")` — the localized `CreatureFamily.dbc` word ("Imp", "Wind
/// Serpent"), read from the cached creature-query record (`0x51a310`: `[[unit+0xb30]+0x1c]` →
/// column `8 + locale`). Exactly **one** return on every path.
///
/// `None` is the binding's **nil**, and the reference reaches it four ways that all mean "no
/// word to print": no cached record yet (the creature query is still in flight), family id `0`,
/// an id past the table, and — the one nobody guesses — **a null row in the middle of the id
/// space**: 10/13/14/18/22 have no row in the shipped file, so this is a lookup miss, never a
/// bounds check. The page guards its whole level-line `SetText` on this
/// (ref `PetPaperDollFrame.lua:68-70`), so nil renders no line rather than a half one.
pub family: Option<String>,
/// `GetPetFoodTypes()` — the localized diet names the family's pet-food mask selects, in
/// **record order** (`0x4bea10`: bit `1 << (recordID - 1)` against `CreatureFamily` column 7,
/// the name from `ItemPetFood` column `1 + locale`). Varargs; the client returns the count and
/// **never a nil**, so empty is zero values rather than one nil.
///
/// **Empty is a real answer**, and it has two independent causes: a family whose mask is `0`
/// (every warlock minion), and the binding's own `0x6116e0` gate — owner-is-me *and* the local
/// player is a Hunter — which a charmed beast under a non-hunter fails even though its family
/// row has a mask. The app applies that gate; this field is what it produced.
pub food_types: Vec<String>,
}
/// The pet bar's pushed state: the slots plus the two bar-wide bits the reference exposes
@@ -136,6 +168,16 @@ pub(crate) struct PetBarState {
///
/// It sits before the cursor fork, so it blocks the DROP as well as the pick-up.
pub(crate) pickup_allowed: bool,
/// `PetCanBeAbandoned()` — **the pet right-click menu's whole fork** (decision 1066).
///
/// Three of the PET menu's four rows show only when this is true (paperdoll, rename, abandon)
/// and the fourth — Dismiss — shows only when it is *false* (`UnitPopup.lua:402-417`). So it is
/// not "am I allowed to abandon"; it is "is this a pet I keep rather than a summon I called",
/// and it is what makes one menu read *Abandon* on a hunter's pet and *Dismiss* on a demon.
pub(crate) can_be_abandoned: bool,
/// `PetCanBeRenamed()` — an independent predicate, ANDed with the one above for the rename row
/// alone. One-shot server-side: a hunter pet carries it only until its first rename.
pub(crate) can_be_renamed: bool,
}
impl super::UiScript {
@@ -148,42 +190,50 @@ impl super::UiScript {
pickup_allowed: bool,
slots: Vec<PetActionView>,
) {
let mut model = self.model_mut();
model.pet_bar = PetBarState {
has_bar,
actions_usable,
pickup_allowed,
slots: slots
.into_iter()
.map(|view| {
// The cooldown arrives with its absolute start already on the `GetTime` clock
// (ms) — storing is a pure unit conversion, `set_shapeshift_forms`' seam.
let cooldown = view.cooldown.map(|(start_ms, duration_ms, enabled)| {
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
)
});
StoredPetAction { view, cooldown }
})
.collect(),
// The stat block rides its own setter: `SMSG_PET_SPELLS` replaces the bar wholesale,
// but happiness moves every few seconds off a plain descriptor field, so tying the two
// together would make the bar's diff-and-fire churn on a number no button draws.
has_ui: model.pet_bar.has_ui,
stats: std::mem::take(&mut model.pet_bar.stats),
};
// Field-by-field, never `pet_bar = PetBarState { .. }`: the struct also holds state that
// moves on OTHER clocks (the stat block, the menu predicates), and rebuilding it wholesale
// meant every one of those had to be hand-carried across this assignment or be silently
// reset once a frame. Assigning what this setter owns cannot forget them.
let bar = &mut self.model_mut().pet_bar;
bar.has_bar = has_bar;
bar.actions_usable = actions_usable;
bar.pickup_allowed = pickup_allowed;
bar.slots = slots
.into_iter()
.map(|view| {
// The cooldown arrives with its absolute start already on the `GetTime` clock
// (ms) — storing is a pure unit conversion, `set_shapeshift_forms`' seam.
let cooldown = view.cooldown.map(|(start_ms, duration_ms, enabled)| {
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
)
});
StoredPetAction { view, cooldown }
})
.collect();
}
/// Push the hunter-pet stat block ([`PetStats`]) — the four paper-doll bindings plus
/// `HasPetUI`. Separate from [`Self::set_pet_actions`] because it changes on a different clock.
/// `HasPetUI`. Separate from [`Self::set_pet_actions`] because it changes on a different clock:
/// `SMSG_PET_SPELLS` replaces the bar wholesale, but happiness moves every few seconds off a
/// plain descriptor field, so tying the two together would make the bar's diff-and-fire churn
/// on a number no button draws.
pub fn set_pet_stats(&mut self, has_ui: bool, stats: PetStats) {
let bar = &mut self.model_mut().pet_bar;
bar.has_ui = has_ui;
bar.stats = stats;
}
/// Push the right-click menu's two predicates (decision 1066) — a third clock again, the pet's
/// own `UNIT_FIELD_FLAGS`, which the rename's one-shot bit moves independently of both.
pub fn set_pet_menu(&mut self, can_be_abandoned: bool, can_be_renamed: bool) {
let bar = &mut self.model_mut().pet_bar;
bar.can_be_abandoned = can_be_abandoned;
bar.can_be_renamed = can_be_renamed;
}
/// Drain the 1-based slot indices `CastPetAction` queued since the last call. What each index
/// *means* on the wire (a command, a reaction, a cast) is the app's to decide at drain time
/// from the slot it still owns.
@@ -210,6 +260,22 @@ impl super::UiScript {
pub fn take_pet_set_actions(&mut self) -> Vec<Vec<(u32, u32)>> {
std::mem::take(&mut self.model_mut().pet_set_actions)
}
/// Drain the `PetAbandon()` and `PetDismiss()` calls queued, as `(abandons, dismisses)` —
/// counts, since neither verb carries an argument. Kept apart for the reason on the model's
/// fields: two bindings, not one, whatever they end up sharing on the wire.
pub fn take_pet_gives_up(&mut self) -> (u32, u32) {
let m = &mut *self.model_mut();
(
std::mem::replace(&mut m.pet_abandons, 0),
std::mem::replace(&mut m.pet_dismisses, 0),
)
}
/// Drain the names `PetRename(name)` queued, in order.
pub fn take_pet_renames(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().pet_renames)
}
}
/// The 1-based button index → stored slot, the reference's own indexing.
@@ -413,6 +479,64 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitCreatureFamily(unit) → the localized family word, or NIL. Exactly ONE return on every
// path (`0x51a310`, wow-re-VERIFIED) — decision 1062.
//
// **Scoped to the `"pet"` token, and that narrowing is stated rather than hidden.** The real
// binding resolves any unit and reads `[[unit+0xb30]+0x1c]` off its cached creature-query
// record, so `UnitCreatureFamily("target")` on a wild boar answers "Boar" there and nil here —
// INTERIM, exactly the shape `UnitDefense`'s non-player answer took in 1057. The pet page is
// the only consumer in the shipped FrameXML, and a pet is the one unit whose record we cannot
// reach through `guid::entry` (its guid slot holds a pet number, not a template id), so the pet
// feed resolves it explicitly and the other tokens wait for a second consumer — at which point
// this moves onto `UnitState` beside `creature_type_name`.
//
// Note there is NO class gate here: a warlock's imp answers "Imp". That is the carved shape,
// and it is what makes the family word and `GetPetFoodTypes` below behave differently for the
// same pet.
//
// All four of the reference's nil paths arrive as one pushed `None` — no cached record, id 0,
// id past the table, and a null row (ids 10/13/14/18/22 are absent from the shipped file). A
// missing or absent token is nil too, through the same match.
g.set(
"UnitCreatureFamily",
lua.create_function(move |lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let family = token
.filter(|t| t == "pet")
.and_then(|_| model.pet_bar.stats.family.as_deref());
Ok(match family {
Some(name) => Value::String(lua.create_string(name)?),
None => Value::Nil,
})
})?,
)?;
// GetPetFoodTypes() → the diet names as VARARGS, one Lua return per food type, in record
// order — the shape `BuildListString(GetPetFoodTypes())` needs (ref
// `PetPaperDollFrame.xml:269`). `0x4bea10` returns the pushed COUNT and never a nil, so the
// empty case is zero values.
//
// An empty diet returns NOTHING, not an empty string: the reference's `BuildListString` then
// reads `arg[1]` as nil and returns nil, which is the behaviour a single empty-string return
// would silently break. It is reachable two ways, both real — a family whose mask is 0 (every
// warlock minion), and the binding's own `0x6116e0` hunter gate, which the app applies before
// filling this list.
g.set(
"GetPetFoodTypes",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let values: Vec<Value> = model
.pet_bar
.stats
.food_types
.iter()
.map(|f| lua.create_string(f).map(Value::String))
.collect::<mlua::Result<_>>()?;
Ok(MultiValue::from_vec(values))
})?,
)?;
// PetStopAttack() — queue the call-off. No argument: the wire carries only the pet's guid.
g.set(
"PetStopAttack",
@@ -423,6 +547,71 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ── The right-click menu (decision 1066) ─────────────────────────────────────────────────
// Two predicates that decide what the PET menu SHOWS, and three verbs it can pick. The
// predicates are 1/nil like the rest of this file, which is all `UnitPopup.lua` needs — every
// one of its four uses is a bare `not PetCanBeAbandoned()` or an AND of the two.
// PetCanBeAbandoned() → 1/nil. Do not read this as "may I abandon": it forks the whole menu.
g.set(
"PetCanBeAbandoned",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.pet_bar.can_be_abandoned))
})?,
)?;
// PetCanBeRenamed() → 1/nil. Independent of the above; the rename row wants both.
g.set(
"PetCanBeRenamed",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.pet_bar.can_be_renamed))
})?,
)?;
// PetAbandon() — the ABANDON_PET popup's OnAccept, i.e. the confirmed permanent one.
g.set(
"PetAbandon",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pet_abandons += 1;
Ok(())
})?,
)?;
// PetDismiss() — the menu row itself, with NO confirm in front of it (`UnitPopup.lua:590`
// calls it directly, unlike abandon). Nothing is lost when a summon is sent away.
g.set(
"PetDismiss",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pet_dismisses += 1;
Ok(())
})?,
)?;
// PetRename(name) — the PETRENAMECONFIRM popup's OnAccept, carrying the text the RENAME_PET
// edit box collected.
//
// The argument reaches `lua_tostring`, so a NUMBER coerces (wow-re §11c) — `mlua::String`
// accepts one the same way. What the reference does with the RESULT is the app's business and
// is queued for it rather than dropped here: an empty name raises `ERR_NULL_PETNAME` and an
// over-long one is truncated, both at the send. Only a missing or unconvertible argument dies
// here, because there is nothing to queue.
g.set(
"PetRename",
lua.create_function(|lua, name: Option<mlua::String>| {
let Some(name) = name else {
return Ok(());
};
let name = name.to_str()?.to_string();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pet_renames.push(name);
Ok(())
})?,
)?;
Ok(())
}
@@ -590,6 +779,81 @@ mod tests {
.unwrap());
}
/// The right-click menu's two predicates and three verbs (decision 1066).
///
/// The predicate half is checked the way `UnitPopup.lua` actually reads them — as the four
/// row conditions — because that is the only thing they are for, and getting the Dismiss row's
/// **inverted** sense wrong would show a hunter both Abandon and Dismiss.
#[test]
fn the_menu_predicates_fork_the_rows_and_the_verbs_queue() {
let mut s = UiScript::new().unwrap();
// No pet pushed: every row is off, which is what keeps a menu of dead rows from opening.
assert!(s
.eval::<bool>("return PetCanBeAbandoned() == nil and PetCanBeRenamed() == nil")
.unwrap());
// A hunter's freshly tamed pet: abandon/rename/paperdoll show, dismiss hides.
s.set_pet_menu(true, true);
assert_eq!(s.eval::<i64>("return PetCanBeAbandoned()").unwrap(), 1);
assert!(
s.eval::<bool>("return PetCanBeAbandoned() and PetCanBeRenamed()")
.unwrap(),
"the rename row wants BOTH"
);
assert!(!s.eval::<bool>("return not PetCanBeAbandoned()").unwrap());
// The same pet after one rename — the bit is one-shot, so only the rename row goes.
s.set_pet_menu(true, false);
assert!(s.eval::<bool>("return PetCanBeAbandoned() ~= nil").unwrap());
assert!(s.eval::<bool>("return PetCanBeRenamed() == nil").unwrap());
// A warlock's demon: the fork flips whole. Dismiss is the row that shows.
s.set_pet_menu(false, false);
assert!(s.eval::<bool>("return not PetCanBeAbandoned()").unwrap());
// The verbs. Abandon and dismiss are counted apart; rename carries its text.
assert_eq!(s.take_pet_gives_up(), (0, 0));
s.run("PetAbandon() PetDismiss() PetDismiss()").unwrap();
assert_eq!(s.take_pet_gives_up(), (1, 2));
assert_eq!(s.take_pet_gives_up(), (0, 0), "drain empties");
// An empty name IS queued — the reference's empty check raises `ERR_NULL_PETNAME` at the
// send, so swallowing it here would swallow the error with it. A number coerces
// (`lua_tostring`); a missing argument has nothing to queue.
s.run("PetRename(\"Bruce\") PetRename(\"\") PetRename(7) PetRename()")
.unwrap();
assert_eq!(
s.take_pet_renames(),
vec!["Bruce".to_string(), String::new(), "7".to_string()]
);
assert!(s.take_pet_renames().is_empty(), "drain empties");
}
/// A bar push must not wipe the state that rides other clocks — the trap the old wholesale
/// `pet_bar = PetBarState { .. }` assignment set, and the reason [`UiScript::set_pet_actions`]
/// assigns field by field.
#[test]
fn pushing_the_bar_leaves_the_stats_and_the_menu_alone() {
let mut s = UiScript::new().unwrap();
s.set_pet_menu(true, true);
s.set_pet_stats(
true,
PetStats {
hunter_pet: true,
happiness: Some(3),
..Default::default()
},
);
s.set_pet_actions(true, true, true, slots());
assert_eq!(s.eval::<i64>("return PetCanBeAbandoned()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return PetCanBeRenamed()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return HasPetUI()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetPetHappiness()").unwrap(), 3);
}
/// A disabled bar still EXISTS — `PetHasActionBar` stays true while `GetPetActionsUsable`
/// goes false. The pair is what greys every icon without taking the bar off screen.
#[test]
@@ -611,6 +875,15 @@ mod tests {
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
family: Some("Boar".into()),
food_types: vec![
"Meat".into(),
"Fish".into(),
"Cheese".into(),
"Bread".into(),
"Fungus".into(),
"Fruit".into(),
],
}
}
@@ -720,4 +993,127 @@ mod tests {
.eval::<bool>("local h, dmg = GetPetHappiness() return h == nil and dmg == 100")
.unwrap());
}
/// **`UnitCreatureFamily`'s nil paths — all four of them** (decision 1062). The reference
/// guards its whole level-line `SetText` on this binding, so an accidental `""` in place of
/// nil would print a bare "Level 58 " with a trailing space instead of nothing at all.
#[test]
fn unit_creature_family_is_nil_on_every_absent_path() {
let mut s = UiScript::new().unwrap();
// 1. No pet at all — nothing has ever been pushed.
assert!(s
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
.unwrap());
// 2. A pet whose template carries family 0, and 3. a pet whose creature query has not
// answered yet. Both arrive here as the same pushed `None` (the app resolves which is
// which); what matters at this seam is that a live pet with no family word is nil and
// not an empty string.
s.set_pet_stats(
true,
PetStats {
family: None,
..hunter_stats()
},
);
assert!(s
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
.unwrap());
assert!(
s.eval::<bool>(r#"return UnitCreatureFamily("pet") ~= ''"#)
.unwrap(),
"nil, never an empty string — '' is TRUTHY in Lua, so it would pass the ref's guard \
and print a bare 'Level 58 ' with a trailing space"
);
// 4. Any other token: the INTERIM narrowing, stated in the binding's own comment.
s.set_pet_stats(true, hunter_stats());
assert_eq!(
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
.unwrap(),
"Boar"
);
for token in [r#""target""#, r#""player""#, "nil"] {
assert!(
s.eval::<bool>(&format!("return UnitCreatureFamily({token}) == nil"))
.unwrap(),
"{token} must answer nil"
);
}
}
/// `GetPetFoodTypes` returns **varargs**, one value per diet — the shape
/// `BuildListString(GetPetFoodTypes())` depends on. A single comma-joined string would read
/// identically in the tooltip and be wrong for every other caller.
#[test]
fn get_pet_food_types_returns_one_value_per_diet() {
let mut s = UiScript::new().unwrap();
// No pet: ZERO returns, which is what makes the ref's `BuildListString` answer nil.
assert_eq!(
s.eval::<i64>(r##"return select("#", GetPetFoodTypes())"##)
.unwrap(),
0
);
s.set_pet_stats(true, hunter_stats());
assert_eq!(
s.eval::<i64>(r##"return select("#", GetPetFoodTypes())"##)
.unwrap(),
6,
"a boar's six diets are six returns, not one string"
);
assert!(s
.eval::<bool>(
"local a, b, c = GetPetFoodTypes() \
return a == 'Meat' and b == 'Fish' and c == 'Cheese'"
)
.unwrap());
// An empty diet is a real answer (every warlock family ships a zero food mask) and is
// still zero returns, not one empty string.
s.set_pet_stats(
true,
PetStats {
food_types: vec![],
..hunter_stats()
},
);
assert_eq!(
s.eval::<i64>(r##"return select("#", GetPetFoodTypes())"##)
.unwrap(),
0
);
}
/// The family **word** answers regardless of the hunter gate — `UnitCreatureFamily 0x51a310`
/// has no class test at all, so a warlock's minion shows "Imp" on the page's level line while
/// every hunter-gated binding beside it says nothing.
///
/// (The **diet** is the other way round — it shares `0x6116e0` — but that gate is the app's to
/// apply, so at this seam it is simply an empty list. `ui_pet_stats` pins the gate itself.)
#[test]
fn the_family_word_answers_for_a_non_hunter_pet() {
let mut s = UiScript::new().unwrap();
s.set_pet_stats(
true,
PetStats {
hunter_pet: false,
family: Some("Imp".into()),
food_types: vec![],
..PetStats::default()
},
);
assert_eq!(
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
.unwrap(),
"Imp"
);
// …while every hunter-gated binding still says nothing.
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
assert_eq!(
s.eval::<i64>(r##"return select("#", GetPetFoodTypes())"##)
.unwrap(),
0
);
}
}
+52 -1
View File
@@ -59,6 +59,11 @@ pub struct QuestItemView {
/// Whether the reward is usable by the player's class/race — v1 always `true` (soft gray only,
/// the server stays authoritative).
pub usable: bool,
/// The full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetQuestItemLink` /
/// `GetQuestLogItemLink` serve it) — the ctrl/shift click arms' payload (decisions 1059/1060).
/// `None` until the ask-once item template lands: the link is built from the name **and** the
/// quality, and neither is known before then, exactly like [`super::InvSlotView::link`].
pub link: Option<String>,
}
/// One open questgiver panel: the active sub-panel plus every field the four panels might read.
@@ -275,6 +280,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetQuestItemLink(type, index) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil. The
// questgiver panels' ctrl/shift click arms read it — `DressUpItemLink(GetQuestItemLink(
// this.type, this:GetID()))` (ref QuestFrame.lua:118/130) and the shift-click
// `ChatFrameEditBox:Insert(...)` beside it (l.122/134). Same `type`/`index` addressing as
// `GetQuestItemInfo` above; nil for an out-of-range row, an unknown type, or a row whose
// template answer is still in flight (see [`QuestItemView::link`]). Decisions 1059/1060.
g.set(
"GetQuestItemLink",
lua.create_function(|lua, (kind, index): (String, usize)| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest.as_ref().and_then(|q| {
item_vec(q, &kind)
.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n)))
.and_then(|it| it.link.clone())
})
};
match link {
Some(l) => Ok(Value::String(lua.create_string(&l)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetRewardSpell() → nil (spell-reward rows out of scope in v1, decision 0088).
g.set(
"GetRewardSpell",
@@ -344,12 +373,13 @@ mod tests {
body: "Kill the kobolds infesting the mine.".into(),
objectives: "Slay 10 Kobold Vermin.".into(),
rewards: vec![QuestItemView {
item_id: 0,
item_id: 7278,
name: Some("Brdle Leather Boots".into()),
texture: Some("Interface\\Icons\\INV_Boots_01".into()),
count: 1,
quality: 2,
usable: true,
link: Some("|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r".into()),
}],
choices: vec![
QuestItemView {
@@ -359,6 +389,7 @@ mod tests {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
QuestItemView {
item_id: 0,
@@ -367,6 +398,7 @@ mod tests {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
],
reward_money: 1234,
@@ -416,6 +448,24 @@ mod tests {
assert!(s
.eval::<bool>("return GetQuestItemInfo(\"bogus\", 1) == nil")
.unwrap());
// GetQuestItemLink: the resolved row's full escaped link; nil for a row still waiting on
// its template (choice 2 has no name yet, so no link either), and nil out of range /
// unknown type — the three nils the ref's ctrl/shift arms hand to DressUpItemLink.
assert_eq!(
s.eval::<String>("return GetQuestItemLink(\"reward\", 1)")
.unwrap(),
"|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r"
);
assert!(s
.eval::<bool>("return GetQuestItemLink(\"choice\", 2) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestItemLink(\"reward\", 9) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestItemLink(\"bogus\", 1) == nil")
.unwrap());
}
#[test]
@@ -474,6 +524,7 @@ mod tests {
count: 8,
quality: 0,
usable: true,
..Default::default()
}],
required_money: 500,
completable: true,
+52 -1
View File
@@ -355,6 +355,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_detail_item(lua, "GetQuestLogChoiceInfo", |d| &d.choices)?;
install_detail_item(lua, "GetQuestLogRewardInfo", |d| &d.rewards)?;
// GetQuestLogItemLink(type, index) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil — the
// quest log's twin of `GetQuestItemLink` ([`super::quest`]), read by the reward rows' ctrl/shift
// click arms: `DressUpItemLink(GetQuestLogItemLink(this.type, this:GetID()))` (ref
// QuestLogFrame.lua:545) and the `ChatFrameEditBox:Insert(...)` beside it (l.549). Unlike the
// *Info* pair above this one is kind-DISPATCHED, because that is the shape the reference's own
// handler calls it with (`this.type` is "choice"/"reward", set by the shared
// `QuestFrameItems_Update`); an unknown type, an out-of-range index, or a row whose template
// answer is still in flight all read nil. Decisions 1059/1060.
g.set(
"GetQuestLogItemLink",
lua.create_function(|lua, (kind, index): (String, usize)| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest_log.detail.as_ref().and_then(|d| {
let v = match kind.as_str() {
"choice" => &d.choices,
"reward" => &d.rewards,
_ => return None,
};
index
.checked_sub(1)
.and_then(|n| v.get(n))
.and_then(|it| it.link.clone())
})
};
match link {
Some(l) => Ok(Value::String(lua.create_string(&l)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// IsCurrentQuestFailed() → the selection's slot state is FAIL (ref appends " - (Failed)").
g.set(
"IsCurrentQuestFailed",
@@ -566,12 +598,13 @@ mod tests {
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 0,
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()),
}],
}),
}
@@ -674,6 +707,24 @@ mod tests {
assert!(s
.eval::<bool>("return GetQuestLogChoiceInfo(1) == nil")
.unwrap());
// GetQuestLogItemLink: kind-dispatched (the shape the ref's QuestLogRewardItem_OnClick
// calls it with — `this.type`), nil for the empty choice list, an out-of-range index and
// an unknown type.
assert_eq!(
s.eval::<String>("return GetQuestLogItemLink(\"reward\", 1)")
.unwrap(),
"|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r"
);
assert!(s
.eval::<bool>("return GetQuestLogItemLink(\"choice\", 1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestLogItemLink(\"reward\", 9) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetQuestLogItemLink(\"bogus\", 1) == nil")
.unwrap());
}
#[test]
+22
View File
@@ -673,6 +673,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SpellCanTargetUnit("unit") — the ref's `0x6e6d00`: resolve the token, then ask `0x6e6460`'s
// UNIT leg whether the standing word can bind it. Its one shipped caller is
// `UnitFrame_OnEnter`, and it is what picks CAST_CURSOR over CAST_ERROR_CURSOR — the only
// lit/grey cursor split over a UI element in 1.12.
//
// The token is not consulted yet, and that is honest rather than lazy: the answer is `false`
// for **every** unit while any word benilla can arm is standing (location / item / gameobject —
// no unit satisfies those), so no token can change it. The app derives the flag from the word
// itself, so this starts discriminating by unit the moment the residual unit-word machine
// lands rather than silently staying wrong.
g.set(
"SpellCanTargetUnit",
lua.create_function(|lua, _unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.spell_can_target_unit {
Ok(Value::Boolean(true))
} else {
Ok(Value::Nil)
}
})?,
)?;
// SpellStopTargeting() — the ref's Script::SpellStopTargeting (`0x6e6e30`: if IsTargeting →
// StopTargeting `0x6e4900` → AbortCast(0x1c), which in targeting mode just clears the word,
// no packet). The 1/nil return is load-bearing exactly like SpellStopCasting's above: the
@@ -556,6 +556,44 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// DropItemOnUnit(unit) — drop the cursor's held item onto a unit (`0x48d960`). Two legs in the
// reference: the PET leg feeds the pet, the PLAYER leg opens a trade. Queues the raw token and
// nothing else — every gate reads state this VM does not hold, so the app owns all of them
// (`ui_action::targeting::drop_item_on_unit`).
//
// This binding **existed in our shipped `PetFrame_OnClick` before it existed here**: the
// handler transcribed the reference's three legs faithfully, and the middle one called a nil
// global, so the whole handler errored out the moment you clicked your pet holding anything.
// That is B208's "dropping food onto the pet doesn't feed" — the reported bug was a missing
// registration, not a missing mechanism.
g.set(
"DropItemOnUnit",
lua.create_function(|lua, token: Option<String>| {
if let Some(token) = token {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.drop_item_on_unit.push(token);
}
Ok(())
})?,
)?;
// SpellTargetUnit(unit) — bind a unit to the spell awaiting its click. The other leg of
// `PetFrame_OnClick`, tested BEFORE `CursorHasItem()` (ref `PetFrame.lua:114-129`), and dead
// in our VM for the same reason `DropItemOnUnit` was.
//
// It is registered as an accepted **no-op**, deliberately, and that is faithful for every word
// benilla can currently arm: the targeting cursor models the location / item / gameobject
// seams (0792/0923/0939), and a *unit* cannot satisfy any of them — the reference's
// `BindTarget 0x6e5b40` would reject it at the same three mask tests our seams ask. The word
// that would make this do something is the residual unit-word machine that `cast_target`'s
// header names as still deferred (a unit-target spell never enters targeting mode here at all;
// it resolves to `CastWireTarget::Unit` or refuses). So: present, silent, and honest — what it
// must NOT be is absent, which is what took the handler down with it.
g.set(
"SpellTargetUnit",
lua.create_function(|_, _token: Option<String>| Ok(()))?,
)?;
// ClearTarget() — drop the current selection (the reference API returns 1 when it cleared,
// nil when there was nothing to clear; `ToggleGameMenu`'s ESC chain depends on the nil to
// fall through to opening the menu). Reads the same per-token store `UnitExists("target")`
+6
View File
@@ -301,6 +301,12 @@ impl super::UiScript {
pub fn take_target_clear(&mut self) -> bool {
std::mem::take(&mut self.model_mut().target_clear)
}
/// Drain the unit tokens `DropItemOnUnit` queued since the last call — the app runs every gate
/// and, on the pet leg, casts the learned Feed Pet spell at the held item (`0x48d960`).
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().drop_item_on_unit)
}
}
/// Pick the interesting token from a directional two-unit call (`UnitIsEnemy(a, b)`): whichever