the auction house opens, the Honor tab lands, and a day of fixes

- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 19:04:27 +07:00
co-authored by Claude Fable 5
parent 6aa78bc468
commit cf581aa2ac
170 changed files with 20573 additions and 1578 deletions
+48 -1
View File
@@ -10,7 +10,8 @@
//! Coordinates stay **raw WoW** (the `benilla` boundary applies `bevy = (-y, z, -x)`).
use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
@@ -1142,6 +1143,52 @@ pub enum SessionEvent {
/// mail waiting, `-86400.0` = none. Drives `HasNewMail()`/`UPDATE_PENDING_MAIL` (decision 0544
/// P3).
NextMailTime { seconds: f32 },
/// `MSG_AUCTION_HELLO`'s reply — the auctioneer we greeted plus its `AuctionHouse.dbc` row
/// (1..7, the deposit/cut rates). This packet, not our send, is what opens the auction window
/// (decision 1511 P0's wire layer; the window itself is a later phase).
AuctionHello { auctioneer: u64, house_id: u32 },
/// `SMSG_AUCTION_COMMAND_RESULT` — the verdict on a sell/cancel/bid, keyed by `action`
/// ([`crate::messages::auction_action`]) and `error` ([`crate::messages::auction_error`]);
/// `tail` is the conditional payload the error selects. `auction_id` is `0` on most failures,
/// and several refusals send no packet at all (decision 1511).
AuctionCommandResult {
auction_id: u32,
action: u32,
error: u32,
tail: AuctionCommandTail,
},
/// `SMSG_AUCTION_LIST_RESULT` — a Browse page (max 50 rows). `total_count` is the pre-cap
/// match count the pager needs, and it rides at the END of the wire body.
AuctionListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_OWNER_LIST_RESULT` — the Auctions tab page (our own listings). Same frame
/// and record as [`Self::AuctionListResult`].
AuctionOwnerListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_BIDDER_LIST_RESULT` — the Bid tab page. Same frame and record as
/// [`Self::AuctionListResult`]; the server emits the explicitly-refreshed ids first and then
/// our live bids, so one row can appear twice in a page.
AuctionBidderListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_BIDDER_NOTIFICATION` — we won, or we were outbid (`bid_or_zero == 0` means
/// **won**, not "no bid").
AuctionBidderNotification(AuctionBidderNotification),
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — our own auction sold or took a bid. A deliberately
/// different shape from the bidder notice: no house id, guid fourth, and an all-zero
/// `bidder_guid` is the "sold" signal.
AuctionOwnerNotification(AuctionOwnerNotification),
/// `SMSG_AUCTION_REMOVED_NOTIFICATION` — an auction we had bid on was cancelled by its seller.
AuctionRemovedNotification {
auction_id: u32,
item_entry: u32,
random_property_id: i32,
},
/// `SMSG_TRADE_STATUS` — one pulse of the player-trade state machine (open/accept/cancel/
/// complete + the refusal reasons). The app drives the trade window and the auto-`BEGIN_TRADE`
/// reply off this (decision 0592; the window itself is P1).
@@ -824,6 +824,60 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::NextMailTime { seconds } => {
vec![SessionEvent::NextMailTime { seconds }]
}
ServerPacket::AuctionHello {
auctioneer,
house_id,
} => vec![SessionEvent::AuctionHello {
auctioneer,
house_id,
}],
ServerPacket::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => vec![SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
}],
ServerPacket::AuctionListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionListResult {
auctions,
total_count,
}],
ServerPacket::AuctionOwnerListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionOwnerListResult {
auctions,
total_count,
}],
ServerPacket::AuctionBidderListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionBidderListResult {
auctions,
total_count,
}],
ServerPacket::AuctionBidderNotification(n) => {
vec![SessionEvent::AuctionBidderNotification(n)]
}
ServerPacket::AuctionOwnerNotification(n) => {
vec![SessionEvent::AuctionOwnerNotification(n)]
}
ServerPacket::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
} => vec![SessionEvent::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
}],
ServerPacket::TradeStatus { status } => vec![SessionEvent::TradeStatus { status }],
ServerPacket::TradeStatusExtended { state } => {
vec![SessionEvent::TradeStatusExtended { state }]
@@ -0,0 +1,506 @@
//! The auction house arc's wire layer (decision 1511 phase P0): the auctioneer hello, the three
//! list pages (browse / your bids / your auctions), the sell/bid/cancel verbs, the one command
//! verdict, and the three notifications (vmangos `Handlers/AuctionHouseHandler.cpp`,
//! `AuctionHouse/AuctionHouseMgr.{h,cpp}`, `Server/Packets/AuctionHouse.{h,cpp}`,
//! `Opcodes_1_12_1.h`, VERIFIED — read twice independently at decision time and re-read against
//! the source while writing this module).
//!
//! **There is no `*_LIST_PENDING_SALES` opcode on 5875** — the symbol does not exist anywhere in
//! vmangos's opcode tables (0x264/0x265 sit between `SMSG_SPELLORDAMAGE_IMMUNE` and
//! `SMSG_SET_FLAT_SPELL_MODIFIER` with no gap for one), and the matching mail answer
//! `AUCTION_SALE_PENDING(6)` is dead in the tree. The same shape of recorded fact as `mail`'s
//! missing `SMSG_SHOW_MAILBOX`: a pane that exists in later clients has no wire here to build.
//!
//! Two things hold across every packet below. **Every guid is a plain 8-byte LE `u64`, never a
//! packed guid** — vmangos's `ObjectGuid` stream operators write and read `GetRawValue()` whole
//! (`ObjectGuid.cpp`), and `PackedGuid` never appears on the auction path. And **the auctioneer
//! guid rides at the head of every CMSG** because the server re-validates the 5 yd interact
//! distance on each one independently (`GetCheckedAuctionHouseForAuctioneer`): the hello is not a
//! session token.
use std::io;
use crate::wire::{read_i32_le, read_u32_le, read_u64_le};
/// `SMSG_AUCTION_COMMAND_RESULT`'s `action` field — which verb the verdict answers (VERIFIED
/// vmangos `AuctionHouseMgr.h:53-58`, `AuctionAction`).
pub mod auction_action {
/// Answers [`super::auction_sell_item`] — a new listing was created.
pub const STARTED: u32 = 0;
/// Answers [`super::auction_remove_item`] — a listing was cancelled.
pub const REMOVED: u32 = 1;
/// Answers [`super::auction_place_bid`]. A *buyout* rides this action too: buyout is inferred
/// server-side (`price >= buyout && buyout != 0`), never flagged on the wire.
pub const BID_PLACED: u32 = 2;
}
/// `SMSG_AUCTION_COMMAND_RESULT`'s `error` field (VERIFIED vmangos `AuctionHouseMgr.h:40-51`,
/// `AuctionError`). **6, 8, 9, 11 and 12 have no name**: vmangos has no enumerator for them and
/// never sends them, and the real client's `ERR_AUCTION_*` table falls through to a generic
/// failure branch for anything it doesn't recognize — so an unknown code here is a generic
/// refusal to report, not a parse failure.
pub mod auction_error {
/// Success — read [`super::auction_action`] to know *what* succeeded.
pub const OK: u32 = 0;
/// The only error carrying an [`super::AuctionCommandTail::Inventory`] tail: an
/// `InventoryResult` (`EQUIP_ERR_*`) code, the same table the bag/equip refusals use.
pub const INVENTORY: u32 = 1;
/// `ERR_AUCTION_DATABASE_ERROR` — also vmangos's catch-all refusal (a bad `etime` lands here).
pub const DATABASE: u32 = 2;
pub const NOT_ENOUGH_MONEY: u32 = 3;
pub const ITEM_NOT_FOUND: u32 = 4;
/// Someone outbid us mid-flight; carries the [`super::AuctionCommandTail::HigherBid`] tail.
pub const HIGHER_BID: u32 = 5;
/// The bid did not clear the minimum increment (5% of the current bid, floored, min 1 copper).
pub const BID_INCREMENT: u32 = 7;
/// You cannot bid on your own auction.
pub const BID_OWN: u32 = 10;
pub const RESTRICTED_ACCOUNT: u32 = 13;
}
/// The "don't filter" sentinels [`auction_list_items`]'s browse filters use (VERIFIED vmangos
/// `AuctionHouseObject::BuildListAuctionItems`, which compares each filter against exactly these
/// before applying it — and takes a whole-table fast path when *all* of them are unset).
pub mod auction_filter {
/// `slot_id` / `main_category` / `sub_category` / `quality`: `0xFFFF_FFFF` = no filter.
pub const ANY: u32 = 0xFFFF_FFFF;
/// `level_min` / `level_max`: `0` = no filter. The pair gates `RequiredLevel`, **not** item
/// level, and `level_max` is only consulted when `level_min` is set.
pub const ANY_LEVEL: u8 = 0;
/// `usable`: `0` = no filter. Nonzero drops everything `CanUseItem` refuses (and recipes we
/// already know).
pub const ANY_USABILITY: u8 = 0;
}
/// The three `etime` values `CMSG_AUCTION_SELL_ITEM` may carry, in **minutes** (VERIFIED vmangos
/// `HandleAuctionSellItem`, `AuctionHouseHandler.cpp:281-296`: `etime * MINUTE` is switched
/// against 1/4/12 × `MIN_AUCTION_TIME` — and `MIN_AUCTION_TIME` is `2*HOUR`). Anything else is
/// refused with [`auction_error::DATABASE`]. The same 1/4/12 multiple also scales the deposit.
pub mod auction_duration {
pub const SHORT_MINUTES: u32 = 120;
pub const MEDIUM_MINUTES: u32 = 480;
pub const LONG_MINUTES: u32 = 1440;
}
/// Records per list page. Every server list builder stops at 50 (`count < 50`), so paging is
/// `list_from = page * AUCTION_PAGE_SIZE` and the pager's "of N" comes from the list result's
/// trailing `total_count`, which is counted *before* this cap.
pub const AUCTION_PAGE_SIZE: u32 = 50;
/// One list-result record's fixed width: **64** bytes — twelve 4-byte fields plus the two 8-byte
/// guids (`owner_guid`, `bidder_guid`). Decision 1511's prose says 60; that is an arithmetic slip
/// in the summary, not in its field list, which sums to 64 (VERIFIED by re-reading the single
/// producer, `AuctionEntry::BuildAuctionInfo`, `AuctionHouseMgr.cpp:811-842`, whose writes are
/// `7×u32, u64, 4×u32, u64, u32`). Load-bearing rather than decorative — it is the bound
/// [`read_auction_list_result`] uses to survive a body shorter than its own `count` claims.
pub const AUCTION_RECORD_BYTES: usize = 64;
/// One row of any of the three auction list results. `SMSG_AUCTION_LIST_RESULT`,
/// `SMSG_AUCTION_OWNER_LIST_RESULT` and `SMSG_AUCTION_BIDDER_LIST_RESULT` share **one** record
/// layout and one frame — all three call the same producer (VERIFIED vmangos
/// `AuctionEntry::BuildAuctionInfo`). Fixed width ([`AUCTION_RECORD_BYTES`]), no cstrings: the
/// item's *name* is not on the wire at all, and comes from the ordinary item-template cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionListEntry {
/// The auction's server id — the handle [`auction_place_bid`]/[`auction_remove_item`] name.
pub auction_id: u32,
pub item_entry: u32,
/// `PERM_ENCHANTMENT_SLOT` only. 1.12 carries no other enchant information on an auction row:
/// vmangos's own `[-ZERO] no other infos about enchantment in 1.12 [?]` comment sits on the
/// commented-out loop that would have written the durations and charges a later client reads.
pub perm_enchant: u32,
/// **Signed** — negative is a random *suffix* id, positive a random *property* id.
/// `Item::GetItemRandomPropertyId` returns `int32` and the server casts it through `uint32`,
/// so the wire bytes are a two's-complement `i32` and reading them unsigned turns every
/// suffixed item ("of the Bear") into a ~4.29-billion property id that resolves to nothing.
pub random_property_id: i32,
pub suffix_factor: u32,
/// The stack size.
pub count: u32,
/// **Signed** — a negative charge count means "N charges, then destroy the item".
/// `Item::GetSpellCharges` returns `int32` and the server casts it through `uint32` (its own
/// comment on the line reads `item->charge FFFFFFF`).
pub spell_charges: i32,
pub owner_guid: u64,
/// The seller's **opening** price, captured at listing time and constant for the auction's
/// life. NOT the current bid ([`Self::current_bid`]) and not the minimum next bid — vmangos's
/// own field comment calls it "maybe useless", but it *is* what the first bid must meet.
pub start_bid: u32,
/// How much above [`Self::current_bid`] the next bid must go: 5% of the current bid, floored,
/// minimum 1 copper. **`0` while nobody has bid** — the server writes
/// `bid ? GetAuctionOutBid() : 0`, so a UI must fall back to [`Self::start_bid`] for the
/// opening bid rather than reading a `0` here as "any amount will do".
pub min_increment: u32,
/// `0` = no buyout. Buyout is *inferred*, never flagged: a bid of `>= buyout` on a nonzero
/// buyout is a buyout.
pub buyout: u32,
/// **Milliseconds** remaining, and **unclamped**. The server writes
/// `(expireTime - now) * IN_MILLISECONDS` with no floor and only sweeps expiry on its own
/// timer, so an expired-but-still-listed auction wraps to a value near 4.29 billion. Read
/// faithfully here — a consumer must treat an implausibly large value as **expired**, not as
/// "very long".
pub time_left_ms: u32,
/// `0` = nobody has bid.
pub bidder_guid: u64,
/// `0` = no bid yet.
pub current_bid: u32,
}
/// The conditional tail `SMSG_AUCTION_COMMAND_RESULT` carries after its three dwords — which
/// shape follows is decided by `error` first and `action` only inside the `OK` arm (VERIFIED
/// vmangos `WorldSession::SendAuctionCommandResult`, `AuctionHouseHandler.cpp:70-96`, whose
/// `switch` is on the error code).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuctionCommandTail {
/// Every other (error, action) pair — the three dwords are the whole packet. This is the
/// common case: a plain `OK` + `STARTED`/`REMOVED`, and every named error but two.
Empty,
/// [`auction_error::OK`] **and** [`auction_action::BID_PLACED`]: the outbid step the *next*
/// bid must clear, recomputed from the bid just accepted.
BidPlaced { new_min_outbid: u32 },
/// [`auction_error::INVENTORY`]: an `InventoryResult` (`EQUIP_ERR_*`) code.
Inventory { result: u32 },
/// [`auction_error::HIGHER_BID`]: we were outbid between reading the page and bidding — who
/// holds it now, at what bid, and the step the next bid must clear.
HigherBid {
new_bidder_guid: u64,
new_bid: u32,
new_min_outbid: u32,
},
}
/// `SMSG_AUCTION_BIDDER_NOTIFICATION` — "you won" / "you were outbid", pushed to the *bidder*
/// (VERIFIED vmangos `AuctionBidderNotification::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:78-87`). Deliberately **not** the same shape as
/// [`AuctionOwnerNotification`]: this one leads with the house id and puts the guid third, the
/// owner one has no house id and puts the guid fourth. Two structs and two readers, on purpose —
/// sharing either would silently mis-decode one of them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionBidderNotification {
/// `AuctionHouse.dbc` row (1..7) — the same id [`read_auction_hello`] answers with.
pub house_id: u32,
pub auction_id: u32,
/// The bidder the notice is *about* — us.
pub bidder_guid: u64,
/// **`0` means WON**, not "no bid" (vmangos writes `won ? 0 : auction->bid`, and the real
/// client branches `ERR_AUCTION_WON_S` vs `ERR_AUCTION_OUTBID_S` on exactly this). Nonzero is
/// the bid that beat us.
pub bid_or_zero: u32,
/// The step the next bid must clear (`GetAuctionOutBid()`).
pub out_bid: u32,
pub item_entry: u32,
/// **Signed**, as on [`AuctionListEntry::random_property_id`] — the same
/// `Item::GetItemRandomPropertyId()` value, cast through vmangos's `uint32` packet field.
/// `0` when the item is gone.
pub random_property_id: i32,
}
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — "your auction sold" / a bid landed, pushed to the *seller*
/// (VERIFIED vmangos `AuctionOwnerNotification::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:89-97`). **No `house_id`, and the guid sits fourth** — see
/// [`AuctionBidderNotification`] for why the two are kept apart.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionOwnerNotification {
pub auction_id: u32,
/// The current bid.
pub bid: u32,
/// The step the next bid must clear (`GetAuctionOutBid()`).
pub out_bid: u32,
/// Who bid. **All-zero on a sale**: vmangos fills this field only when the auction has *not*
/// sold yet (`if (!sold)`), so a zero guid here is the "sold" signal, not a missing bidder.
pub bidder_guid: u64,
pub item_entry: u32,
/// **Signed**, as on [`AuctionListEntry::random_property_id`]. `0` when the item is gone.
pub random_property_id: i32,
}
/// Body of `MSG_AUCTION_HELLO` (VERIFIED vmangos `AuctionHello::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:3-6`): one full 8-byte auctioneer guid. **Same opcode both
/// directions** — the reply ([`read_auction_hello`]) echoes the guid and adds the house id, and it
/// is the *reply* that opens the window, not our send.
pub fn auction_hello(auctioneer: u64) -> Vec<u8> {
auctioneer.to_le_bytes().to_vec()
}
/// Body of `CMSG_AUCTION_SELL_ITEM` (VERIFIED vmangos `AuctionSellItem::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:42-49`): `u64 auctioneer, u64 itemGuid, u32 bid, u32 buyout,
/// u32 etime`.
///
/// `etime_minutes` may only be one of [`auction_duration`]'s three values; anything else is
/// refused with [`auction_error::DATABASE`]. A `bid` or `etime_minutes` of `0` is answered with
/// **nothing at all** (`HandleAuctionSellItem`'s silent `return`), as is a bid the player cannot
/// pay the deposit for — a UI that blocks waiting for an ack will hang.
pub fn auction_sell_item(
auctioneer: u64,
item_guid: u64,
bid: u32,
buyout: u32,
etime_minutes: u32,
) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 8 + 4 + 4 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&item_guid.to_le_bytes());
body.extend_from_slice(&bid.to_le_bytes());
body.extend_from_slice(&buyout.to_le_bytes());
body.extend_from_slice(&etime_minutes.to_le_bytes());
body
}
/// Body of `CMSG_AUCTION_REMOVE_ITEM` (VERIFIED vmangos `AuctionRemoveItem::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:36-40`): `u64 auctioneer, u32 auctionId` — the seller's
/// cancel. Answered by `SMSG_AUCTION_COMMAND_RESULT` (action [`auction_action::REMOVED`]) *except*
/// when the auction already carries a bid and the seller cannot pay the 5% cut out of pocket, in
/// which case the server refuses **silently**.
pub fn auction_remove_item(auctioneer: u64, auction_id: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&auction_id.to_le_bytes());
body
}
/// Body of `CMSG_AUCTION_LIST_ITEMS` — the Browse tab's search (VERIFIED vmangos
/// `AuctionListItems::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:51-63`): `u64
/// auctioneer, u32 listfrom, cstr searchedname, u8 levelmin, u8 levelmax, u32 auctionSlotID, u32
/// auctionMainCategory, u32 auctionSubCategory, u32 quality, u8 usable`.
///
/// **Ten fields and nothing else: no sort columns, no sort count, no trailing padding.** That is
/// the field an implementer invents, because later expansions do carry sort bytes here — on 5875
/// *filtering* is entirely server-side and *sorting* is entirely client-side. (Corroborated
/// beyond the reader: vmangos runs `VerifyPacketWasCorrectlyRead` after every `ClientPacket`
/// parse and error-logs a body it did not consume to the end, which a real 5875 client appending
/// sort bytes would trip on every search.)
///
/// The sentinels are in [`auction_filter`]: `0xFFFF_FFFF` for the four u32 filters, `0` for the
/// three small ones, and an empty `searched_name` is just the lone NUL. `quality` is a
/// **minimum**, not an equality (`proto->Quality < query.quality` is what drops a row).
/// `level_min`/`level_max` gate `RequiredLevel`, not item level. The name match is a
/// case-insensitive substring against the localized name *with the random-property suffix
/// appended*. `list_from` pages by [`AUCTION_PAGE_SIZE`].
#[allow(clippy::too_many_arguments)]
pub fn auction_list_items(
auctioneer: u64,
list_from: u32,
searched_name: &str,
level_min: u8,
level_max: u8,
slot_id: u32,
main_category: u32,
sub_category: u32,
quality: u32,
usable: u8,
) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4 + searched_name.len() + 1 + 1 + 1 + 4 + 4 + 4 + 4 + 1);
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&list_from.to_le_bytes());
body.extend_from_slice(searched_name.as_bytes());
body.push(0);
body.push(level_min);
body.push(level_max);
body.extend_from_slice(&slot_id.to_le_bytes());
body.extend_from_slice(&main_category.to_le_bytes());
body.extend_from_slice(&sub_category.to_le_bytes());
body.extend_from_slice(&quality.to_le_bytes());
body.push(usable);
body
}
/// Body of `CMSG_AUCTION_LIST_OWNER_ITEMS` — the Auctions tab (VERIFIED vmangos
/// `AuctionListOwnerItems::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:23-27`): `u64
/// auctioneer, u32 listfrom`. Answered by `SMSG_AUCTION_OWNER_LIST_RESULT`. The server matches on
/// the **account**, then filters to this character's guid.
pub fn auction_list_owner_items(auctioneer: u64, list_from: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&list_from.to_le_bytes());
body
}
/// Body of `CMSG_AUCTION_PLACE_BID` (VERIFIED vmangos `AuctionPlaceBid::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:29-34`): `u64 auctioneer, u32 auctionId, u32 price`. The
/// buyout verb too — send `price = buyout` and the server takes it as one; there is no separate
/// opcode and no flag. A bid the player cannot afford is refused **silently**.
pub fn auction_place_bid(auctioneer: u64, auction_id: u32, price: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&auction_id.to_le_bytes());
body.extend_from_slice(&price.to_le_bytes());
body
}
/// Body of `CMSG_AUCTION_LIST_BIDDER_ITEMS` — the Bid tab (VERIFIED vmangos
/// `AuctionListBidderItem::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:8-21`): `u64
/// auctioneer, u32 listfrom, u32 count`, then `count` × `u32 auctionId`.
///
/// The trailing id list is a **refresh set**, not a filter: the server emits a record for each id
/// named here *first* and then appends every auction this player is the current bidder on, both
/// feeding one `count`/`total_count` — so an id that is also still ours appears twice in the page.
/// Send it empty (`&[]`) for a plain page fetch; the reference client fills it with the auctions
/// it has been outbid on so their rows re-sync.
pub fn auction_list_bidder_items(auctioneer: u64, list_from: u32, auction_ids: &[u32]) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4 + 4 + 4 * auction_ids.len());
body.extend_from_slice(&auctioneer.to_le_bytes());
body.extend_from_slice(&list_from.to_le_bytes());
body.extend_from_slice(&(auction_ids.len() as u32).to_le_bytes());
for id in auction_ids {
body.extend_from_slice(&id.to_le_bytes());
}
body
}
/// Read `MSG_AUCTION_HELLO`'s **reply** (VERIFIED vmangos `AuctionHelloResponse::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:72-76`): `u64 auctioneerGuid, u32 houseId`. Nothing follows
/// the house id on 5875.
///
/// `house_id` indexes `AuctionHouse.dbc` (rows 1..7: the six faction houses, then Blackwater at 7)
/// and is the field the sell pane needs — the deposit and cut rates are read out of that row, so a
/// window that assumes a house shows the neutral 25% where it should show 5%. It is also the id
/// the auction invoice mails arrive under (`MailListEntry::sender_id` on a
/// [`super::mail_message_type::AUCTION`] mail).
pub(super) fn read_auction_hello(r: &mut &[u8]) -> io::Result<(u64, u32)> {
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// One [`AuctionListEntry`] — [`AUCTION_RECORD_BYTES`] of fixed-width fields, 14 reads, in
/// `AuctionEntry::BuildAuctionInfo`'s order.
fn read_auction_list_entry(r: &mut &[u8]) -> io::Result<AuctionListEntry> {
Ok(AuctionListEntry {
auction_id: read_u32_le(r)?,
item_entry: read_u32_le(r)?,
perm_enchant: read_u32_le(r)?,
random_property_id: read_i32_le(r)?,
suffix_factor: read_u32_le(r)?,
count: read_u32_le(r)?,
spell_charges: read_i32_le(r)?,
owner_guid: read_u64_le(r)?,
start_bid: read_u32_le(r)?,
min_increment: read_u32_le(r)?,
buyout: read_u32_le(r)?,
time_left_ms: read_u32_le(r)?,
bidder_guid: read_u64_le(r)?,
current_bid: read_u32_le(r)?,
})
}
/// Read the body all three list results share — `SMSG_AUCTION_LIST_RESULT`,
/// `SMSG_AUCTION_OWNER_LIST_RESULT`, `SMSG_AUCTION_BIDDER_LIST_RESULT` (VERIFIED vmangos's async
/// `AuctionHouseClientQueryTask`, `AuctionHouseHandler.cpp:651-701`, which builds all three and
/// differs only in `SetOpcode`): `u32 count`, then `count` × [`AuctionListEntry`], then `u32
/// totalCount`.
///
/// **`total_count` rides at the very END, after the records.** The leading `count` is written as
/// a placeholder and patched with `put<uint32>` once the records are in; the total is appended
/// last. It is the match count *before* the [`AUCTION_PAGE_SIZE`] cap — the pager's "of N" — so
/// it is routinely larger than the number of records, and reading it in the wrong place silently
/// yields a record field.
///
/// Returns `(records, total_count)`.
pub(super) fn read_auction_list_result(r: &mut &[u8]) -> io::Result<(Vec<AuctionListEntry>, u32)> {
let count = read_u32_le(r)?;
// Bound the allocation by what the buffer could actually hold, not by a `count` we do not
// trust (see the loop guard below).
let mut auctions = Vec::with_capacity((count as usize).min(r.len() / AUCTION_RECORD_BYTES));
for _ in 0..count {
// `count` is an UPPER BOUND, not a record count. vmangos's *browse fast
// path* (the no-filter branch of `BuildListAuctionItems`, `AuctionHouseMgr.cpp:716-735`)
// does `BuildAuctionInfo(data); if ((++count) >= 50) break;` — discarding a `false`
// return. A stale auction whose item row is gone therefore writes ZERO bytes and still
// counts, so a real server sends `count = N` with N-k records. Stop cleanly on a short
// buffer and return what we did get: the layout is fixed-width, so a dropped record
// shifts nothing, and every record we read is intact.
if r.len() < AUCTION_RECORD_BYTES {
break;
}
auctions.push(read_auction_list_entry(r)?);
}
// A body truncated past the last record has no trailing total either; fall back to what we
// actually read rather than failing the whole page over the pager's hint.
let total_count = if r.len() >= 4 {
read_u32_le(r)?
} else {
auctions.len() as u32
};
Ok((auctions, total_count))
}
/// Read `SMSG_AUCTION_COMMAND_RESULT` (VERIFIED vmangos `WorldSession::SendAuctionCommandResult`,
/// `AuctionHouseHandler.cpp:70-96`): `u32 auctionId, u32 action ([`auction_action`]), u32 error
/// ([`auction_error`])`, then the conditional [`AuctionCommandTail`] the error selects.
///
/// `auction_id` is **`0`** whenever the server had no `AuctionEntry` to name — which is most of
/// the failure paths, so it is not a usable correlation handle on an error. The
/// remaining-length guard on each tail mirrors `read_send_mail_result`: a body that doesn't carry
/// the tail its fields predict decodes as [`AuctionCommandTail::Empty`] rather than erroring.
///
/// Returns `(auction_id, action, error, tail)`.
pub(super) fn read_auction_command_result(
r: &mut &[u8],
) -> io::Result<(u32, u32, u32, AuctionCommandTail)> {
let auction_id = read_u32_le(r)?;
let action = read_u32_le(r)?;
let error = read_u32_le(r)?;
// The server switches on `error`; only its OK arm then looks at `action`. Mirror that order —
// an OK with any other action carries no tail at all.
let tail = match error {
auction_error::OK if action == auction_action::BID_PLACED && !r.is_empty() => {
AuctionCommandTail::BidPlaced {
new_min_outbid: read_u32_le(r)?,
}
}
auction_error::INVENTORY if !r.is_empty() => AuctionCommandTail::Inventory {
result: read_u32_le(r)?,
},
auction_error::HIGHER_BID if !r.is_empty() => AuctionCommandTail::HigherBid {
new_bidder_guid: read_u64_le(r)?,
new_bid: read_u32_le(r)?,
new_min_outbid: read_u32_le(r)?,
},
_ => AuctionCommandTail::Empty,
};
Ok((auction_id, action, error, tail))
}
/// Read `SMSG_AUCTION_BIDDER_NOTIFICATION` (VERIFIED vmangos
/// `AuctionBidderNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:78-87`): `u32
/// houseId, u32 auctionId, u64 bidderGuid, u32 bidOrZero, u32 outBid, u32 itemEntry, i32
/// randomPropertyId`. **Not interchangeable with [`read_auction_owner_notification`]** — see
/// [`AuctionBidderNotification`].
pub(super) fn read_auction_bidder_notification(
r: &mut &[u8],
) -> io::Result<AuctionBidderNotification> {
Ok(AuctionBidderNotification {
house_id: read_u32_le(r)?,
auction_id: read_u32_le(r)?,
bidder_guid: read_u64_le(r)?,
bid_or_zero: read_u32_le(r)?,
out_bid: read_u32_le(r)?,
item_entry: read_u32_le(r)?,
random_property_id: read_i32_le(r)?,
})
}
/// Read `SMSG_AUCTION_OWNER_NOTIFICATION` (VERIFIED vmangos
/// `AuctionOwnerNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:89-97`): `u32
/// auctionId, u32 bid, u32 outBid, u64 bidderGuid, u32 itemEntry, i32 randomPropertyId` — a
/// different field order from the bidder notification, and no house id.
pub(super) fn read_auction_owner_notification(
r: &mut &[u8],
) -> io::Result<AuctionOwnerNotification> {
Ok(AuctionOwnerNotification {
auction_id: read_u32_le(r)?,
bid: read_u32_le(r)?,
out_bid: read_u32_le(r)?,
bidder_guid: read_u64_le(r)?,
item_entry: read_u32_le(r)?,
random_property_id: read_i32_le(r)?,
})
}
/// Read `SMSG_AUCTION_REMOVED_NOTIFICATION` (VERIFIED vmangos
/// `AuctionRemovedNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:65-70`): `u32
/// auctionId, u32 itemEntry, i32 randomPropertyId` — pushed to a *bidder* whose auction the seller
/// cancelled (the real client shows `ERR_AUCTION_REMOVED_S`).
///
/// Returns `(auction_id, item_entry, random_property_id)`.
pub(super) fn read_auction_removed_notification(r: &mut &[u8]) -> io::Result<(u32, u32, i32)> {
Ok((read_u32_le(r)?, read_u32_le(r)?, read_i32_le(r)?))
}
@@ -19,6 +19,7 @@ mod action_bar;
pub mod addons;
mod area_trigger;
mod attack;
mod auction;
mod bank;
mod binder;
mod channel;
@@ -66,6 +67,12 @@ pub use action_bar::{
pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
pub use area_trigger::area_trigger;
pub use attack::{attack_swing, AttackerState};
pub use auction::{
auction_action, auction_duration, auction_error, auction_filter, auction_hello,
auction_list_bidder_items, auction_list_items, auction_list_owner_items, auction_place_bid,
auction_remove_item, auction_sell_item, AuctionBidderNotification, AuctionCommandTail,
AuctionListEntry, AuctionOwnerNotification, AUCTION_PAGE_SIZE, AUCTION_RECORD_BYTES,
};
pub use bank::{
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
};
@@ -989,6 +989,43 @@ pub const MSG_QUERY_NEXT_MAIL_TIME: u16 = 0x0284; // 644
/// timer's expiry otherwise).
pub const SMSG_RECEIVED_MAIL: u16 = 0x0285; // 645
// The auction house arc (decision 1511 P0; VERIFIED vmangos `Opcodes_1_12_1.h`: 597-607, 612-613,
// 653 — every value re-read against that table). Bodies in [`super::auction`]. Two family facts
// belong here: **there is no `*_LIST_PENDING_SALES` on 5875** — the symbol exists in no vmangos
// opcode table at all, so the pending-sales pane is a later client's and has no wire to build —
// and every CMSG below carries the auctioneer guid at its head because the server re-validates
// the 5 yd interact distance on each one independently; the hello is not a session token.
/// Same opcode both directions (597): our request is one auctioneer guid; the reply echoes it and
/// adds `u32 houseId` (`AuctionHouse.dbc`, rows 1..7, which carries the deposit/cut rates). It is
/// the **reply** that opens the window, not our send.
pub const MSG_AUCTION_HELLO: u16 = 0x0255; // 597
pub const CMSG_AUCTION_SELL_ITEM: u16 = 0x0256; // 598
pub const CMSG_AUCTION_REMOVE_ITEM: u16 = 0x0257; // 599
/// The Browse search — ten fields and **no sort bytes** on 5875: filtering is server-side,
/// sorting entirely client-side ([`super::auction::auction_list_items`]).
pub const CMSG_AUCTION_LIST_ITEMS: u16 = 0x0258; // 600
pub const CMSG_AUCTION_LIST_OWNER_ITEMS: u16 = 0x0259; // 601
/// Bid *and* buy out: buyout is inferred from the price (`price >= buyout && buyout != 0`), never
/// flagged, and there is no separate opcode for it.
pub const CMSG_AUCTION_PLACE_BID: u16 = 0x025A; // 602
/// The one verdict opcode for sell/cancel/bid, with a conditional tail keyed on its error
/// ([`super::auction::AuctionCommandTail`]). Several refusals send **nothing at all** instead.
pub const SMSG_AUCTION_COMMAND_RESULT: u16 = 0x025B; // 603
/// This and the two list results below share ONE frame and ONE 64-byte record layout
/// ([`super::auction::read_auction_list_result`]): `u32 count`, the records, then `u32 totalCount`
/// at the very end — the match count *before* the 50-row page cap.
pub const SMSG_AUCTION_LIST_RESULT: u16 = 0x025C; // 604
pub const SMSG_AUCTION_OWNER_LIST_RESULT: u16 = 0x025D; // 605
/// Pushed to the bidder: won, or outbid — `bidOrZero == 0` means **won**, not "no bid".
pub const SMSG_AUCTION_BIDDER_NOTIFICATION: u16 = 0x025E; // 606
/// Pushed to the seller. A **different field order** from the bidder notification, and no
/// `houseId`; the two never share a struct or a reader.
pub const SMSG_AUCTION_OWNER_NOTIFICATION: u16 = 0x025F; // 607
pub const CMSG_AUCTION_LIST_BIDDER_ITEMS: u16 = 0x0264; // 612
pub const SMSG_AUCTION_BIDDER_LIST_RESULT: u16 = 0x0265; // 613
/// Pushed to a bidder whose auction the seller cancelled.
pub const SMSG_AUCTION_REMOVED_NOTIFICATION: u16 = 0x028D; // 653
// The world-state table (VERIFIED both ways: vmangos `Opcodes_1_12_1.h` 706-707, and wow-re's read
// of the reference's own handler `0x48f690`, whose registration at `0x48f515-0x48f52f` selects these
// two arms as the ONLY writers of the table setter `0x4c5870`). Bodies in
+74 -12
View File
@@ -5,18 +5,18 @@
use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo,
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption,
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss,
SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
VendorItem, WhoResults, XpGain,
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, CastOutcome, ChannelNotify, Character, ChatMessage, CorpseLocation,
DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate,
GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, PvpCredit,
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems,
QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown,
SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask,
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -1155,6 +1155,56 @@ pub enum ServerPacket {
NextMailTime {
seconds: f32,
},
/// `MSG_AUCTION_HELLO`'s **reply** (same opcode as our one-guid request, decision 1511 P0):
/// the auctioneer we greeted plus the `AuctionHouse.dbc` row (1..7) that names its deposit and
/// cut rates. It is this packet, not our send, that opens the window.
AuctionHello {
auctioneer: u64,
house_id: u32,
},
/// `SMSG_AUCTION_COMMAND_RESULT` — the verdict on a sell/cancel/bid, keyed by `action`
/// ([`super::auction::auction_action`]) and `error` ([`super::auction::auction_error`]);
/// `tail` is the conditional payload the error selects (layout in
/// [`super::auction::read_auction_command_result`]). `auction_id` is `0` on most failures.
AuctionCommandResult {
auction_id: u32,
action: u32,
error: u32,
tail: AuctionCommandTail,
},
/// `SMSG_AUCTION_LIST_RESULT` — a Browse page, answering `CMSG_AUCTION_LIST_ITEMS`.
/// `total_count` is the match count *before* the 50-row cap (the pager's "of N"), and rides
/// at the END of the body (layout in [`super::auction::read_auction_list_result`]).
AuctionListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_OWNER_LIST_RESULT` — the Auctions tab page (your own listings), answering
/// `CMSG_AUCTION_LIST_OWNER_ITEMS`. Same frame and record as [`Self::AuctionListResult`].
AuctionOwnerListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_BIDDER_LIST_RESULT` — the Bid tab page, answering
/// `CMSG_AUCTION_LIST_BIDDER_ITEMS`. Same frame and record as [`Self::AuctionListResult`];
/// the server emits the explicitly-refreshed ids first and then our own live bids, so a row
/// can legitimately appear twice in one page.
AuctionBidderListResult {
auctions: Vec<AuctionListEntry>,
total_count: u32,
},
/// `SMSG_AUCTION_BIDDER_NOTIFICATION` — we won, or we were outbid (`bid_or_zero == 0` means
/// **won**). A different shape from [`Self::AuctionOwnerNotification`], deliberately.
AuctionBidderNotification(AuctionBidderNotification),
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — our own auction sold or took a bid. No house id, and
/// the bidder guid sits fourth rather than third; an all-zero guid is the "sold" signal.
AuctionOwnerNotification(AuctionOwnerNotification),
/// `SMSG_AUCTION_REMOVED_NOTIFICATION` — an auction we had bid on was cancelled by its seller.
AuctionRemovedNotification {
auction_id: u32,
item_entry: u32,
random_property_id: i32,
},
/// `SMSG_TRADE_STATUS` — one pulse of the trade state machine (open/accept/cancel/complete/
/// the refusal reasons); the tail-carrying statuses hold their payload in [`TradeStatus`]
/// (layout in [`super::trade::read_trade_status`], decision 0592 P0).
@@ -1393,6 +1443,18 @@ impl ServerPacket {
ServerPacket::ItemTextQueryResponse { .. } => "SMSG_ITEM_TEXT_QUERY_RESPONSE".into(),
ServerPacket::ReceivedMail { .. } => "SMSG_RECEIVED_MAIL".into(),
ServerPacket::NextMailTime { .. } => "MSG_QUERY_NEXT_MAIL_TIME".into(),
ServerPacket::AuctionHello { .. } => "MSG_AUCTION_HELLO".into(),
ServerPacket::AuctionCommandResult { .. } => "SMSG_AUCTION_COMMAND_RESULT".into(),
ServerPacket::AuctionListResult { .. } => "SMSG_AUCTION_LIST_RESULT".into(),
ServerPacket::AuctionOwnerListResult { .. } => "SMSG_AUCTION_OWNER_LIST_RESULT".into(),
ServerPacket::AuctionBidderListResult { .. } => {
"SMSG_AUCTION_BIDDER_LIST_RESULT".into()
}
ServerPacket::AuctionBidderNotification(_) => "SMSG_AUCTION_BIDDER_NOTIFICATION".into(),
ServerPacket::AuctionOwnerNotification(_) => "SMSG_AUCTION_OWNER_NOTIFICATION".into(),
ServerPacket::AuctionRemovedNotification { .. } => {
"SMSG_AUCTION_REMOVED_NOTIFICATION".into()
}
ServerPacket::TradeStatus { .. } => "SMSG_TRADE_STATUS".into(),
ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(),
ServerPacket::InitWorldStates(_) => "SMSG_INIT_WORLD_STATES".into(),
+63 -4
View File
@@ -11,10 +11,10 @@ use crate::wire::{
};
use super::{
action_bar, area_trigger, attack, bank, binder, channel, chat, combat_log, death, duel,
gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move, movement,
opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi, trade,
trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode,
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move,
movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi,
trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode,
ServerPacket, SpeedKind,
};
@@ -1103,6 +1103,65 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::MSG_QUERY_NEXT_MAIL_TIME => ServerPacket::NextMailTime {
seconds: mail::read_query_next_mail_time(&mut r)?,
},
// The auction house arc (decision 1511 P0). MSG_AUCTION_HELLO is two-way — this is the
// REPLY (our request is the same opcode with just the guid), and it is what opens the
// window. The three list results share one frame and one 64-byte record; their reader is
// bounded by the buffer as well as by the wire `count`, because vmangos's browse fast path
// can count a record it then fails to write.
opcode::MSG_AUCTION_HELLO => {
let (auctioneer, house_id) = auction::read_auction_hello(&mut r)?;
ServerPacket::AuctionHello {
auctioneer,
house_id,
}
}
opcode::SMSG_AUCTION_COMMAND_RESULT => {
let (auction_id, action, error, tail) = auction::read_auction_command_result(&mut r)?;
ServerPacket::AuctionCommandResult {
auction_id,
action,
error,
tail,
}
}
opcode::SMSG_AUCTION_LIST_RESULT => {
let (auctions, total_count) = auction::read_auction_list_result(&mut r)?;
ServerPacket::AuctionListResult {
auctions,
total_count,
}
}
opcode::SMSG_AUCTION_OWNER_LIST_RESULT => {
let (auctions, total_count) = auction::read_auction_list_result(&mut r)?;
ServerPacket::AuctionOwnerListResult {
auctions,
total_count,
}
}
opcode::SMSG_AUCTION_BIDDER_LIST_RESULT => {
let (auctions, total_count) = auction::read_auction_list_result(&mut r)?;
ServerPacket::AuctionBidderListResult {
auctions,
total_count,
}
}
// The two notifications carry different field orders and the owner one has no house id —
// two readers, never one.
opcode::SMSG_AUCTION_BIDDER_NOTIFICATION => ServerPacket::AuctionBidderNotification(
auction::read_auction_bidder_notification(&mut r)?,
),
opcode::SMSG_AUCTION_OWNER_NOTIFICATION => ServerPacket::AuctionOwnerNotification(
auction::read_auction_owner_notification(&mut r)?,
),
opcode::SMSG_AUCTION_REMOVED_NOTIFICATION => {
let (auction_id, item_entry, random_property_id) =
auction::read_auction_removed_notification(&mut r)?;
ServerPacket::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
}
}
// The player-trade arc (decision 0592 P0): the state-machine status pulse and the
// item/gold snapshot for one window side.
opcode::SMSG_TRADE_STATUS => ServerPacket::TradeStatus {
+20 -6
View File
@@ -63,9 +63,10 @@ pub struct CharEnumItem {
}
/// A character from `SMSG_CHAR_ENUM` — the full roster entry the character-select screen renders
/// (decision 0465): identity, the five appearance bytes, zone/map/position, the display flags, and
/// the visible equipment display ids. Only the guild id, first-login byte, pet triple, and first-bag
/// pair are parsed for alignment and discarded (nothing at select renders them).
/// (decision 0465): identity, the five appearance bytes, zone/map/position, the display flags, the
/// visible equipment display ids, and the **pet triple** (the hunter/warlock companion standing
/// beside the selected character). Only the guild id, first-login byte and first-bag pair are
/// parsed for alignment and discarded (nothing at select renders them).
#[derive(Debug, Clone)]
pub struct Character {
pub guid: u64,
@@ -92,6 +93,16 @@ pub struct Character {
/// The 19 visible equipment slots (vanilla order: head, neck, shoulders, shirt, chest, waist,
/// legs, feet, wrists, hands, finger×2, trinket×2, back, main hand, off hand, ranged, tabard).
pub equipment: [CharEnumItem; 19],
/// The pet's `CreatureDisplayInfo.dbc` id — the companion the select screen stands beside the
/// character. **0 = no pet**, and that one test is the whole client-side gate: the server
/// already suppresses the triple for anything but a living hunter/warlock (vmangos
/// `Player::BuildEnumData` — `!GHOST && (CLASS_WARLOCK || CLASS_HUNTER)`), so no class or
/// ghost check belongs here.
pub pet_display_id: u32,
/// The pet's level, and its `CreatureFamily.dbc` id. Carried because they are the record's own
/// fields; **nothing renders them today** — the select screen's pet is display-id-driven.
pub pet_level: u32,
pub pet_family: u32,
}
impl Character {
@@ -113,9 +124,9 @@ impl Character {
let _guild_id = read_u32_le(r)?;
let flags = read_u32_le(r)?;
let _first_login = read_u8(r)?;
let _pet_display_id = read_u32_le(r)?;
let _pet_level = read_u32_le(r)?;
let _pet_family = read_u32_le(r)?;
let pet_display_id = read_u32_le(r)?;
let pet_level = read_u32_le(r)?;
let pet_family = read_u32_le(r)?;
let mut equipment = [CharEnumItem::default(); 19];
for slot in &mut equipment {
slot.display_id = read_u32_le(r)?;
@@ -140,6 +151,9 @@ impl Character {
position,
flags,
equipment,
pet_display_id,
pet_level,
pet_family,
})
}
}
@@ -85,6 +85,19 @@ impl ObjectFields {
.flatten()
.unwrap_or(0)
}
/// `ITEM_FIELD_RANDOM_PROPERTIES_ID` (field 44 — vmangos `UpdateFields_1_12_1.h`
/// `OBJECT_END + 0x26`, chain-locked here by the tested `ITEM_TEXT_ID` 45 / `DURABILITY` 46
/// anchors on either side): the **random-suffix roll** this item instance carries, an
/// `ItemRandomProperties.dbc` id. `0` = unrolled.
///
/// Its one consumer is the NAME: the client joins the row's suffix through
/// `ITEM_SUFFIX_TEMPLATE` in its single name formatter `0x5d8b00`, so the item reads "Chipped
/// Claw of the Bear" everywhere it is named. The roll's **enchants** never come from here on
/// an owned item — the server writes them into `ITEM_FIELD_ENCHANTMENT` slots 2..6, which
/// [`Self::item_enchant`] already reads.
pub fn item_random_properties_id(&self) -> u32 {
self.get_u32(44).unwrap_or(0)
}
/// `ITEM_FIELD_DURABILITY` — an item's current durability (field 46: chain-locked between the
/// tested STACK_COUNT 14 and CONTAINER_NUM_SLOTS 48 = ITEM_END anchors).
pub fn item_durability(&self) -> Option<u32> {
@@ -132,6 +145,22 @@ impl ObjectFields {
})
.flatten()
}
/// `PLAYER_VISIBLE_ITEM_<slot>_PROPERTIES` — the **broadcast random-suffix roll** of the item
/// worn in equipment slot `i`. The 12-dword per-slot record is `creator(2) · entry ·
/// enchant[7] · PROPERTIES · PAD`, and PROPERTIES is a `TWO_SHORT` whose **low half** is the
/// `ItemRandomProperties` id — the reference reads it with a `movzx WORD` (`0x53339f`, the
/// inspect leg's `+0x424` source; wow-re `tooltip-content-law.md` §E7).
///
/// This is the only roll feed a client has for anybody else's gear, and its one consumer is
/// the NAME: vmangos fills only the PERM/TEMP enchant slots, so an inspected item's suffix
/// *lines* stay empty on both sides of the wire — the same thing the reference shows.
pub fn player_visible_item_properties(&self, i: u8) -> u32 {
(i < 19)
.then(|| self.get_u32(FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR + 10 + 12 * u16::from(i)))
.flatten()
.unwrap_or(0)
& 0xffff
}
/// `PLAYER_FIELD_INV_SLOT_HEAD + 2i` — our equipment (0–18) and equipped-bag (19–22) guids.
pub fn player_inv_slot(&self, i: u8) -> Option<u64> {
(i < 23).then(|| self.get_guid(FIELD_PLAYER_INV_SLOT_HEAD + 2 * u16::from(i)))?
@@ -0,0 +1,145 @@
//! The auction house's `WorldWriter` sends (decision 1511 P0): the auctioneer greeting, the three
//! list pages (browse / your bids / your auctions), and the three market verbs — list an item,
//! bid or buy out, cancel. Bodies in [`crate::messages`]'s `auction` builders (layout VERIFIED
//! against vmangos `Handlers/AuctionHouseHandler.cpp` + `Server/Packets/AuctionHouse.cpp`). Split
//! out per `writer/mod.rs`'s rule — the module a body builder lives in names the module its send
//! lives in.
//!
//! Two things every caller here has to know. **The auctioneer guid rides on every send**, not
//! just the hello: the server re-validates the 5 yd interact distance on each one independently
//! (`GetCheckedAuctionHouseForAuctioneer`), so there is no session to hold and a walk-away
//! invalidates the next verb rather than the window. And **several refusals are silent** — a sell
//! with a zero bid or duration, a bid the player cannot afford, a cancel whose 5% cut they cannot
//! pay, and any list request while one is already in flight all get *no packet at all*. A UI that
//! blocks waiting for an ack hangs.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Greet an auctioneer (`MSG_AUCTION_HELLO`, layout in [`messages::auction_hello`]) — one
/// auctioneer guid. **Two-way opcode**: the reply carries the guid back plus the
/// `AuctionHouse.dbc` house id (an `AuctionHello` event), and it is that reply which opens the
/// window.
pub fn auction_hello(&mut self, auctioneer: u64) -> Result<()> {
self.send(
opcode::MSG_AUCTION_HELLO,
&messages::auction_hello(auctioneer),
)
}
/// Ask a Browse page (`CMSG_AUCTION_LIST_ITEMS`, layout in
/// [`messages::auction_list_items`]) — the search's filters as the server reads them, with
/// [`messages::auction_filter`]'s sentinels for the ones left unset. **No sort rides the
/// wire**: sorting the returned page is entirely ours. `list_from` pages by
/// [`messages::AUCTION_PAGE_SIZE`]. Answered by `SMSG_AUCTION_LIST_RESULT`.
#[allow(clippy::too_many_arguments)]
pub fn auction_list_items(
&mut self,
auctioneer: u64,
list_from: u32,
searched_name: &str,
level_min: u8,
level_max: u8,
slot_id: u32,
main_category: u32,
sub_category: u32,
quality: u32,
usable: u8,
) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_LIST_ITEMS,
&messages::auction_list_items(
auctioneer,
list_from,
searched_name,
level_min,
level_max,
slot_id,
main_category,
sub_category,
quality,
usable,
),
)
}
/// Ask the Auctions tab page (`CMSG_AUCTION_LIST_OWNER_ITEMS`, layout in
/// [`messages::auction_list_owner_items`]) — our own listings. Answered by
/// `SMSG_AUCTION_OWNER_LIST_RESULT`.
pub fn auction_list_owner_items(&mut self, auctioneer: u64, list_from: u32) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_LIST_OWNER_ITEMS,
&messages::auction_list_owner_items(auctioneer, list_from),
)
}
/// Ask the Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`, layout in
/// [`messages::auction_list_bidder_items`]). `auction_ids` is a **refresh set**, not a filter:
/// the server prepends a record for each id named and then appends every auction we are the
/// current bidder on. Pass `&[]` for a plain page. Answered by
/// `SMSG_AUCTION_BIDDER_LIST_RESULT`.
pub fn auction_list_bidder_items(
&mut self,
auctioneer: u64,
list_from: u32,
auction_ids: &[u32],
) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_LIST_BIDDER_ITEMS,
&messages::auction_list_bidder_items(auctioneer, list_from, auction_ids),
)
}
/// List an item for auction (`CMSG_AUCTION_SELL_ITEM`, layout in
/// [`messages::auction_sell_item`]) — the Create Auction pane's Create button.
/// `etime_minutes` must be one of [`messages::auction_duration`]'s three values (120/480/1440);
/// the deposit is charged immediately out of the player's purse. Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::STARTED`]) — or by
/// silence, if the bid or duration is zero or the deposit is unaffordable.
pub fn auction_sell_item(
&mut self,
auctioneer: u64,
item_guid: u64,
bid: u32,
buyout: u32,
etime_minutes: u32,
) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_SELL_ITEM,
&messages::auction_sell_item(auctioneer, item_guid, bid, buyout, etime_minutes),
)
}
/// Bid on — or buy out — an auction (`CMSG_AUCTION_PLACE_BID`, layout in
/// [`messages::auction_place_bid`]). One verb for both: the server reads a `price` at or above
/// a nonzero buyout *as* a buyout; there is no separate opcode and no flag. Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::BID_PLACED`]), or by
/// silence if the player cannot afford the price.
pub fn auction_place_bid(
&mut self,
auctioneer: u64,
auction_id: u32,
price: u32,
) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_PLACE_BID,
&messages::auction_place_bid(auctioneer, auction_id, price),
)
}
/// Cancel one of our own auctions (`CMSG_AUCTION_REMOVE_ITEM`, layout in
/// [`messages::auction_remove_item`]) — the Auctions tab's Cancel button. The deposit is
/// **forfeit**, and if the auction already carries a bid the 5% cut comes out of the seller's
/// pocket (and the cancel is refused *silently* when they cannot pay it). Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::REMOVED`]).
pub fn auction_remove_item(&mut self, auctioneer: u64, auction_id: u32) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_REMOVE_ITEM,
&messages::auction_remove_item(auctioneer, auction_id),
)
}
}
@@ -41,6 +41,7 @@
//! | [`trade`] | the player-trade dance |
//! | [`duel`] | accept and cancel |
//! | [`mail`] | the mailbox |
//! | [`auction`] | the auction house: the hello, the three list pages, sell/bid/cancel |
//! | [`player_flags`] | the empty-bodied `PLAYER_FLAGS` toggles: PvP, show-helm, show-cloak |
//! | [`social`] | friends, ignores, and `/who` |
//! | [`area_trigger`] | the one "I walked into trigger N" report |
@@ -55,6 +56,7 @@ use super::send_packet;
mod action_bar;
mod area_trigger;
mod attack;
mod auction;
mod bank;
mod binder;
mod channel;