Files
moonwell-client-core/crates/benilla-ui/src/script/auction.rs
T
Sam Sung 831310c1f2 The auction sell slot asks for a missing record, redrawing when it lands (#2760)
An item put in the auction sell slot before its record was cached showed a price and deposit of 0 until something else redrew the frame. In 1.12.1, `CalculateAuctionDeposit` and `GetAuctionSellItemInfo` ask for the record with the callback `0x4ce2f0` (`0x4ce25e`, `0x4ce5e3`). It signals `NEW_AUCTION_UPDATE` when the record is found (`0x4ce2ff`).

Both getters now ask, gated on the item's object (`0x468460`), with the item's own guid (`0x4ce263`, `0x4ce5e8`). Each asking call keeps its callback, and the event goes on the house lane. On a miss they return the reference's empty shape: a deposit of 0, and `nil, nil, 1, -1, nil, 0` (`0x4ce726`). The deposit does no lookup with no house (`0x4ce1f7`). With a host, the name, icon, quality and price read live.

Tests: a record that lands later fires the event once per call and then answers; a gone object asks nothing.

Fixes #2747

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:22:53 +07:00

2790 lines
113 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The auction-house bindings: the app pushes each list's page as its handler lands it, rows
//! resolved and in wire order; the getters read each row's record, the throttle and the deposit
//! rate through the lent host at the call ([`super::auction_live`]), and the verbs that send hand
//! their call to the host, which applies it inside the call ([`AuctionCall`]). Getters are keyed by
//! `"list"` (Browse), `"bidder"` (Bids) or `"owner"` (Auctions), with `index` 1-based in the
//! 50-row batch.
//!
//! The order is the VM's: a list is sorted where the reference sorts it, by its key stack
//! ([`AuctionSortStack`]), in `SortAuctionItems` and in the tail the app runs once a result's
//! items are all known ([`super::UiScript::sort_auction_list`]), and holds still in between.
use mlua::{Lua, MultiValue, Value};
use super::auction_live::{deposit_rate, live_row_at, refresh_live, row_at, AuctionCanQuery};
use super::binding_abi::{self, flag};
use super::cursor;
use super::item_read::{read_live, ItemKey};
use super::Model;
use crate::auction_sort::{floor_level, sort_column, AuctionSortStack};
/// The three list types, in the order the app pushes them.
pub const LIST: usize = 0;
pub const BIDDER: usize = 1;
pub const OWNER: usize = 2;
/// The eight sort columns by number, as `SortAuctionItems` names them (`0x4cfc4b`-`0x4cfd1d`)
/// and the comparator `0x4cd940` numbers its modes: level 0, quality 1, bid 2, duration 3,
/// buyout 4, status 5, name 6, seller 7.
pub const SORT_KEYS: [&str; 8] = [
"level", "quality", "bid", "duration", "buyout", "status", "name", "seller",
];
/// A list type's snapshot index, for the other modules (the tooltip's `SetAuctionItem`).
pub(super) fn list_index_of(kind: &str) -> Option<usize> {
list_index(kind)
}
/// `"owner"`, `"bidder"` or `"list"`, matched without case in that order (`SStrCmpI`,
/// `0x4ced6b`, `0x4cedb0`, `0x4cedf4`), by every function that takes a list type.
fn list_index(kind: &str) -> Option<usize> {
[("owner", OWNER), ("bidder", BIDDER), ("list", LIST)]
.into_iter()
.find(|(name, _)| crate::crt::str_cmp_i(name, kind) == 0)
.map(|(_, i)| i)
}
/// `SortAuctionItems`' and `IsAuctionSortReversed`' two arguments: `lua_isstring` on each, a number
/// read as its text (`0x6f3510`, `0x6f3690`), else the usage line.
fn type_and_sort(
lua: &Lua,
kind: Value,
sort: Value,
usage: &'static str,
) -> mlua::Result<(String, String)> {
Ok((
binding_abi::string_arg(lua, kind, usage)?,
binding_abi::string_arg(lua, sort, usage)?,
))
}
/// One auction row, resolved by the app from a wire record.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionItemRow {
/// The wire auction id a bid or a cancel addresses; never shown.
pub auction_id: u32,
pub item_id: u32,
/// The wire's `randomPropertyId`, 0 when unrolled; [`Self::name`] already carries its suffix,
/// and the reference's `SetAuctionItem` writes it into the tooltip's `+0x424`.
pub random_property_id: u32,
/// The wire's spell charges, which `SetAuctionItem` writes into the tooltip's `+0x428`
/// (`0x535a4a`).
pub charges: i32,
/// `None` while the item template query is in flight.
pub name: Option<String>,
pub texture: Option<String>,
pub count: u32,
/// `None` while the item template query is in flight.
pub quality: Option<u32>,
/// The template's `RequiredLevel` (`+0x3c`), 0 for none; `GetAuctionItemInfo` and the level
/// sort read it floored at 1.
pub level: u32,
/// The seller's opening price, shown as the current bid while [`Self::bid_amount`] is 0.
pub min_bid: u32,
/// The minimum step above the current bid; 0 before any bid.
pub min_increment: u32,
/// `0` = no buyout.
pub buyout_price: u32,
/// The current high bid; 0 before any bid.
pub bid_amount: u32,
/// `GetAuctionItemInfo`'s eleventh value, which the app picks per list and row.
pub high_bidder: AuctionHighBidder,
/// The seller's name; `None` while the name query is in flight.
pub owner: Option<String>,
/// The auction's deadline in milliseconds on `GetTime`'s clock ([`UiScript::auction_tick_ms`]):
/// the tick its list was read at plus the wire's milliseconds left, wrapping (`0x4cc865`,
/// `0x4cc966`-`0x4cc975`). `GetAuctionItemTimeLeft` reads it against the clock at the call.
///
/// [`UiScript::auction_tick_ms`]: super::UiScript::auction_tick_ms
pub deadline: u32,
/// The high bidder's rank (`0x4cdb10`-`0x4cdb6a`): 2 the player, 1 another bidder, 0 no bid.
pub bidder_rank: u8,
/// The item template's own name (`[rec+8]`), without the random suffix [`Self::name`]
/// carries; `None` while the template query is in flight.
pub base_name: Option<String>,
/// The full item link: auction rows carry entry, enchant, random property and suffix.
pub link: Option<String>,
/// The seller's guid (`rec+0x20`), whose name `GetAuctionItemInfo` asks the name cache for
/// with the list's callback when it is not cached (`0x4cf1c8`); 0 asks nothing (`0x4cf1b2`).
pub owner_guid: u64,
/// The bidder the eleventh value names: on an owner row the player owns with a bid
/// (`0x4cf131`-`0x4cf152`), the bid's guid, asked like the seller's (`0x4cf167`); else 0.
pub named_bidder: u64,
}
/// `GetAuctionItemInfo`'s `highBidder` (`0x4cf12a`–`0x4cf1a0`): on an `"owner"` row the player
/// owns, the bidder's name from the name cache; on any other row, `1` when the player holds the
/// bid. Nil otherwise, which the stock Auctions tab prints as `NO_BIDS`.
#[derive(Clone, Debug, Default, PartialEq)]
pub enum AuctionHighBidder {
/// No bid, another player's bid on a row the player does not own, or a name not arrived.
#[default]
Nil,
/// The player holds the bid on a row it does not own: `1`.
Player,
/// The bidder on the player's own auction, by name.
Name(String),
}
/// What a bid result or notice writes into a held row: the bid (`+0x40`), the step (`+0x2c`), the
/// deadline (`+0x34`) and the bidder (`+0x38`), with what the getters read off the bidder, as the
/// app resolves a row's ([`UiScript::patch_auction_row`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionRowBid {
pub bid_amount: u32,
pub min_increment: u32,
pub deadline: u32,
pub bidder_rank: u8,
pub high_bidder: AuctionHighBidder,
pub named_bidder: u64,
}
/// One of the three lists: the batch the server sent, the pre-cap match count and its sort stack.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionListState {
/// The current batch, at most 50 (the server's page cap): in wire order as the app pushes it,
/// in display order once the VM holds it.
pub rows: Vec<AuctionItemRow>,
/// `totalAuctions`: the match count before the 50-row cap.
pub total: u32,
/// The list's key stack. The VM takes the app's copy at its first push and keeps its own
/// after it, which the header clicks promote in place.
pub sort: AuctionSortStack,
/// Which result the rows came from, counted by the app across every list: a new one is laid
/// in wire order, as the result handler writes its records (`0x4cc880`), and one the VM holds
/// keeps the VM's order.
pub page: u32,
/// The list's sort tail is still owed: its pending-lookups counter is not back at 0
/// (`0xb72644`, `0xb72648`, `0xb7264c`), so a removal or a bid patch signals nothing
/// (`0x4ce035`, `0x4cc5c3`, `0x4cd090`).
pub pending: bool,
/// The auctions a time-left read removed from this page ([`remove_auction`]), which a push of
/// the same page leaves out. The VM's own: a push's value is not read.
pub removed: Vec<u32>,
}
/// One class of the Browse tab's category tree, from the player's `ItemClass.dbc`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionCategory {
/// The wire's `mainCategory`: an item class id, not the menu position.
pub class_id: u32,
pub name: String,
/// The `0x807060` table's second column. False (Consumable, Trade Goods, Reagent,
/// Miscellaneous) empties `GetAuctionItemSubClasses` and `GetAuctionInvTypes`, but
/// `QueryAuctionItems` never reads it.
pub has_subclass_filter: bool,
/// The class's `ItemSubClass.dbc` rows with `DisplayFlags & 2` clear, in file order: what both
/// the menu and the query's index scan walk.
pub subclasses: Vec<AuctionSubCategory>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionSubCategory {
/// The wire's `subCategory`.
pub sub_id: u32,
pub name: String,
/// `ItemSubClass.Flags & 0x200` (`0x4cfb63`): whether the subclass offers the 14
/// inventory-slot rows.
pub has_inv_types: bool,
}
/// The auction house as the VM holds it, seeded whole into a new VM and reset whole at the
/// world-enter cascade: the three lists, which outlive the session as the reference's arrays do,
/// and the session's deposit rate.
#[derive(Clone, Debug, PartialEq)]
pub struct AuctionState {
/// Browse / Bids / Auctions, indexed by [`LIST`], [`BIDDER`], [`OWNER`].
pub lists: [AuctionListState; 3],
/// `GetAuctionHouseDepositRate()`: the house's `AuctionHouse.dbc` deposit percentage.
pub deposit_percent: u32,
}
impl Default for AuctionState {
/// Three empty lists, each with its world-entry stack, and no deposit rate.
fn default() -> Self {
Self {
lists: AuctionSortStack::seeded_all().map(|sort| AuctionListState {
sort,
..Default::default()
}),
deposit_percent: 0,
}
}
}
/// `QueryAuctionItems`' arguments, the `CMSG_AUCTION_LIST_ITEMS` filters: each is already a wire
/// id, mapped from the Lua's menu position as `0x4ce980` does.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionQuery {
pub name: String,
/// `0` = no filter, for both: the argument's `_ftol` low byte, 0 for a non-number
/// (`0x4ce9fd`-`0x4cea3b`).
pub min_level: u8,
pub max_level: u8,
/// `None` = no filter (the wire's `0xFFFFFFFF`), for all three.
pub inv_type: Option<u32>,
pub class: Option<u32>,
pub sub_class: Option<u32>,
/// A minimum quality, not an equality, as the wire takes it: the argument's `_ftol`, a
/// negative's 32-bit pattern, `u32::MAX` (-1, the dropdown's All) for a non-number
/// (`0x4ceb72`-`0x4ceb96`).
pub quality: u32,
/// The wire's item offset: the page argument's `_ftol` times 50 in 32 bits, 0 for a
/// non-number (`0x4ceb28`-`0x4ceb44`).
pub list_from: u32,
/// The usable-only byte: the argument's `_ftol` low byte, 0 for a non-number
/// (`0x4ceb47`-`0x4ceb6f`, sent at `0x4cec32`).
pub usable: u8,
}
/// `StartAuction`'s send (`0x4ce8c5`-`0x4ce925`): the sell slot's item as the attach stored it.
#[derive(Clone, Debug, PartialEq)]
pub struct AuctionStartRequest {
/// The item's guid as the attach found it (`[0xb72608]`, sent at `0x4ce8f3`).
pub item_guid: u64,
pub min_bid: u32,
/// `0` = no buyout; else at least the minimum bid (`0x4ce87b`-`0x4ce883`).
pub buyout: u32,
/// Minutes: 120, 480 or 1440, the only values the verb sends (`0x4ce8a0`).
pub duration: u32,
}
/// The run times `StartAuction` and the deposit accept, in minutes (`0x807028`, `0x80e4b0`).
const RUN_TIMES: [u32; 3] = [120, 480, 1440];
/// `PlaceAuctionBid` past its row (`0x4cf610`): the bid as passed and the fields of the record its
/// checks read, as the call found them.
#[derive(Clone, Debug, PartialEq)]
pub struct AuctionBid {
/// [`LIST`], [`BIDDER`] or [`OWNER`]: the verb takes any of the three (`0x4cf6ae`-`0x4cf70f`).
pub list: usize,
/// `rec+0x04`, the id the packet carries (`0x4cf8f6`).
pub auction_id: u32,
/// The bid, `_ftol` of argument 3 (`0x4cf696`).
pub amount: u32,
/// `rec+0x08`, the entry the unique-item check looks up (`0x4cf86f`).
pub item_id: u32,
/// `rec+0x28`, the start bid.
pub min_bid: u32,
/// `rec+0x2c`, the minimum increment.
pub increment: u32,
/// `rec+0x30`, 0 for none.
pub buyout: u32,
/// `rec+0x40`, 0 before any bid.
pub current: u32,
/// The player holds the bid: `rec+0x38` is its guid (`0x4cf7fb`-`0x4cf810`).
pub player_leads: bool,
}
/// What `PlaceAuctionBid`'s checks make of a bid.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BidVerdict {
/// Send `CMSG_AUCTION_PLACE_BID` with this bid (`0x4cf915`), the buyout when it was lowered.
Send(u32),
/// Show this line and send nothing.
Refuse(&'static str),
/// Send nothing and say nothing: a bid past 2,000,000,000 and the next step (`0x4cf7f0`).
Drop,
}
impl AuctionBid {
/// The checks in the reference's order (`0x4cf762`-`0x4cf8a6`), every compare unsigned but the
/// unique count's. `money` is the player's coinage, 0 when it is not the active player
/// (`0x4cf834`); `unique` gives the template's `MaxCount` (`+0x5c`) and the carried count
/// (`0x622130` with scope `0x4f`), asked only past the money check and only for a cached record
/// (`0x4cf879`, no callback).
pub fn verdict(&self, money: u32, unique: impl FnOnce() -> Option<(i32, i32)>) -> BidVerdict {
// A bid above a nonzero buyout becomes the buyout (`0x4cf762`-`0x4cf773`).
let bid = if self.buyout > 0 && self.amount > self.buyout {
self.buyout
} else {
self.amount
};
// Under the start bid, whatever the current bid (`0x4cf775`).
if bid < self.min_bid {
return BidVerdict::Refuse("ERR_AUCTION_MIN_BID");
}
// Not above the current bid (`0x4cf795`).
if bid <= self.current {
return BidVerdict::Refuse("ERR_AUCTION_HIGHER_BID");
}
// With a bid, under it plus the increment, unless it reaches a nonzero buyout
// (`0x4cf7b1`-`0x4cf7c4`).
let step = self.current.wrapping_add(self.increment);
if self.current != 0 && bid < step && (self.buyout == 0 || bid < self.buyout) {
return BidVerdict::Refuse("ERR_AUCTION_BID_INCREMENT");
}
// Past 2,000,000,000 and the step, dropped (`0x4cf7e1`-`0x4cf7f0`).
if bid > 2_000_000_000 && bid > step {
return BidVerdict::Drop;
}
// The money covers the bid, or only the rise when the player leads (`0x4cf812`-`0x4cf84a`).
let needed = if self.player_leads {
bid.wrapping_sub(self.current)
} else {
bid
};
if money < needed {
return BidVerdict::Refuse("ERR_NOT_ENOUGH_MONEY");
}
// A unique item carried at its limit is error 0x12 (`0x4cf887`-`0x4cf8a8`).
if unique().is_some_and(|(max, carried)| max > 0 && carried >= max) {
return BidVerdict::Refuse("ERR_ITEM_MAX_COUNT");
}
BidVerdict::Send(bid)
}
}
/// `CancelAuction`'s fee on an auction with a bid (`0x4d0338`-`0x4d0341`): `fild` of the bid times
/// the `f32` 0.05 at `0x807024`, truncated by `_ftol`, and compared unsigned (`0x4d0346`).
pub fn cancel_fee(bid: u32) -> u32 {
binding_abi::ftol(f64::from(bid) * f64::from(f32::from_bits(0x3d4c_cccd))) as u32
}
/// An auction verb past its binding's checks, which the host applies inside the call: its gates
/// on the auctioneer and the session's flags, its refusals and its packet, before the verb
/// returns.
#[derive(Clone, Debug, PartialEq)]
pub enum AuctionCall {
/// `QueryAuctionItems` (`0x4ce980`), its arguments read: with an auctioneer and the throttle
/// open (`0x4ce99c`, `0x4ce9c0`), `CMSG_AUCTION_LIST_ITEMS` and the throttle armed.
Query(AuctionQuery),
/// `GetOwnerAuctionItems(page)` (`0x4cec80`): the list offset, page × 50 (`0x4cecbf`), which
/// the sender `0x4cd680` sends while the owner flag is armed.
OwnerList { offset: u32 },
/// `GetBidderAuctionItems(page)` (`0x4cecd0`), the offset `0x4ced0f`, through `0x4cd720`.
BidderList { offset: u32 },
/// `PlaceAuctionBid` past its row: with a player and an auctioneer (`0x4cf735`-`0x4cf75c`),
/// [`AuctionBid::verdict`].
Bid(AuctionBid),
/// `CancelAuction(index)` past its row: with an auctioneer (`0x4d02a5`), the fee check on a
/// bid (`0x4d02dc`) and `CMSG_AUCTION_REMOVE_ITEM`.
Cancel { auction_id: u32, bid: u32 },
/// `StartAuction` past its slot: with an auctioneer (`0x4ce7da`), the damaged item's refusal
/// or the send and the sell slot's clear (`0x4ce925`, `0x4ce92a`).
Start(Result<AuctionStartRequest, &'static str>),
/// `SortAuctionItems` past its names (`0x4cfc00`): list `list`'s driver promotes `column` in
/// the engine's stack, with a session or without (`0x4cd850`).
Sort { list: usize, column: u8 },
/// `CloseAuctionHouse()` (`0x4ce160`): the close core `0x4cd5f0`.
Close,
}
impl super::UiScript {
/// Seat the Browse tab's class tree. Seeded in each VM's prepare: the reference reads the DBCs
/// inside the verbs, so file scope and the stock `AuctionFrameBrowse_OnLoad` see the classes
/// before any auctioneer session.
pub fn set_auction_item_classes(&mut self, classes: Vec<AuctionCategory>) {
self.model_mut().auction_item_classes = classes;
}
/// Push the lists and the session. The VM's first push takes the app's key stacks and every
/// later one keeps the VM's; a list pushed with the page it holds keeps the VM's order and
/// leaves out what a time-left read removed. The selection is an auction id no push writes
/// (`0x849058`): it stops resolving when its auction leaves the list.
pub fn set_auction(&mut self, state: AuctionState) {
let mut model = self.model_mut();
model.auction = match (model.auction.take(), state) {
(Some(held), mut state) => {
for (list, held) in state.lists.iter_mut().zip(held.lists) {
merge_list(list, held);
}
Some(state)
}
(None, state) => Some(state),
};
}
/// The hello's half the VM holds (`0x4cd59d`): the house's deposit rate, which
/// `GetAuctionHouseDepositRate` reads with no host lent.
pub fn open_auction_session(&mut self, deposit_percent: u32) {
let mut model = self.model_mut();
model
.auction
.get_or_insert_with(AuctionState::default)
.deposit_percent = deposit_percent;
}
/// The close's half the VM holds (`0x4cd613`-`0x4cd61f`): the house goes, and its deposit rate
/// with it; the lists, their stacks and the selection stand, and the getters still answer them.
pub fn close_auction_session(&mut self) {
if let Some(a) = self.model_mut().auction.as_mut() {
a.deposit_percent = 0;
}
}
/// The world-enter cascade's reset of the selection (`0x4cc2d5`), the one writer of the cells
/// besides `SetSelectedAuctionItem`.
pub fn clear_auction_selections(&mut self) {
self.model_mut().auction_selected = [0; 3];
}
/// One list's result as its handler writes it (`0x4cc880`), merged as [`Self::set_auction`]
/// merges each list; with no lists held yet, the others take their empty state and every list
/// the app's stack `sorts`.
pub fn set_auction_list(
&mut self,
list: usize,
mut state: AuctionListState,
sorts: [AuctionSortStack; 3],
) {
let mut model = self.model_mut();
match model.auction.as_mut() {
Some(held) => {
let old = std::mem::take(&mut held.lists[list]);
merge_list(&mut state, old);
held.lists[list] = state;
}
None => {
let mut fresh = AuctionState::default();
for (l, sort) in fresh.lists.iter_mut().zip(sorts) {
l.sort = sort;
}
fresh.lists[list] = state;
model.auction = Some(fresh);
}
}
}
/// A list's sort tail, run by the app when the last of a result's items is known (`0x4cc9f1`,
/// `0x4cca21`): the list sorted by its stack and its update signalled.
pub fn sort_auction_list(&mut self, list: usize) {
super::tick::verb(&self.lua, |model| sort_tail(model, list));
}
/// The 1-based row the auction `auction_id` shows at in `list`, for an instrument that clicks
/// it.
pub fn auction_display_index(&self, list: usize, auction_id: u32) -> Option<u32> {
let model = self.model_ref();
let rows = &model.auction.as_ref()?.lists.get(list)?.rows;
rows.iter()
.position(|r| r.auction_id == auction_id)
.map(|i| i as u32 + 1)
}
/// The millisecond tick a list result is read at (`0x42c010` at `0x4cc865`), on `GetTime`'s
/// clock: the one `GetAuctionItemTimeLeft` reads at its call (`0x4cf548`), so each row's
/// deadline is this plus the wire's milliseconds left.
pub fn auction_tick_ms(&self) -> u32 {
tick_ms(self.model_ref().now)
}
/// The auction calls a harness's VM kept ([`Self::keep_calls`]), in call order.
pub fn take_auction_calls(&mut self) -> Vec<AuctionCall> {
self.take_calls_where(|call| match call {
super::ScriptCall::Auction(call) => Some(call.clone()),
_ => None,
})
}
/// The names `GetAuctionItemInfo` missed, each with the list whose callback it registers, as a
/// harness's VM kept them ([`Self::keep_calls`]), in read order (`0x4cf167`, `0x4cf1c8`).
pub fn take_auction_name_asks(&mut self) -> Vec<(u64, usize)> {
self.take_calls_where(|call| match call {
super::ScriptCall::RecordAsk(super::RecordAsk::AuctionName { guid, list }) => {
Some((*guid, *list))
}
_ => None,
})
}
/// `0x4cdfe0` as a result or notice runs it (`0x4cc658`, `0x4cd173`, `0x4cd468`,
/// `0x4cd550`): the auction leaves each list that holds it on the page `pages` names, browse,
/// owner and bidder in that order, each signalled as its copy finishes ([`remove_from`]).
pub fn remove_auction_from(&mut self, auction_id: u32, pages: [Option<u32>; 3]) {
for list in [LIST, OWNER, BIDDER] {
if let Some(page) = pages[list] {
super::tick::verb(&self.lua, |model| {
remove_from(model, list, Some(page), auction_id)
});
}
}
}
/// A bid result or notice's write into `list`'s row for the auction on `page`, then the
/// list's update unless it waits on an item record: browse `0x4cc5cb`, `0x4cc6fe` and
/// `0x4cd13e`, owner `0x4cd2fc`, bidder `0x4cc764` and `0x4cd099`, each after its pending
/// count's test. The update fires whether or not the row changed.
pub fn patch_auction_row(
&mut self,
list: usize,
page: u32,
auction_id: u32,
bid: AuctionRowBid,
) {
super::tick::verb(&self.lua, |model| {
patch_row(model, list, page, auction_id, bid)
});
}
/// The sell slot's `(bag, slot)`, resolved by the app to the item guid on `StartAuction`.
pub fn auction_sell_item(&mut self) -> Option<(i64, u32)> {
self.model_mut()
.auction_sell_item
.as_ref()
.map(|it| (it.bag, it.slot))
}
/// Empty the sell slot at the close and the world-enter reset: the sell-slot clear
/// (`0x4cd640`), as [`clear_sell_slot`] runs it.
pub fn clear_auction_sell_item(&mut self) {
super::tick::verb(&self.lua, clear_sell_slot);
}
}
/// The sell-slot clear (`0x4cd640`): it lifts the attached item's lock with its
/// `ITEM_LOCK_CHANGED` (`0x495420` at `0x4cd653`), empties the slot and then signals
/// `NEW_AUCTION_UPDATE` (`0x4cd678`); an empty slot signals nothing (`0x4cd64f`).
pub(crate) fn clear_sell_slot(model: &mut Model) {
if let Some(item) = model.auction_sell_item.take() {
super::cursor::unlock_item(model, item.guid);
model.raise("NEW_AUCTION_UPDATE", Vec::new());
}
}
/// The error `ClickAuctionSellItemButton` refuses a held item with (`0x4ce360`-`0x4ce4f1`), read
/// off the item and its record as the reference reads its object; none when the item is gone, as
/// an object the cursor's guid no longer finds goes straight to the attach (`0x4ce35a`). In order:
/// a bound item (`0x5da2c0`) is `ERR_AUCTION_QUEST_ITEM` for quest bonding 4 or 5 (`[rec+0x194]`)
/// and `ERR_AUCTION_BOUND_ITEM` otherwise, a missing record included (`0x4ce38d`); record flags
/// `0x2` or `0x10000` (`0x4ce3e2`, `0x4ce3ec`), `ITEM_FIELD_DURATION` (`0x4ce411`), an area
/// (`0x5da500`) or a map (`0x5da570`) are `ERR_AUCTION_CONJURED_ITEM`; a container with an item in
/// a seat is `ERR_AUCTION_BAG` (`0x4ce45e`-`0x4ce48f`); `ITEM_FIELD_FLAGS & 8` is
/// `ERR_AUCTION_WRAPPED_ITEM` (`0x4ce4b1`); record flags `& 4` are `ERR_AUCTION_LOOT_ITEM`
/// (`0x4ce4d6`). The record is the item cache's, read with no callback (`0x55ba30`); without one
/// its flags read 0 (`0x5da2b3`).
fn sell_refusal(lua: &Lua, model: &Model, item: &cursor::CursorItem) -> Option<&'static str> {
let (bound, flags, duration, seats) = if item.bag == cursor::EQUIPMENT_BAG {
let s = crate::script::char_stats::player_slot_object(lua, model, item.slot as usize)
.filter(|s| s.guid == item.guid)?;
(s.already_bound, s.flags, s.field_duration, s.occupied_seats)
} else {
let s = super::held::held_slot(lua, model, item.bag, item.slot)
.filter(|s| s.guid == item.guid)?;
(s.already_bound, s.flags, s.field_duration, s.occupied_seats)
};
let record = model.item_templates.get(&item.item_id);
/// `BIND_QUEST_ITEM` and `BIND_QUEST_ITEM1`.
const QUEST_BONDING: [u32; 2] = [4, 5];
if bound {
return Some(
if record.is_some_and(|r| QUEST_BONDING.contains(&r.bonding)) {
"ERR_AUCTION_QUEST_ITEM"
} else {
"ERR_AUCTION_BOUND_ITEM"
},
);
}
let record_flags = record.map_or(0, |r| r.flags);
if record_flags & (0x2 | 0x10000) != 0
|| duration != 0
|| record.is_some_and(|r| r.area != 0 || r.map != 0)
{
return Some("ERR_AUCTION_CONJURED_ITEM");
}
if seats.is_some_and(|n| n > 0) {
return Some("ERR_AUCTION_BAG");
}
if flags & 0x8 != 0 {
return Some("ERR_AUCTION_WRAPPED_ITEM");
}
(record_flags & 0x4 != 0).then_some("ERR_AUCTION_LOOT_ITEM")
}
/// `pushed`, a page the VM already holds, in the order of `held`, its rows matched by auction id;
/// a page whose ids differ is taken as pushed.
fn in_held_order(held: &[AuctionItemRow], pushed: Vec<AuctionItemRow>) -> Vec<AuctionItemRow> {
if held.len() != pushed.len() {
return pushed;
}
let mut used = vec![false; pushed.len()];
let mut order = Vec::with_capacity(held.len());
for h in held {
match (0..pushed.len()).find(|&k| !used[k] && pushed[k].auction_id == h.auction_id) {
Some(k) => {
used[k] = true;
order.push(k);
}
None => return pushed,
}
}
let mut rows: Vec<Option<AuctionItemRow>> = pushed.into_iter().map(Some).collect();
order.into_iter().filter_map(|k| rows[k].take()).collect()
}
/// A pushed list against the one the VM held: the VM's stack kept, and on the same page the VM's
/// order and the rows a time-left read removed.
fn merge_list(list: &mut AuctionListState, held: AuctionListState) {
list.sort = held.sort;
list.removed = Vec::new();
if list.page == held.page {
list.removed = held.removed;
let removed = &list.removed;
list.rows.retain(|r| !removed.contains(&r.auction_id));
list.rows = in_held_order(&held.rows, std::mem::take(&mut list.rows));
}
}
/// The sort tails `0x4cd8b0`, `0x4cdcd0` and `0x4cddc0`: `qsort` by the list's stack, then the
/// list's event with no test (`mov ecx,0x1a8`-`0x1aa`; `jmp 0x703e50`), a session or none.
fn sort_tail(model: &mut Model, list: usize) {
if let Some(l) = model.auction.as_mut().and_then(|a| a.lists.get_mut(list)) {
let stack = l.sort;
stack.sort(&mut l.rows);
}
match list {
LIST => model.raise("AUCTION_ITEM_LIST_UPDATE", Vec::new()),
BIDDER => model.raise("AUCTION_BIDDER_LIST_UPDATE", Vec::new()),
_ => model.raise("AUCTION_OWNED_LIST_UPDATE", Vec::new()),
}
}
/// `GetTime`'s seconds as the reference's millisecond tick (`0x42c010`), the dword it wraps at: the
/// clock the auction records' deadlines and the bid notices' 90 s floor are on.
pub fn tick_ms(now: f64) -> u32 {
(now * 1000.0) as u64 as u32
}
/// The time-left bands' upper bounds in milliseconds left (`0x8072a8`): band 1 under 30
/// minutes, 2 under 2 hours, 3 under 8 hours, 4 past them (`0x4cf5a1`).
const TIME_LEFT_BOUNDS: [u32; 3] = [1_800_000, 7_200_000, 28_800_000];
/// How far past its deadline a row still answers 1 and stays: further, the read removes it
/// (`0x4cf569`).
const TIME_LEFT_GRACE_MS: u32 = 10_000;
/// `GetAuctionItemTimeLeft`'s answer for a row with `deadline` at the tick `now` (`0x4cf548`-
/// `0x4cf5a1`): 1 at or past it, the sign read off the wrapping difference (`0x4cf556`), else the
/// band of the time left; and whether it is far enough past to be removed (`0x4cf569`).
fn time_left_band(deadline: u32, now: u32) -> (i64, bool) {
let past = now.wrapping_sub(deadline);
if (past as i32) >= 0 {
return (1, past > TIME_LEFT_GRACE_MS);
}
let left = deadline.wrapping_sub(now);
let band = TIME_LEFT_BOUNDS
.iter()
.position(|&bound| left < bound)
.map_or(4, |i| i as i64 + 1);
(band, false)
}
/// `0x4cdfe0`: the auction leaves browse, owner and bidder in that order (`0x4cdfec`, `0x4ce04b`,
/// `0x4ce0ab`), each list signalled as its own copy finishes (`0x4ce03e`, `0x4ce09e`,
/// `0x4ce101`), so each segment is its own verb and the browse list's handlers still see the row
/// on the owner list.
fn remove_auction(lua: &Lua, auction_id: u32) {
for list in [LIST, OWNER, BIDDER] {
super::tick::verb(lua, |model| remove_from(model, list, None, auction_id));
}
}
/// One list's copy of `0x4cdfe0`: the auction's row goes, the rows after it moving up and the
/// total kept, and a list that held it signals unless its sort tail is owed (`0x4ce035`,
/// `0x4ce095`, `0x4ce0f5`). The VM keeps the id, so a push of the same page leaves it out. With a
/// `page`, a list holding another page is left alone.
fn remove_from(model: &mut Model, list: usize, page: Option<u32>, auction_id: u32) {
let Some(l) = model.auction.as_mut().map(|a| &mut a.lists[list]) else {
return;
};
if page.is_some_and(|p| p != l.page) {
return;
}
let Some(i) = l.rows.iter().position(|r| r.auction_id == auction_id) else {
return;
};
l.rows.remove(i);
l.removed.push(auction_id);
if l.pending {
return;
}
match list {
LIST => model.raise("AUCTION_ITEM_LIST_UPDATE", Vec::new()),
OWNER => model.raise("AUCTION_OWNED_LIST_UPDATE", Vec::new()),
_ => model.raise("AUCTION_BIDDER_LIST_UPDATE", Vec::new()),
}
}
/// One list's copy of a bid patch ([`UiScript::patch_auction_row`]): the row's bid, step,
/// deadline and bidder written, then the list's update unless its pending count is not 0.
fn patch_row(model: &mut Model, list: usize, page: u32, auction_id: u32, bid: AuctionRowBid) {
let Some(l) = model.auction.as_mut().map(|a| &mut a.lists[list]) else {
return;
};
if l.page != page {
return;
}
let Some(r) = l.rows.iter_mut().find(|r| r.auction_id == auction_id) else {
return;
};
r.bid_amount = bid.bid_amount;
r.min_increment = bid.min_increment;
r.deadline = bid.deadline;
r.bidder_rank = bid.bidder_rank;
r.high_bidder = bid.high_bidder;
r.named_bidder = bid.named_bidder;
if l.pending {
return;
}
match list {
LIST => model.raise("AUCTION_ITEM_LIST_UPDATE", Vec::new()),
OWNER => model.raise("AUCTION_OWNED_LIST_UPDATE", Vec::new()),
_ => model.raise("AUCTION_BIDDER_LIST_UPDATE", Vec::new()),
}
}
/// Hand `call` to the host, which applies it before this returns ([`super::calls::submit`]).
fn submit(lua: &Lua, call: AuctionCall) -> mlua::Result<()> {
super::calls::submit(lua, super::ScriptCall::Auction(call))
}
/// A level bound of `QueryAuctionItems`: a number's `_ftol` low byte (`0x4cea17`, `0x4cea3b`),
/// anything else 0.
fn level_byte(lua: &Lua, v: Option<Value>) -> u8 {
v.and_then(|v| binding_abi::is_number(lua, v))
.map_or(0, |n| binding_abi::ftol(n) as u8)
}
/// A list verb's offset: a number's `_ftol` times 50 in 32 bits (`0x4ceb44`, `0x4cecbf`,
/// `0x4ced0f`), anything else 0 (`0x4ceb2f`, `0x4cecaa`, `0x4cecfa`); the verbs never raise.
fn list_offset(lua: &Lua, page: Option<Value>) -> u32 {
match page.and_then(|v| binding_abi::is_number(lua, v)) {
Some(n) => binding_abi::ftol(n).wrapping_mul(50) as u32,
None => 0,
}
}
/// The sell slot's `(name, texture, count, quality, canUse, stackValue)`, `stackValue` being the
/// vendor price times the stack: the base of the suggested opening price and the deposit.
type SellInfo = (String, Option<String>, u32, i64, bool, u32);
/// What the sell slot answers the two getters.
enum SellSlot {
/// No item (`0x4ce21d`, `0x4ce599`).
Empty,
/// An item whose record the item cache lacks, by its object's guid: both getters answer as for
/// an empty slot (`0x4ce274`, `0x4ce5f9`), after asking for the record
/// ([`super::RecordAsk::AuctionSellItem`]).
Unrecorded(u64),
Held(SellInfo),
}
/// The sell slot as the getters read it: the item's name, icon, quality and price off its object
/// and record through the lent host (`0x4ce60f`, `0x4ce641`, `0x4ce68e`, `0x4ce703`), else off the
/// VM's copy, which only a snapshot without a host answers.
fn sell_item_info(lua: &Lua, model: &Model) -> SellSlot {
let Some(it) = model.auction_sell_item.as_ref() else {
return SellSlot::Empty;
};
// The name, icon, quality and vendor price; `None` while the record is uncached.
type Record = (String, Option<String>, i64, u32);
let live = read_live(
lua,
Some(ItemKey::Object(it.guid)),
|item| -> Option<Record> {
let price = item.sell_price()?;
// An item that cannot be equipped reads quality -1 (`0x4ce691`-`0x4ce69d`).
let quality = match item.inventory_type() {
0 => -1,
_ => item.quality().map_or(-1, i64::from),
};
Some((
item.name().unwrap_or_default(),
item.texture(),
quality,
price,
))
},
);
let record = live.unwrap_or_else(|| {
let template = model.item_templates.get(&it.item_id)?;
Some((
cursor::item_link_name(it.link.as_deref()),
it.texture.clone(),
it.quality.map_or(-1, i64::from),
template.sell_price,
))
});
let Some((name, texture, quality, sell_price)) = record else {
return SellSlot::Unrecorded(it.guid);
};
// The real stack size, not the split-carry field: the deposit is per stack.
let count = cursor::held_count(lua, model, it);
let can_use = super::item_stats::item_usable_by_id(lua, model, it.item_id);
// The price is a 32-bit `imul` in both readers (`0x4ce295`, `0x4ce703`).
SellSlot::Held((
name,
texture,
count,
quality,
can_use,
sell_price.wrapping_mul(count),
))
}
/// [`sell_item_info`] with the borrow of the model ended and the record asked for when the slot's
/// item lacks it, inside the call.
fn sell_slot(lua: &Lua) -> mlua::Result<Option<SellInfo>> {
let slot = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
sell_item_info(lua, &model)
};
Ok(match slot {
SellSlot::Empty => None,
SellSlot::Unrecorded(guid) => {
super::calls::ask(lua, super::RecordAsk::AuctionSellItem(guid))?;
None
}
SellSlot::Held(info) => Some(info),
})
}
/// The reference client's deposit, `0x644bc0(rate, stackValue, minutes)`: 0 for a run time off the
/// table at `0x80e4b0` (`0x644bd0`-`0x644be1`), else `rate × stackValue` in 32 bits (`0x644be9`)
/// over 100, times `minutes / 120` (`0x644bf3`-`0x644c17`). The inner floor zeroes a cheap stack
/// the server still charges a few copper for.
fn deposit_for(rate: u32, stack_value: u32, minutes: u32) -> u32 {
if !RUN_TIMES.contains(&minutes) {
return 0;
}
// At most `u32::MAX / 100 × 12`, so the x87 product `_ftol` truncates is exact.
rate.wrapping_mul(stack_value) / 100 * (minutes / 120)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetNumAuctionItems(type) → numBatchAuctions (at most 50), totalAuctions (before the cap).
g.set(
"GetNumAuctionItems",
lua.create_function(|lua, kind: Value| {
// `is-number-or-string` else `Usage: GetNumAuctionItems("type")` (`0x849424`,
// `0x4ced37`).
let kind = binding_abi::string_arg(lua, kind, r#"Usage: GetNumAuctionItems("type")"#)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some((batch, total)) = list_index(&kind).and_then(|i| {
model
.auction
.as_ref()
.map(|a| (a.lists[i].rows.len() as i64, i64::from(a.lists[i].total)))
}) else {
return Ok(MultiValue::from_vec(vec![
Value::Integer(0),
Value::Integer(0),
]));
};
Ok(MultiValue::from_vec(vec![
Value::Integer(batch),
Value::Integer(total.max(batch)),
]))
})?,
)?;
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil.
g.set(
"GetAuctionItemInfo",
lua.create_function(|lua, (kind, index): (Value, Value)| {
// `is-number-or-string` and `is-number` else `Usage: GetAuctionItemInfo("type", index)`
// (`0x849448`, `0x4cf270`).
const USAGE: &str = r#"Usage: GetAuctionItemInfo("type", index)"#;
let kind = binding_abi::string_arg(lua, kind, USAGE)?;
let index = binding_abi::index_arg(lua, index, USAGE)?;
let row = row_at(lua, &kind, index);
// A miss (unknown type, out of range, or an item template not cached: `quality` is
// `None` until it lands) answers twelve values, `count = 1` and `quality = -1`, through
// the one exit `0x4cf1ec`; the item-cache lookup `0x55ba30` returning 0 reaches it at
// `0x4cefa0`.
let Some(r) = row.filter(|r| r.quality.is_some()) else {
return Ok(MultiValue::from_vec(vec![
Value::Nil, // name
Value::Nil, // texture
Value::Integer(1), // count
Value::Integer(-1), // quality
Value::Nil, // canUse
Value::Integer(0), // level
Value::Integer(0), // minBid
Value::Integer(0), // minIncrement
Value::Integer(0), // buyoutPrice
Value::Integer(0), // bidAmount
Value::Nil, // highBidder
Value::Nil, // owner
]));
};
// `canUse` is per call, not pushed: it follows the player's level, class and spellbook.
let can_use = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
super::item_stats::item_usable_by_id(lua, &model, r.item_id)
};
// The names off the name cache as the call runs, the bidder's on an owned row first
// (`0x4cf167`), then the seller's (`0x4cf1c8`); the push's only with no host lent. A
// miss is asked inside the call with the list's callback; nothing else asks a name.
let mut r = r;
let live = |guid: u64| super::live::ask(lua, &super::PlayerNameQuery(guid));
let bidder = (r.named_bidder != 0).then(|| match live(r.named_bidder) {
Some(name) => name,
None => match &r.high_bidder {
AuctionHighBidder::Name(n) => Some(n.clone()),
_ => None,
},
});
let owner = (r.owner_guid != 0).then(|| live(r.owner_guid).unwrap_or(r.owner.take()));
if let Some(name) = &bidder {
r.high_bidder = name
.clone()
.map_or(AuctionHighBidder::Nil, AuctionHighBidder::Name);
}
if let Some(name) = &owner {
r.owner.clone_from(name);
}
if let Some(list) = list_index(&kind) {
for (name, guid) in [(&bidder, r.named_bidder), (&owner, r.owner_guid)] {
if matches!(name, Some(None)) {
super::calls::ask(lua, super::RecordAsk::AuctionName { guid, list })?;
}
}
}
let opt_str = |s: &Option<String>| -> mlua::Result<Value> {
Ok(match s {
Some(v) => Value::String(lua.create_string(v)?),
None => Value::Nil,
})
};
Ok(MultiValue::from_vec(vec![
opt_str(&r.name)?,
opt_str(&r.texture)?,
Value::Integer(i64::from(r.count)),
match r.quality {
Some(q) => Value::Integer(i64::from(q)),
None => Value::Nil,
},
flag(can_use),
// The required level floored at 1 (`0x4cf091`-`0x4cf0b1`).
Value::Integer(i64::from(floor_level(&r))),
Value::Integer(i64::from(r.min_bid)),
Value::Integer(i64::from(r.min_increment)),
Value::Integer(i64::from(r.buyout_price)),
Value::Integer(i64::from(r.bid_amount)),
match &r.high_bidder {
AuctionHighBidder::Nil => Value::Nil,
AuctionHighBidder::Player => flag(true),
AuctionHighBidder::Name(n) => Value::String(lua.create_string(n)?),
},
opt_str(&r.owner)?,
]))
})?,
)?;
// GetAuctionItemTimeLeft(type, index) (`0x4cf470`) → the band 1 to 4 of the row's time left,
// read off its deadline against the clock at the call ([`time_left_band`]); 0 for a type or
// row that misses (`0x4cf5e1`). A row far enough past is removed after its 1 is pushed
// (`0x4cf574` → `0x4cdfe0`), its lists signalled inside the call.
g.set(
"GetAuctionItemTimeLeft",
lua.create_function(|lua, (kind, index): (Value, Value)| {
// `is-number-or-string` and `is-number` else `Usage: GetAuctionItemTimeLeft("type",
// index)` (`0x8494a0`, `0x4cf5f8`).
let kind = binding_abi::string_arg(
lua,
kind,
r#"Usage: GetAuctionItemTimeLeft("type", index)"#,
)?;
let index = binding_abi::index_arg(
lua,
index,
r#"Usage: GetAuctionItemTimeLeft("type", index)"#,
)?;
let Some(row) = row_at(lua, &kind, index) else {
return Ok(0);
};
let (band, remove) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (band, remove) = time_left_band(row.deadline, tick_ms(model.now));
(band, remove.then_some(row.auction_id))
};
if let Some(auction_id) = remove {
remove_auction(lua, auction_id);
}
Ok(band)
})?,
)?;
g.set(
"GetAuctionItemLink",
lua.create_function(|lua, (kind, index): (Value, Value)| {
// `is-number-or-string` and `is-number` else `Usage: GetAuctionItemLink("type", index)`
// (`0x849474`, `0x4cf44d`).
const USAGE: &str = r#"Usage: GetAuctionItemLink("type", index)"#;
let kind = binding_abi::string_arg(lua, kind, USAGE)?;
let index = binding_abi::index_arg(lua, index, USAGE)?;
let link = row_at(lua, &kind, index).and_then(|r| r.link);
// A miss returns no values at all, not nil (`0x4cf45b`).
Ok(match link {
Some(l) => MultiValue::from_vec(vec![Value::String(lua.create_string(&l)?)]),
None => MultiValue::new(),
})
})?,
)?;
// GetSelectedAuctionItem(type) (`0x4cfec0`) → the selected row, 0 for none or gone. Stored as
// the auction id and resolved on the way out (`0x4cff21`-`0x4cff35`), so it follows its
// auction through a re-sort. A type that is none of the three answers 1 (`0x4cffc5` →
// `0x4d0013`, `push 0x3ff00000`).
g.set(
"GetSelectedAuctionItem",
lua.create_function(|lua, kind: Value| {
// `is-number-or-string` else the usage line (`0x4cfecc`, `0x8495e4` at `0x4cfed5`).
let kind =
binding_abi::string_arg(lua, kind, r#"Usage: GetSelectedAuctionItem("type")"#)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(i) = list_index(&kind) else {
return Ok(1i64);
};
let id = model.auction_selected[i];
if id == 0 {
return Ok(0i64);
}
Ok(model.auction.as_ref().map_or(0, |a| {
a.lists[i]
.rows
.iter()
.position(|r| r.auction_id == id)
.map_or(0, |p| p as i64 + 1)
}))
})?,
)?;
// SetSelectedAuctionItem(type, index): remembers the auction at that row; out of range clears.
g.set(
"SetSelectedAuctionItem",
lua.create_function(|lua, (kind, index): (Value, Value)| {
// `is-number-or-string` and `is-number` else `Usage: SetSelectedAuctionItem("type",
// index)` (`0x8495b4`, `0x4cfeab`).
let kind = binding_abi::string_arg(
lua,
kind,
r#"Usage: SetSelectedAuctionItem("type", index)"#,
)?;
let index = binding_abi::index_arg(
lua,
index,
r#"Usage: SetSelectedAuctionItem("type", index)"#,
)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(i) = list_index(&kind) else {
return Ok(());
};
let id = model
.auction
.as_ref()
.and_then(|a| index.checked_sub(1).and_then(|n| a.lists[i].rows.get(n)))
.map_or(0, |r| r.auction_id);
model.auction_selected[i] = id;
Ok(())
})?,
)?;
// SortAuctionItems(type, sort) (`0x4cfc00`): the sort name is matched first, then the type;
// either unknown sorts nothing (`0x4cfd90`). Otherwise the list's driver promotes the column
// in the engine's stack, the one that outlives the session and the VM, then runs the sort
// tail, which sorts and signals before the call returns (`0x4cd850`, `0x4cdc70`, `0x4cdd60`).
g.set(
"SortAuctionItems",
lua.create_function(|lua, (kind, sort): (Value, Value)| {
let (kind, sort) = type_and_sort(
lua,
kind,
sort,
r#"Usage: SortAuctionItems("type", "sort")"#,
)?;
let Some(column) = sort_column(&sort) else {
return Ok(());
};
let Some(list) = list_index(&kind) else {
return Ok(());
};
submit(lua, AuctionCall::Sort { list, column })?;
refresh_live(lua, list);
super::tick::verb(lua, |model| {
if let Some(a) = model.auction.as_mut() {
a.lists[list].sort.promote(column);
}
sort_tail(model, list);
});
Ok(())
})?,
)?;
// IsAuctionSortReversed(type, sort) → 1/nil (`0x4d0030`): an unknown sort name returns no
// value (`0x4d024e`), an unknown type nil (`0x4d0230`).
g.set(
"IsAuctionSortReversed",
lua.create_function(|lua, (kind, sort): (Value, Value)| {
let (kind, sort) = type_and_sort(
lua,
kind,
sort,
r#"Usage: IsAuctionSortReversed("type", "sort")"#,
)?;
let Some(column) = sort_column(&sort) else {
return Ok(MultiValue::new());
};
let model = lua.app_data_ref::<Model>().expect("model app_data");
let rev = list_index(&kind)
.zip(model.auction.as_ref())
.and_then(|(i, a)| a.lists[i].sort.reversed(column))
.unwrap_or(false);
Ok(MultiValue::from_vec(vec![flag(rev)]))
})?,
)?;
// CanSendAuctionQuery() → 1/nil, the house's throttle read at the call; with no host lent
// there is none, so 1. A throttled query is dropped silently.
g.set(
"CanSendAuctionQuery",
lua.create_function(|lua, ()| {
Ok(flag(
super::live::ask(lua, &AuctionCanQuery).unwrap_or(true),
))
})?,
)?;
// GetAuctionItemClasses() → the ten auctionable class names in the reference's menu order, off
// the player's ItemClass.dbc; seeded in the prepare, no session needed.
g.set(
"GetAuctionItemClasses",
lua.create_function(|lua, ()| {
let names: Vec<String> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.auction_item_classes
.iter()
.map(|c| c.name.clone())
.collect()
};
let mut out = Vec::with_capacity(names.len());
for n in &names {
out.push(Value::String(lua.create_string(n)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// GetAuctionItemSubClasses(classIndex) → the subclass names; an out-of-range index or a class
// whose `0x807060` flag is 0 returns nothing, without an error (`0x4cfa00`, `0x4cfa0e`).
g.set(
"GetAuctionItemSubClasses",
lua.create_function(|lua, class_index: Value| {
// `is-number` else `Usage: GetAuctionItemSubClasses(index)` (`0x8494fc`, `0x4cf9d8`).
let class_index =
binding_abi::index_arg(lua, class_index, "Usage: GetAuctionItemSubClasses(index)")?;
let names: Vec<String> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
class_index
.checked_sub(1)
.and_then(|i| model.auction_item_classes.get(i))
.filter(|c| c.has_subclass_filter)
.map_or_else(Vec::new, |c| {
c.subclasses.iter().map(|s| s.name.clone()).collect()
})
};
let mut out = Vec::with_capacity(names.len());
for n in &names {
out.push(Value::String(lua.create_string(n)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// GetAuctionInvTypes(classIndex, subclassIndex) → the fixed 14 inventory-slot GlobalString
// keys, or nothing (`0x4cfab0`): nothing without a class (or with its `0x807060` flag 0); a
// found subclass needs `Flags & 0x200`, but an index the scan runs past skips that gate (the
// exhaust edge at `0x4cfb61` lands past the test at `0x4cfb63`).
g.set(
"GetAuctionInvTypes",
lua.create_function(|lua, (class_index, sub_index): (Value, Value)| {
// `is-number` on args 1 and 2 else
// `Usage: GetAuctionInvTypes(classIndex, subClassIndex)` (`0x849524`, `0x4cfb99`).
let class_index = binding_abi::index_arg(
lua,
class_index,
"Usage: GetAuctionInvTypes(classIndex, subClassIndex)",
)?;
let sub_index = binding_abi::index_arg(
lua,
sub_index,
"Usage: GetAuctionInvTypes(classIndex, subClassIndex)",
)?;
let offers = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
class_index
.checked_sub(1)
.and_then(|i| model.auction_item_classes.get(i))
.filter(|c| c.has_subclass_filter)
.is_some_and(|c| {
sub_index
.checked_sub(1)
.and_then(|i| c.subclasses.get(i))
.is_none_or(|s| s.has_inv_types)
})
};
if !offers {
return Ok(MultiValue::new());
}
let mut out = Vec::with_capacity(AUCTION_INV_TYPES.len());
for (_, key) in AUCTION_INV_TYPES {
out.push(Value::String(lua.create_string(key)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// QueryAuctionItems(name, minLevel, maxLevel, invTypeIndex, classIndex, subclassIndex, page,
// isUsable, qualityIndex): nine arguments, no `exactMatch` in 1.12. Each filter is a 1-based
// menu position mapped to the wire id as `0x4ce980` does: invType through the `0x8070b0` rows,
// class through `0x807060`, subclass as the class's Nth non-excluded row (a scan that never
// reads the class's filter flag). Anything unresolved is the wire's "any".
g.set(
"QueryAuctionItems",
lua.create_function(|lua, args: MultiValue| {
let a: Vec<Value> = args.into_iter().collect();
// `is-number-or-string` then `tostring`, a number as its text, else ""
// (`0x4ce9cd`-`0x4ce9e7`).
let name = a
.first()
.and_then(|v| binding_abi::optional_string(lua, v))
.unwrap_or_default();
// A number's `_ftol`, numeric strings included, for `usable` and `quality`.
let number = |i: usize| {
a.get(i)
.cloned()
.and_then(|v| binding_abi::is_number(lua, v))
.map(binding_abi::ftol)
};
// A menu position: a number's `_ftol` less one, compared unsigned, so 0 and a
// negative fall out of range (`0x4cea62`-`0x4cea6b`).
let position = |i: usize| {
a.get(i)
.cloned()
.and_then(|v| binding_abi::is_number(lua, v))
.map(|n| binding_abi::ftol(n).wrapping_sub(1) as u32 as usize)
};
let query = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let inv_type = position(3)
.and_then(|i| AUCTION_INV_TYPES.get(i))
.map(|&(id, _)| id);
let category = position(4).and_then(|i| model.auction_item_classes.get(i));
let class = category.map(|c| c.class_id);
let sub_class = category.and_then(|c| {
position(5)
.and_then(|i| c.subclasses.get(i))
.map(|s| s.sub_id)
});
AuctionQuery {
name,
min_level: level_byte(lua, a.get(1).cloned()),
max_level: level_byte(lua, a.get(2).cloned()),
inv_type,
class,
sub_class,
// Any number's `_ftol`, a negative too, else -1, the wire's "any"
// (`0x4ceb72`-`0x4ceb96`).
quality: number(8).map_or(u32::MAX, |n| n as u32),
list_from: list_offset(lua, a.get(6).cloned()),
// A number's `_ftol` low byte, else 0, so `true` is 0 (`0x4ceb47`-`0x4ceb6f`).
usable: number(7).map_or(0, |n| n as u8),
}
};
submit(lua, AuctionCall::Query(query))
})?,
)?;
// GetOwnerAuctionItems([page]) (`0x4cec80`) / GetBidderAuctionItems([page]) (`0x4cecd0`):
// unthrottled, and sent only while the list's one-shot flag is armed, which the host tests
// with the auctioneer (`0x4cec8e`-`0x4cec97`, `0x4cecde`-`0x4cece7`).
g.set(
"GetOwnerAuctionItems",
lua.create_function(|lua, page: Option<Value>| {
submit(
lua,
AuctionCall::OwnerList {
offset: list_offset(lua, page),
},
)
})?,
)?;
g.set(
"GetBidderAuctionItems",
lua.create_function(|lua, page: Option<Value>| {
submit(
lua,
AuctionCall::BidderList {
offset: list_offset(lua, page),
},
)
})?,
)?;
// PlaceAuctionBid(type, index, bidAmount); a buyout is a bid of exactly the buyout price.
g.set(
"PlaceAuctionBid",
lua.create_function(|lua, (kind, index, amount): (Value, Value, Value)| {
// `is-number` on args 2 and 3, `is-number-or-string` on arg 1 else
// `Usage: PlaceAuctionBid("type", index, bid)` (`0x8494d0`, `0x4cf94d`).
let kind = binding_abi::string_arg(
lua,
kind,
r#"Usage: PlaceAuctionBid("type", index, bid)"#,
)?;
let index = binding_abi::index_arg32(
lua,
index,
r#"Usage: PlaceAuctionBid("type", index, bid)"#,
)?;
let amount = binding_abi::index_arg32(
lua,
amount,
r#"Usage: PlaceAuctionBid("type", index, bid)"#,
)?;
let Some(list) = list_index(&kind) else {
return Ok(());
};
// The record at the row as the call finds it; no row, no bid (`0x4cf735`).
let bid = {
let Some(r) = live_row_at(lua, list, index as usize) else {
return Ok(());
};
AuctionBid {
list,
auction_id: r.auction_id,
amount,
item_id: r.item_id,
min_bid: r.min_bid,
increment: r.min_increment,
buyout: r.buyout_price,
current: r.bid_amount,
player_leads: r.bidder_rank == 2,
}
};
submit(lua, AuctionCall::Bid(bid))
})?,
)?;
// StartAuction(minBid, buyoutPrice, runTime), runTime in minutes.
g.set(
"StartAuction",
lua.create_function(|lua, (bid, buyout, run): (Value, Value, Value)| {
const USAGE: &str = "Usage: StartAuction(minBid, buyoutPrice, runTime)";
let min_bid = super::binding_abi::number_arg(lua, bid, USAGE)? as u32;
let buyout = super::binding_abi::number_arg(lua, buyout, USAGE)? as u32;
let duration = super::binding_abi::number_arg(lua, run, USAGE)? as u32;
let call = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// An empty slot, its stored guid zero, ends it before the send (`0x4ce7e4`); the
// host tests the auctioneer ahead of it (`0x4ce7da`).
let guid = model.auction_sell_item.as_ref().map_or(0, |it| it.guid);
if guid == 0 {
return Ok(());
}
// A damaged item, not wrapped (`flags & 8`), with a max durability whose current
// falls short, refuses with `ERR_AUCTION_REPAIR_ITEM` (`0x4ce80f`-`0x4ce83f`). An
// item not found skips the check (`0x4ce802`).
let damaged = super::held::find_guid(lua, &model, guid)
.map(|(_, _, s)| s)
.is_some_and(|s| {
s.flags & 0x8 == 0
&& s.durability.is_some_and(|(cur, max)| max > 0 && cur < max)
});
if damaged {
Err("ERR_AUCTION_REPAIR_ITEM")
} else {
// A buyout under the minimum bid is raised to it (`0x4ce87b`-`0x4ce883`).
let buyout = if buyout != 0 && buyout < min_bid {
min_bid
} else {
buyout
};
// A run time off the table returns silently (`0x4ce8a0`-`0x4ce8b8`).
if !RUN_TIMES.contains(&duration) {
return Ok(());
}
Ok(AuctionStartRequest {
item_guid: guid,
min_bid,
buyout,
duration,
})
}
};
submit(lua, AuctionCall::Start(call))
})?,
)?;
// CancelAuction(index), always an "owner" row.
g.set(
"CancelAuction",
lua.create_function(|lua, index: Value| {
// `is-number` else `Usage: SetSelectedAuctionItem(index)` (`0x84963c`, `0x4d0279`), the
// client's own text naming `SetSelectedAuctionItem`.
let index =
binding_abi::index_arg32(lua, index, "Usage: SetSelectedAuctionItem(index)")?;
// The owner record at the row; out of range sends nothing (`0x4d02c5`).
let cancel = live_row_at(lua, OWNER, index as usize).map(|r| AuctionCall::Cancel {
auction_id: r.auction_id,
bid: r.bid_amount,
});
match cancel {
Some(call) => submit(lua, call),
None => Ok(()),
}
})?,
)?;
g.set(
"GetAuctionHouseDepositRate",
lua.create_function(|lua, ()| Ok(i64::from(deposit_rate(lua))))?,
)?;
// CalculateAuctionDeposit(runTime) (`0x4ce1c0`) → copper, by the client's arithmetic
// ([`deposit_for`]), 0 for a run time other than 120, 480 or 1440.
g.set(
"CalculateAuctionDeposit",
lua.create_function(|lua, minutes: Value| {
// `is-number` else `Usage: CalculateAuctionDeposit(runTime)` (`0x8493b0`,
// `0x4ce1d9`); the run time is `_ftol`'s 32 bits (`0x4ce28d`).
let minutes =
binding_abi::index_arg32(lua, minutes, "Usage: CalculateAuctionDeposit(runTime)")?;
// No house, no lookup (`0x4ce1f7`-`0x4ce217`); then the slot's guid, its object and its
// record, the last asking (`0x4ce21d`-`0x4ce25e`).
let house = lua
.app_data_ref::<Model>()
.is_some_and(|m| m.auction.is_some());
if !house {
return Ok(0);
}
let rate = deposit_rate(lua);
let stack_value = sell_slot(lua)?.map_or(0, |(_, _, _, _, _, v)| v);
Ok(i64::from(deposit_for(rate, stack_value, minutes)))
})?,
)?;
// GetAuctionSellItemInfo() → name, texture, count, quality, canUse, price: six values always.
// An empty slot, a gone object and a missing record (`0x4ce726`) answer count 1 and quality -1:
// the stock Lua reads `count > 1` unguarded.
g.set(
"GetAuctionSellItemInfo",
lua.create_function(|lua, ()| {
let Some((name, texture, count, quality, can_use, price)) = sell_slot(lua)? else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(1),
Value::Integer(-1),
Value::Nil,
Value::Integer(0),
]));
};
Ok(MultiValue::from_vec(vec![
if name.is_empty() {
Value::Nil
} else {
Value::String(lua.create_string(&name)?)
},
match &texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
},
Value::Integer(i64::from(count)),
Value::Integer(quality),
flag(can_use),
Value::Integer(i64::from(price)),
]))
})?,
)?;
// ClickAuctionSellItemButton() (`0x4ce310`): a held item the reference refuses shows its
// error and changes nothing ([`sell_refusal`]); otherwise attach the cursor's item, putting an
// earlier attachment back on the cursor ([`cursor::click_attach_slot`]), and a changed slot
// signals `NEW_AUCTION_UPDATE` right after that `SetCursorItem` (`0x4ce56c`, `0x4ce576`).
g.set(
"ClickAuctionSellItemButton",
lua.create_function(|lua, ()| {
let refusal = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &model.cursor {
Some(cursor::CursorPayload::Item(item)) => sell_refusal(lua, &model, item),
_ => None,
}
};
if let Some(key) = refusal {
return super::calls::display_error(lua, key);
}
if cursor::click_attach_slot(lua, |model| &mut model.auction_sell_item) {
super::tick::verb(lua, |model| {
model.raise("NEW_AUCTION_UPDATE", Vec::new());
});
}
Ok(())
})?,
)?;
// CloseAuctionHouse() (`0x4ce160`): the close core `0x4cd5f0` inside the call; nothing goes
// on the wire.
g.set(
"CloseAuctionHouse",
lua.create_function(|lua, ()| submit(lua, AuctionCall::Close))?,
)?;
Ok(())
}
/// The `0x8070b0` table's fourteen `{u32 invTypeId; char name[0x20]}` rows, in its order: the id
/// goes on the wire, the name is a GlobalString key.
const AUCTION_INV_TYPES: [(u32, &str); 14] = [
(1, "INVTYPE_HEAD"),
(2, "INVTYPE_NECK"),
(3, "INVTYPE_SHOULDER"),
(4, "INVTYPE_BODY"),
(5, "INVTYPE_CHEST"),
(6, "INVTYPE_WAIST"),
(7, "INVTYPE_LEGS"),
(8, "INVTYPE_FEET"),
(9, "INVTYPE_WRIST"),
(10, "INVTYPE_HAND"),
(11, "INVTYPE_FINGER"),
(12, "INVTYPE_TRINKET"),
(16, "INVTYPE_CLOAK"),
(23, "INVTYPE_HOLDABLE"),
];
#[cfg(test)]
mod tests {
/// The owner and bidder queries (`0x4cec80`, `0x4cecd0`) read `trunc(n)` as the page, times 50
/// in 32 bits (`0x4cecbf`, `0x4ced0f`), and take anything else for page 0; they never raise,
/// and hand their offset to the host.
#[test]
fn owner_and_bidder_queries_default_to_page_zero_and_never_raise() {
use super::AuctionCall;
let mut s = crate::script::UiScript::new().unwrap();
s.keep_calls();
for (call, offset) in [
("()", 0),
("(2)", 100),
("(2.9)", 100),
("('x')", 0),
("({})", 0),
("(nil)", 0),
("(-1)", (-50i32) as u32),
] {
s.run(&format!(
"GetOwnerAuctionItems{call} GetBidderAuctionItems{call}"
))
.unwrap_or_else(|e| panic!("{call} raised: {e}"));
assert_eq!(
s.take_auction_calls(),
[
AuctionCall::OwnerList { offset },
AuctionCall::BidderList { offset }
],
"{call}"
);
}
}
use super::*;
use crate::script::UiScript;
/// A session holding one browse page of the given auction ids.
fn page(ids: &[u32]) -> AuctionState {
let rows: Vec<AuctionItemRow> = ids
.iter()
.map(|&auction_id| AuctionItemRow {
auction_id,
item_id: 1000 + auction_id,
// A cached template: a `None` quality is a row still in the item cache's miss.
name: Some(format!("Item {auction_id}")),
base_name: Some(format!("Item {auction_id}")),
quality: Some(1),
count: 1,
..Default::default()
})
.collect();
let mut state = AuctionState::default();
state.lists[LIST] = AuctionListState {
total: rows.len() as u32,
rows,
..Default::default()
};
state
}
/// Every list type and sort name matches without case (`SStrCmpI`, `0x64a4c0`), an unknown
/// one sorts nothing (`0x4cfd90`), and only an argument that is neither string nor number
/// raises (`0x4cfd82`). `IsAuctionSortReversed` answers nothing for an unknown sort name
/// (`0x4d024e`) and nil for an unknown type (`0x4d0230`).
#[test]
fn list_types_and_sort_names_match_without_case() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_auction(page(&[7]));
assert_eq!(
s.eval::<i64>(r#"return (GetNumAuctionItems("LIST"))"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<String>(r#"return (GetAuctionItemInfo("List", 1))"#)
.unwrap(),
"Item 7"
);
s.run(r#"SortAuctionItems("LIST", "Name")"#).unwrap();
let name = crate::auction_sort::sort_column("name").unwrap();
assert_eq!(
s.take_auction_calls(),
[AuctionCall::Sort {
list: LIST,
column: name
}]
);
s.run(r#"SortAuctionItems("list", "nonsense") SortAuctionItems("nowhere", "name")"#)
.unwrap();
s.run("SortAuctionItems(1, 2)").unwrap();
assert!(
s.take_auction_calls().is_empty(),
"an unknown one sorts nothing"
);
let err = s
.run(r#"SortAuctionItems({}, "name")"#)
.unwrap_err()
.to_string();
assert!(
err.contains(r#"Usage: SortAuctionItems("type", "sort")"#),
"{err}"
);
assert_eq!(
s.arity(r#"IsAuctionSortReversed("list", "nonsense")"#)
.unwrap(),
0
);
assert_eq!(
s.arity(r#"IsAuctionSortReversed("nowhere", "bid")"#)
.unwrap(),
1
);
}
/// `GetAuctionItemInfo`'s sixth value is the required level floored at 1
/// (`0x4cf091`-`0x4cf0b1`, `max(template+0x3c, 1)`).
#[test]
fn the_level_is_floored_at_one() {
let mut s = UiScript::new().unwrap();
let mut state = page(&[7, 8]);
state.lists[LIST].rows[0].level = 0;
state.lists[LIST].rows[1].level = 12;
s.set_auction(state);
let level = |s: &UiScript, i: usize| {
s.eval::<i64>(&format!(
r#"local _, _, _, _, _, l = GetAuctionItemInfo("list", {i}) return l"#
))
.unwrap()
};
assert_eq!(level(&s, 1), 1, "no requirement reads 1");
assert_eq!(level(&s, 2), 12);
}
/// `SortAuctionItems` sorts and signals before it returns (`0x4cd850` → `0x4cd8b0`: `qsort`,
/// then `jmp 0x703e50`), so an addon that flags its own call, as Auctioneer's
/// `PerformingSortAuctionItems` does, sees the update inside it with the rows in order, and
/// reads the flipped flag on the next line.
#[test]
fn a_sort_signals_its_list_inside_the_call_with_the_rows_in_order() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
let mut state = page(&[1, 2]);
state.lists[LIST].rows[0].name = Some("Cheap".into());
state.lists[LIST].rows[0].buyout_price = 100;
state.lists[LIST].rows[1].name = Some("Dear".into());
state.lists[LIST].rows[1].buyout_price = 900;
s.set_auction(state);
s.run(
r#"SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("AUCTION_ITEM_LIST_UPDATE")
f:SetScript("OnEvent", function()
table.insert(SEEN, tostring(INSIDE) .. ":" .. (GetAuctionItemInfo("list", 1)))
end)
INSIDE = true
SortAuctionItems("list", "buyout")
INSIDE = false
REVERSED = IsAuctionSortReversed("list", "buyout")"#,
)
.unwrap();
assert_eq!(
s.eval::<Vec<String>>("return SEEN").unwrap(),
["true:Dear"],
"the highest buyout first, signalled inside the call"
);
assert!(s.eval::<bool>("return REVERSED == nil").unwrap());
s.run(
r#"SEEN = {}
SortAuctionItems("list", "buyout")
REVERSED = IsAuctionSortReversed("list", "buyout")"#,
)
.unwrap();
assert_eq!(
s.eval::<Vec<String>>("return SEEN").unwrap(),
["false:Cheap"]
);
assert!(
s.eval::<bool>("return REVERSED == 1").unwrap(),
"the second click flips the primary, and the next line reads it"
);
}
/// `GetAuctionItemTimeLeft` reads the row's deadline against the clock at the call
/// (`0x4cf548`): 1 to 3 under 30 minutes, 2 hours and 8 hours left (`0x8072a8`), else 4
/// (`0x4cf5a1`); 1 at or past the deadline (`0x4cf558`). More than 10 s past (`0x4cf569`) the
/// call also removes the auction (`0x4cdfe0`) from browse, owner and bidder in that order, each
/// list that held it signalled inside the call unless its sort tail is owed (`0x4ce035`), the
/// totals kept; a push of the same page keeps it out. A type or row that misses answers 0
/// (`0x4cf5e1`).
#[test]
fn the_time_left_reads_the_deadline_at_the_call() {
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_now(1000.0);
let now: u32 = 1_000_000;
let list = |rows: &[(u32, u32)], pending: bool| {
let rows: Vec<AuctionItemRow> = rows
.iter()
.map(|&(auction_id, deadline)| AuctionItemRow {
auction_id,
item_id: 1000 + auction_id,
name: Some(format!("Item {auction_id}")),
quality: Some(1),
count: 1,
deadline,
..Default::default()
})
.collect();
AuctionListState {
total: rows.len() as u32 + 2,
rows,
pending,
..Default::default()
}
};
let gone = now - 10_001;
let mut state = AuctionState::default();
state.lists[LIST] = list(
&[
(1, now + 1_799_999),
(2, now + 1_800_000),
(3, now + 7_200_000),
(4, now + 28_800_000),
(5, now),
(6, now - 10_000),
(7, gone),
],
false,
);
state.lists[OWNER] = list(&[(7, gone)], false);
state.lists[BIDDER] = list(&[(7, gone)], true);
s.set_auction(state.clone());
heard::listen(
&s,
&[
"AUCTION_ITEM_LIST_UPDATE",
"AUCTION_OWNED_LIST_UPDATE",
"AUCTION_BIDDER_LIST_UPDATE",
],
Some("(GetNumAuctionItems('list')) .. '/' .. (GetNumAuctionItems('owner'))"),
);
let left = |s: &UiScript, kind: &str, i: i64| {
s.eval::<i64>(&format!("return GetAuctionItemTimeLeft('{kind}', {i})"))
.unwrap()
};
let bands: Vec<i64> = (1..=6).map(|i| left(&s, "list", i)).collect();
assert_eq!(bands, [1, 2, 3, 4, 1, 1]);
assert!(heard::take(&s).is_empty(), "10 s past is kept");
assert_eq!(left(&s, "list", 7), 1);
assert_eq!(
heard::take(&s),
[
"AUCTION_ITEM_LIST_UPDATE 6/1",
"AUCTION_OWNED_LIST_UPDATE 6/0"
],
"the bidder list owes its tail"
);
let counts = "local a, b = GetNumAuctionItems('list') local c, d = \
GetNumAuctionItems('bidder') return a, b, c, d";
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(counts).unwrap(),
(6, 9, 0, 3)
);
s.set_auction(state);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(counts).unwrap(),
(6, 9, 0, 3)
);
assert_eq!(left(&s, "list", 7), 0, "a row past the list");
assert_eq!(left(&s, "list", 0), 0);
assert_eq!(left(&s, "auction", 1), 0);
assert_eq!(left(&s, "LIST", 6), 1);
}
/// The band reads the wrapping difference's sign (`0x4cf556`): an unclamped wire time gone
/// negative (`AuctionHouseMgr.cpp:838`) is past, not eternal, and a deadline across the
/// dword's wrap still counts down.
#[test]
fn the_time_left_band_reads_the_wrapping_difference() {
let now: u32 = 5_000_000;
assert_eq!(
time_left_band(now.wrapping_add(u32::MAX - 1000), now),
(1, false)
);
assert_eq!(
time_left_band(now.wrapping_add(u32::MAX - 20_000), now),
(1, true)
);
assert_eq!(time_left_band(100, u32::MAX - 5), (1, false), "106 ms left");
assert_eq!(time_left_band(now + 20 * 3_600_000, now), (4, false));
}
#[test]
fn the_selection_follows_its_auction_through_a_resort() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_auction(page(&[11, 22]));
s.eval::<()>(r#"SetSelectedAuctionItem("list", 2)"#)
.unwrap();
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
.unwrap(),
2
);
// The same two auctions, sorted into the opposite order: name, reversed.
s.run(r#"SortAuctionItems("list", "name") SortAuctionItems("list", "name")"#)
.unwrap();
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
.unwrap(),
1,
"auction 22 moved to row 1 and the selection went with it"
);
// It leaves the page.
s.set_auction(page(&[33, 44]));
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
.unwrap(),
0,
"gone means nothing selected, never its neighbour"
);
// The close keeps it (`0x4cd5f0`); the world-enter cascade's reset drops it.
s.set_auction(page(&[33]));
s.eval::<()>(r#"SetSelectedAuctionItem("list", 1)"#)
.unwrap();
s.close_auction_session();
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
.unwrap(),
1
);
s.clear_auction_selections();
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
.unwrap(),
0
);
}
#[test]
fn the_sell_slot_reads_a_whole_stacks_real_size() {
use crate::script::container::{ContainerSlot, ContainerState};
use crate::script::cursor::CursorItem;
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
1u32,
ContainerSlot {
count: 20,
item_id: 2589,
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: None,
num_slots: 16,
slots,
}),
);
// The record is cached, or the getter answers the miss (`0x4ce5f9`).
s.set_item_template(2589, crate::script::ItemTemplateView::default());
// Picked up whole: `count: None` means the whole stack, not one.
{
let mut model = s.model_mut();
model.auction_sell_item = Some(CursorItem {
item_class: None,
guid: 0x108,
bag: 0,
slot: 1,
item_id: 2589,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
bar_placeable: false,
consumable: false,
});
}
let count: i64 = s
.eval(r#"local _, _, c = GetAuctionSellItemInfo() return c"#)
.unwrap();
assert_eq!(count, 20, "the whole stack, not one of it");
}
#[test]
fn a_missing_row_still_answers_twelve_values() {
let s = UiScript::new().unwrap();
let n = s.arity(r#"GetAuctionItemInfo("list", 99)"#).unwrap();
assert_eq!(n, 12, "twelve, even with no session open at all");
let (count, quality): (i64, i64) = s
.eval(r#"local _, _, c, q = GetAuctionItemInfo("list", 99) return c, q"#)
.unwrap();
assert_eq!((count, quality), (1, -1));
// The link answers no values on a miss, not a nil.
let n = s.arity(r#"GetAuctionItemLink("list", 99)"#).unwrap();
assert_eq!(n, 0, "zero values, not one nil");
}
/// A row whose item template has not landed answers the client's miss shape, not a half-filled
/// row: `0x55ba30` returns 0, `je 0x4cefa0` -> `0x4cf1ec`, nil,nil,1,-1,nil,0,0,0,0,0,nil,nil.
#[test]
fn an_uncached_row_answers_the_miss_shape() {
let mut s = UiScript::new().unwrap();
let mut state = page(&[7]);
// The template query is still in flight: no name, icon or quality, a count and prices.
state.lists[LIST].rows[0] = AuctionItemRow {
auction_id: 7,
item_id: 1007,
count: 20,
min_bid: 100,
buyout_price: 200,
high_bidder: AuctionHighBidder::Player,
owner: Some("Seller".into()),
..Default::default()
};
s.set_auction(state);
let shape: String = s
.eval(
r#"local t = {GetAuctionItemInfo("list", 1)}
local out = {}
for i = 1, 12 do out[i] = tostring(t[i]) end
return table.concat(out, ",")"#,
)
.unwrap();
assert_eq!(shape, "nil,nil,1,-1,nil,0,0,0,0,0,nil,nil");
assert_eq!(s.arity(r#"GetAuctionItemInfo("list", 1)"#).unwrap(), 12);
// The stock lines index the colour table with the quality, unguarded.
let r: f64 = s
.eval(
r#"ITEM_QUALITY_COLORS = { [-1] = { r = 0.5 }, [1] = { r = 1 } }
local name, texture, count, quality = GetAuctionItemInfo("list", 1)
local color = ITEM_QUALITY_COLORS[quality]
return color.r"#,
)
.expect("the colour lookup must not throw on a miss row");
assert_eq!(r, 0.5);
}
/// The same row once its template lands answers its real values.
#[test]
fn a_cached_row_answers_its_quality() {
let mut s = UiScript::new().unwrap();
s.set_auction(page(&[7]));
let (name, quality): (String, i64) = s
.eval(r#"local n, _, _, q = GetAuctionItemInfo("list", 1) return n, q"#)
.unwrap();
assert_eq!((name.as_str(), quality), ("Item 7", 1));
}
/// The eleventh value is the name, `1` or nil as the app picked it (`0x4cf12a`–`0x4cf1a0`).
#[test]
fn high_bidder_answers_a_name_a_one_or_nil() {
let mut s = UiScript::new().unwrap();
let mut state = page(&[1, 2, 3]);
state.lists[LIST].rows[0].high_bidder = AuctionHighBidder::Name("Bidder".into());
state.lists[LIST].rows[1].high_bidder = AuctionHighBidder::Player;
s.set_auction(state);
let eleventh = |i: usize| {
s.eval::<Value>(&format!(
r#"local _,_,_,_,_,_,_,_,_,_,h = GetAuctionItemInfo("list", {i}) return h"#
))
.unwrap()
};
assert_eq!(
eleventh(1)
.as_string()
.map(|v| v.to_str().unwrap().to_string()),
Some("Bidder".into())
);
assert_eq!(eleventh(2), Value::Integer(1));
assert_eq!(eleventh(3), Value::Nil);
}
/// A 9-copper stack at 5% over 24 h deposits 0 here, where vmangos charges 5.
#[test]
fn the_deposit_is_the_clients_arithmetic_truncation_and_all() {
assert_eq!(deposit_for(5, 9, 1440), 0);
// Once the inner floor clears, the duration scales it 1 / 4 / 12.
assert_eq!(deposit_for(5, 10_000, 120), 500);
assert_eq!(deposit_for(5, 10_000, 480), 2_000);
assert_eq!(deposit_for(5, 10_000, 1440), 6_000);
// Blackwater's 25% on the same stack.
assert_eq!(deposit_for(25, 10_000, 120), 2_500);
// Under the two-hour unit it floors to 0; the reference never sends such a duration.
assert_eq!(deposit_for(5, 10_000, 60), 0);
}
/// Weapon and Armor, then Consumable (flag 0: no subclass rows, but the query scans them).
fn tree() -> Vec<AuctionCategory> {
let sub = |sub_id: u32, name: &str, has_inv_types: bool| AuctionSubCategory {
sub_id,
name: name.into(),
has_inv_types,
};
vec![
AuctionCategory {
class_id: 2,
name: "Weapon".into(),
has_subclass_filter: true,
subclasses: vec![sub(0, "Axe", false), sub(1, "Axe", false)],
},
AuctionCategory {
class_id: 4,
name: "Armor".into(),
has_subclass_filter: true,
subclasses: vec![sub(1, "Cloth", true), sub(6, "Shield", false)],
},
AuctionCategory {
class_id: 0,
name: "Consumable".into(),
has_subclass_filter: false,
subclasses: vec![sub(0, "Consumable", false)],
},
]
}
#[test]
fn query_sends_ids_not_menu_positions() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_auction_item_classes(tree());
let query = |s: &mut UiScript| match s.take_auction_calls().pop() {
Some(AuctionCall::Query(q)) => q,
other => panic!("one query handed to the host, got {other:?}"),
};
// Weapon / its first subclass / Back (the 13th inventory row).
s.run(r#"QueryAuctionItems("", "", "", 13, 1, 1, 0, nil, -1)"#)
.unwrap();
let q = query(&mut s);
assert_eq!(q.inv_type, Some(16), "INVTYPE_CLOAK is inventory type 16");
assert_eq!(q.class, Some(2), "Weapon is item class 2, not position 1");
assert_eq!(q.sub_class, Some(0), "the first weapon subclass is id 0");
// Armor / its second subclass skips the id gap: Shield is 6, not 2.
s.run(r#"QueryAuctionItems("", "", "", 14, 2, 2, 0, nil, -1)"#)
.unwrap();
let q = query(&mut s);
assert_eq!(
(q.inv_type, q.class, q.sub_class),
(Some(23), Some(4), Some(6))
);
// The scan ignores the filter flag: Consumable offers no subclass rows, yet resolves one.
s.run(r#"QueryAuctionItems("", "", "", 0, 3, 1, 0, nil, -1)"#)
.unwrap();
let q = query(&mut s);
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(0), Some(0)));
// Out of range, or no class to scan under: the wire's "any".
s.run(r#"QueryAuctionItems("", "", "", 15, 11, 1, 0, nil, -1)"#)
.unwrap();
let q = query(&mut s);
assert_eq!((q.inv_type, q.class, q.sub_class), (None, None, None));
s.run(r#"QueryAuctionItems("", "", "", nil, 1, 3, 0, nil, -1)"#)
.unwrap();
let q = query(&mut s);
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(2), None));
}
/// The gates of `0x4cf9c0` (subclasses) and `0x4cfab0` (inventory types).
#[test]
fn subclass_and_inv_type_menus_follow_the_reference_gates() {
let mut s = UiScript::new().unwrap();
s.set_auction_item_classes(tree());
assert_eq!(s.arity("GetAuctionItemSubClasses(1)").unwrap(), 2);
assert_eq!(
s.arity("GetAuctionItemSubClasses(3)").unwrap(),
0,
"Consumable's flag is 0: no subclass rows"
);
assert_eq!(s.arity("GetAuctionInvTypes(2, 1)").unwrap(), 14, "Cloth");
assert_eq!(s.arity("GetAuctionInvTypes(2, 2)").unwrap(), 0, "Shield");
assert_eq!(s.arity("GetAuctionInvTypes(3, 1)").unwrap(), 0, "flag 0");
assert_eq!(s.arity("GetAuctionInvTypes(11, 1)").unwrap(), 0, "no class");
assert_eq!(
s.arity("GetAuctionInvTypes(1, 9)").unwrap(),
14,
"an exhausted scan skips the gate"
);
}
/// The attach clears with `SetCursorItem(0, …)` (`0x4ce56a`): no unlock and no
/// `ITEM_LOCK_CHANGED`, so the item stays locked in its bag and cannot be picked up again,
/// until the sell-slot clear lifts the lock with its event (`0x4cd653`).
#[test]
fn an_item_in_the_sell_slot_keeps_its_lock_until_it_leaves() {
use crate::script::cursor::{bag_lock_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "CURSOR_UPDATE", "NEW_AUCTION_UPDATE"],
Some(&bag_lock_probe(1)),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(
heard::take(&s),
["CURSOR_UPDATE 1", "NEW_AUCTION_UPDATE 1"],
"the attach signals in place, after its SetCursorItem (0x4ce576)"
);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked item is not picked up"
);
s.clear_auction_sell_item();
assert_eq!(
heard::take(&s),
["ITEM_LOCK_CHANGED nil", "NEW_AUCTION_UPDATE nil"]
);
s.clear_auction_sell_item();
assert!(
heard::take(&s).is_empty(),
"an empty slot's clear signals nothing"
);
}
/// An occupied sell slot puts the old item back on the cursor through `SetCursorItem(0, …)`
/// (`0x4ce4f2`-`0x4ce56c`), both items staying locked; with nothing held and nothing attached
/// the click is `ClearCursor(1,1)` (`0x4ce523`).
#[test]
fn an_attach_onto_an_occupied_sell_slot_puts_the_old_item_on_the_cursor() {
use crate::script::cursor::{bag_lock_probe, install_kind_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
install_kind_probe(&s);
s.run("PickupContainerItem(0, 1) ClickAuctionSellItemButton() PickupContainerItem(0, 2)")
.unwrap();
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED", "NEW_AUCTION_UPDATE"],
Some("CursorKind()"),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(
heard::take(&s),
[
"CURSOR_UPDATE none",
"CURSOR_UPDATE item",
"NEW_AUCTION_UPDATE item"
]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
assert_eq!(s.auction_sell_item(), Some((0, 2)));
for slot in [1, 2] {
let probe = bag_lock_probe(slot);
assert!(s.eval::<bool>(&format!("return {probe} == 1")).unwrap());
}
s.run("ClearCursor() ClickAuctionSellItemButton() ClearCursor()")
.unwrap();
assert_eq!(s.auction_sell_item(), None);
heard::take(&s);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE none"]);
}
/// `ClickAuctionSellItemButton`'s refusals (`0x4ce360`-`0x4ce4f1`), in the reference's order:
/// each shows its error (`0x496720`) and returns, the cursor kept, the slot holding what it
/// held and nothing signalled. A bound item (`0x5da2c0`) is `ERR_AUCTION_QUEST_ITEM` for quest
/// bonding 4 or 5 and `ERR_AUCTION_BOUND_ITEM` otherwise, a missing record included; then
/// `ERR_AUCTION_CONJURED_ITEM` for record flags `0x2` or `0x10000`, a nonzero
/// `ITEM_FIELD_DURATION`, an area or a map; then `ERR_AUCTION_BAG` for a container with an
/// item in a seat, `ERR_AUCTION_WRAPPED_ITEM` for `ITEM_FIELD_FLAGS & 8` and
/// `ERR_AUCTION_LOOT_ITEM` for record flags `& 4`. An item that passes them all attaches, one
/// with no record among them, whose flags read 0 (`0x5da2b3`).
#[test]
fn the_sell_slot_refuses_in_the_references_order() {
use crate::script::container::{ContainerSlot, ContainerState};
use crate::script::tick::heard;
use crate::script::ItemTemplateView;
let mut s = UiScript::new().unwrap();
// `(bound, ITEM_FIELD_FLAGS, ITEM_FIELD_DURATION, filled seats, record as (flags,
// bonding, area, map))` per backpack slot, and what the click answers.
type Record = Option<(u32, u32, u32, u32)>;
type Case = (bool, u32, u32, Option<u32>, Record, Option<&'static str>);
#[rustfmt::skip]
let cases: [Case; 14] = [
(true, 0, 0, None, Some((0, 4, 0, 0)), Some("ERR_AUCTION_QUEST_ITEM")),
(true, 0, 0, None, Some((0x2, 5, 12, 0)), Some("ERR_AUCTION_QUEST_ITEM")),
(true, 0, 0, None, Some((0, 1, 0, 0)), Some("ERR_AUCTION_BOUND_ITEM")),
(true, 0, 0, None, None, Some("ERR_AUCTION_BOUND_ITEM")),
(false, 0, 0, None, Some((0x2, 0, 0, 0)), Some("ERR_AUCTION_CONJURED_ITEM")),
(false, 0, 0, None, Some((0x10000, 0, 0, 0)), Some("ERR_AUCTION_CONJURED_ITEM")),
(false, 0, 60_000, Some(2), Some((0, 0, 0, 0)), Some("ERR_AUCTION_CONJURED_ITEM")),
(false, 0, 0, None, Some((0, 0, 12, 0)), Some("ERR_AUCTION_CONJURED_ITEM")),
(false, 0, 0, None, Some((0, 0, 0, 33)), Some("ERR_AUCTION_CONJURED_ITEM")),
(false, 0x8, 0, Some(2), Some((0x4, 0, 0, 0)), Some("ERR_AUCTION_BAG")),
(false, 0x8, 0, None, Some((0x4, 0, 0, 0)), Some("ERR_AUCTION_WRAPPED_ITEM")),
(false, 0, 0, None, Some((0x4, 0, 0, 0)), Some("ERR_AUCTION_LOOT_ITEM")),
(false, 0, 0, Some(0), Some((0, 0, 0, 0)), None),
(false, 0, 0, None, None, None),
];
let mut bag = ContainerState {
num_slots: 16,
..Default::default()
};
for (i, &(bound, flags, duration, seats, record, _)) in cases.iter().enumerate() {
let slot = i as u32 + 1;
bag.slots.insert(
slot,
ContainerSlot {
item_id: 1000 + slot,
guid: 0x500 + u64::from(slot),
count: 1,
quality: record.map(|_| 1),
already_bound: bound,
flags,
field_duration: duration,
occupied_seats: seats,
..Default::default()
},
);
if let Some((flags, bonding, area, map)) = record {
s.set_item_template(
1000 + slot,
ItemTemplateView {
flags,
bonding,
area,
map,
..Default::default()
},
);
}
}
s.set_container(0, Some(bag));
// A worn trinket with a lifetime, picked up off the doll.
let mut doll: crate::script::InventorySlots = Default::default();
doll[14] = Some(crate::script::InvSlotView {
item_id: 7000,
guid: 0x714,
field_duration: 3_600_000,
..Default::default()
});
s.set_inventory_slots(doll);
heard::listen(
&s,
&["CURSOR_UPDATE", "NEW_AUCTION_UPDATE", "ITEM_LOCK_CHANGED"],
None,
);
let click = |s: &mut UiScript, pick: &str| {
s.run(&format!("ClearCursor() {pick}")).unwrap();
heard::take(s);
s.run("ClickAuctionSellItemButton()").unwrap();
(s.take_ui_errors(), heard::take(s))
};
// The last slot attaches first, so each refusal shows the slot left as it was.
let (errors, heard) = click(&mut s, "PickupContainerItem(0, 14)");
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(heard, ["CURSOR_UPDATE", "NEW_AUCTION_UPDATE"]);
let mut held = 14;
for (i, &(.., refusal)) in cases.iter().enumerate() {
let slot = i as u32 + 1;
let pick = format!("PickupContainerItem(0, {slot})");
let (errors, heard) = click(&mut s, &pick);
match refusal {
Some(key) => {
assert_eq!((errors, heard), (vec![key], vec![]), "slot {slot}");
assert_eq!(
s.cursor_item().map(|c| c.slot),
Some(slot),
"slot {slot}: the cursor keeps it"
);
}
None => {
assert!(errors.is_empty(), "slot {slot}: {errors:?}");
held = slot;
}
}
assert_eq!(s.auction_sell_item(), Some((0, held)), "slot {slot}");
}
let (errors, heard) = click(&mut s, "PickupInventoryItem(14)");
assert_eq!(
(errors, heard),
(vec!["ERR_AUCTION_CONJURED_ITEM"], vec![]),
"a worn item answers to its own ITEM_FIELD_DURATION"
);
assert_eq!(s.auction_sell_item(), Some((0, held)));
}
/// `StartAuction` (`0x4ce770`) hands the host its send, the guid the attach stored
/// (`0x4ce8f3`) wherever the item has since gone; the host tests the auctioneer, sends and
/// empties the slot. A run time off the table hands nothing and keeps the slot (`0x4ce8a0`); a
/// buyout under the minimum bid is raised to it (`0x4ce883`); an empty slot hands nothing
/// (`0x4ce7e4`).
#[test]
fn start_auction_hands_its_send_to_the_host() {
use crate::script::cursor::two_item_bag;
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.run("StartAuction(100, 0, 120)").unwrap();
assert!(s.take_auction_calls().is_empty(), "an empty slot");
let mut bag = two_item_bag();
for (slot, item) in bag.slots.iter_mut() {
item.guid = 0x4000 + u64::from(*slot);
}
s.set_container(0, Some(bag));
s.run("PickupContainerItem(0, 1) ClickAuctionSellItemButton()")
.unwrap();
// The attach stored the guid (`[0xb72608]`); the item moving on leaves it.
s.set_container(0, Some(two_item_bag()));
s.run("StartAuction(100, 50, 100)").unwrap();
assert!(
s.take_auction_calls().is_empty(),
"a run time off the table"
);
assert_eq!(s.auction_sell_item(), Some((0, 1)));
s.run("StartAuction(100, 50, 480)").unwrap();
assert_eq!(
s.take_auction_calls(),
[AuctionCall::Start(Ok(AuctionStartRequest {
item_guid: 0x4001,
min_bid: 100,
buyout: 100,
duration: 480,
}))]
);
let err = s.run("StartAuction(100, 0)").unwrap_err().to_string();
assert!(err.contains("Usage: StartAuction(minBid, buyoutPrice, runTime)"));
}
/// A damaged item, not wrapped, hands the host the refusal `ERR_AUCTION_REPAIR_ITEM` (id 375,
/// `0x4ce80f`-`0x4ce83f`) in place of the send, and keeps its slot; a wrapped one is not
/// checked.
#[test]
fn start_auction_refuses_a_damaged_item() {
use crate::script::cursor::two_item_bag;
let mut s = UiScript::new().unwrap();
s.keep_calls();
let bag = |durability, flags| {
let mut bag = two_item_bag();
for (slot, item) in bag.slots.iter_mut() {
item.guid = 0x4000 + u64::from(*slot);
item.durability = durability;
item.flags = flags;
}
bag
};
s.set_container(0, Some(bag(Some((10, 40)), 0)));
s.run("PickupContainerItem(0, 1) ClickAuctionSellItemButton() StartAuction(100, 0, 120)")
.unwrap();
assert_eq!(
s.take_auction_calls(),
[AuctionCall::Start(Err("ERR_AUCTION_REPAIR_ITEM"))]
);
assert_eq!(s.auction_sell_item(), Some((0, 1)), "the slot keeps it");
s.set_container(0, Some(bag(Some((10, 40)), 0x8)));
s.run("StartAuction(100, 0, 120)").unwrap();
assert!(
matches!(s.take_auction_calls()[..], [AuctionCall::Start(Ok(_))]),
"a wrapped item is not checked"
);
}
/// `GetSelectedAuctionItem` (`0x4cfec0`) answers 1 for a type that is none of the three
/// (`0x4cffc5` → `0x4d0013`, the double 1.0), and raises its usage line for a non-string
/// (`0x4cfecc`, `0x8495e4`); a number is a type's text.
#[test]
fn an_unknown_selection_type_answers_one() {
let mut s = UiScript::new().unwrap();
s.set_auction(page(&[11]));
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("foo")"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return GetSelectedAuctionItem(5)").unwrap(),
1
);
assert_eq!(
s.eval::<i64>(r#"return GetSelectedAuctionItem("LIST")"#)
.unwrap(),
0,
"a known type with no selection"
);
for call in ["GetSelectedAuctionItem()", "GetSelectedAuctionItem({})"] {
let err = s.run(call).unwrap_err().to_string();
assert!(
err.contains(r#"Usage: GetSelectedAuctionItem("type")"#),
"{call}: {err}"
);
}
}
/// `0x644bc0` answers 0 unless the run time is one of the three at `0x80e4b0`
/// (`0x644bd0`-`0x644be1`), and multiplies the rate in 32 bits (`0x644be9`).
#[test]
fn the_deposit_is_zero_off_the_three_run_times() {
for minutes in [0, 60, 119, 121, 240, 479, 960, 1439, 1441, 2880] {
assert_eq!(deposit_for(5, 10_000, minutes), 0, "{minutes} minutes");
}
assert_eq!(deposit_for(5, 10_000, 480), 2_000);
// 25 × 200,000,000 wraps to 705,032,704 (`imul ecx,edx`), over 100.
assert_eq!(deposit_for(25, 200_000_000, 120), 7_050_327);
}
/// `CalculateAuctionDeposit(240)` reads 0 where it read twice the two-hour deposit.
#[test]
fn calculate_auction_deposit_answers_zero_for_four_hours() {
use crate::script::container::{ContainerSlot, ContainerState};
use crate::script::cursor::CursorItem;
use crate::script::ItemTemplateView;
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
1u32,
ContainerSlot {
count: 1,
item_id: 2589,
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: None,
num_slots: 16,
slots,
}),
);
s.set_item_template(
2589,
ItemTemplateView {
sell_price: 10_000,
..Default::default()
},
);
s.set_auction(AuctionState {
deposit_percent: 5,
..Default::default()
});
s.model_mut().auction_sell_item = Some(CursorItem {
item_class: None,
guid: 0x108,
bag: 0,
slot: 1,
item_id: 2589,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
bar_placeable: false,
consumable: false,
});
let deposit = |s: &UiScript, minutes: u32| {
s.eval::<i64>(&format!("return CalculateAuctionDeposit({minutes})"))
.unwrap()
};
assert_eq!(deposit(&s, 120), 500);
assert_eq!(deposit(&s, 240), 0);
assert_eq!(deposit(&s, 1440), 6_000);
}
/// The stack's value is a 32-bit `imul` of the vendor price and the count, in the deposit and
/// in `GetAuctionSellItemInfo` (`0x4ce295`, `0x4ce703`): `0x80000001 × 2` is 2.
#[test]
fn the_stack_value_is_a_32_bit_product() {
use crate::script::container::{ContainerSlot, ContainerState};
use crate::script::cursor::CursorItem;
use crate::script::ItemTemplateView;
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
1u32,
ContainerSlot {
count: 2,
item_id: 2589,
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: None,
num_slots: 16,
slots,
}),
);
s.set_item_template(
2589,
ItemTemplateView {
sell_price: 0x8000_0001,
..Default::default()
},
);
s.set_auction(AuctionState {
deposit_percent: 100,
..Default::default()
});
s.model_mut().auction_sell_item = Some(CursorItem {
item_class: None,
guid: 0x108,
bag: 0,
slot: 1,
item_id: 2589,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
bar_placeable: false,
consumable: false,
});
assert_eq!(
s.eval::<i64>("local _, _, _, _, _, price = GetAuctionSellItemInfo() return price")
.unwrap(),
2
);
assert_eq!(
s.eval::<i64>("return CalculateAuctionDeposit(120)")
.unwrap(),
2
);
}
/// A bid on a row: 100c to start, 500c bid with a 25c step, a 2000c buyout, from someone else.
fn bid(amount: u32) -> AuctionBid {
AuctionBid {
list: LIST,
auction_id: 7,
amount,
item_id: 2589,
min_bid: 100,
increment: 25,
buyout: 2000,
current: 500,
player_leads: false,
}
}
/// `PlaceAuctionBid`'s checks in their order (`0x4cf762`-`0x4cf8a8`): the buyout clamp, the
/// start bid, the current bid, the step unless the buyout is reached, the silent cap, the
/// money (only the rise when the player leads) and the unique item.
#[test]
fn a_bid_is_checked_in_the_references_order() {
use BidVerdict::{Drop, Refuse, Send};
let rich = u32::MAX;
let none = || None;
assert_eq!(
bid(5000).verdict(rich, none),
Send(2000),
"lowered to the buyout"
);
let mut b = bid(2500);
b.buyout = 0;
assert_eq!(b.verdict(rich, none), Send(2500), "no buyout, no clamp");
let mut b = bid(99);
b.current = 0;
assert_eq!(b.verdict(rich, none), Refuse("ERR_AUCTION_MIN_BID"));
assert_eq!(
bid(99).verdict(rich, none),
Refuse("ERR_AUCTION_MIN_BID"),
"the start bid is tested before the current one"
);
assert_eq!(
bid(500).verdict(rich, none),
Refuse("ERR_AUCTION_HIGHER_BID")
);
assert_eq!(
bid(524).verdict(rich, none),
Refuse("ERR_AUCTION_BID_INCREMENT")
);
assert_eq!(bid(525).verdict(rich, none), Send(525));
// A buyout under the step: reaching it passes.
let mut b = bid(510);
b.buyout = 510;
assert_eq!(b.verdict(rich, none), Send(510));
let mut b = bid(100);
b.current = 0;
b.increment = 0;
assert_eq!(
b.verdict(rich, none),
Send(100),
"no bid yet: the step is not asked"
);
// Past 2,000,000,000 and the step: nothing at all.
let mut b = bid(2_000_000_001);
b.buyout = 0;
assert_eq!(b.verdict(rich, none), Drop);
b.current = 2_000_000_000;
b.increment = 1;
assert_eq!(
b.verdict(rich, none),
Send(2_000_000_001),
"not past the step"
);
assert_eq!(bid(525).verdict(524, none), Refuse("ERR_NOT_ENOUGH_MONEY"));
let mut b = bid(525);
b.player_leads = true;
assert_eq!(b.verdict(25, none), Send(525), "the rise alone");
assert_eq!(b.verdict(24, none), Refuse("ERR_NOT_ENOUGH_MONEY"));
assert_eq!(
bid(525).verdict(rich, || Some((1, 1))),
Refuse("ERR_ITEM_MAX_COUNT")
);
assert_eq!(bid(525).verdict(rich, || Some((2, 1))), Send(525));
assert_eq!(
bid(525).verdict(rich, || Some((0, 9))),
Send(525),
"no limit"
);
let mut asked = false;
let _ = bid(525).verdict(0, || {
asked = true;
None
});
assert!(!asked, "the unique check comes after the money's");
}
/// `CancelAuction`'s fee (`0x4d0338`-`0x4d0341`): 5% of the bid, truncated, with the `f32`
/// 0.05 a hair above it.
#[test]
fn the_cancel_fee_is_five_percent_truncated() {
assert_eq!(cancel_fee(0), 0);
assert_eq!(cancel_fee(19), 0);
assert_eq!(cancel_fee(20), 1);
assert_eq!(cancel_fee(39), 1);
assert_eq!(cancel_fee(1_000_000), 50_000);
assert_eq!(cancel_fee(u32::MAX), 214_748_367);
}
/// `PlaceAuctionBid` and `CancelAuction` hand the host the record at the row as the call
/// finds it (`0x4cf6b7`-`0x4cf716`, `0x4d02c5`-`0x4d02cb`); no row, no call.
#[test]
fn the_bid_and_cancel_verbs_hand_the_host_their_row() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
let mut state = page(&[7]);
let r = &mut state.lists[LIST].rows[0];
(r.min_bid, r.min_increment, r.buyout_price, r.bid_amount) = (100, 25, 2000, 500);
r.bidder_rank = 2;
state.lists[OWNER] = state.lists[LIST].clone();
s.set_auction(state);
s.run(r#"PlaceAuctionBid("list", 1, 600.9) PlaceAuctionBid("list", 2, 600)"#)
.unwrap();
s.run("CancelAuction(1) CancelAuction(2) CancelAuction(0)")
.unwrap();
assert_eq!(
s.take_auction_calls(),
[
AuctionCall::Bid(AuctionBid {
list: LIST,
auction_id: 7,
amount: 600,
item_id: 1007,
min_bid: 100,
increment: 25,
buyout: 2000,
current: 500,
player_leads: true,
}),
AuctionCall::Cancel {
auction_id: 7,
bid: 500
},
]
);
}
/// `GetAuctionItemInfo` asks the name cache for an owned row's bidder (`0x4cf167`) and then the
/// seller (`0x4cf1c8`) when either misses, with the list's callback; a cached name, a row
/// with no bid to name, or a row whose item is not cached (`0x4cefa0`) asks nothing.
#[test]
fn the_row_read_asks_the_names_it_misses() {
let mut s = UiScript::new().unwrap();
let mut state = page(&[1, 2, 3]);
state.lists[OWNER] = state.lists[LIST].clone();
let rows = &mut state.lists[OWNER].rows;
// Owned with a bid: neither name known.
rows[0].named_bidder = 0x99;
rows[0].owner_guid = 0x77;
// Named already.
rows[1].owner_guid = 0x55;
rows[1].owner = Some("Seller".into());
rows[1].named_bidder = 0x98;
rows[1].high_bidder = AuctionHighBidder::Name("Bidder".into());
// Uncached item: the miss shape, before any name.
rows[2].owner_guid = 0x56;
rows[2].quality = None;
s.set_auction(state);
s.keep_calls();
s.run(
r#"for i = 1, 3 do GetAuctionItemInfo("owner", i) end GetAuctionItemInfo("owner", 1)"#,
)
.unwrap();
// Every read asks; the host keeps each name and list once.
assert_eq!(
s.take_auction_name_asks(),
[(0x99, OWNER), (0x77, OWNER), (0x99, OWNER), (0x77, OWNER)]
);
assert!(s.take_auction_name_asks().is_empty());
}
/// `ClickAuctionSellItemButton`'s refusal shows inside the verb (`0x496720`): the chunk's next
/// line reads it.
#[test]
fn the_sell_slots_refusal_shows_inside_its_call() {
use crate::script::ui_errors::shown::seen_in_call;
let mut s = UiScript::new().unwrap();
let mut bag = crate::script::cursor::two_item_bag();
bag.slots.get_mut(&1).unwrap().already_bound = true;
s.set_container(0, Some(bag));
let (seen, _) = seen_in_call(
&mut s,
"PickupContainerItem(0, 1) ClickAuctionSellItemButton()",
);
assert_eq!(seen, "ERR_AUCTION_BOUND_ITEM");
assert!(s.take_ui_errors().is_empty(), "nothing waits for the feed");
}
}