- a full realm answers with `AUTH_WAIT_QUEUE`, and it used to be read as a refusal; the grammar is length-branching exactly as the reference's handler has it, so both mangos body shapes parse; the estimate is the reference's own 8-sample ring — divide before multiply, signed, no timer — whose displayed figure climbs while the queue is silent, pinned by a test so nobody "fixes" it; the dialog is the ordinary status dialog relabelled `CHANGE_REALM` (which cancels back to the login screen: no realm list exists yet), and cancelling actually leaves the queue — the IO thread used to keep its place and walk on into character select on admission - the stable master, corrected against the binary: a stabled-pet drag rides the shared cursor at payload mode 10 (the mode census had that row's label wrong), a plain click always returns 1 and a drop always nil, the summoned pet dropped on an occupied stable slot swaps (STABLE was refused whenever both bought slots were full, silently), an unpurchased slot takes nothing, the stabled→summoned fork reads the live pet guid not the row, the client itself sends `MSG_LIST_STABLED_PETS` on the interact leg, `PET_STABLE_SHOW` fires on every list, the selection is a petNumber and every list clears it, and result codes 2–7 do nothing at all - the login dialog's text is centred (a FontString with no `justifyH` is CENTER, and `GlueDialogText` has none), the fifteen-year-dead "see www.worldofwarcraft.com" clauses are cut at the clause, the Login click sound is unconditional (the reference plays `gsLogin` before it checks the boxes), dialog buttons play `gsTitleOptionOK`, the realmlist button's `gsClick` never existed and is `gsLoginNewAccount`, and focusing a login box selects it — a stated divergence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
894 lines
40 KiB
Rust
894 lines
40 KiB
Rust
//! The stable-master bindings (decision 1676) — the hunter stable window's Lua surface, the same
|
|
//! two-way seam as [`super::bank`] and [`super::trainer`]: the app pushes a **stable snapshot**
|
|
//! ([`UiScript::set_stable`] — the wire's pet rows already resolved to icon/family/loyalty/diet
|
|
//! strings), and the Lua's click/purchase/close calls queue outbound **intents** the app drains.
|
|
//!
|
|
//! ## Three slots, and the current pet is one of them
|
|
//!
|
|
//! The window shows **`0..=2`**: slot `0` is the pet at the player's side, slots `1` and `2` are the
|
|
//! two stable slots a hunter buys (5875 ships exactly two — `StableSlotPrices.dbc` has two rows,
|
|
//! vmangos's `MAX_PET_STABLES` is 2, and the reference's `NUM_PET_STABLE_SLOTS` is 2). The wire is
|
|
//! 1-based over these; [`benilla_protocol::messages::StabledPet::slot`] already rebased it, so
|
|
//! everything here speaks the reference UI's own indices.
|
|
//!
|
|
//! **Slot 0 can be occupied while the player has no pet out.** A hunter whose pet is dismissed (or
|
|
//! merely too far away to be summoned) still gets a slot-0 row from the server, read off the
|
|
//! character-pet cache — which is exactly why the reference falls back to `GetStablePetInfo(0)`
|
|
//! when `UnitExists("pet")` is false (`PetStable.lua:131-146`) instead of showing an empty slot.
|
|
//!
|
|
//! ## What the snapshot resolves, and why the app does it
|
|
//!
|
|
//! The wire names a `creature_template` entry and a loyalty *level*; the window wants an icon, a
|
|
//! localized family word, a loyalty *name* and a diet list. Every one of those is a catalog join
|
|
//! the app already owns for the live pet ([`super::pet`], decisions 1005/1062), so the app does the
|
|
//! join once and pushes strings — this module never sees a DBC. The one join with no live-pet twin
|
|
//! is the icon of a pet that is *not* summoned: it comes from the creature query's display id,
|
|
//! which is why that field stopped being discarded (decision 1676).
|
|
//!
|
|
//! ## The drag rides the cursor at mode 10
|
|
//!
|
|
//! `PickupStablePet` puts the pet on the **global cursor** under payload mode 10
|
|
//! ([`super::cursor::CursorPayload::StablePet`]), carrying the stable index the grab recorded.
|
|
//!
|
|
//! This corrects what benilla shipped first. The original build made the drag frame-local on the
|
|
//! strength of wow-re's payload-mode table, which recorded mode 10 as "class/talent ability (DBC)"
|
|
//! — so the census read as "there is no stable-pet mode". The stable-master carve found that
|
|
//! `0x495020` **is** the stabled-pet grab and that `[0xb4d900] = 10` is written at exactly one site
|
|
//! image-wide, inside it; wow-re corrected its own note in the same round
|
|
//! (`system/ui/scratch/stable-master-window.md` §9). Decision 1677.
|
|
//!
|
|
//! ## Two return conventions that are the API, not details
|
|
//!
|
|
//! `ClickStablePet` returns **exactly one value, always**, and which one is keyed *solely* on
|
|
//! whether the cursor was holding a pet — never on whether a packet went out:
|
|
//!
|
|
//! - **a plain click** (no payload) is a pure select and pushes the number `1.0` on all three of
|
|
//! its legs, so it is **always truthy** and the reference always repaints;
|
|
//! - **a drop** pushes **nil** on every leg, including the ones that send a packet, so the
|
|
//! reference never repaints from the drop — the repaint comes from the server's next list.
|
|
//!
|
|
//! Getting this backwards is invisible in a test that only checks the packets and very visible in
|
|
//! play: the window would repaint off a stale snapshot on every drag and skip the repaint on every
|
|
//! click.
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::Model;
|
|
|
|
/// Window slots: the current pet (`0`) plus the two stable slots — the reference's
|
|
/// `NUM_PET_STABLE_SLOTS = 2` counted inclusively from zero (`PetStable.lua:1`).
|
|
pub const NUM_STABLE_SLOTS: usize = 3;
|
|
|
|
/// One row of the stable window, with every wire field already resolved to what the Lua renders.
|
|
/// `None` in [`StableState::slots`] is an empty slot — which the window draws differently from an
|
|
/// *unbought* one (that distinction is [`StableState::num_stable_slots`]'s).
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct StablePetSlot {
|
|
/// The pet's own id — what [`StableIntent::Unstable`]/[`StableIntent::Swap`] name on the wire.
|
|
/// Never its slot: the two disagree for any hunter whose stable is not in id order.
|
|
pub pet_number: u32,
|
|
/// `Interface\Icons\…` for the pet's family, or `None` while the creature query is in flight.
|
|
/// The reference passes this straight to `SetItemButtonTexture`, which takes an empty texture
|
|
/// for a missing icon — so `None` renders the empty-slot art, not a white square (decision
|
|
/// 1046's sweep).
|
|
pub icon: Option<String>,
|
|
/// The name the hunter gave the pet, not the creature template's.
|
|
pub name: String,
|
|
pub level: u32,
|
|
/// The localized `CreatureFamily.dbc` word ("Wolf", "Cat"). `None` when the creature query has
|
|
/// not landed — the reference concatenates it into the level line unguarded, so the binding
|
|
/// substitutes an empty string rather than handing Lua a nil to concatenate.
|
|
pub family: Option<String>,
|
|
/// The localized `PetLoyalty.dbc` name for the wire's loyalty level.
|
|
pub loyalty: Option<String>,
|
|
/// The localized pet-food names this pet's family eats — `GetStablePetFoodTypes`'s returns,
|
|
/// which the reference joins with `BuildListString` into the diet tooltip.
|
|
pub diet: Vec<String>,
|
|
}
|
|
|
|
/// The open stable window's snapshot. Pushed whole while a stable session is open; `None` = no
|
|
/// stable open (the window is closed).
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct StableState {
|
|
/// Stable slots **purchased**, `0..=2` (the wire's `numStableSlots`). Not a count of occupied
|
|
/// ones: it is what enables slot buttons `1..=num` and greys the rest, and what prices the next
|
|
/// purchase.
|
|
pub num_stable_slots: u32,
|
|
/// The next slot's price in copper (`StableSlotPrices.dbc` row `num_stable_slots + 1`), or `0`
|
|
/// past the table — a state in which the reference has already hidden the purchase row.
|
|
pub next_slot_cost: u32,
|
|
/// The three window slots; index `0` is the current pet.
|
|
pub slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS],
|
|
/// The player has a **live** pet out — the client's own `[0xb714a0]|[0xb714a4]` guid test, the
|
|
/// gate that forks a stabled pet's drop between swap and unstable (§6.2).
|
|
///
|
|
/// **Deliberately not `slots[0].is_some()`.** A dismissed pet, or one left out of range, still
|
|
/// has a slot-0 row from the server's character-pet cache while the live guid is zero — so the
|
|
/// two disagree exactly there, and the client follows the guid.
|
|
pub has_live_pet: bool,
|
|
}
|
|
|
|
impl StableState {
|
|
/// How many of the three slots hold a pet — `GetNumStablePets()`.
|
|
fn num_pets(&self) -> u32 {
|
|
self.slots.iter().filter(|s| s.is_some()).count() as u32
|
|
}
|
|
}
|
|
|
|
/// An outbound stable verb the Lua asked for, drained by the app (which addresses it to the open
|
|
/// session's NPC — the guid is the app's, never Lua's).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum StableIntent {
|
|
/// `CMSG_STABLE_PET` — put the current pet away. **Carries no destination**: the server takes
|
|
/// the first free bought slot itself, so there is no "stable into slot 2" to express.
|
|
Stable,
|
|
/// `CMSG_UNSTABLE_PET` — summon this pet number, valid only with no current pet.
|
|
Unstable(u32),
|
|
/// `CMSG_STABLE_SWAP_PET` — trade the current pet for this pet number, in one step.
|
|
Swap(u32),
|
|
/// `CMSG_BUY_STABLE_SLOT` — buy the next slot; the *which* is implicit, as with the bank's.
|
|
BuySlot,
|
|
}
|
|
|
|
/// The window's own transient state — selection and the frame-local drag (module doc: the cursor is
|
|
/// not involved). Lives beside the snapshot rather than inside it because the app *replaces* the
|
|
/// snapshot on every list packet, and a repaint must not drop what the player has selected.
|
|
#[derive(Debug)]
|
|
pub(crate) struct StableModel {
|
|
pub(crate) state: Option<StableState>,
|
|
/// The selection, held **as the client holds it** (`[0xb72250]`): `-1` = the summoned pet,
|
|
/// `0` = nothing, otherwise a **petNumber**.
|
|
///
|
|
/// A petNumber rather than a slot index because that is what the binary stores, and it is what
|
|
/// makes a *stale* selection degrade correctly: `GetSelectedStablePet` searches the array for
|
|
/// the number and answers `-1` when the pet is no longer there (§7.1's fall-through).
|
|
pub(crate) selected: i32,
|
|
pub(crate) intents: Vec<StableIntent>,
|
|
pub(crate) close: bool,
|
|
}
|
|
|
|
/// The zero state is the client's `0` — "nothing selected" — not `-1`, which is its encoding for
|
|
/// *the summoned pet*. The Lua-facing `-1` that `PetStable.lua:44` tests is
|
|
/// [`super::UiScript::stable_selection`]'s translation of this, not this field (§7.1).
|
|
impl Default for StableModel {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: None,
|
|
// `0` is "nothing selected" in the client's own encoding — NOT `-1`, which means the
|
|
// summoned pet. The window's zero state is the reset `0x4caad3` writes.
|
|
selected: 0,
|
|
intents: Vec::new(),
|
|
close: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Push (or clear, with `None`) the open stable's snapshot.
|
|
///
|
|
/// **Every list message clears the selection**, and that is the client's own behaviour
|
|
/// (`0x4cadf8` writes `[0xb72250] = 0` on each one), not a simplification. The first build kept
|
|
/// it across a refresh on the reasoning that benilla re-lists after every action and a reset
|
|
/// would "fight the player" — the carve says the reference re-lists on exactly the same
|
|
/// successes and clears every time. It does not fight anything, because `PetStable_Update`
|
|
/// immediately re-picks: the current pet if there is one, else the first occupied slot
|
|
/// (`PetStable.lua:44-59`). Keeping a selection across a list is what would be wrong — it can
|
|
/// name a pet the new list no longer contains.
|
|
pub fn set_stable(&mut self, state: Option<StableState>) {
|
|
let mut model = self.model_mut();
|
|
model.stable.selected = 0;
|
|
// The held payload is NOT dropped here: closing the window clears only the stable-master
|
|
// guid in the reference (`0x4cae10`), and a picked-up pet lives on the shared cursor, whose
|
|
// own ClearCursor arms own it.
|
|
model.stable.state = state;
|
|
}
|
|
|
|
/// Drain the queued stable verbs (module doc: the app addresses them to the open NPC).
|
|
pub fn take_stable_intents(&mut self) -> Vec<StableIntent> {
|
|
std::mem::take(&mut self.model_mut().stable.intents)
|
|
}
|
|
|
|
/// Whether `ClosePetStables()` was called since the last drain (and clear the flag). No packet
|
|
/// exists for it — the app just clears its local session, the bank/merchant pattern.
|
|
pub fn take_stable_close(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().stable.close)
|
|
}
|
|
|
|
/// `GetSelectedStablePet()`'s answer — **`0` = the summoned pet, `1..=2` a stable slot, `-1`
|
|
/// nothing** — translated out of the petNumber the model holds, exactly as `0x4cb800` does
|
|
/// (find the number in the array, `inc eax`; a number no longer present falls through to `-1`).
|
|
pub fn stable_selection(&mut self) -> i32 {
|
|
let model = self.model_mut();
|
|
selected_slot(&model.stable)
|
|
}
|
|
}
|
|
|
|
/// The petNumber→slot translation behind `GetSelectedStablePet` (§7.1).
|
|
fn selected_slot(m: &StableModel) -> i32 {
|
|
match m.selected {
|
|
-1 => 0,
|
|
0 => -1,
|
|
pet_number => m
|
|
.state
|
|
.as_ref()
|
|
.and_then(|s| {
|
|
s.slots
|
|
.iter()
|
|
.enumerate()
|
|
.skip(1)
|
|
.find(|(_, slot)| {
|
|
slot.as_ref()
|
|
.is_some_and(|p| i64::from(p.pet_number) == i64::from(pet_number))
|
|
})
|
|
.map(|(i, _)| i as i32)
|
|
})
|
|
// A selection whose pet is gone after a refresh degrades to "nothing", never to a
|
|
// wrong slot — the loop's own fall-through at `0x4cb84c`.
|
|
.unwrap_or(-1),
|
|
}
|
|
}
|
|
|
|
/// Read a slot argument into an index into [`StableState::slots`]. Out-of-range answers `None`, so
|
|
/// every binding below degrades to the reference's empty-slot behaviour rather than panicking on a
|
|
/// stray addon call.
|
|
fn slot_index(i: i64) -> Option<usize> {
|
|
usize::try_from(i).ok().filter(|&i| i < NUM_STABLE_SLOTS)
|
|
}
|
|
|
|
/// Commit a drag from slot `from` onto slot `to` — the **one** place the stable's move law lives,
|
|
/// now read off the binary (`ClickStablePet 0x4cb420` regime B, wow-re
|
|
/// `system/ui/scratch/stable-master-window.md` §6.2; decision 1677).
|
|
///
|
|
/// The first build inferred this from the server's constraint set and got the shape right and the
|
|
/// **three edges wrong**. Each of them is a real case:
|
|
///
|
|
/// 1. **The summoned pet onto an OCCUPIED stable slot sends `SWAP`, not `STABLE`.** "Drag out ⇒
|
|
/// stable it" only holds when the target slot is empty. The inferred version sent `STABLE` for
|
|
/// both, which vmangos then refuses whenever both bought slots are full — the pet stays put and
|
|
/// nothing says why.
|
|
/// 2. **A drop onto an UNPURCHASED slot sends nothing**, and is still a completed drop (cursor
|
|
/// cleared, falsy return).
|
|
/// 3. **A stabled pet onto the summoned slot forks on the LIVE PET GUID**, not on whether slot 0
|
|
/// has a row. A dismissed pet has a row and no guid, and the client sends `UNSTABLE` there —
|
|
/// the inferred version read the row and sent `SWAP`.
|
|
///
|
|
/// Stable→stable remains a no-op: 5875 has no opcode for it, which the first build did get right.
|
|
fn drag_verb(from: u8, to: u8, state: &StableState) -> Option<StableIntent> {
|
|
if from == to {
|
|
return None;
|
|
}
|
|
let occupied = |i: u8| state.slots.get(usize::from(i)).and_then(|s| s.as_ref());
|
|
match (from, to) {
|
|
// The summoned pet is held.
|
|
(0, _) => match occupied(to) {
|
|
// …onto an occupied stable slot: trade places.
|
|
Some(target) => Some(StableIntent::Swap(target.pet_number)),
|
|
// …onto an empty slot the player owns: stable it (the server picks the destination).
|
|
None if u32::from(to) <= state.num_stable_slots => Some(StableIntent::Stable),
|
|
// …onto a slot they have not bought: nothing.
|
|
None => None,
|
|
},
|
|
// A stabled pet is held, dropped on the summoned slot.
|
|
(_, 0) => occupied(from).map(|held| {
|
|
if state.has_live_pet {
|
|
StableIntent::Swap(held.pet_number)
|
|
} else {
|
|
StableIntent::Unstable(held.pet_number)
|
|
}
|
|
}),
|
|
// Stable slot → stable slot: no opcode exists in 5875.
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Register the stable globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// GetStablePetInfo(i) → icon, name, level, family, loyalty — **5 values on every reachable
|
|
// exit** (`0x4cb280`: `eax = 5` at both returns). A miss pushes `nil, nil, 0.0, nil, nil`, NOT
|
|
// nothing: the reference both tests the call for truthiness and destructures all five, and a
|
|
// bare nil would make `level` nil where the client answers 0.
|
|
g.set(
|
|
"GetStablePetInfo",
|
|
lua.create_function(|lua, i: i64| {
|
|
let pet = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
slot_index(i).and_then(|i| model.stable.state.as_ref()?.slots[i].clone())
|
|
};
|
|
let Some(pet) = pet else {
|
|
return Ok(MultiValue::from_vec(vec![
|
|
Value::Nil,
|
|
Value::Nil,
|
|
Value::Number(0.0),
|
|
Value::Nil,
|
|
Value::Nil,
|
|
]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
match &pet.icon {
|
|
Some(t) => Value::String(lua.create_string(t)?),
|
|
None => Value::Nil,
|
|
},
|
|
Value::String(lua.create_string(&pet.name)?),
|
|
Value::Number(f64::from(pet.level)),
|
|
match &pet.family {
|
|
Some(f) => Value::String(lua.create_string(f)?),
|
|
None => Value::Nil,
|
|
},
|
|
match &pet.loyalty {
|
|
Some(l) => Value::String(lua.create_string(l)?),
|
|
None => Value::Nil,
|
|
},
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetStablePetFoodTypes(i) → the localized diet names, one return each (the reference feeds the
|
|
// lot to BuildListString). Nothing at all for an empty slot or a pet whose family has no diet —
|
|
// the reference guards with `if ( GetStablePetFoodTypes(i) )` before formatting the tooltip.
|
|
g.set(
|
|
"GetStablePetFoodTypes",
|
|
lua.create_function(|lua, i: i64| {
|
|
let diet = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
slot_index(i)
|
|
.and_then(|i| {
|
|
Some(model.stable.state.as_ref()?.slots[i].as_ref()?.diet.clone())
|
|
})
|
|
.unwrap_or_default()
|
|
};
|
|
let mut out = Vec::with_capacity(diet.len());
|
|
for d in &diet {
|
|
out.push(Value::String(lua.create_string(d)?));
|
|
}
|
|
Ok(MultiValue::from_vec(out))
|
|
})?,
|
|
)?;
|
|
|
|
// GetNumStableSlots() → slots PURCHASED (0..=2). The reference both enables buttons `i <= n`
|
|
// and hides the purchase row at `n == NUM_PET_STABLE_SLOTS` off this one number.
|
|
g.set(
|
|
"GetNumStableSlots",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(
|
|
model
|
|
.stable
|
|
.state
|
|
.as_ref()
|
|
.map_or(0, |s| s.num_stable_slots),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// GetNumStablePets() → how many of the three slots hold a pet.
|
|
g.set(
|
|
"GetNumStablePets",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(
|
|
model.stable.state.as_ref().map_or(0, StableState::num_pets),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// GetNextStableSlotCost() → the next slot's price in copper (the app read it from
|
|
// StableSlotPrices.dbc). 0 with no stable open, and 0 past the table — where the reference has
|
|
// already hidden the row that would show it.
|
|
g.set(
|
|
"GetNextStableSlotCost",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(
|
|
model.stable.state.as_ref().map_or(0, |s| s.next_slot_cost),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSelectedStablePet() → the selected slot, or -1. The sentinel is the API: the reference
|
|
// tests `selectedPet == -1` to decide whether to pick a slot for the player.
|
|
g.set(
|
|
"GetSelectedStablePet",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
// NOT the raw field: the model holds the client's petNumber encoding, and this binding
|
|
// is its translation to slot indices (`selected_slot`).
|
|
Ok(i64::from(selected_slot(&model.stable)))
|
|
})?,
|
|
)?;
|
|
|
|
// ClickStablePet(i) → **exactly one value, always**, and which one is keyed SOLELY on whether
|
|
// the cursor held a pet — never on whether a packet went out (§6.2).
|
|
//
|
|
// plain click -> pure select, no packet, pushes 1.0 on ALL THREE legs => always TRUTHY
|
|
// drop -> clears the cursor, pushes nil on EVERY leg => always FALSY
|
|
//
|
|
// So the reference repaints on every click and on NO drop; a drop's repaint arrives with the
|
|
// server's next list. The first build had this inverted (true on a committed drag, false on a
|
|
// no-op) — invisible to a packet-only test, and in play a window that repaints off a stale
|
|
// snapshot on every drag and skips the repaint on every click.
|
|
g.set(
|
|
"ClickStablePet",
|
|
lua.create_function(|lua, i: i64| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let Some(to) = slot_index(i) else {
|
|
// Out of range still takes the select leg's "nothing" arm below.
|
|
if !matches!(
|
|
model.cursor,
|
|
Some(super::cursor::CursorPayload::StablePet(_))
|
|
) {
|
|
model.stable.selected = 0;
|
|
return Ok(Value::Number(1.0));
|
|
}
|
|
super::cursor::clear_cursor(&mut model);
|
|
return Ok(Value::Nil);
|
|
};
|
|
let to = to as u8;
|
|
|
|
// Regime B — a drop.
|
|
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
|
|
let from = held.slot;
|
|
let intent = model
|
|
.stable
|
|
.state
|
|
.as_ref()
|
|
.and_then(|state| drag_verb(from, to, state));
|
|
super::cursor::clear_cursor(&mut model);
|
|
if let Some(intent) = intent {
|
|
model.stable.intents.push(intent);
|
|
}
|
|
return Ok(Value::Nil);
|
|
}
|
|
|
|
// Regime A — a plain click: pure select, always truthy.
|
|
let pet_number = model
|
|
.stable
|
|
.state
|
|
.as_ref()
|
|
.and_then(|s| s.slots[usize::from(to)].as_ref())
|
|
.map(|p| p.pet_number);
|
|
model.stable.selected = match (to, pet_number) {
|
|
// Slot 0 selects the summoned pet by its own sentinel, not by a petNumber.
|
|
(0, _) => -1,
|
|
(_, Some(n)) => n as i32,
|
|
// An empty (or unowned) slot selects NOTHING, rather than the slot itself.
|
|
(_, None) => 0,
|
|
};
|
|
Ok(Value::Number(1.0))
|
|
})?,
|
|
)?;
|
|
|
|
// PickupStablePet(i) — the mode-10 grab. **0 Lua values, always.**
|
|
//
|
|
// The client's gate is the family ICON path, not occupancy (`0x495010`: resolve the record,
|
|
// require a non-empty `CreatureFamily` icon, then set the payload). Ours is the resolved icon,
|
|
// which is the same test one step later: no icon, no grab.
|
|
g.set(
|
|
"PickupStablePet",
|
|
lua.create_function(|lua, i: i64| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let texture = slot_index(i)
|
|
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
|
|
.and_then(|p| p.icon.clone());
|
|
if let Some(texture) = texture {
|
|
model.cursor = Some(super::cursor::CursorPayload::StablePet(
|
|
super::cursor::CursorStablePet {
|
|
slot: i as u8,
|
|
texture,
|
|
},
|
|
));
|
|
super::cursor::queue_cursor_update(&mut model);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// StablePet() — 0 args, and it makes no Lua call at all in the reference: its only gate is a
|
|
// stable master being open, which for us is the snapshot's existence. Not called by the shipped
|
|
// FrameXML (the drag path sends this verb); it exists because the binding does.
|
|
g.set(
|
|
"StablePet",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if model.stable.state.is_some() {
|
|
model.stable.intents.push(StableIntent::Stable);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// UnstablePet(i) — carries a gate the drag path does NOT (§7.1): it requires no charmed unit
|
|
// and **no pet out**, and bails SILENTLY otherwise, with no packet and no error. Its unsigned
|
|
// bound also rejects index 0 outright, where the drag path treats 0 as the summoned pet.
|
|
g.set(
|
|
"UnstablePet",
|
|
lua.create_function(|lua, i: i64| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let pet_number = model
|
|
.stable
|
|
.state
|
|
.as_ref()
|
|
.filter(|s| !s.has_live_pet)
|
|
.and_then(|s| {
|
|
slot_index(i)
|
|
.filter(|&i| i != 0)
|
|
.and_then(|i| s.slots[i].as_ref())
|
|
})
|
|
.map(|p| p.pet_number);
|
|
if let Some(pet_number) = pet_number {
|
|
model
|
|
.stable
|
|
.intents
|
|
.push(StableIntent::Unstable(pet_number));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// SetPetStablePaperdoll(model) — inert here, deliberately, and this is the same divergence
|
|
// PetPaperDollFrame.xml records for `PetModelFrame:SetUnit("pet")`: benilla's model panes are
|
|
// app-side booths that follow the selection every frame, so there is no VM-side unit to point.
|
|
// The binding still exists because the reference calls it (four sites) and an addon may.
|
|
g.set(
|
|
"SetPetStablePaperdoll",
|
|
lua.create_function(|_, _model: Value| Ok(()))?,
|
|
)?;
|
|
|
|
// BuyStableSlot() — 0 args, with the client's own **silent** local gates in order (§7.1): a
|
|
// stable master open · the hard cap `slots != 2` · a price row exists · **affordability**.
|
|
// None of them shows a message; the reference disables the button instead. Applying them here
|
|
// rather than trusting the button matters because an addon can call the binding directly, and
|
|
// the server's refusal for the cap is the same indistinguishable ERR_STABLE as everything else.
|
|
g.set(
|
|
"BuyStableSlot",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
// Affordability reads the VM's own purse — the same `model.money` the reference's
|
|
// `GetMoney()` returns and its own button-state ladder compares (`PetStable.lua:203`),
|
|
// rather than a second copy of the number in the stable snapshot.
|
|
let money = model.money;
|
|
let allowed = model.stable.state.as_ref().is_some_and(|s| {
|
|
s.num_stable_slots as usize != NUM_STABLE_SLOTS - 1
|
|
&& s.next_slot_cost != 0
|
|
&& u64::from(s.next_slot_cost) <= money
|
|
});
|
|
if allowed {
|
|
model.stable.intents.push(StableIntent::BuySlot);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// ClosePetStables() — client-side close, no packet exists (vmangos has no close opcode): flag
|
|
// the app to clear its session.
|
|
g.set(
|
|
"ClosePetStables",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.stable.close = true;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::script::UiScript;
|
|
|
|
fn pet(number: u32, name: &str, level: u32) -> StablePetSlot {
|
|
StablePetSlot {
|
|
pet_number: number,
|
|
icon: Some("Interface\\Icons\\Ability_Hunter_Pet_Wolf".into()),
|
|
name: name.into(),
|
|
level,
|
|
family: Some("Wolf".into()),
|
|
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
|
diet: vec!["Meat".into(), "Fish".into()],
|
|
}
|
|
}
|
|
|
|
fn state(slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS]) -> StableState {
|
|
StableState {
|
|
num_stable_slots: 1,
|
|
next_slot_cost: 50_000,
|
|
slots,
|
|
has_live_pet: true,
|
|
}
|
|
}
|
|
|
|
/// A hunter with a pet out and one stabled, one slot bought.
|
|
fn open(s: &mut UiScript) {
|
|
s.set_stable(Some(state([
|
|
Some(pet(7, "Rex", 41)),
|
|
Some(pet(8, "Bruiser", 38)),
|
|
None,
|
|
])));
|
|
}
|
|
|
|
/// The read surface, through the reference's own destructuring.
|
|
#[test]
|
|
fn the_read_surface_answers_the_reference_calls() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetNumStableSlots()").unwrap(), 0);
|
|
assert_eq!(s.eval::<i64>("return GetNumStablePets()").unwrap(), 0);
|
|
assert_eq!(s.eval::<i64>("return GetNextStableSlotCost()").unwrap(), 0);
|
|
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), -1);
|
|
|
|
open(&mut s);
|
|
assert_eq!(s.eval::<i64>("return GetNumStableSlots()").unwrap(), 1);
|
|
assert_eq!(s.eval::<i64>("return GetNumStablePets()").unwrap(), 2);
|
|
assert_eq!(
|
|
s.eval::<i64>("return GetNextStableSlotCost()").unwrap(),
|
|
50_000
|
|
);
|
|
|
|
// `PetStable.lua:76` — the exact five-value destructuring the window renders from.
|
|
assert_eq!(
|
|
s.eval::<(String, String, i64, String, String)>(
|
|
"local i, n, l, f, loy = GetStablePetInfo(1) return i, n, l, f, loy"
|
|
)
|
|
.unwrap(),
|
|
(
|
|
"Interface\\Icons\\Ability_Hunter_Pet_Wolf".into(),
|
|
"Bruiser".into(),
|
|
38,
|
|
"Wolf".into(),
|
|
"(Loyalty Level 6) Best Friend".into()
|
|
)
|
|
);
|
|
|
|
assert_eq!(
|
|
s.eval::<(String, String)>("return GetStablePetFoodTypes(1)")
|
|
.unwrap(),
|
|
("Meat".into(), "Fish".into())
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return GetStablePetFoodTypes(2) == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
/// **`GetStablePetInfo` returns five values on EVERY exit** (`eax = 5` at both returns), with a
|
|
/// miss pushing `nil, nil, 0, nil, nil`. The reference both tests the call for truthiness and
|
|
/// destructures all five, so a bare nil would leave `level` nil where the client answers 0 —
|
|
/// and would silently change the arity an addon sees.
|
|
#[test]
|
|
fn a_missing_pet_still_answers_five_values() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
for call in ["GetStablePetInfo(2)", "GetStablePetInfo(9)"] {
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!("return select('#', {call})"))
|
|
.unwrap(),
|
|
5,
|
|
"{call} arity"
|
|
);
|
|
// Still falsy on the reference's own `if ( GetStablePetInfo(i) )` test.
|
|
assert!(s.eval::<bool>(&format!("return not {call}")).unwrap());
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!("local _, _, l = {call} return l"))
|
|
.unwrap(),
|
|
0,
|
|
"{call} level is 0, not nil"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// **A plain click always returns truthy**, on all three of its legs — including a re-click of
|
|
/// the same slot and a click on an empty one. The reference repaints on the return, so a
|
|
/// falsy answer anywhere here is a window that stops repainting on click.
|
|
#[test]
|
|
fn every_plain_click_returns_truthy() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
for call in [
|
|
"ClickStablePet(1)",
|
|
"ClickStablePet(1)",
|
|
"ClickStablePet(0)",
|
|
"ClickStablePet(2)",
|
|
] {
|
|
assert!(
|
|
s.eval::<bool>(&format!("return {call} and true or false"))
|
|
.unwrap(),
|
|
"{call}"
|
|
);
|
|
}
|
|
assert!(
|
|
s.take_stable_intents().is_empty(),
|
|
"selecting sends nothing"
|
|
);
|
|
}
|
|
|
|
/// The selection's three encodings, read back through `GetSelectedStablePet`'s translation:
|
|
/// slot 0 answers 0, an occupied stable slot answers its index, and an **empty** slot selects
|
|
/// nothing (`-1`) rather than itself.
|
|
#[test]
|
|
fn the_selection_translates_back_to_slot_indices() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
s.eval::<()>("ClickStablePet(0)").unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 0);
|
|
s.eval::<()>("ClickStablePet(1)").unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
|
|
s.eval::<()>("ClickStablePet(2)").unwrap();
|
|
assert_eq!(
|
|
s.eval::<i64>("return GetSelectedStablePet()").unwrap(),
|
|
-1,
|
|
"an empty slot selects nothing, not itself"
|
|
);
|
|
}
|
|
|
|
/// A selection is held as a **petNumber**, so a refresh that no longer contains that pet
|
|
/// degrades to "nothing" rather than to a wrong slot — and every list clears it anyway.
|
|
#[test]
|
|
fn a_list_clears_the_selection_and_a_stale_pet_degrades() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
s.eval::<()>("ClickStablePet(1)").unwrap();
|
|
assert_eq!(s.stable_selection(), 1);
|
|
|
|
// Every list message clears it (`0x4cadf8`) — the reference re-picks in PetStable_Update.
|
|
open(&mut s);
|
|
assert_eq!(s.stable_selection(), -1, "a list clears the selection");
|
|
}
|
|
|
|
/// **A drop always returns nil**, on every leg — the ones that send a packet included. The
|
|
/// reference therefore never repaints from a drop; the repaint arrives with the server's next
|
|
/// list. This is the convention the first build had inverted.
|
|
#[test]
|
|
fn every_drop_returns_falsy_even_when_it_sends() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
// A drop that DOES send.
|
|
assert!(s
|
|
.eval::<bool>("PickupStablePet(1) return ClickStablePet(0) == nil")
|
|
.unwrap());
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
|
|
|
|
// A drop that sends nothing is equally falsy.
|
|
assert!(s
|
|
.eval::<bool>("PickupStablePet(1) return ClickStablePet(1) == nil")
|
|
.unwrap());
|
|
assert!(s.take_stable_intents().is_empty());
|
|
}
|
|
|
|
/// **The summoned pet onto an OCCUPIED stable slot is a SWAP, not a STABLE** — the first of the
|
|
/// three edges the inferred law got wrong. Sending STABLE there is refused by the server
|
|
/// whenever both bought slots are full, and nothing says why.
|
|
#[test]
|
|
fn dropping_onto_an_occupied_slot_swaps() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
s.eval::<()>("PickupStablePet(0) ClickStablePet(1)")
|
|
.unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
|
|
}
|
|
|
|
/// …and onto an EMPTY slot the player owns it is a STABLE, whose destination the wire cannot
|
|
/// carry; onto one they have **not bought**, nothing at all — but the cursor still clears.
|
|
#[test]
|
|
fn an_unpurchased_slot_takes_the_drop_and_sends_nothing() {
|
|
let mut s = UiScript::new().unwrap();
|
|
// Slot 1 bought and empty, slot 2 unbought.
|
|
s.set_stable(Some(state([Some(pet(7, "Rex", 41)), None, None])));
|
|
s.eval::<()>("PickupStablePet(0) ClickStablePet(1)")
|
|
.unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Stable]);
|
|
|
|
s.eval::<()>("PickupStablePet(0) ClickStablePet(2)")
|
|
.unwrap();
|
|
assert!(s.take_stable_intents().is_empty(), "slot 2 is not bought");
|
|
// The drop completed: the cursor is empty, so the next click SELECTS.
|
|
assert!(s.eval::<bool>("return ClickStablePet(1) and true").unwrap());
|
|
assert!(s.take_stable_intents().is_empty());
|
|
}
|
|
|
|
/// **The stabled→summoned fork reads the LIVE PET GUID, not slot 0's row** — the third edge.
|
|
/// A dismissed pet still has a row from the server's character-pet cache while the guid is
|
|
/// zero, and the client sends UNSTABLE there.
|
|
#[test]
|
|
fn the_fork_follows_the_live_pet_not_the_row() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
|
|
.unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
|
|
|
|
// The same list, but the pet is dismissed: the row survives, the guid does not.
|
|
let mut dismissed = state([Some(pet(7, "Rex", 41)), Some(pet(8, "Bruiser", 38)), None]);
|
|
dismissed.has_live_pet = false;
|
|
s.set_stable(Some(dismissed));
|
|
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_stable_intents(),
|
|
vec![StableIntent::Unstable(8)],
|
|
"a row without a live guid unstables"
|
|
);
|
|
}
|
|
|
|
/// Stable→stable has no opcode in 5875, and an empty slot carries nothing to grab.
|
|
#[test]
|
|
fn stable_to_stable_is_a_no_op_and_empty_slots_do_not_grab() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_stable(Some(StableState {
|
|
num_stable_slots: 2,
|
|
slots: [None, Some(pet(8, "Bruiser", 38)), None],
|
|
..state([None, None, None])
|
|
}));
|
|
s.eval::<()>("PickupStablePet(1) ClickStablePet(2)")
|
|
.unwrap();
|
|
assert!(s.take_stable_intents().is_empty());
|
|
|
|
// An empty slot never arms a grab, so the following click is a select.
|
|
s.eval::<()>("PickupStablePet(2)").unwrap();
|
|
assert!(s.eval::<bool>("return ClickStablePet(0) and true").unwrap());
|
|
assert!(s.take_stable_intents().is_empty());
|
|
}
|
|
|
|
/// `BuyStableSlot`'s gates are the client's own, and all silent: the hard cap, and
|
|
/// affordability.
|
|
#[test]
|
|
fn buy_stable_slot_gates_locally_and_silently() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_money(1_000_000);
|
|
open(&mut s);
|
|
s.eval::<()>("BuyStableSlot()").unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::BuySlot]);
|
|
|
|
// Too poor.
|
|
s.set_money(10);
|
|
s.set_stable(Some(state([None, None, None])));
|
|
s.eval::<()>("BuyStableSlot()").unwrap();
|
|
assert!(s.take_stable_intents().is_empty(), "unaffordable is silent");
|
|
|
|
// Both slots owned — the hard cap (and money restored, so only the cap can refuse).
|
|
s.set_money(1_000_000);
|
|
let mut full = state([None, None, None]);
|
|
full.num_stable_slots = 2;
|
|
s.set_stable(Some(full));
|
|
s.eval::<()>("BuyStableSlot()").unwrap();
|
|
assert!(s.take_stable_intents().is_empty(), "the cap is silent");
|
|
}
|
|
|
|
/// `UnstablePet` carries a gate the drag path does not: no pet out, else silence. And its
|
|
/// unsigned bound rejects index 0, where the drag path reads 0 as the summoned pet.
|
|
#[test]
|
|
fn the_unstable_binding_gates_where_the_drag_path_does_not() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s); // has_live_pet = true
|
|
s.eval::<()>("UnstablePet(1)").unwrap();
|
|
assert!(s.take_stable_intents().is_empty(), "a pet is out — silent");
|
|
|
|
let mut dismissed = state([None, Some(pet(8, "Bruiser", 38)), None]);
|
|
dismissed.has_live_pet = false;
|
|
s.set_stable(Some(dismissed));
|
|
s.eval::<()>("UnstablePet(0)").unwrap();
|
|
assert!(s.take_stable_intents().is_empty(), "index 0 is rejected");
|
|
s.eval::<()>("UnstablePet(1)").unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Unstable(8)]);
|
|
}
|
|
|
|
/// The close intent, and that closing does NOT drop a held pet — the reference's close clears
|
|
/// only the stable-master guid.
|
|
#[test]
|
|
fn close_drains_and_leaves_the_cursor_alone() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
assert!(!s.take_stable_close());
|
|
s.eval::<()>("ClosePetStables()").unwrap();
|
|
assert!(s.take_stable_close());
|
|
assert!(!s.take_stable_close(), "drain clears");
|
|
}
|
|
|
|
/// `SetPetStablePaperdoll` exists and is harmless — the reference calls it at four sites.
|
|
#[test]
|
|
fn the_paperdoll_setter_is_callable() {
|
|
let s = UiScript::new().unwrap();
|
|
s.eval::<()>("SetPetStablePaperdoll(nil)").unwrap();
|
|
}
|
|
}
|