- 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>
745 lines
29 KiB
Rust
745 lines
29 KiB
Rust
//! Oracle-free golden tests for the auction house arc's protocol layer (decision 1511 phase P0):
|
|
//! byte-exact goldens for all seven CMSG bodies, the `MSG_AUCTION_HELLO` reply, the four
|
|
//! `SMSG_AUCTION_COMMAND_RESULT` tail shapes, the one 64-byte record and one frame the three list
|
|
//! results share (including that `total_count` rides *after* the records, and that a body shorter
|
|
//! than its own `count` claims still yields the records it does carry), the two deliberately
|
|
//! different notifications, and the removal notice. Same idioms as `tests/mail.rs` — `hx(...)`
|
|
//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`, and a
|
|
//! `decode()` bridge assertion.
|
|
|
|
use benilla_protocol::events::{decode, SessionEvent};
|
|
use benilla_protocol::messages::{
|
|
self, auction_action, auction_duration, auction_error, auction_filter,
|
|
AuctionBidderNotification, AuctionCommandTail, AuctionListEntry, AuctionOwnerNotification,
|
|
AUCTION_RECORD_BYTES,
|
|
};
|
|
use benilla_protocol::ServerPacket;
|
|
|
|
fn hx(s: &str) -> Vec<u8> {
|
|
(0..s.len())
|
|
.step_by(2)
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
const AUCTIONEER: u64 = 0x00F1_3000_0000_0055;
|
|
|
|
#[test]
|
|
fn auction_send_bodies_golden() {
|
|
// MSG_AUCTION_HELLO: one full auctioneer guid, nothing else.
|
|
assert_eq!(
|
|
messages::auction_hello(AUCTIONEER),
|
|
hx("550000000030f100"),
|
|
"MSG_AUCTION_HELLO body"
|
|
);
|
|
|
|
// CMSG_AUCTION_SELL_ITEM: u64 auctioneer, u64 itemGuid, u32 bid, u32 buyout, u32 etime.
|
|
assert_eq!(
|
|
messages::auction_sell_item(
|
|
AUCTIONEER,
|
|
0x0000_0000_0100_00AB,
|
|
10_000,
|
|
50_000,
|
|
auction_duration::MEDIUM_MINUTES,
|
|
),
|
|
hx(concat!(
|
|
"550000000030f100", // auctioneer
|
|
"ab00000100000000", // item guid
|
|
"10270000", // bid 10000
|
|
"50c30000", // buyout 50000
|
|
"e0010000", // etime 480 minutes
|
|
)),
|
|
"CMSG_AUCTION_SELL_ITEM body"
|
|
);
|
|
|
|
// CMSG_AUCTION_REMOVE_ITEM: u64 auctioneer, u32 auctionId.
|
|
assert_eq!(
|
|
messages::auction_remove_item(AUCTIONEER, 4242),
|
|
hx("550000000030f10092100000"),
|
|
"CMSG_AUCTION_REMOVE_ITEM body"
|
|
);
|
|
|
|
// CMSG_AUCTION_PLACE_BID: u64 auctioneer, u32 auctionId, u32 price.
|
|
assert_eq!(
|
|
messages::auction_place_bid(AUCTIONEER, 4242, 12_345),
|
|
hx("550000000030f1009210000039300000"),
|
|
"CMSG_AUCTION_PLACE_BID body"
|
|
);
|
|
|
|
// CMSG_AUCTION_LIST_OWNER_ITEMS: u64 auctioneer, u32 listfrom.
|
|
assert_eq!(
|
|
messages::auction_list_owner_items(AUCTIONEER, 50),
|
|
hx("550000000030f10032000000"),
|
|
"CMSG_AUCTION_LIST_OWNER_ITEMS body"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_items_body_golden() {
|
|
// CMSG_AUCTION_LIST_ITEMS with every filter set: u64 auctioneer, u32 listfrom, cstr name,
|
|
// u8 levelmin, u8 levelmax, u32 slotId, u32 mainCategory, u32 subCategory, u32 quality,
|
|
// u8 usable. TEN fields — no sort column, no sort count, no trailing padding on 5875.
|
|
assert_eq!(
|
|
messages::auction_list_items(AUCTIONEER, 50, "Copper", 10, 20, 1, 2, 3, 4, 1),
|
|
hx(concat!(
|
|
"550000000030f100", // auctioneer
|
|
"32000000", // listfrom 50
|
|
"436f7070657200", // "Copper\0"
|
|
"0a", // levelmin 10
|
|
"14", // levelmax 20
|
|
"01000000", // slotId
|
|
"02000000", // mainCategory
|
|
"03000000", // subCategory
|
|
"04000000", // quality (a MINIMUM, not an equality)
|
|
"01", // usable
|
|
)),
|
|
"CMSG_AUCTION_LIST_ITEMS body (filters set)"
|
|
);
|
|
|
|
// The default browse: every filter at its sentinel and an EMPTY search name — which is just
|
|
// the lone NUL byte between `listfrom` and `levelmin`, nothing more. This exact body is the
|
|
// one that puts vmangos on its no-filter fast path.
|
|
let body = messages::auction_list_items(
|
|
AUCTIONEER,
|
|
0,
|
|
"",
|
|
auction_filter::ANY_LEVEL,
|
|
auction_filter::ANY_LEVEL,
|
|
auction_filter::ANY,
|
|
auction_filter::ANY,
|
|
auction_filter::ANY,
|
|
auction_filter::ANY,
|
|
auction_filter::ANY_USABILITY,
|
|
);
|
|
assert_eq!(
|
|
body,
|
|
hx(concat!(
|
|
"550000000030f100", // auctioneer
|
|
"00000000", // listfrom 0
|
|
"00", // "" — the lone NUL, and the whole of the name field
|
|
"00", // levelmin 0
|
|
"00", // levelmax 0
|
|
"ffffffff", // slotId ANY
|
|
"ffffffff", // mainCategory ANY
|
|
"ffffffff", // subCategory ANY
|
|
"ffffffff", // quality ANY
|
|
"00", // usable 0
|
|
)),
|
|
"CMSG_AUCTION_LIST_ITEMS body (empty name, all sentinels)"
|
|
);
|
|
// 8 + 4 + 1 + 1 + 1 + 4*4 + 1 — the tightest possible browse body. Stated as a number so an
|
|
// invented trailing field cannot slip in behind a hex string nobody re-counts.
|
|
assert_eq!(body.len(), 32, "no trailing sort bytes ride this opcode");
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_bidder_items_body_golden() {
|
|
// CMSG_AUCTION_LIST_BIDDER_ITEMS with NO refresh ids: u64 auctioneer, u32 listfrom, u32 0.
|
|
// The count field is always present even when the list is empty.
|
|
assert_eq!(
|
|
messages::auction_list_bidder_items(AUCTIONEER, 0, &[]),
|
|
hx("550000000030f1000000000000000000"),
|
|
"CMSG_AUCTION_LIST_BIDDER_ITEMS body (no ids)"
|
|
);
|
|
|
|
// ...and with several: the count, then that many u32 auction ids.
|
|
assert_eq!(
|
|
messages::auction_list_bidder_items(AUCTIONEER, 50, &[7, 4242, 0xDEAD_BEEF]),
|
|
hx(concat!(
|
|
"550000000030f100", // auctioneer
|
|
"32000000", // listfrom 50
|
|
"03000000", // 3 ids follow
|
|
"07000000", // id 7
|
|
"92100000", // id 4242
|
|
"efbeadde", // id 0xDEADBEEF
|
|
)),
|
|
"CMSG_AUCTION_LIST_BIDDER_ITEMS body (3 ids)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_hello_reply_wire() {
|
|
// MSG_AUCTION_HELLO's reply (same opcode as our request): u64 auctioneerGuid, u32 houseId.
|
|
let mut body = AUCTIONEER.to_le_bytes().to_vec();
|
|
body.extend_from_slice(&6u32.to_le_bytes()); // houseId 6 (Orgrimmar), AuctionHouse.dbc 1..7
|
|
match messages::parse_server(messages::opcode::MSG_AUCTION_HELLO, &body).unwrap() {
|
|
ServerPacket::AuctionHello {
|
|
auctioneer,
|
|
house_id,
|
|
} => assert_eq!((auctioneer, house_id), (AUCTIONEER, 6)),
|
|
other => panic!("auction hello, got {}", other.name()),
|
|
}
|
|
// The decode() bridge carries both fields through unchanged.
|
|
let packet = messages::parse_server(messages::opcode::MSG_AUCTION_HELLO, &body).unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionHello {
|
|
auctioneer,
|
|
house_id,
|
|
} => assert_eq!((auctioneer, house_id), (AUCTIONEER, 6)),
|
|
other => panic!("auction hello event, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Build one `SMSG_AUCTION_COMMAND_RESULT` head: `u32 auctionId, u32 action, u32 error`.
|
|
fn command_head(auction_id: u32, action: u32, error: u32) -> Vec<u8> {
|
|
let mut body = auction_id.to_le_bytes().to_vec();
|
|
body.extend_from_slice(&action.to_le_bytes());
|
|
body.extend_from_slice(&error.to_le_bytes());
|
|
body
|
|
}
|
|
|
|
fn parse_command(body: &[u8]) -> (u32, u32, u32, AuctionCommandTail) {
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_COMMAND_RESULT, body).unwrap() {
|
|
ServerPacket::AuctionCommandResult {
|
|
auction_id,
|
|
action,
|
|
error,
|
|
tail,
|
|
} => (auction_id, action, error, tail),
|
|
other => panic!("auction command result, got {}", other.name()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_command_result_bare_wire() {
|
|
// The bare 3-u32 form: OK + STARTED carries NO tail (the server's OK arm writes one only for
|
|
// BID_PLACED). This is the shape a successful listing and a successful cancel both take.
|
|
let body = command_head(4242, auction_action::STARTED, auction_error::OK);
|
|
assert_eq!(body.len(), 12);
|
|
assert_eq!(
|
|
parse_command(&body),
|
|
(
|
|
4242,
|
|
auction_action::STARTED,
|
|
auction_error::OK,
|
|
AuctionCommandTail::Empty
|
|
)
|
|
);
|
|
|
|
// OK + REMOVED, likewise bare.
|
|
assert_eq!(
|
|
parse_command(&command_head(
|
|
4242,
|
|
auction_action::REMOVED,
|
|
auction_error::OK
|
|
))
|
|
.3,
|
|
AuctionCommandTail::Empty
|
|
);
|
|
|
|
// A named error with no tail of its own (NOT_ENOUGH_MONEY) — bare too, and `auction_id` is
|
|
// `0` because the server had no AuctionEntry to name.
|
|
assert_eq!(
|
|
parse_command(&command_head(
|
|
0,
|
|
auction_action::BID_PLACED,
|
|
auction_error::NOT_ENOUGH_MONEY
|
|
)),
|
|
(
|
|
0,
|
|
auction_action::BID_PLACED,
|
|
auction_error::NOT_ENOUGH_MONEY,
|
|
AuctionCommandTail::Empty
|
|
)
|
|
);
|
|
|
|
// An UNNAMED error code (6, 8, 9, 11, 12 have no vmangos enumerator) also decodes bare rather
|
|
// than erroring — the real client falls through to a generic failure branch for these.
|
|
assert_eq!(
|
|
parse_command(&command_head(0, auction_action::BID_PLACED, 9)).3,
|
|
AuctionCommandTail::Empty
|
|
);
|
|
|
|
// The decode() bridge, asserted on this shape.
|
|
let packet = messages::parse_server(
|
|
messages::opcode::SMSG_AUCTION_COMMAND_RESULT,
|
|
&command_head(4242, auction_action::STARTED, auction_error::OK),
|
|
)
|
|
.unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionCommandResult {
|
|
auction_id,
|
|
action,
|
|
error,
|
|
tail,
|
|
} => assert_eq!(
|
|
(auction_id, action, error, tail),
|
|
(
|
|
4242,
|
|
auction_action::STARTED,
|
|
auction_error::OK,
|
|
AuctionCommandTail::Empty
|
|
)
|
|
),
|
|
other => panic!("auction command result event, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_command_result_bid_placed_tail_wire() {
|
|
// OK **and** BID_PLACED: one trailing u32 newMinOutBid. Both fields have to match — an OK on
|
|
// any other action carries nothing (asserted in the bare test above).
|
|
let mut body = command_head(4242, auction_action::BID_PLACED, auction_error::OK);
|
|
body.extend_from_slice(&617u32.to_le_bytes());
|
|
assert_eq!(
|
|
parse_command(&body),
|
|
(
|
|
4242,
|
|
auction_action::BID_PLACED,
|
|
auction_error::OK,
|
|
AuctionCommandTail::BidPlaced {
|
|
new_min_outbid: 617
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_command_result_inventory_tail_wire() {
|
|
// INVENTORY: one trailing u32 InventoryResult (EQUIP_ERR_*). Keyed on the ERROR alone — the
|
|
// action here is STARTED, which carries no tail under OK.
|
|
let mut body = command_head(0, auction_action::STARTED, auction_error::INVENTORY);
|
|
body.extend_from_slice(&2u32.to_le_bytes()); // EQUIP_ERR_* code
|
|
assert_eq!(
|
|
parse_command(&body),
|
|
(
|
|
0,
|
|
auction_action::STARTED,
|
|
auction_error::INVENTORY,
|
|
AuctionCommandTail::Inventory { result: 2 }
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_command_result_higher_bid_tail_wire() {
|
|
// HIGHER_BID: u64 newBidder, u32 newBid, u32 newMinOutBid — 16 trailing bytes, the longest of
|
|
// the three tails and the one a reader most easily under-reads.
|
|
let mut body = command_head(4242, auction_action::BID_PLACED, auction_error::HIGHER_BID);
|
|
body.extend_from_slice(&0x0000_0000_0000_07D1u64.to_le_bytes()); // new bidder
|
|
body.extend_from_slice(&25_000u32.to_le_bytes()); // new bid
|
|
body.extend_from_slice(&1_250u32.to_le_bytes()); // new min outbid
|
|
assert_eq!(body.len(), 12 + 16);
|
|
assert_eq!(
|
|
parse_command(&body),
|
|
(
|
|
4242,
|
|
auction_action::BID_PLACED,
|
|
auction_error::HIGHER_BID,
|
|
AuctionCommandTail::HigherBid {
|
|
new_bidder_guid: 0x07D1,
|
|
new_bid: 25_000,
|
|
new_min_outbid: 1_250,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
/// Append one 64-byte list-result record in `AuctionEntry::BuildAuctionInfo`'s order.
|
|
fn push_record(body: &mut Vec<u8>, e: &AuctionListEntry) {
|
|
let before = body.len();
|
|
body.extend_from_slice(&e.auction_id.to_le_bytes());
|
|
body.extend_from_slice(&e.item_entry.to_le_bytes());
|
|
body.extend_from_slice(&e.perm_enchant.to_le_bytes());
|
|
body.extend_from_slice(&e.random_property_id.to_le_bytes());
|
|
body.extend_from_slice(&e.suffix_factor.to_le_bytes());
|
|
body.extend_from_slice(&e.count.to_le_bytes());
|
|
body.extend_from_slice(&e.spell_charges.to_le_bytes());
|
|
body.extend_from_slice(&e.owner_guid.to_le_bytes());
|
|
body.extend_from_slice(&e.start_bid.to_le_bytes());
|
|
body.extend_from_slice(&e.min_increment.to_le_bytes());
|
|
body.extend_from_slice(&e.buyout.to_le_bytes());
|
|
body.extend_from_slice(&e.time_left_ms.to_le_bytes());
|
|
body.extend_from_slice(&e.bidder_guid.to_le_bytes());
|
|
body.extend_from_slice(&e.current_bid.to_le_bytes());
|
|
// The record's width is load-bearing (it is the reader's short-buffer bound) and decision
|
|
// 1511's prose miscounts it as 60; pin it here so the two can never drift apart silently.
|
|
assert_eq!(body.len() - before, AUCTION_RECORD_BYTES);
|
|
assert_eq!(AUCTION_RECORD_BYTES, 64);
|
|
}
|
|
|
|
/// A no-bids row: `min_increment`/`bidder_guid`/`current_bid` all zero, a buyout set.
|
|
fn unbid_row(auction_id: u32) -> AuctionListEntry {
|
|
AuctionListEntry {
|
|
auction_id,
|
|
item_entry: 2589, // Linen Cloth
|
|
perm_enchant: 0,
|
|
random_property_id: 0,
|
|
suffix_factor: 0,
|
|
count: 20,
|
|
spell_charges: 0,
|
|
owner_guid: 0x0000_0000_0000_0101,
|
|
start_bid: 5_000,
|
|
min_increment: 0,
|
|
buyout: 20_000,
|
|
time_left_ms: 7_200_000,
|
|
bidder_guid: 0,
|
|
current_bid: 0,
|
|
}
|
|
}
|
|
|
|
fn parse_list(opcode: u16, body: &[u8]) -> (Vec<AuctionListEntry>, u32) {
|
|
match messages::parse_server(opcode, body).unwrap() {
|
|
ServerPacket::AuctionListResult {
|
|
auctions,
|
|
total_count,
|
|
}
|
|
| ServerPacket::AuctionOwnerListResult {
|
|
auctions,
|
|
total_count,
|
|
}
|
|
| ServerPacket::AuctionBidderListResult {
|
|
auctions,
|
|
total_count,
|
|
} => (auctions, total_count),
|
|
other => panic!("auction list result, got {}", other.name()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_result_empty_wire() {
|
|
// count 0, no records, then the trailing totalCount — which can still be nonzero (page 2 of a
|
|
// one-page result set asked past the end).
|
|
let mut body = 0u32.to_le_bytes().to_vec();
|
|
body.extend_from_slice(&37u32.to_le_bytes());
|
|
assert_eq!(body.len(), 8);
|
|
|
|
// All three list opcodes share this frame and this record; assert on each so a future arm
|
|
// cannot be wired to the wrong reader.
|
|
for opcode in [
|
|
messages::opcode::SMSG_AUCTION_LIST_RESULT,
|
|
messages::opcode::SMSG_AUCTION_OWNER_LIST_RESULT,
|
|
messages::opcode::SMSG_AUCTION_BIDDER_LIST_RESULT,
|
|
] {
|
|
let (auctions, total_count) = parse_list(opcode, &body);
|
|
assert!(auctions.is_empty());
|
|
assert_eq!(total_count, 37);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_result_records_wire() {
|
|
// Three rows through the real parse_server entry point, then the trailing totalCount.
|
|
let rows = [
|
|
unbid_row(1),
|
|
AuctionListEntry {
|
|
auction_id: 2,
|
|
item_entry: 12_640, // Lionheart Helm
|
|
perm_enchant: 2_504,
|
|
random_property_id: 0,
|
|
suffix_factor: 0,
|
|
count: 1,
|
|
spell_charges: 0,
|
|
owner_guid: 0x0000_0000_0000_0202,
|
|
start_bid: 1_000_000,
|
|
min_increment: 60_000,
|
|
buyout: 0, // no buyout
|
|
time_left_ms: 86_400_000,
|
|
bidder_guid: 0x0000_0000_0000_0303,
|
|
current_bid: 1_200_000,
|
|
},
|
|
unbid_row(3),
|
|
];
|
|
let mut body = (rows.len() as u32).to_le_bytes().to_vec();
|
|
for row in &rows {
|
|
push_record(&mut body, row);
|
|
}
|
|
body.extend_from_slice(&129u32.to_le_bytes()); // totalCount, well past the 50-row page cap
|
|
assert_eq!(body.len(), 4 + 3 * AUCTION_RECORD_BYTES + 4);
|
|
|
|
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions, rows);
|
|
assert_eq!(total_count, 129);
|
|
|
|
// Field semantics worth pinning rather than assuming: `min_increment` is 0 while nobody has
|
|
// bid, `buyout` 0 means no buyout, and `start_bid` is NOT the current bid.
|
|
assert_eq!(auctions[0].min_increment, 0);
|
|
assert_eq!(auctions[0].current_bid, 0);
|
|
assert_eq!(auctions[1].buyout, 0);
|
|
assert_ne!(auctions[1].start_bid, auctions[1].current_bid);
|
|
|
|
// The decode() bridge carries the rows and the total through unchanged.
|
|
let packet = messages::parse_server(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body).unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionListResult {
|
|
auctions,
|
|
total_count,
|
|
} => {
|
|
assert_eq!(auctions.len(), 3);
|
|
assert_eq!(total_count, 129);
|
|
}
|
|
other => panic!("auction list result event, got {other:?}"),
|
|
}
|
|
|
|
// The owner/bidder opcodes land on their own variants off the same bytes.
|
|
let packet =
|
|
messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_LIST_RESULT, &body).unwrap();
|
|
assert!(matches!(
|
|
decode(packet).pop().unwrap(),
|
|
SessionEvent::AuctionOwnerListResult { .. }
|
|
));
|
|
let packet =
|
|
messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_LIST_RESULT, &body).unwrap();
|
|
assert!(matches!(
|
|
decode(packet).pop().unwrap(),
|
|
SessionEvent::AuctionBidderListResult { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_result_total_count_rides_after_the_records() {
|
|
// `totalCount` is at the very END of the body, not beside the leading `count`. Build ONE
|
|
// record whose every field differs from the total, so a reader that took the total from the
|
|
// front (or from any record field) is caught: the leading count is 1, the total is 9999, and
|
|
// no record field holds 9999.
|
|
let row = AuctionListEntry {
|
|
auction_id: 11,
|
|
item_entry: 12,
|
|
perm_enchant: 13,
|
|
random_property_id: 14,
|
|
suffix_factor: 15,
|
|
count: 16,
|
|
spell_charges: 17,
|
|
owner_guid: 18,
|
|
start_bid: 19,
|
|
min_increment: 20,
|
|
buyout: 21,
|
|
time_left_ms: 22,
|
|
bidder_guid: 23,
|
|
current_bid: 24,
|
|
};
|
|
let mut body = 1u32.to_le_bytes().to_vec();
|
|
push_record(&mut body, &row);
|
|
body.extend_from_slice(&9_999u32.to_le_bytes());
|
|
|
|
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions, [row], "the record reads intact");
|
|
assert_eq!(
|
|
total_count, 9_999,
|
|
"totalCount comes from AFTER the records, not from the head or a record field"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_record_round_trips_negative_signed_fields() {
|
|
// `random_property_id` and `spell_charges` are SIGNED (the server casts int32 through uint32).
|
|
// A negative random property id is a random *suffix* ("of the Bear"); a negative charge count
|
|
// means "N charges then destroy". Read unsigned, both become ~4.29-billion nonsense.
|
|
let row = AuctionListEntry {
|
|
auction_id: 77,
|
|
item_entry: 7_078,
|
|
perm_enchant: 0,
|
|
random_property_id: -19, // a suffix id
|
|
suffix_factor: 143,
|
|
count: 1,
|
|
spell_charges: -5, // 5 charges, then destroy
|
|
owner_guid: 0x0000_0000_0000_0404,
|
|
start_bid: 1,
|
|
min_increment: 0,
|
|
buyout: 0,
|
|
time_left_ms: 1_000,
|
|
bidder_guid: 0,
|
|
current_bid: 0,
|
|
};
|
|
let mut body = 1u32.to_le_bytes().to_vec();
|
|
push_record(&mut body, &row);
|
|
body.extend_from_slice(&1u32.to_le_bytes());
|
|
|
|
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions[0].random_property_id, -19);
|
|
assert_eq!(auctions[0].spell_charges, -5);
|
|
assert_eq!(auctions[0].suffix_factor, 143);
|
|
|
|
// And the unclamped time_left: an expired-but-unswept auction wraps rather than clamping to
|
|
// 0. The reader must pass it through faithfully; judging it is the consumer's job.
|
|
let mut expired = unbid_row(78);
|
|
expired.time_left_ms = u32::MAX - 500; // (expire - now) * 1000 on a negative difference
|
|
let mut body = 1u32.to_le_bytes().to_vec();
|
|
push_record(&mut body, &expired);
|
|
body.extend_from_slice(&1u32.to_le_bytes());
|
|
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions[0].time_left_ms, u32::MAX - 500);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_list_result_survives_a_count_larger_than_the_records() {
|
|
// vmangos's browse fast path increments `count` for a record it then fails to write (a stale
|
|
// auction whose item row is gone writes ZERO bytes and still counts). A body claiming 3 and
|
|
// carrying 2 must yield those 2 rather than failing the whole page.
|
|
let rows = [unbid_row(1), unbid_row(2)];
|
|
let mut body = 3u32.to_le_bytes().to_vec(); // the server's inflated count
|
|
for row in &rows {
|
|
push_record(&mut body, row);
|
|
}
|
|
body.extend_from_slice(&2u32.to_le_bytes()); // totalCount still rides at the end
|
|
|
|
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions, rows, "the records that DID arrive come back");
|
|
assert_eq!(total_count, 2);
|
|
|
|
// The harsher variant: truncated past the last record, so even the trailing total is missing.
|
|
// Still not an error — the total falls back to what we read.
|
|
let mut body = 3u32.to_le_bytes().to_vec();
|
|
for row in &rows {
|
|
push_record(&mut body, row);
|
|
}
|
|
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions, rows);
|
|
assert_eq!(total_count, 2, "falls back to the records actually read");
|
|
|
|
// And a count that is pure nonsense (a hostile/desynced body) must not allocate on its word
|
|
// alone or error — it yields the one record the buffer holds.
|
|
let mut body = u32::MAX.to_le_bytes().to_vec();
|
|
push_record(&mut body, &rows[0]);
|
|
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
|
|
assert_eq!(auctions, [rows[0]]);
|
|
}
|
|
|
|
#[test]
|
|
fn auction_bidder_notification_wire() {
|
|
// SMSG_AUCTION_BIDDER_NOTIFICATION: u32 houseId, u32 auctionId, u64 bidderGuid,
|
|
// u32 bidOrZero, u32 outBid, u32 itemEntry, i32 randomPropertyId — houseId FIRST and the
|
|
// guid THIRD, unlike the owner notification.
|
|
let mut body = 6u32.to_le_bytes().to_vec(); // houseId
|
|
body.extend_from_slice(&4242u32.to_le_bytes()); // auctionId
|
|
body.extend_from_slice(&0x0000_0000_0000_0505u64.to_le_bytes()); // bidderGuid
|
|
body.extend_from_slice(&15_000u32.to_le_bytes()); // bidOrZero — nonzero: OUTBID
|
|
body.extend_from_slice(&750u32.to_le_bytes()); // outBid
|
|
body.extend_from_slice(&12_640u32.to_le_bytes()); // itemEntry
|
|
body.extend_from_slice(&(-19i32).to_le_bytes()); // randomPropertyId, signed
|
|
|
|
let expected = AuctionBidderNotification {
|
|
house_id: 6,
|
|
auction_id: 4242,
|
|
bidder_guid: 0x0505,
|
|
bid_or_zero: 15_000,
|
|
out_bid: 750,
|
|
item_entry: 12_640,
|
|
random_property_id: -19,
|
|
};
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &body).unwrap()
|
|
{
|
|
ServerPacket::AuctionBidderNotification(n) => assert_eq!(n, expected),
|
|
other => panic!("auction bidder notification, got {}", other.name()),
|
|
}
|
|
|
|
// bidOrZero == 0 means WON, not "no bid" — the one field whose zero is a *state*, and the
|
|
// reason this notification cannot share a struct with the owner one.
|
|
let mut won = body.clone();
|
|
won[16..20].copy_from_slice(&0u32.to_le_bytes());
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &won).unwrap()
|
|
{
|
|
ServerPacket::AuctionBidderNotification(n) => {
|
|
assert_eq!(n.bid_or_zero, 0, "0 = WON");
|
|
assert_eq!(n.auction_id, 4242, "the rest of the row is unmoved");
|
|
assert_eq!(n.out_bid, 750);
|
|
}
|
|
other => panic!("auction bidder notification (won), got {}", other.name()),
|
|
}
|
|
|
|
// The two notifications must never share a reader. Feed these same bytes to the OWNER opcode
|
|
// (28 bytes needed, 32 available, so it parses rather than EOF-ing) and every field lands
|
|
// somewhere else: the house id is eaten as the auction id and the guid slides four bytes.
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap()
|
|
{
|
|
ServerPacket::AuctionOwnerNotification(n) => {
|
|
assert_eq!(
|
|
n.auction_id, 6,
|
|
"the owner reader eats houseId as auctionId"
|
|
);
|
|
assert_ne!(n.bidder_guid, expected.bidder_guid, "the guid slides");
|
|
assert_ne!(n.item_entry, expected.item_entry);
|
|
}
|
|
other => panic!("auction owner notification, got {}", other.name()),
|
|
}
|
|
|
|
let packet =
|
|
messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &body).unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionBidderNotification(n) => assert_eq!(n, expected),
|
|
other => panic!("auction bidder notification event, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_owner_notification_wire() {
|
|
// SMSG_AUCTION_OWNER_NOTIFICATION: u32 auctionId, u32 bid, u32 outBid, u64 bidderGuid,
|
|
// u32 itemEntry, i32 randomPropertyId — NO houseId, and the guid sits FOURTH. Deliberately a
|
|
// different shape from the bidder notification above.
|
|
let mut body = 4242u32.to_le_bytes().to_vec(); // auctionId
|
|
body.extend_from_slice(&15_000u32.to_le_bytes()); // bid
|
|
body.extend_from_slice(&750u32.to_le_bytes()); // outBid
|
|
body.extend_from_slice(&0x0000_0000_0000_0505u64.to_le_bytes()); // bidderGuid
|
|
body.extend_from_slice(&12_640u32.to_le_bytes()); // itemEntry
|
|
body.extend_from_slice(&(-19i32).to_le_bytes()); // randomPropertyId, signed
|
|
assert_eq!(body.len(), 28, "four bytes shorter than the bidder notice");
|
|
|
|
let expected = AuctionOwnerNotification {
|
|
auction_id: 4242,
|
|
bid: 15_000,
|
|
out_bid: 750,
|
|
bidder_guid: 0x0505,
|
|
item_entry: 12_640,
|
|
random_property_id: -19,
|
|
};
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap()
|
|
{
|
|
ServerPacket::AuctionOwnerNotification(n) => assert_eq!(n, expected),
|
|
other => panic!("auction owner notification, got {}", other.name()),
|
|
}
|
|
|
|
// An all-zero bidder guid is the "sold" signal, not a missing bidder.
|
|
let mut sold = body.clone();
|
|
sold[12..20].copy_from_slice(&0u64.to_le_bytes());
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &sold).unwrap()
|
|
{
|
|
ServerPacket::AuctionOwnerNotification(n) => {
|
|
assert_eq!(n.bidder_guid, 0, "0 = sold");
|
|
assert_eq!(n.item_entry, 12_640, "the rest of the row is unmoved");
|
|
}
|
|
other => panic!("auction owner notification (sold), got {}", other.name()),
|
|
}
|
|
|
|
let packet =
|
|
messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionOwnerNotification(n) => assert_eq!(n, expected),
|
|
other => panic!("auction owner notification event, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auction_removed_notification_wire() {
|
|
// SMSG_AUCTION_REMOVED_NOTIFICATION: u32 auctionId, u32 itemEntry, i32 randomPropertyId.
|
|
let mut body = 4242u32.to_le_bytes().to_vec();
|
|
body.extend_from_slice(&2589u32.to_le_bytes());
|
|
body.extend_from_slice(&(-7i32).to_le_bytes());
|
|
match messages::parse_server(messages::opcode::SMSG_AUCTION_REMOVED_NOTIFICATION, &body)
|
|
.unwrap()
|
|
{
|
|
ServerPacket::AuctionRemovedNotification {
|
|
auction_id,
|
|
item_entry,
|
|
random_property_id,
|
|
} => assert_eq!(
|
|
(auction_id, item_entry, random_property_id),
|
|
(4242, 2589, -7)
|
|
),
|
|
other => panic!("auction removed notification, got {}", other.name()),
|
|
}
|
|
|
|
let packet =
|
|
messages::parse_server(messages::opcode::SMSG_AUCTION_REMOVED_NOTIFICATION, &body).unwrap();
|
|
match decode(packet).pop().unwrap() {
|
|
SessionEvent::AuctionRemovedNotification {
|
|
auction_id,
|
|
item_entry,
|
|
random_property_id,
|
|
} => assert_eq!(
|
|
(auction_id, item_entry, random_property_id),
|
|
(4242, 2589, -7)
|
|
),
|
|
other => panic!("auction removed notification event, got {other:?}"),
|
|
}
|
|
}
|