- 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>
368 lines
15 KiB
Rust
368 lines
15 KiB
Rust
//! Oracle-free golden tests for the mail arc's protocol layer (decision 0544 phase P0): the
|
||
//! `CMSG_GET_MAIL_LIST`/`CMSG_SEND_MAIL`/take-money/take-item/mark-read/return/delete/
|
||
//! `CMSG_ITEM_TEXT_QUERY` send verbs, the `SMSG_MAIL_LIST_RESULT` inbox page (all three sender
|
||
//! shapes + the always-present item block), `SMSG_SEND_MAIL_RESULT`'s three tail shapes, the
|
||
//! letter-body fetch reply, and the arrival pair (`SMSG_RECEIVED_MAIL` / `MSG_QUERY_NEXT_MAIL_TIME`).
|
||
//! Same idioms as `tests/trainer.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, mail_action, mail_error, mail_message_type, MailAttachment,
|
||
};
|
||
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 MAILBOX: u64 = 0x00F1_1000_0000_0042;
|
||
|
||
#[test]
|
||
fn mail_send_bodies_golden() {
|
||
// CMSG_GET_MAIL_LIST: one full mailbox guid.
|
||
assert_eq!(
|
||
messages::get_mail_list(MAILBOX),
|
||
hx("420000000010f100"),
|
||
"CMSG_GET_MAIL_LIST body"
|
||
);
|
||
|
||
// CMSG_SEND_MAIL: u64 mailbox, cstr receiver, cstr subject, cstr body, u32 stationery, u32
|
||
// package, u64 itemGuid, u32 money, u32 COD, then the 9-byte zero tail (u64 0 + u8 0).
|
||
assert_eq!(
|
||
messages::send_mail(
|
||
MAILBOX,
|
||
"Bob",
|
||
"Hello",
|
||
"Here is your item.",
|
||
41,
|
||
0,
|
||
0x0001_0000_0000_0099,
|
||
12_345,
|
||
500,
|
||
),
|
||
hx(concat!(
|
||
"420000000010f100", // mailbox
|
||
"426f6200", // "Bob\0"
|
||
"48656c6c6f00", // "Hello\0"
|
||
"4865726520697320796f7572206974656d2e00", // "Here is your item.\0"
|
||
"29000000", // stationery 41
|
||
"00000000", // package 0
|
||
"9900000000000100", // item guid
|
||
"39300000", // money 12345
|
||
"f4010000", // cod 500
|
||
"0000000000000000", // the 9-byte zero tail: u64 0
|
||
"00", // + u8 0
|
||
)),
|
||
"CMSG_SEND_MAIL body"
|
||
);
|
||
|
||
// The five `u64 mailbox, u32 mailId` CMSGs share one shape.
|
||
let golden = hx("420000000010f1004d000000"); // mailbox + mailId 77
|
||
assert_eq!(messages::mail_take_money(MAILBOX, 77), golden);
|
||
assert_eq!(messages::mail_take_item(MAILBOX, 77), golden);
|
||
assert_eq!(messages::mail_mark_as_read(MAILBOX, 77), golden);
|
||
assert_eq!(messages::mail_return_to_sender(MAILBOX, 77), golden);
|
||
assert_eq!(messages::mail_delete(MAILBOX, 77), golden);
|
||
|
||
// CMSG_MAIL_CREATE_TEXT_ITEM: the shared shape + u32 mailTemplateId(0) (vmangos
|
||
// `MailCreateTextItem::ReadFromWorldPacket`).
|
||
assert_eq!(
|
||
messages::mail_create_text_item(MAILBOX, 77),
|
||
hx("420000000010f1004d00000000000000"),
|
||
"CMSG_MAIL_CREATE_TEXT_ITEM body"
|
||
);
|
||
|
||
// CMSG_ITEM_TEXT_QUERY: u32 textId, u32 mailId, u32 unk(0).
|
||
assert_eq!(
|
||
messages::item_text_query(1234, 77),
|
||
hx("d20400004d00000000000000"),
|
||
"CMSG_ITEM_TEXT_QUERY body"
|
||
);
|
||
}
|
||
|
||
/// Append one 8-byte-fixed item block (the tail every `SMSG_MAIL_LIST_RESULT` row always carries,
|
||
/// zeroed when the mail has no attachment): entry, permEnchant, randomPropId, suffixFactor u32 ×4,
|
||
/// stackCount u8, spellCharges/durabilityMax/durabilityCur u32 ×3.
|
||
fn push_item_block(
|
||
body: &mut Vec<u8>,
|
||
entry: u32,
|
||
perm_enchant: u32,
|
||
random_prop_id: u32,
|
||
suffix_factor: u32,
|
||
count: u8,
|
||
charges: u32,
|
||
durability_max: u32,
|
||
durability: u32,
|
||
) {
|
||
body.extend_from_slice(&entry.to_le_bytes());
|
||
body.extend_from_slice(&perm_enchant.to_le_bytes());
|
||
body.extend_from_slice(&random_prop_id.to_le_bytes());
|
||
body.extend_from_slice(&suffix_factor.to_le_bytes());
|
||
body.push(count);
|
||
body.extend_from_slice(&charges.to_le_bytes());
|
||
body.extend_from_slice(&durability_max.to_le_bytes());
|
||
body.extend_from_slice(&durability.to_le_bytes());
|
||
}
|
||
|
||
#[test]
|
||
fn mail_list_result_wire() {
|
||
// SMSG_MAIL_LIST_RESULT: u8 count, then per row: u32 messageId, u8 messageType, the sender
|
||
// branch keyed by messageType, cstr subject, u32 itemTextId, u32 package(dropped), u32
|
||
// stationery, the item block (ALWAYS present), u32 money, u32 COD, u32 checked, f32
|
||
// expireDays, u32 mailTemplateId. Three rows exercise all three sender shapes.
|
||
let mut body = vec![3u8]; // count
|
||
|
||
// Row 1 — MAIL_NORMAL: a player sender (guid), an attached item, money + COD + checked flags,
|
||
// a fractional expire_days.
|
||
body.extend_from_slice(&1u32.to_le_bytes()); // messageId
|
||
body.push(mail_message_type::NORMAL);
|
||
body.extend_from_slice(&0x0000_0001_0000_00AAu64.to_le_bytes()); // sender guid
|
||
body.extend_from_slice(b"Old Friend\0");
|
||
body.extend_from_slice(&555u32.to_le_bytes()); // itemTextId
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // package (dropped)
|
||
body.extend_from_slice(&1u32.to_le_bytes()); // stationery
|
||
push_item_block(&mut body, 6948, 0, 0, 0, 3, 0, 100, 80);
|
||
body.extend_from_slice(&10_000u32.to_le_bytes()); // money
|
||
body.extend_from_slice(&2_500u32.to_le_bytes()); // COD
|
||
body.extend_from_slice(&0x9u32.to_le_bytes()); // checked: READ|COD_PAYMENT
|
||
body.extend_from_slice(&2.5f32.to_le_bytes()); // expire_days
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // mailTemplateId
|
||
|
||
// Row 2 — MAIL_AUCTION: a u32 sender id, no item (all-zero item block).
|
||
body.extend_from_slice(&2u32.to_le_bytes());
|
||
body.push(mail_message_type::AUCTION);
|
||
body.extend_from_slice(&42u32.to_le_bytes()); // sender id (auction id)
|
||
body.extend_from_slice(b"Auction won\0");
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // itemTextId
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // package
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // stationery
|
||
push_item_block(&mut body, 0, 0, 0, 0, 0, 0, 0, 0);
|
||
body.extend_from_slice(&500u32.to_le_bytes()); // money
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // COD
|
||
body.extend_from_slice(&0x1u32.to_le_bytes()); // checked: READ
|
||
body.extend_from_slice(&1.0f32.to_le_bytes()); // expire_days
|
||
body.extend_from_slice(&0u32.to_le_bytes());
|
||
|
||
// Row 3 — MAIL_ITEM: NO sender bytes at all.
|
||
body.extend_from_slice(&3u32.to_le_bytes());
|
||
body.push(mail_message_type::ITEM);
|
||
body.extend_from_slice(b"Welcome\0");
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // itemTextId
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // package
|
||
body.extend_from_slice(&41u32.to_le_bytes()); // stationery
|
||
push_item_block(&mut body, 0, 0, 0, 0, 0, 0, 0, 0);
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // money
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // COD
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // checked
|
||
body.extend_from_slice(&30.0f32.to_le_bytes()); // expire_days
|
||
body.extend_from_slice(&8383u32.to_le_bytes()); // mailTemplateId
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_MAIL_LIST_RESULT, &body).unwrap();
|
||
match &packet {
|
||
ServerPacket::MailList { mails } => {
|
||
assert_eq!(mails.len(), 3);
|
||
|
||
let m = &mails[0];
|
||
assert_eq!(m.message_id, 1);
|
||
assert_eq!(m.message_type, mail_message_type::NORMAL);
|
||
assert_eq!(m.sender_guid, Some(0x0000_0001_0000_00AA));
|
||
assert_eq!(m.sender_id, None);
|
||
assert_eq!(m.subject, "Old Friend");
|
||
assert_eq!(m.item_text_id, 555);
|
||
assert_eq!(m.stationery, 1);
|
||
assert_eq!(
|
||
m.item,
|
||
Some(MailAttachment {
|
||
entry: 6948,
|
||
perm_enchant: 0,
|
||
random_prop_id: 0,
|
||
suffix_factor: 0,
|
||
count: 3,
|
||
charges: 0,
|
||
durability_max: 100,
|
||
durability: 80,
|
||
})
|
||
);
|
||
assert_eq!(m.money, 10_000);
|
||
assert_eq!(m.cod, 2_500);
|
||
assert_eq!(m.checked, 0x9);
|
||
assert_eq!(m.expire_days, 2.5);
|
||
assert_eq!(m.mail_template_id, 0);
|
||
|
||
let m = &mails[1];
|
||
assert_eq!(m.message_id, 2);
|
||
assert_eq!(m.message_type, mail_message_type::AUCTION);
|
||
assert_eq!(m.sender_guid, None);
|
||
assert_eq!(m.sender_id, Some(42));
|
||
assert_eq!(m.subject, "Auction won");
|
||
assert_eq!(m.item, None, "all-zero item block folds to None");
|
||
assert_eq!(m.money, 500);
|
||
assert_eq!(m.checked, 0x1);
|
||
assert_eq!(m.expire_days, 1.0);
|
||
|
||
let m = &mails[2];
|
||
assert_eq!(m.message_id, 3);
|
||
assert_eq!(m.message_type, mail_message_type::ITEM);
|
||
assert_eq!(m.sender_guid, None);
|
||
assert_eq!(m.sender_id, None, "MAIL_ITEM reads no sender bytes at all");
|
||
assert_eq!(m.subject, "Welcome");
|
||
assert_eq!(m.item, None);
|
||
assert_eq!(m.mail_template_id, 8383);
|
||
}
|
||
other => panic!("mail list, got {}", other.name()),
|
||
}
|
||
|
||
// The decode() bridge carries the wire rows through unchanged.
|
||
match decode(packet).pop().unwrap() {
|
||
SessionEvent::MailList { mails } => assert_eq!(mails.len(), 3),
|
||
other => panic!("mail list event, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn send_mail_result_wire() {
|
||
// Plain shape: action SEND, error OK — neither conditional tail rides.
|
||
let mut plain = 1u32.to_le_bytes().to_vec();
|
||
plain.extend_from_slice(&mail_action::SEND.to_le_bytes());
|
||
plain.extend_from_slice(&mail_error::OK.to_le_bytes());
|
||
match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &plain).unwrap() {
|
||
ServerPacket::SendMailResult {
|
||
mail_id,
|
||
action,
|
||
error,
|
||
equip_error,
|
||
item,
|
||
} => {
|
||
assert_eq!(
|
||
(mail_id, action, error),
|
||
(1, mail_action::SEND, mail_error::OK)
|
||
);
|
||
assert_eq!(equip_error, None);
|
||
assert_eq!(item, None);
|
||
}
|
||
other => panic!("send mail result (plain), got {}", other.name()),
|
||
}
|
||
// The decode() bridge, asserted on this shape.
|
||
let packet = messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &plain).unwrap();
|
||
match decode(packet).pop().unwrap() {
|
||
SessionEvent::SendMailResult {
|
||
mail_id,
|
||
action,
|
||
error,
|
||
equip_error,
|
||
item,
|
||
} => {
|
||
assert_eq!(
|
||
(mail_id, action, error),
|
||
(1, mail_action::SEND, mail_error::OK)
|
||
);
|
||
assert_eq!((equip_error, item), (None, None));
|
||
}
|
||
other => panic!("send mail result event, got {other:?}"),
|
||
}
|
||
|
||
// Equip-error shape: error == MAIL_ERR_EQUIP_ERROR carries one trailing u32.
|
||
let mut equip = 2u32.to_le_bytes().to_vec();
|
||
equip.extend_from_slice(&mail_action::ITEM_TAKEN.to_le_bytes());
|
||
equip.extend_from_slice(&mail_error::EQUIP_ERROR.to_le_bytes());
|
||
equip.extend_from_slice(&1234u32.to_le_bytes()); // the equip-error code
|
||
match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &equip).unwrap() {
|
||
ServerPacket::SendMailResult {
|
||
mail_id,
|
||
error,
|
||
equip_error,
|
||
item,
|
||
..
|
||
} => {
|
||
assert_eq!((mail_id, error), (2, mail_error::EQUIP_ERROR));
|
||
assert_eq!(equip_error, Some(1234));
|
||
assert_eq!(item, None);
|
||
}
|
||
other => panic!("send mail result (equip error), got {}", other.name()),
|
||
}
|
||
|
||
// Item-taken shape: action == MAIL_ITEM_TAKEN and error == OK carries entry + count.
|
||
let mut taken = 3u32.to_le_bytes().to_vec();
|
||
taken.extend_from_slice(&mail_action::ITEM_TAKEN.to_le_bytes());
|
||
taken.extend_from_slice(&mail_error::OK.to_le_bytes());
|
||
taken.extend_from_slice(&6948u32.to_le_bytes()); // item entry
|
||
taken.extend_from_slice(&3u32.to_le_bytes()); // item count
|
||
match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &taken).unwrap() {
|
||
ServerPacket::SendMailResult {
|
||
mail_id,
|
||
action,
|
||
error,
|
||
equip_error,
|
||
item,
|
||
} => {
|
||
assert_eq!(
|
||
(mail_id, action, error),
|
||
(3, mail_action::ITEM_TAKEN, mail_error::OK)
|
||
);
|
||
assert_eq!(equip_error, None);
|
||
assert_eq!(item, Some((6948, 3)));
|
||
}
|
||
other => panic!("send mail result (item taken), got {}", other.name()),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn item_text_query_response_wire() {
|
||
// SMSG_ITEM_TEXT_QUERY_RESPONSE: u32 textId, cstr text.
|
||
let mut body = 1234u32.to_le_bytes().to_vec();
|
||
body.extend_from_slice(b"This is the letter body.\0");
|
||
match messages::parse_server(messages::opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE, &body).unwrap() {
|
||
ServerPacket::ItemTextQueryResponse { text_id, text } => {
|
||
assert_eq!(text_id, 1234);
|
||
assert_eq!(text, "This is the letter body.");
|
||
}
|
||
other => panic!("item text query response, got {}", other.name()),
|
||
}
|
||
let packet =
|
||
messages::parse_server(messages::opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE, &body).unwrap();
|
||
match decode(packet).pop().unwrap() {
|
||
SessionEvent::MailItemText { text_id, text } => {
|
||
assert_eq!(text_id, 1234);
|
||
assert_eq!(text, "This is the letter body.");
|
||
}
|
||
other => panic!("mail item text event, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn received_mail_wire() {
|
||
// SMSG_RECEIVED_MAIL: one f32 delay, the same countdown units as MSG_QUERY_NEXT_MAIL_TIME's
|
||
// reply (decision 0913 — the real client reads these four bytes as a float). vmangos writes
|
||
// them as `uint32(0)`, and 0x00000000 *is* 0.0f, so its only value decodes as "waiting now".
|
||
let vmangos_body = 0u32.to_le_bytes();
|
||
match messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &vmangos_body).unwrap() {
|
||
ServerPacket::ReceivedMail { seconds } => assert_eq!(seconds, 0.0),
|
||
other => panic!("received mail, got {}", other.name()),
|
||
}
|
||
// A server that sends a real delay round-trips it.
|
||
for seconds in [0.0f32, 120.0f32] {
|
||
let body = seconds.to_le_bytes();
|
||
let packet = messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap();
|
||
match decode(packet).pop().unwrap() {
|
||
SessionEvent::ReceivedMail { seconds: got } => assert_eq!(got, seconds),
|
||
other => panic!("received mail event, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn query_next_mail_time_wire() {
|
||
// MSG_QUERY_NEXT_MAIL_TIME's reply: one f32 — 0.0 unread waiting, -86400.0 none.
|
||
for seconds in [0.0f32, -86400.0f32] {
|
||
let body = seconds.to_le_bytes();
|
||
match messages::parse_server(messages::opcode::MSG_QUERY_NEXT_MAIL_TIME, &body).unwrap() {
|
||
ServerPacket::NextMailTime { seconds: got } => assert_eq!(got, seconds),
|
||
other => panic!("query next mail time, got {}", other.name()),
|
||
}
|
||
}
|
||
}
|