ad0fd89729
Lua getters that answered what 1.12.1 never does now read what their handlers read: - **Bind area.** GetBindLocation and the `$z` spell token fall back to HOME_INN (`0x507f3a`). - **Craft.** GetCraftName answers the trade-skill slot by craft type (`0x4f601c`). - **Trainer.** The skill line and requirement come from the service's spell (`0x4d9261`, `0x4d94d6`). - **Map.** GetPlayerMapPosition takes any unit token; GetMapInfo answers its sizes. - **Mail.** GetInboxItem gates by quality and misses uncached items (`0x4af721`). - **Pets.** Stabled pets pack in list order, the summoned one by its byte's bit 1 (`0x4cabe3`). UnitCreatureFamily reads any unit's record. - **Gates.** HasFullControl and UseAction gate as the client's do; ItemTextGetItem answers nil with no text open. Each fix has a test. Fixes #2475 Fixes #2497 Fixes #2499 Fixes #2508 Fixes #2509 Fixes #2510 Fixes #2511 Fixes #2513 Fixes #2514 Fixes #2515 Fixes #2516 Fixes #2519 Fixes #2523 Fixes #2524 Fixes #2525 Fixes #2528 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1114 lines
43 KiB
Rust
1114 lines
43 KiB
Rust
//! The stable-master bindings: the getters read the hunter's stable through the lent host at the
|
|
//! call ([`StableQuery`], every wire row resolved to strings), a row whose creature record the
|
|
//! cache lacks asks it with the window's callback ([`super::RecordAsk::StableCreature`]), and each
|
|
//! request goes out inside its call through the host ([`StableCall`]).
|
|
//!
|
|
//! The window shows slots 0..=2: 0 is the current pet, 1 and 2 the stable slots a hunter buys
|
|
//! (`NUM_PET_STABLE_SLOTS` 2, two `StableSlotPrices.dbc` rows, vmangos `MAX_PET_STABLES` 2). The
|
|
//! wire's 1-based slot arrives rebased ([`benilla_protocol::messages::StabledPet::slot`]). Slot 0
|
|
//! can hold a row with no pet out: a dismissed or distant pet still comes from the server's
|
|
//! character-pet cache, and the stock window falls back to `GetStablePetInfo(0)` when
|
|
//! `UnitExists("pet")` is false (`PetStable.lua:138`).
|
|
//!
|
|
//! `PickupStablePet` puts the pet on the global cursor as payload mode 10
|
|
//! ([`super::cursor::CursorPayload::StablePet`]): `[0xb4d900] = 10` is written at one site,
|
|
//! `0x4950ae`, in the stabled-pet grab `0x495010`.
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::binding_abi;
|
|
use super::live::{ask, LiveQuery};
|
|
use super::Model;
|
|
|
|
/// The current pet plus the `NUM_PET_STABLE_SLOTS` (2) stable slots (`PetStable.lua:1`).
|
|
pub const NUM_STABLE_SLOTS: usize = 3;
|
|
|
|
/// One stable window row, every wire field resolved to what the Lua renders. An empty slot is
|
|
/// `None` in [`StableState::slots`]; an unbought one lies past [`StableState::num_stable_slots`].
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct StablePetSlot {
|
|
/// The pet's id, which the unstable and swap verbs name; never its slot.
|
|
pub pet_number: u32,
|
|
/// The record's creature entry (`+0x8`), which the getters look the template up by.
|
|
pub entry: u32,
|
|
/// The family icon path, `None` while the creature query is in flight; the stock window passes
|
|
/// it to `SetItemButtonTexture`, which draws the empty-slot art for nil.
|
|
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"), `None` until the creature query lands.
|
|
pub family: Option<String>,
|
|
/// The localized `PetLoyalty.dbc` name for the wire's loyalty level.
|
|
pub loyalty: Option<String>,
|
|
/// The localized food names the pet's family eats: `GetStablePetFoodTypes`'s returns.
|
|
pub diet: Vec<String>,
|
|
/// The creature display the row's entry names in the creature cache, the record
|
|
/// `SetPetStablePaperdoll` shows (`0x4cba0e`); `None` while the query is in flight.
|
|
pub display_id: Option<u32>,
|
|
}
|
|
|
|
/// The open stable as the getters read it; `None` when no stable is open.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct StableState {
|
|
/// Stable slots bought, 0..=2 (the wire's `numStableSlots`), not occupied: it enables buttons
|
|
/// `1..=n` and prices the next slot.
|
|
pub num_stable_slots: u32,
|
|
/// The next slot's price in copper (`StableSlotPrices.dbc` row `num_stable_slots + 1`), 0 past
|
|
/// the table, where the stock window has 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],
|
|
/// A live pet is out: the client's pet-guid test (`[0xb714a0]|[0xb714a4]`), which forks a
|
|
/// stabled pet's drop between swap and unstable. Not `slots[0].is_some()`: a dismissed or
|
|
/// distant pet keeps its slot-0 row with no live guid.
|
|
pub has_live_pet: bool,
|
|
}
|
|
|
|
impl StableState {
|
|
/// `GetNumStablePets()`: how many of the three slots hold a pet.
|
|
fn num_pets(&self) -> u32 {
|
|
self.slots.iter().filter(|s| s.is_some()).count() as u32
|
|
}
|
|
}
|
|
|
|
/// An outbound stable request, which the host addresses to the open stable master.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum StableIntent {
|
|
/// `CMSG_STABLE_PET`: stable the current pet; the server picks the first free bought slot.
|
|
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.
|
|
BuySlot,
|
|
}
|
|
|
|
/// A stable verb the host applies inside its call ([`super::ScriptCall::Stable`]).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum StableCall {
|
|
/// `StablePet()` or `BuyStableSlot()` past the binding's checks: its packet, sent only while a
|
|
/// master is stored (`0x4cae56`, `0x4cb0ae`).
|
|
Request(StableIntent),
|
|
/// A `ClickStablePet` drop's request, sent with the stored master's guid, 0 when none, with no
|
|
/// test of it (`0x4cb4e9`, `0x4cb57a`, `0x4cb623`, `0x4cb693`).
|
|
Drop(StableIntent),
|
|
/// `UnstablePet(index)` past its index, by the pet number found: the host refuses it while the
|
|
/// active player holds a charm or a summon, or is not held (`0x4caf4e`-`0x4caf88`), then sends
|
|
/// the unstable request with the stored master's guid, 0 when none (`0x4caff7`-`0x4cb00c`).
|
|
Unstable(u32),
|
|
/// `ClosePetStables()` (`0x4cae40`): the close core `0x4cae10`.
|
|
Close,
|
|
}
|
|
|
|
/// The stable as the getters read it at the call: the window and the summoned pet's live display.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct StableView {
|
|
pub state: Option<StableState>,
|
|
/// The summoned pet's creature-cache display while the client holds its unit
|
|
/// ([`StableModel::live_pet_display`]).
|
|
pub live_pet_display: Option<u32>,
|
|
}
|
|
|
|
/// The question: [`StableView`] now, the module's records and cells (`0xb72058`, `0xb720c8`,
|
|
/// `0xb72240`-`0xb7224c`) as the handlers left them, if they hold the list the VM's copy came from:
|
|
/// a later list has not signalled yet, and its listeners' moment reads the copy.
|
|
pub struct StableQuery {
|
|
pub list: u64,
|
|
}
|
|
|
|
impl LiveQuery for StableQuery {
|
|
type Answer = StableView;
|
|
}
|
|
|
|
/// The stable now: the host's, else the VM's copy, the last signalled list's.
|
|
fn view(lua: &Lua, model: &Model) -> StableView {
|
|
let query = StableQuery {
|
|
list: model.stable.list,
|
|
};
|
|
ask(lua, &query).unwrap_or_else(|| StableView {
|
|
state: model.stable.state.clone(),
|
|
live_pet_display: model.stable.live_pet_display,
|
|
})
|
|
}
|
|
|
|
/// A getter's creature lookup of `pet`'s entry with the window's callback (`0x4cb2ce`,
|
|
/// `0x4cbb05` with `0x4cb3b0`; `0x4cba13` with `0x4cba50` for the paperdoll), made only on a miss:
|
|
/// a held record touches nothing.
|
|
fn ask_creature(lua: &Lua, pet: &StablePetSlot, paperdoll: bool) -> mlua::Result<()> {
|
|
if pet.display_id.is_some() {
|
|
return Ok(());
|
|
}
|
|
super::calls::ask(
|
|
lua,
|
|
super::RecordAsk::StableCreature {
|
|
entry: pet.entry,
|
|
paperdoll,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The window's own state beside the snapshot: the selection.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct StableModel {
|
|
pub(crate) state: Option<StableState>,
|
|
/// The selection as the client holds it (`[0xb72250]`): -1 the summoned pet, 0 nothing, else
|
|
/// a pet number, so a pet no longer listed reads back as nothing (`0x4cb84c`).
|
|
pub(crate) selected: i32,
|
|
/// The summoned pet's creature-cache display while the client holds its unit: `[unit+0xb30]`
|
|
/// is the unit's cache record (`0x60b131`), whose display `0x505a70` reads at `[rec+0x2c]`
|
|
/// (`0x505a83`). Apart from the snapshot, whose changes fire the window's events.
|
|
pub(crate) live_pet_display: Option<u32>,
|
|
/// The list the copy came from ([`StableQuery`]).
|
|
pub(crate) list: u64,
|
|
}
|
|
|
|
/// Nothing selected, the client's reset (`0x4caad3`); Lua reads it as -1 through
|
|
/// [`super::UiScript::stable_selection`] (`PetStable.lua:50`).
|
|
impl Default for StableModel {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: None,
|
|
selected: 0,
|
|
live_pet_display: None,
|
|
list: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// A list's clear of the selection (`0x4cadf8`), ahead of its `PET_STABLE_SHOW`, and the VM's
|
|
/// copy of the stable as list `list` left it; the stock window then re-picks the current pet
|
|
/// or the first occupied slot (`PetStable.lua:49-62`).
|
|
pub fn stable_listed(&mut self, list: u64, view: StableView) {
|
|
let mut model = self.model_mut();
|
|
model.stable.selected = 0;
|
|
model.stable.state = view.state;
|
|
model.stable.live_pet_display = view.live_pet_display;
|
|
model.stable.list = list;
|
|
}
|
|
|
|
/// Set the VM's copy of the open stable, or clear it with `None`, which the getters read with
|
|
/// no host to ask; it clears the selection as a list does ([`Self::stable_listed`]).
|
|
pub fn set_stable(&mut self, state: Option<StableState>) {
|
|
let mut model = self.model_mut();
|
|
model.stable.selected = 0;
|
|
// A held pet stays on the cursor: the reference's close clears only the stable-master
|
|
// guid (`0x4cae10`).
|
|
model.stable.state = state;
|
|
}
|
|
|
|
/// Set the VM's copy of the summoned pet's creature-cache display, `None` while the client
|
|
/// holds no pet unit or its record has not answered ([`StableModel::live_pet_display`]).
|
|
pub fn set_stable_live_pet_display(&mut self, display: Option<u32>) {
|
|
self.model_mut().stable.live_pet_display = display;
|
|
}
|
|
|
|
/// The requests the stable calls a harness VM kept would send, ungated, in call order.
|
|
pub fn take_stable_intents(&mut self) -> Vec<StableIntent> {
|
|
self.take_stable_calls()
|
|
.into_iter()
|
|
.filter_map(|call| match call {
|
|
StableCall::Request(intent) | StableCall::Drop(intent) => Some(intent),
|
|
StableCall::Unstable(pet_number) => Some(StableIntent::Unstable(pet_number)),
|
|
StableCall::Close => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The stable calls a harness VM kept ([`Self::keep_calls`]), in call order.
|
|
pub fn take_stable_calls(&mut self) -> Vec<StableCall> {
|
|
self.take_calls_where(|c| match c {
|
|
super::ScriptCall::Stable(call) => Some(*call),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// `GetSelectedStablePet()`'s answer off the VM's copy, translated from the held pet number: 0
|
|
/// the summoned pet, 1..=2 a stable slot, -1 nothing.
|
|
pub fn stable_selection(&mut self) -> i32 {
|
|
let model = self.model_mut();
|
|
selected_slot(model.stable.selected, model.stable.state.as_ref())
|
|
}
|
|
}
|
|
|
|
/// The pet number to slot translation behind `GetSelectedStablePet` (`0x4cb810`).
|
|
fn selected_slot(selected: i32, state: Option<&StableState>) -> i32 {
|
|
match selected {
|
|
-1 => 0,
|
|
0 => -1,
|
|
pet_number => state
|
|
.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 pet no longer listed reads as nothing, the loop's fall-through at `0x4cb84c`.
|
|
.unwrap_or(-1),
|
|
}
|
|
}
|
|
|
|
/// What `SetPetStablePaperdoll` shows for the selection: the summoned pet's live display, else the
|
|
/// row whose record it reads; a stabled pet's row; nothing for nothing selected or a pet the list
|
|
/// no longer holds.
|
|
enum Paperdoll<'a> {
|
|
Live(u32),
|
|
Row(&'a StablePetSlot),
|
|
Nothing,
|
|
}
|
|
|
|
fn paperdoll<'a>(selected: i32, view: &'a StableView) -> Paperdoll<'a> {
|
|
let Some(state) = view.state.as_ref() else {
|
|
return Paperdoll::Nothing;
|
|
};
|
|
let row = |pet_number: i64| {
|
|
state
|
|
.slots
|
|
.iter()
|
|
.flatten()
|
|
.find(|p| i64::from(p.pet_number) == pet_number)
|
|
};
|
|
let shown = match selected {
|
|
0 => None,
|
|
-1 => match view.live_pet_display {
|
|
Some(display) => return Paperdoll::Live(display),
|
|
None => state.slots[0].as_ref(),
|
|
},
|
|
pet_number => row(i64::from(pet_number)),
|
|
};
|
|
shown.map_or(Paperdoll::Nothing, Paperdoll::Row)
|
|
}
|
|
|
|
/// A slot argument as an index into [`StableState::slots`], `None` out of range.
|
|
fn slot_index(i: i64) -> Option<usize> {
|
|
usize::try_from(i).ok().filter(|&i| i < NUM_STABLE_SLOTS)
|
|
}
|
|
|
|
/// The verb a drop from slot `from` onto slot `to` sends (`ClickStablePet 0x4cb420`): the summoned
|
|
/// pet onto an occupied slot swaps, onto an empty bought slot stables, onto an unbought slot sends
|
|
/// nothing; a stabled pet onto slot 0 swaps when a live pet is out, else unstables. Stable to
|
|
/// stable has no opcode.
|
|
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) {
|
|
(0, _) => match occupied(to) {
|
|
Some(target) => Some(StableIntent::Swap(target.pet_number)),
|
|
None if u32::from(to) <= state.num_stable_slots => Some(StableIntent::Stable),
|
|
None => None,
|
|
},
|
|
(_, 0) => occupied(from).map(|held| {
|
|
if state.has_live_pet {
|
|
StableIntent::Swap(held.pet_number)
|
|
} else {
|
|
StableIntent::Unstable(held.pet_number)
|
|
}
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// What `ClickStablePet(i)` does (`0x4cb420`), keyed only on whether the cursor held a pet.
|
|
enum Click {
|
|
/// A plain click: it selected, and answers 1, so the stock window repaints.
|
|
Selected,
|
|
/// A drop: the request it sends, if any, then the cursor's clear (`0x4cb70a`) and nil; the
|
|
/// repaint comes with the server's next list.
|
|
Dropped(Option<StableIntent>),
|
|
}
|
|
|
|
/// `ClickStablePet(i)`'s choice ([`Click`]) over the stable `state` as of the call; a plain
|
|
/// click's selection is made here.
|
|
fn click_stable_pet(model: &mut Model, state: Option<&StableState>, i: i64) -> Click {
|
|
let held = match &model.cursor {
|
|
Some(super::cursor::CursorPayload::StablePet(held)) => Some(held.slot),
|
|
_ => None,
|
|
};
|
|
let Some(to) = slot_index(i) else {
|
|
// Out of range: a click selects nothing, a drop sends nothing.
|
|
if held.is_none() {
|
|
model.stable.selected = 0;
|
|
return Click::Selected;
|
|
}
|
|
return Click::Dropped(None);
|
|
};
|
|
let to = to as u8;
|
|
|
|
// A drop.
|
|
if let Some(from) = held {
|
|
let intent = state.and_then(|state| drag_verb(from, to, state));
|
|
return Click::Dropped(intent);
|
|
}
|
|
|
|
// A plain click: select only.
|
|
let pet_number = state
|
|
.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 sentinel, not a pet number.
|
|
(0, _) => -1,
|
|
(_, Some(n)) => n as i32,
|
|
// An empty slot selects nothing.
|
|
(_, None) => 0,
|
|
};
|
|
Click::Selected
|
|
}
|
|
|
|
/// Register the stable globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// GetStablePetInfo(i) → icon, name, level, family, loyalty: five values on every exit
|
|
// (`0x4cb280`), a miss answering nil, nil, 0, nil, nil.
|
|
g.set(
|
|
"GetStablePetInfo",
|
|
lua.create_function(|lua, i: Value| {
|
|
// `is-number` else `Usage: GetStablePetInfo(index)` (`0x848f18`, `0x4cb25b`).
|
|
let i = binding_abi::number_arg(lua, i, "Usage: GetStablePetInfo(index)")?;
|
|
// The signed test on `i - 1` (`0x4cb285`-`0x4cb286`): 0 or below is the summoned pet's
|
|
// record, anything else the stabled array's `i - 1` (`0x4cb288`-`0x4cb298`).
|
|
let row = if i.wrapping_sub(1) < 0 {
|
|
Some(0)
|
|
} else {
|
|
slot_index(i64::from(i))
|
|
};
|
|
let pet = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let mut view = view(lua, &model);
|
|
row.and_then(|i| view.state.as_mut()?.slots[i].take())
|
|
};
|
|
if let Some(pet) = &pet {
|
|
ask_creature(lua, pet, false)?;
|
|
}
|
|
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) → one localized diet name per value; none for an empty slot or a
|
|
// family with no diet.
|
|
g.set(
|
|
"GetStablePetFoodTypes",
|
|
lua.create_function(|lua, i: Value| {
|
|
// `is-number` else `Usage: GetStablePetInfo(index)` (`0x848f18`, `0x4cbaa3`).
|
|
let i = i64::from(binding_abi::number_arg(
|
|
lua,
|
|
i,
|
|
"Usage: GetStablePetInfo(index)",
|
|
)?);
|
|
let pet = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let mut view = view(lua, &model);
|
|
slot_index(i).and_then(|i| view.state.as_mut()?.slots[i].take())
|
|
};
|
|
if let Some(pet) = &pet {
|
|
ask_creature(lua, pet, false)?;
|
|
}
|
|
let diet = pet.map(|p| p.diet).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 bought, 0..=2.
|
|
g.set(
|
|
"GetNumStableSlots",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(
|
|
view(lua, &model)
|
|
.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(
|
|
view(lua, &model)
|
|
.state
|
|
.as_ref()
|
|
.map_or(0, StableState::num_pets),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// GetNextStableSlotCost() → the next slot's price in copper, 0 with no stable open or past the
|
|
// table.
|
|
g.set(
|
|
"GetNextStableSlotCost",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(
|
|
view(lua, &model)
|
|
.state
|
|
.as_ref()
|
|
.map_or(0, |s| s.next_slot_cost),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSelectedStablePet() → the selected slot, or -1, on which the stock window picks one itself
|
|
// (`PetStable.lua:50`).
|
|
g.set(
|
|
"GetSelectedStablePet",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let view = view(lua, &model);
|
|
Ok(i64::from(selected_slot(
|
|
model.stable.selected,
|
|
view.state.as_ref(),
|
|
)))
|
|
})?,
|
|
)?;
|
|
|
|
// ClickStablePet(i) → one value ([`click_stable_pet`]).
|
|
g.set(
|
|
"ClickStablePet",
|
|
lua.create_function(|lua, i: Value| {
|
|
// `is-number` else `Usage: ClickStablePet(index)` (`0x848f38`, `0x4cb43b`).
|
|
let i = i64::from(binding_abi::number_arg(
|
|
lua,
|
|
i,
|
|
"Usage: ClickStablePet(index)",
|
|
)?);
|
|
let state = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
view(lua, &model).state
|
|
};
|
|
match super::tick::verb(lua, |model| click_stable_pet(model, state.as_ref(), i)) {
|
|
Click::Selected => Ok(Value::Number(1.0)),
|
|
Click::Dropped(intent) => {
|
|
if let Some(intent) = intent {
|
|
super::calls::submit(
|
|
lua,
|
|
super::ScriptCall::Stable(StableCall::Drop(intent)),
|
|
)?;
|
|
}
|
|
super::tick::verb(lua, super::cursor::clear_cursor);
|
|
Ok(Value::Nil)
|
|
}
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// PickupStablePet(i) (`0x4cb7a0`): the mode-10 cursor grab, returning nothing. The binding
|
|
// takes an occupied slot only (`0x4cb7f9`); the grab (`0x495010`) clears with
|
|
// `ClearCursor(1,1)` (`0x495034`) before its own check, the family icon: no icon, no grab, the
|
|
// cursor left clear. The set fires nothing of its own.
|
|
g.set(
|
|
"PickupStablePet",
|
|
lua.create_function(|lua, i: Value| {
|
|
// `is-number` else `Usage: PickupStablePet(index)` (`0x848f58`, `0x4cb7b1`).
|
|
let i = i64::from(binding_abi::number_arg(
|
|
lua,
|
|
i,
|
|
"Usage: PickupStablePet(index)",
|
|
)?);
|
|
let pet = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let mut view = view(lua, &model);
|
|
slot_index(i).and_then(|i| view.state.as_mut()?.slots[i].take())
|
|
};
|
|
if let Some(pet) = pet {
|
|
super::cursor::set_cursor(lua, true, super::cursor::Setter::Free, |_| {
|
|
let texture = pet.icon?;
|
|
Some(super::cursor::CursorPayload::StablePet(
|
|
super::cursor::CursorStablePet {
|
|
slot: i as u8,
|
|
texture,
|
|
},
|
|
))
|
|
});
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// StablePet() (`0x4cae50`): no arguments, gated only on a stored master (`0x4cae56`), which
|
|
// the host checks; stock FrameXML stables by drag.
|
|
g.set(
|
|
"StablePet",
|
|
lua.create_function(|lua, ()| {
|
|
super::calls::submit(
|
|
lua,
|
|
super::ScriptCall::Stable(StableCall::Request(StableIntent::Stable)),
|
|
)
|
|
})?,
|
|
)?;
|
|
|
|
// UnstablePet(i) (`0x4caf20`): silently refused while the active player holds a charm or a
|
|
// summon, a gate the drag path lacks, which the host asks of the player's own descriptor; the
|
|
// index's unsigned bound rejects slot 0.
|
|
g.set(
|
|
"UnstablePet",
|
|
lua.create_function(|lua, i: Value| {
|
|
// `is-number` else `Usage: UnstablePet(index)` (`0x848efc`, `0x4caf38`).
|
|
let i = i64::from(binding_abi::number_arg(
|
|
lua,
|
|
i,
|
|
"Usage: UnstablePet(index)",
|
|
)?);
|
|
let pet_number = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
view(lua, &model)
|
|
.state
|
|
.as_ref()
|
|
.and_then(|s| {
|
|
slot_index(i)
|
|
.filter(|&i| i != 0)
|
|
.and_then(|i| s.slots[i].as_ref())
|
|
})
|
|
.map(|p| p.pet_number)
|
|
};
|
|
match pet_number {
|
|
Some(pet_number) => super::calls::submit(
|
|
lua,
|
|
super::ScriptCall::Stable(StableCall::Unstable(pet_number)),
|
|
),
|
|
None => Ok(()),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// SetPetStablePaperdoll(frame) (`0x4cb870`): the selection's creature display onto a
|
|
// character pane (`0x505cb0`), never a unit copy. The summoned pet shows its live display
|
|
// (`[unit+0xb30]`, `0x4cb99e`), else its record's through the creature cache (`0x4cb9bc`); a
|
|
// stabled pet its row's (`0x4cb9dc`); nothing selected sets nothing (`0x4cb931`), and neither
|
|
// does a display the cache has not answered (`0x4cba1a`), whose answer fires
|
|
// `PET_STABLE_UPDATE_PAPERDOLL` (`0x4cba5f`).
|
|
g.set(
|
|
"SetPetStablePaperdoll",
|
|
lua.create_function(|lua, frame: Value| {
|
|
let Value::Table(frame) = frame else {
|
|
return Err(mlua::Error::runtime(
|
|
"Usage: SetPetStablePaperdoll(characterModel)",
|
|
));
|
|
};
|
|
let h = super::object::frame_handle_of(lua, &frame).map_err(|_| {
|
|
mlua::Error::runtime(
|
|
"SetPetStablePaperdoll(): Couldn't find 'this' in frame object",
|
|
)
|
|
})?;
|
|
let (selected, view) = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
// The object type check (`0x4cb904`): a character pane, else the error at
|
|
// `0x848fd8`.
|
|
let character = matches!(
|
|
model.arena.frame(h).map(|f| &f.kind_state),
|
|
Some(crate::widget::KindState::Model(m)) if m.character.is_some()
|
|
);
|
|
if !character {
|
|
return Err(mlua::Error::runtime(
|
|
"SetPetStablePaperdoll(): Wrong object type, expected frame",
|
|
));
|
|
}
|
|
(model.stable.selected, view(lua, &model))
|
|
};
|
|
let display = match paperdoll(selected, &view) {
|
|
Paperdoll::Live(display) => Some(display),
|
|
Paperdoll::Row(pet) => {
|
|
ask_creature(lua, pet, true)?;
|
|
pet.display_id
|
|
}
|
|
Paperdoll::Nothing => None,
|
|
};
|
|
if let Some(display) = display.filter(|&d| d != 0) {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.set_pane_display(h, display);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// BuyStableSlot(): the client's silent gates, in order: an open stable, fewer than 2 slots
|
|
// (`0x4cb0c4`), a price row and affordability (`0x4cb122`).
|
|
g.set(
|
|
"BuyStableSlot",
|
|
lua.create_function(|lua, ()| {
|
|
let allowed = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
// Affordability reads the purse `GetMoney()` answers (`0x4cb122`).
|
|
let money = super::stat_read::purse(lua, &model);
|
|
view(lua, &model).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 {
|
|
super::calls::submit(
|
|
lua,
|
|
super::ScriptCall::Stable(StableCall::Request(StableIntent::BuySlot)),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// ClosePetStables() (`0x4cae40`): no packet exists; the close core `0x4cae10` inside the
|
|
// call.
|
|
g.set(
|
|
"ClosePetStables",
|
|
lua.create_function(|lua, ()| {
|
|
super::calls::submit(lua, super::ScriptCall::Stable(StableCall::Close))
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::script::UiScript;
|
|
|
|
fn pet(number: u32, name: &str, level: u32) -> StablePetSlot {
|
|
StablePetSlot {
|
|
pet_number: number,
|
|
entry: 100 + 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()],
|
|
// One display per pet number, so a test can tell the rows apart.
|
|
display_id: Some(1000 + number),
|
|
}
|
|
}
|
|
|
|
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.keep_calls();
|
|
s.set_stable(Some(state([
|
|
Some(pet(7, "Rex", 41)),
|
|
Some(pet(8, "Bruiser", 38)),
|
|
None,
|
|
])));
|
|
}
|
|
|
|
#[test]
|
|
fn the_read_surface_answers_the_reference_calls() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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:77`'s five-value destructuring.
|
|
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());
|
|
}
|
|
|
|
/// The signed test on `i - 1` (`0x4cb285`, `js 0x4cb2a0`) sends 0 and below to the summoned
|
|
/// pet's record; the stabled pets are `i - 1` of the array.
|
|
#[test]
|
|
fn an_index_of_zero_or_below_reads_the_summoned_pet() {
|
|
let mut s = UiScript::new().unwrap();
|
|
open(&mut s);
|
|
for i in ["0", "-1", "-40"] {
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("local _, n = GetStablePetInfo({i}) return n"))
|
|
.unwrap(),
|
|
"Rex",
|
|
"index {i}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
s.eval::<String>("local _, n = GetStablePetInfo(1) return n")
|
|
.unwrap(),
|
|
"Bruiser"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_pet_still_answers_five_values() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
for call in ["GetStablePetInfo(2)", "GetStablePetInfo(9)"] {
|
|
assert_eq!(s.arity(call).unwrap(), 5, "{call} arity");
|
|
// Still falsy for the stock window's `if ( GetStablePetInfo(i) )`.
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_plain_click_returns_truthy() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_selection_translates_back_to_slot_indices() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_list_clears_the_selection_and_a_stale_pet_degrades() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
s.eval::<()>("ClickStablePet(1)").unwrap();
|
|
assert_eq!(s.stable_selection(), 1);
|
|
|
|
// Every list clears it (`0x4cadf8`).
|
|
open(&mut s);
|
|
assert_eq!(s.stable_selection(), -1, "a list clears the selection");
|
|
}
|
|
|
|
#[test]
|
|
fn every_drop_returns_falsy_even_when_it_sends() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
// A drop that sends.
|
|
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.
|
|
assert!(s
|
|
.eval::<bool>("PickupStablePet(1) return ClickStablePet(1) == nil")
|
|
.unwrap());
|
|
assert!(s.take_stable_intents().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dropping_onto_an_occupied_slot_swaps() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
s.eval::<()>("PickupStablePet(0) ClickStablePet(1)")
|
|
.unwrap();
|
|
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unpurchased_slot_takes_the_drop_and_sends_nothing() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
// 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());
|
|
}
|
|
|
|
#[test]
|
|
fn the_fork_follows_the_live_pet_not_the_row() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stable_to_stable_is_a_no_op_and_empty_slots_do_not_grab() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn buy_stable_slot_gates_locally_and_silently() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
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, with money restored so only the cap refuses.
|
|
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");
|
|
}
|
|
|
|
/// The binding hands the host the pet number its index finds; the charm and summon gate is
|
|
/// the host's, on the player's own descriptor (`0x4caf73`-`0x4caf88`).
|
|
#[test]
|
|
fn the_unstable_binding_finds_the_pet_by_its_index() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
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_calls(), vec![StableCall::Unstable(8)]);
|
|
}
|
|
|
|
#[test]
|
|
fn close_is_a_call_and_leaves_the_cursor_alone() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
open(&mut s);
|
|
s.eval::<()>("ClosePetStables()").unwrap();
|
|
assert_eq!(s.take_stable_calls(), [StableCall::Close]);
|
|
}
|
|
|
|
/// `0x4cb870` reads its frame argument as a table (`0x4cb89a`, else the usage at `0x848f78`).
|
|
#[test]
|
|
fn the_paperdoll_setter_wants_a_frame() {
|
|
let s = UiScript::new().unwrap();
|
|
let err = s.eval::<()>("SetPetStablePaperdoll(nil)").unwrap_err();
|
|
assert!(
|
|
err.to_string()
|
|
.contains("Usage: SetPetStablePaperdoll(characterModel)"),
|
|
"{err}"
|
|
);
|
|
// A frame, but no character pane: the object type check (`0x4cb904`, `0x848fd8`).
|
|
s.run(r#"plain = CreateFrame("Model")"#).unwrap();
|
|
let err = s.eval::<()>("SetPetStablePaperdoll(plain)").unwrap_err();
|
|
assert!(
|
|
err.to_string()
|
|
.contains("SetPetStablePaperdoll(): Wrong object type, expected frame"),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
/// The pane shows a creature display, never a unit copy: the summoned pet's live one, else
|
|
/// its row's, and a stabled pet its row's (`0x4cb99e`, `0x4cba0e`, then `0x505cb0`).
|
|
#[test]
|
|
fn the_paperdoll_setter_puts_the_selections_display_on_the_pane() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(r#"m = CreateFrame("PlayerModel", "StableDoll", UIParent)"#)
|
|
.unwrap();
|
|
let h = s.frame_handle("StableDoll").unwrap();
|
|
let shown = |s: &UiScript| {
|
|
let c = s.character_pane(h).unwrap();
|
|
(c.guid, c.display)
|
|
};
|
|
open(&mut s);
|
|
// The summoned pet's cache record, held apart from the snapshot.
|
|
s.set_stable_live_pet_display(Some(4449));
|
|
// Nothing selected after a list: the call sets nothing (`0x4cb931`).
|
|
s.eval::<()>("SetPetStablePaperdoll(m)").unwrap();
|
|
assert_eq!(shown(&s), (0, 0));
|
|
|
|
// The current pet out: its live display.
|
|
s.eval::<()>("ClickStablePet(0) SetPetStablePaperdoll(m)")
|
|
.unwrap();
|
|
assert_eq!(shown(&s), (0, 4449));
|
|
let builds = s.character_pane(h).unwrap().builds;
|
|
assert!(builds > 0, "a visible pane asks for its copy");
|
|
|
|
// A stabled pet: its row's display, through the creature cache.
|
|
s.eval::<()>("ClickStablePet(1) SetPetStablePaperdoll(m)")
|
|
.unwrap();
|
|
assert_eq!(shown(&s), (0, 1008));
|
|
assert!(s.character_pane(h).unwrap().builds > builds);
|
|
|
|
// The pet dismissed: the current pet's row.
|
|
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.set_stable_live_pet_display(None);
|
|
s.eval::<()>("ClickStablePet(0) SetPetStablePaperdoll(m)")
|
|
.unwrap();
|
|
assert_eq!(shown(&s), (0, 1007));
|
|
|
|
// A unit stored over it clears the display, as `0x505b50` does.
|
|
s.set_unit_guids(&crate::script::UnitGuids {
|
|
player: 0x42,
|
|
..Default::default()
|
|
});
|
|
s.eval::<()>("m:SetUnit('player')").unwrap();
|
|
assert_eq!(shown(&s), (0x42, 0));
|
|
}
|
|
|
|
/// The grab clears first (`0x495034`) and only then checks the family icon: a slot without one
|
|
/// leaves the cursor clear. The set fires nothing of its own, so every `CURSOR_UPDATE` reads
|
|
/// the emptied cursor.
|
|
#[test]
|
|
fn a_stable_grab_clears_before_its_icon_check() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.keep_calls();
|
|
let mut iconless = pet(8, "Bruiser", 38);
|
|
iconless.icon = None;
|
|
s.set_stable(Some(state([Some(pet(7, "Rex", 41)), Some(iconless), None])));
|
|
crate::script::cursor::install_kind_probe(&s);
|
|
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("CursorKind()"));
|
|
s.set_cursor_for_test(crate::script::cursor::CursorPayload::Macro(
|
|
crate::script::cursor::CursorMacro {
|
|
id: 1,
|
|
texture: None,
|
|
},
|
|
));
|
|
s.run("PickupStablePet(1)").unwrap();
|
|
assert!(s.cursor_payload().is_none(), "cleared, and nothing grabbed");
|
|
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
|
|
s.run("PickupStablePet(0)").unwrap();
|
|
assert!(matches!(
|
|
s.cursor_payload(),
|
|
Some(crate::script::cursor::CursorPayload::StablePet(_))
|
|
));
|
|
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
|
|
// An empty slot is the binding's own refusal, ahead of the grab and its clear.
|
|
s.run("PickupStablePet(2)").unwrap();
|
|
assert!(crate::script::tick::heard::take(&s).is_empty());
|
|
}
|
|
}
|