Files
benilla-octo/crates/benilla-protocol/tests/gossip_vendor.rs
T
SamandClaude Fable 5.1 00150c55b5 a crash leaves a report, and the wire's counts are capped
- a panic writes benilla-config/Diagnostics/crash-<time>.txt with the build id, the location, a backtrace and the last 200 log lines, and names the file as its last line, so a bug report can attach it (2266)
- every wire-count allocation hint is capped and the compressed update object reads through the cursor, so a malformed packet can no longer ask for gigabytes (2266)
- the 23 in-world run conditions are one set, the 45 probe variables one registry, the install-gated tests say when they skip, and every gate is timed (2266)
- one workspace-level lint allowance replaces 413 per-site attributes, and the decision corpus gets a lint in the gate (2266)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:38:59 +07:00

528 lines
22 KiB
Rust

//! Oracle-free golden tests for the gossip/vendor interaction arc's protocol layer (Phase 1 of the
//! gossip/vendor arc): `CMSG_GOSSIP_HELLO`/`SELECT_OPTION`, `CMSG_NPC_TEXT_QUERY`, and the vendor
//! `LIST_INVENTORY`/`BUY_ITEM`/`SELL_ITEM` family. Split out from `messages.rs` (same idioms —
//! `hx(...)` golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`) since
//! it's a self-contained wire family or size.
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages;
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()
}
#[test]
fn gossip_bodies_golden() {
// CMSG_GOSSIP_HELLO (vmangos Npc.cpp:3) / CMSG_LIST_INVENTORY (Item.cpp:94) share the same
// full-guid body shape.
assert_eq!(
messages::gossip_hello(0x1234_5678_9abc_def0),
hx("f0debc9a78563412"),
"CMSG_GOSSIP_HELLO body"
);
// CMSG_NPC_TEXT_QUERY (vmangos Npc.cpp:8-12): u32 textID, u64 guid.
assert_eq!(
messages::npc_text_query(55, 0x1234_5678_9abc_def0),
hx(concat!("37000000", "f0debc9a78563412")),
"CMSG_NPC_TEXT_QUERY body"
);
// CMSG_GOSSIP_SELECT_OPTION (vmangos Npc.cpp:78-86): guid, gossipListId, then an optional
// trailing code cstring — appended only for a coded option with a real code, omitted entirely
// otherwise (the server reads it only when the buffer is non-empty).
assert_eq!(
messages::gossip_select_option(0x1234_5678_9abc_def0, 3, None),
hx(concat!("f0debc9a78563412", "03000000")),
"CMSG_GOSSIP_SELECT_OPTION body, no code"
);
let mut with_code = hx(concat!("f0debc9a78563412", "03000000"));
with_code.extend_from_slice(b"1234\0");
assert_eq!(
messages::gossip_select_option(0x1234_5678_9abc_def0, 3, Some("1234")),
with_code,
"CMSG_GOSSIP_SELECT_OPTION body, coded option with a real code"
);
}
#[test]
fn vendor_bodies_golden() {
// CMSG_LIST_INVENTORY (vmangos Item.cpp:94): a full guid.
assert_eq!(
messages::list_inventory(0x1234_5678_9abc_def0),
hx("f0debc9a78563412"),
"CMSG_LIST_INVENTORY body"
);
// CMSG_BUY_ITEM (vmangos Item.cpp:104-110): vendorGuid, item ENTRY (not muid), count, unk1(0).
// entry 2488 is the live-verified creature-54 vendor's Gladius row (decision 0081).
assert_eq!(
messages::buy_item(0x1234_5678_9abc_def0, 2488, 1),
hx(concat!("f0debc9a78563412", "b8090000", "01", "00")),
"CMSG_BUY_ITEM body"
);
// CMSG_SELL_ITEM (vmangos Item.cpp:87-92): vendorGuid, itemGuid, count (0 = whole stack).
assert_eq!(
messages::sell_item(0x1234_5678_9abc_def0, 0x42, 0),
hx(concat!("f0debc9a78563412", "4200000000000000", "00")),
"CMSG_SELL_ITEM body"
);
}
#[test]
fn gossip_message_wire() {
use benilla_protocol::messages::{GossipOption, QuestOption};
// SMSG_GOSSIP_MESSAGE (vmangos GossipDef.cpp:180-225, the 1.12 shape): objectGuid, textId,
// optionCount + options (index, icon, coded, message), questOptionCount + quest options. Zero
// quest options — the common case for a pure vendor/gossip NPC.
let mut body = 0xAAu64.to_le_bytes().to_vec(); // objectGuid
body.extend_from_slice(&100u32.to_le_bytes()); // textId
body.extend_from_slice(&2u32.to_le_bytes()); // optionCount
body.extend_from_slice(&0u32.to_le_bytes()); // option 0: index
body.push(0); // icon: chat bubble
body.push(0); // coded: false
body.extend_from_slice(b"Train me\0");
body.extend_from_slice(&1u32.to_le_bytes()); // option 1: index
body.push(1); // icon: vendor
body.push(1); // coded: true
body.extend_from_slice(b"Show me your wares\0");
body.extend_from_slice(&0u32.to_le_bytes()); // questOptionCount: 0
match messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body).unwrap() {
ServerPacket::GossipMessage {
npc,
text_id,
options,
quests,
} => {
assert_eq!((npc, text_id), (0xAA, 100));
assert_eq!(
options,
vec![
GossipOption {
index: 0,
icon: 0,
coded: false,
message: "Train me".into(),
},
GossipOption {
index: 1,
icon: 1,
coded: true,
message: "Show me your wares".into(),
},
]
);
assert!(quests.is_empty());
}
other => panic!("gossip message, got {}", other.name()),
}
// decode() surfaces a GossipMenu event carrying the (empty here) quest block.
let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::GossipMenu {
npc,
text_id,
options,
quests,
} => {
assert_eq!((npc, text_id, options.len()), (0xAA, 100, 2));
assert!(quests.is_empty());
}
other => panic!("gossip menu event, got {other:?}"),
}
// Nonzero quest options: parsed for byte alignment, kept on the wire struct (arc ignores them
// downstream — quest-giver flows are out of scope, decision 0081).
let mut body_q = 0xBBu64.to_le_bytes().to_vec();
body_q.extend_from_slice(&200u32.to_le_bytes()); // textId
body_q.extend_from_slice(&0u32.to_le_bytes()); // optionCount: 0
body_q.extend_from_slice(&1u32.to_le_bytes()); // questOptionCount: 1
body_q.extend_from_slice(&42u32.to_le_bytes()); // questId
body_q.extend_from_slice(&7u32.to_le_bytes()); // icon
body_q.extend_from_slice(&10u32.to_le_bytes()); // level
body_q.extend_from_slice(b"A Quest\0");
match messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body_q).unwrap() {
ServerPacket::GossipMessage {
npc,
options,
quests,
..
} => {
assert_eq!(npc, 0xBB);
assert!(options.is_empty());
assert_eq!(
quests,
vec![QuestOption {
quest_id: 42,
icon: 7,
level: 10,
title: "A Quest".into(),
}]
);
}
other => panic!("gossip message with quests, got {}", other.name()),
}
// SMSG_GOSSIP_COMPLETE — empty body.
match messages::parse_server(messages::opcode::SMSG_GOSSIP_COMPLETE, &[]).unwrap() {
ServerPacket::GossipComplete => {}
other => panic!("gossip complete, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_COMPLETE, &[]).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::GossipComplete => {}
other => panic!("gossip complete event, got {other:?}"),
}
}
/// `SMSG_GOSSIP_POI` — the guard's directions marker (vmangos `GossipDef.cpp:239-295`):
/// `u32 flags, f32 x, f32 y, u32 icon, u32 data, cstr name`. The golden body is a REAL row of the
/// 5875-era `points_of_interest` table (entry 658, "Lion's Pride Inn": flags 99, icon 6 =
/// `ICON_POI_REDFLAG`, data 0) — the shape every guard direction actually takes.
#[test]
fn gossip_poi_wire_and_event() {
let body = hx(concat!(
"63000000", // u32 flags = 99 (0x63): candidate | in-range icon
"66cd13c6", // f32 x = -9459.35
"6f522842", // f32 y = 42.0805
"06000000", // u32 icon = 6 (ICON_POI_REDFLAG)
"00000000", // u32 data = 0
"4c696f6e277320507269646520496e6e", // "Lion's Pride Inn"
"00", // … NUL-terminated
));
let expected = messages::GossipPoi {
flags: 99,
pos: [-9459.35, 42.0805],
icon: 6,
data: 0,
name: "Lion's Pride Inn".into(),
};
match messages::parse_server(messages::opcode::SMSG_GOSSIP_POI, &body).unwrap() {
ServerPacket::GossipPoi(poi) => assert_eq!(poi, expected),
other => panic!("gossip poi, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_POI, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::GossipPoi(poi) => assert_eq!(poi, expected),
other => panic!("gossip poi event, got {other:?}"),
}
}
#[test]
fn npc_text_update_greeting_extraction() {
// SMSG_NPC_TEXT_UPDATE (vmangos GossipDef.cpp:298-369): always 8 blocks of {f32 probability,
// cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32 emoteDelay, u32 emoteId)}.
fn block(w: &mut Vec<u8>, probability: f32, text0: &str, text1: &str) {
w.extend_from_slice(&probability.to_le_bytes());
w.extend_from_slice(text0.as_bytes());
w.push(0);
w.extend_from_slice(text1.as_bytes());
w.push(0);
w.extend_from_slice(&0u32.to_le_bytes()); // languageId
for _ in 0..3 {
w.extend_from_slice(&0u32.to_le_bytes()); // emoteDelay
w.extend_from_slice(&0u32.to_le_bytes()); // emoteId
}
}
// The wire carries all 8 blocks through UNDECIDED — the parse layer no longer picks.
let mut body = 321u32.to_le_bytes().to_vec(); // textID
block(&mut body, 0.1, "Low probability greeting", "");
block(&mut body, 0.0, "", "");
block(&mut body, 0.0, "", "");
block(&mut body, 0.5, "Welcome, $N!", "Welcome, traveler!");
block(&mut body, 0.0, "", "");
block(&mut body, 0.0, "", "");
block(&mut body, 0.2, "", "Female-only greeting");
block(&mut body, 0.0, "", "");
match messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap() {
ServerPacket::NpcText { text_id, blocks } => {
assert_eq!(text_id, 321);
assert_eq!(blocks.len(), messages::NPC_TEXT_BLOCKS);
assert_eq!(blocks[3].probability, 0.5);
assert_eq!(blocks[3].male, "Welcome, $N!");
assert_eq!(blocks[3].female, "Welcome, traveler!");
assert_eq!(blocks[6].female, "Female-only greeting");
}
other => panic!("npc text update, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::NpcGreeting { text_id, blocks } => {
assert_eq!(text_id, 321);
assert_eq!(blocks.len(), messages::NPC_TEXT_BLOCKS);
}
other => panic!("npc greeting event, got {other:?}"),
}
}
/// The greeting DRAW (wow-re `gossip-npctext-law.md`, reference `0x4e2010`) — a weighted random
/// pick over the blocks whose chosen gender column is non-empty. Every branch pinned with an
/// explicit roll, because all three rules here are the opposite of what this client used to do.
#[test]
fn greeting_draw_follows_the_weighted_law() {
use benilla_protocol::messages::{select_greeting, NpcTextBlock};
let b = |probability: f32, male: &str, female: &str| NpcTextBlock {
probability,
male: male.into(),
female: female.into(),
};
// roll ∈ [1, 2) ⇒ thr = (2 - roll) * sum ∈ (0, sum]. roll = 1.0 is the far end (thr = sum),
// roll → 2.0 the near end (thr → 0).
const NEAR: f32 = 1.999_999_9;
const FAR: f32 = 1.0;
// 1 · The all-zero record — vmangos's fallback, and much of the 1.12 table. sum = 0 ⇒ thr = 0,
// and the predicate is `<=`, so block 0 wins on EVERY roll rather than nothing winning.
let zeros: Vec<_> = (0..8).map(|i| b(0.0, &format!("line {i}"), "")).collect();
for roll in [FAR, 1.5, NEAR] {
assert_eq!(select_greeting(&zeros, 0, roll), Some("line 0"));
}
// 2 · Real weights: the draw walks in order, so a near-zero threshold takes the first non-empty
// block and a threshold of the full sum takes the last. The old rule — highest probability
// wins — would have answered "big" for both.
let weighted = vec![b(0.1, "small", ""), b(0.8, "big", ""), b(0.1, "last", "")];
assert_eq!(select_greeting(&weighted, 0, NEAR), Some("small"));
assert_eq!(select_greeting(&weighted, 0, FAR), Some("last"));
// 3 · The column is the NPC's gender, chosen once. Genderless (2) reads as male, because the
// reference tests `== 1`, not `!= 0`.
let gendered = vec![b(1.0, "sir", "madam")];
assert_eq!(select_greeting(&gendered, 0, FAR), Some("sir"));
assert_eq!(select_greeting(&gendered, 1, FAR), Some("madam"));
assert_eq!(
select_greeting(&gendered, 2, FAR),
Some("sir"),
"genderless"
);
// 4 · No fallback to the other column. A female NPC skips male-only blocks outright — she does
// NOT borrow their text (this client used to) — and if that empties the record she gets no
// greeting at all, which is the reference's "Missing gossip text!" path.
let male_only = vec![b(1.0, "men only", ""), b(1.0, "", "ladies")];
assert_eq!(select_greeting(&male_only, 1, FAR), Some("ladies"));
assert_eq!(select_greeting(&male_only, 1, NEAR), Some("ladies"));
assert_eq!(select_greeting(&[b(1.0, "men only", "")], 1, FAR), None);
assert_eq!(select_greeting(&[], 0, FAR), None, "empty record");
}
#[test]
fn vendor_list_inventory_wire() {
use benilla_protocol::messages::VendorItem;
// SMSG_LIST_INVENTORY (vmangos ItemHandler.cpp:741-810): vendorGuid, u8 count, count x {muid,
// entry, displayId, currentCount, price, maxDurability, buyCount}. Two rows keyed off the
// live-verified creature-54 weapon vendor data (decision 0081): Gladius (entry
// 2488, price 536, display 22078, maxcount 0 -> unlimited) and a limited-stock second row.
// Trailing junk proves the parser stops reading rows at `count`.
let mut body = 0xCCu64.to_le_bytes().to_vec();
body.push(2); // count
for v in [1u32, 2488, 22078, 0xFFFF_FFFF, 536, 35, 1] {
body.extend_from_slice(&v.to_le_bytes());
}
for v in [2u32, 2489, 22079, 5, 342, 40, 1] {
body.extend_from_slice(&v.to_le_bytes());
}
body.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // trailing junk, must not become a third row
match messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &body).unwrap() {
ServerPacket::VendorList { vendor, items } => {
assert_eq!(vendor, 0xCC);
assert_eq!(
items,
vec![
VendorItem {
slot: 1,
entry: 2488,
display_id: 22078,
current_count: 0xFFFF_FFFF,
price: 536,
max_durability: 35,
buy_count: 1,
},
VendorItem {
slot: 2,
entry: 2489,
display_id: 22079,
current_count: 5,
price: 342,
max_durability: 40,
buy_count: 1,
},
]
);
}
other => panic!("vendor list, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::VendorInventory { vendor, items } => {
assert_eq!((vendor, items.len()), (0xCC, 2));
}
other => panic!("vendor inventory event, got {other:?}"),
}
// Empty stock: count = 0 followed by the trailing error byte (ItemHandler.cpp:728-733,
// 806-809) — consumed explicitly, so the decode leaves no tail to report.
let mut empty_body = 0xDDu64.to_le_bytes().to_vec();
empty_body.push(0); // count
empty_body.push(0); // trailing errorByte(0)
let (packet, tail) =
messages::parse_server_with_tail(messages::opcode::SMSG_LIST_INVENTORY, &empty_body)
.unwrap();
assert_eq!(tail, 0, "the error byte is part of the layout, not a tail");
match packet {
ServerPacket::VendorList { vendor, items } => {
assert_eq!(vendor, 0xDD);
assert!(items.is_empty());
}
other => panic!("empty vendor list, got {}", other.name()),
}
// …and the byte is REQUIRED: a count of 0 with nothing after it is the short read it is.
let truncated = &empty_body[..empty_body.len() - 1];
assert!(messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, truncated).is_err());
}
/// **The falsifier for decision 2265 §B1** on a `u32`-counted list: an option count of
/// `0xFFFF_FFFF` followed by one valid option must come back as a short-read `Err` — the decoder
/// must neither reserve the count nor abort.
#[test]
fn a_lying_gossip_option_count_is_a_short_read_not_an_allocation() {
let mut body = 0x77u64.to_le_bytes().to_vec(); // npc guid
body.extend_from_slice(&1u32.to_le_bytes()); // text id
body.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // option count
body.extend_from_slice(&0u32.to_le_bytes()); // one valid option: index
body.push(0); // icon
body.push(0); // coded
body.extend_from_slice(b"Hello.\0"); // message
let err = messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body)
.err()
.expect("the second option is missing, so the read is short");
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[test]
fn vendor_buy_sell_result_wire() {
use benilla_protocol::messages::{buy_result, sell_result};
// SMSG_BUY_ITEM (vmangos Item.cpp:190-196): vendorGuid, vendorSlot (1-based), newCount
// (0xFFFF_FFFF unlimited), purchaseCount. Only the vendor stock display changes here.
let mut body = 0xCCu64.to_le_bytes().to_vec();
for v in [1u32, 0xFFFF_FFFF, 1] {
body.extend_from_slice(&v.to_le_bytes());
}
match messages::parse_server(messages::opcode::SMSG_BUY_ITEM, &body).unwrap() {
ServerPacket::BuyItem {
vendor,
slot,
new_count,
purchase_count,
} => {
assert_eq!(
(vendor, slot, new_count, purchase_count),
(0xCC, 1, 0xFFFF_FFFF, 1)
);
}
other => panic!("buy item, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_BUY_ITEM, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::VendorBuyResult { vendor, slot, .. } => {
assert_eq!((vendor, slot), (0xCC, 1));
}
other => panic!("vendor buy result event, got {other:?}"),
}
// SMSG_SELL_ITEM (vmangos Item.cpp:183-188) — the error path only: vendorGuid, itemGuid, reason.
let mut body = 0xCCu64.to_le_bytes().to_vec();
body.extend_from_slice(&0x42u64.to_le_bytes());
body.push(sell_result::CANT_FIND_ITEM);
match messages::parse_server(messages::opcode::SMSG_SELL_ITEM, &body).unwrap() {
ServerPacket::SellItemResult {
vendor,
item_guid,
reason,
} => {
assert_eq!(
(vendor, item_guid, reason),
(0xCC, 0x42, sell_result::CANT_FIND_ITEM)
);
}
other => panic!("sell item error, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_SELL_ITEM, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::VendorSellFailed { reason, .. } => {
assert_eq!(reason, sell_result::CANT_FIND_ITEM);
}
other => panic!("vendor sell failed event, got {other:?}"),
}
// SMSG_BUY_FAILED (vmangos Item.h:277): vendorGuid, itemEntry, reason.
let mut body = 0xCCu64.to_le_bytes().to_vec();
body.extend_from_slice(&2488u32.to_le_bytes());
body.push(buy_result::NOT_ENOUGH_MONEY);
match messages::parse_server(messages::opcode::SMSG_BUY_FAILED, &body).unwrap() {
ServerPacket::BuyFailed {
vendor,
item_entry,
reason,
} => {
assert_eq!(
(vendor, item_entry, reason),
(0xCC, 2488, buy_result::NOT_ENOUGH_MONEY)
);
}
other => panic!("buy failed, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_BUY_FAILED, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::VendorBuyFailed { reason, .. } => {
assert_eq!(reason, buy_result::NOT_ENOUGH_MONEY);
}
other => panic!("vendor buy failed event, got {other:?}"),
}
// Result enum values (vmangos ItemDefines.h:120-141) — pinned so a future edit can't drift them.
assert_eq!(
(
buy_result::CANT_FIND_ITEM,
buy_result::ITEM_ALREADY_SOLD,
buy_result::NOT_ENOUGH_MONEY,
buy_result::SELLER_DONT_LIKE_YOU,
buy_result::DISTANCE_TOO_FAR,
buy_result::ITEM_SOLD_OUT,
buy_result::CANT_CARRY_MORE,
buy_result::RANK_REQUIRE,
buy_result::REPUTATION_REQUIRE,
),
(0, 1, 2, 4, 5, 7, 8, 11, 12)
);
assert_eq!(
(
sell_result::CANT_FIND_ITEM,
sell_result::CANT_SELL_ITEM,
sell_result::CANT_FIND_VENDOR,
sell_result::YOU_DONT_OWN_THAT_ITEM,
sell_result::UNK,
sell_result::ONLY_EMPTY_BAG,
),
(1, 2, 3, 4, 5, 6)
);
}