Files
benilla-octo/crates/benilla-protocol/tests/gossip_vendor.rs
T
SamandClaude Opus 5 c48d8c6b7e other players walk the world, and NPCs speak your name
- Other players walk the world. A watched player's motion between server updates now runs through
  the same grounded movement resolve as your own character — same capsule, same colliders, same
  step-up and ground election — so they follow terrain every frame instead of every packet, and
  stop at a wall instead of walking into it and being snapped back. Their jumps are swept against
  the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press
  the reference client broadcasts rather than defers.
- The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps
  ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under
  load — is decoded rather than skipped. Our own send path now matches the reference client's:
  facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so
  left and right can never ride the same packet.
- NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name,
  class and race, the gendered forms, and the server's live world states — and the same
  substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted
  draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable,
  where it used to open a dialog holding nothing but a placeholder greeting.
- Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive
  gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit
  equipped-item requirement named properly instead of by raw bits, and a buff's dispel class.
- The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve
  entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are
  named by their pet number rather than a creature-template entry, so the client asks the query the
  server can actually answer. And the asset extractor grew a stray-geometry census for tracking
  down geometry that renders where it should not.

This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said
nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:57:54 +07:00

469 lines
19 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:?}"),
}
}
#[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) — the parser must tolerate it (the row loop simply never runs).
let mut empty_body = 0xDDu64.to_le_bytes().to_vec();
empty_body.push(0); // count
empty_body.push(0); // trailing errorByte(0)
match messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &empty_body).unwrap() {
ServerPacket::VendorList { vendor, items } => {
assert_eq!(vendor, 0xDD);
assert!(items.is_empty());
}
other => panic!("empty vendor list, got {}", other.name()),
}
}
#[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)
);
}