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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user