1,039 comment lines become 472 across 19 files; the code is unchanged. The movement test's claim that write() never serializes a transport tail is gone: it does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
335 lines
13 KiB
Rust
335 lines
13 KiB
Rust
//! The petition wire: the guild-charter flow that founds a guild, laid out per vmangos.
|
||
|
||
mod common;
|
||
|
||
use benilla_protocol::events::{decode, SessionEvent};
|
||
use benilla_protocol::messages::{
|
||
self, opcode, petition_result, CHARTER_DISPLAY_ID, CHARTER_ITEM_ENTRY, MAX_PETITION_SIGNATURES,
|
||
};
|
||
use benilla_protocol::ServerPacket;
|
||
use common::hx;
|
||
|
||
#[test]
|
||
fn cmsg_bodies_golden() {
|
||
// CMSG_PETITION_SHOWLIST (vmangos Server/Packets/Petition.cpp:3-6): one u64, the NPC.
|
||
assert_eq!(
|
||
messages::petition_show_list(0x0000_0000_0000_2a1f),
|
||
hx("1f2a000000000000"),
|
||
"CMSG_PETITION_SHOWLIST body"
|
||
);
|
||
|
||
// CMSG_PETITION_SHOW_SIGNATURES (Petition.cpp:8-11): one u64, the charter item.
|
||
assert_eq!(
|
||
messages::petition_show_signatures(0x0000_0000_0001_0203),
|
||
hx("0302010000000000"),
|
||
"CMSG_PETITION_SHOW_SIGNATURES body"
|
||
);
|
||
|
||
// CMSG_TURN_IN_PETITION (Petition.cpp:24-27): one u64, the item.
|
||
assert_eq!(
|
||
messages::turn_in_petition(0x0000_0000_0001_0203),
|
||
hx("0302010000000000"),
|
||
"CMSG_TURN_IN_PETITION body"
|
||
);
|
||
|
||
// MSG_PETITION_DECLINE outbound (Petition.cpp:19-22): one u64, the item, not the player.
|
||
assert_eq!(
|
||
messages::petition_decline(0x0000_0000_0001_0203),
|
||
hx("0302010000000000"),
|
||
"MSG_PETITION_DECLINE body"
|
||
);
|
||
|
||
// CMSG_PETITION_SIGN (Petition.cpp:35-39): u64 item, then an i8 the server skips; the
|
||
// reference defaults it to 1 (`0x4f46d9`).
|
||
assert_eq!(
|
||
messages::petition_sign(0x0000_0000_0001_0203, 1),
|
||
hx("030201000000000001"),
|
||
"CMSG_PETITION_SIGN body, the client's default byte"
|
||
);
|
||
assert_eq!(
|
||
messages::petition_sign(0x0000_0000_0001_0203, -1),
|
||
hx("0302010000000000ff"),
|
||
"…and a negative argument rides as its two's complement"
|
||
);
|
||
|
||
// CMSG_OFFER_PETITION (Petition.cpp:41-45): u64 item, u64 target player.
|
||
assert_eq!(
|
||
messages::offer_petition(0x0000_0000_0001_0203, 0x0000_0000_0000_00aa),
|
||
hx("0302010000000000aa00000000000000"),
|
||
"CMSG_OFFER_PETITION body"
|
||
);
|
||
|
||
// CMSG_PETITION_QUERY (Petition.cpp:13-17): u32 petitionId, u64 itemGuid.
|
||
assert_eq!(
|
||
messages::petition_query(7, 0x0000_0000_0001_0203),
|
||
hx("070000000302010000000000"),
|
||
"CMSG_PETITION_QUERY body"
|
||
);
|
||
|
||
// MSG_PETITION_RENAME outbound (Petition.cpp:29-33): u64 item, cstring newName.
|
||
assert_eq!(
|
||
messages::petition_rename(0x0000_0000_0001_0203, "Legacy"),
|
||
hx("03020100000000004c656761637900"),
|
||
"MSG_PETITION_RENAME body"
|
||
);
|
||
}
|
||
|
||
/// `CMSG_PETITION_BUY` is 72 fixed bytes plus the name, in vmangos's read order
|
||
/// (`Server/Packets/Petition.cpp:47-67`); a short body gets no reply at all, since the server has
|
||
/// no error packet for a malformed buy.
|
||
#[test]
|
||
fn petition_buy_body_is_the_full_seventy_two_byte_frame() {
|
||
let body = messages::petition_buy(0x0000_0000_0000_2a1f, "Legacy");
|
||
assert_eq!(
|
||
body,
|
||
hx(concat!(
|
||
"1f2a000000000000", // u64 npcGuid
|
||
"00000000", // u32 skipped
|
||
"0000000000000000", // u64 skipped
|
||
"4c656761637900", // cstring "Legacy"
|
||
// 10 × u32 skipped
|
||
"00000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||
"0000", // u16 skipped
|
||
"00", // u8 skipped
|
||
"00000000", // u32 index, unused by the server
|
||
"00000000", // u32 skipped
|
||
)),
|
||
"CMSG_PETITION_BUY body"
|
||
);
|
||
assert_eq!(body.len(), 72 + "Legacy".len(), "72 fixed bytes + the name");
|
||
|
||
// The name, the only variable part, starts at offset 20 (8 + 4 + 8).
|
||
assert_eq!(
|
||
&body[20..27],
|
||
b"Legacy\0",
|
||
"the name starts at offset 20, NUL-terminated"
|
||
);
|
||
}
|
||
|
||
/// `SMSG_PETITION_SHOWLIST` opens the guild registrar (`Server/Packets/Petition.cpp:115-127`).
|
||
/// The list is counted: vmangos sends one row, the fixture two, so a one-row reader fails.
|
||
#[test]
|
||
fn petition_show_list_parses_a_counted_list() {
|
||
let body = hx(concat!(
|
||
"1f2a000000000000", // u64 npcGuid
|
||
"02", // u8 count, not vmangos's 1
|
||
// row 1: index 1, entry 5863, display 16161, cost 1000, flags 1
|
||
"01000000",
|
||
"e7160000",
|
||
"213f0000",
|
||
"e8030000",
|
||
"01000000",
|
||
// row 2: index 2, entry 5863, display 16161, cost 2000, flags 0 (the "hidden" case)
|
||
"02000000",
|
||
"e7160000",
|
||
"213f0000",
|
||
"d0070000",
|
||
"00000000",
|
||
));
|
||
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOWLIST, &body).unwrap();
|
||
let ServerPacket::PetitionShowList(list) = &packet else {
|
||
panic!("expected PetitionShowList, got {}", packet.name());
|
||
};
|
||
assert_eq!(list.npc, 0x2a1f);
|
||
assert_eq!(
|
||
list.entries.len(),
|
||
2,
|
||
"both rows parsed, not just the first"
|
||
);
|
||
assert_eq!(list.entries[0].index, 1);
|
||
assert_eq!(list.entries[0].charter_entry, CHARTER_ITEM_ENTRY);
|
||
assert_eq!(list.entries[0].charter_display_id, CHARTER_DISPLAY_ID);
|
||
assert_eq!(list.entries[0].charter_cost, 1000, "10 silver, in copper");
|
||
assert_eq!(list.entries[0].entry_flags, 1);
|
||
assert_eq!(list.entries[1].charter_cost, 2000);
|
||
assert_eq!(
|
||
list.entries[1].entry_flags, 0,
|
||
"the flag is kept raw, not folded into a bool"
|
||
);
|
||
|
||
let events = decode(packet);
|
||
assert!(
|
||
matches!(events.as_slice(), [SessionEvent::PetitionShowList(l)] if l.npc == 0x2a1f),
|
||
"one PetitionShowList event, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// `SMSG_PETITION_SHOW_SIGNATURES`: `u64` item, `u64` owner, `u32` petitionId, `u8` count, then
|
||
/// 12 bytes per signature, the signer's guid and a zero dword (vmangos
|
||
/// `Handlers/PetitionsHandler.cpp:160-168`); nine signatures make a wrong stride fail plainly.
|
||
#[test]
|
||
fn petition_show_signatures_stride_is_twelve_bytes() {
|
||
let mut body = hx(concat!(
|
||
"0302010000000000", // u64 itemGuid
|
||
"aa00000000000000", // u64 ownerGuid
|
||
"07000000", // u32 petitionId
|
||
"09", // u8 signatureCount
|
||
));
|
||
for signer in 1..=MAX_PETITION_SIGNATURES as u64 {
|
||
body.extend_from_slice(&signer.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes());
|
||
}
|
||
|
||
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOW_SIGNATURES, &body).unwrap();
|
||
let ServerPacket::PetitionShowSignatures(sigs) = &packet else {
|
||
panic!("expected PetitionShowSignatures, got {}", packet.name());
|
||
};
|
||
assert_eq!(sigs.item, 0x0001_0203);
|
||
assert_eq!(sigs.owner, 0xaa);
|
||
assert_eq!(sigs.petition_id, 7);
|
||
assert_eq!(sigs.signatures.len(), MAX_PETITION_SIGNATURES);
|
||
assert_eq!(
|
||
sigs.signatures[MAX_PETITION_SIGNATURES - 1].signer,
|
||
MAX_PETITION_SIGNATURES as u64,
|
||
"the ninth signer, at stride 12"
|
||
);
|
||
assert!(sigs.signatures.iter().all(|s| s.unknown == 0));
|
||
|
||
let events = decode(packet);
|
||
assert!(
|
||
matches!(events.as_slice(), [SessionEvent::PetitionShowSignatures(s)] if s.petition_id == 7),
|
||
"one PetitionShowSignatures event, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// `SMSG_PETITION_QUERY_RESPONSE`: sixteen fields, of which `allowedGender` alone is a `u16`; the
|
||
/// fields after it get distinct values so a 32-bit read cannot land on a lucky zero.
|
||
#[test]
|
||
fn petition_query_response_gender_is_sixteen_bit() {
|
||
let body = hx(concat!(
|
||
"07000000", // u32 petitionId
|
||
"aa00000000000000", // u64 ownerGuid
|
||
"4c656761637900", // cstring name "Legacy"
|
||
"00", // cstring bodyText ""
|
||
"01000000", // u32 flags
|
||
"09000000", // u32 minSignatures
|
||
"09000000", // u32 maxSignatures
|
||
"11111111", // u32 deadline
|
||
"22222222", // u32 creation
|
||
"33333333", // u32 allowedGuildID
|
||
"44444444", // u32 allowedClasses
|
||
"55555555", // u32 allowedRaces
|
||
"6666", // u16 allowedGender <-- sixteen bits
|
||
"77777777", // u32 allowedMinLevel
|
||
"88888888", // u32 allowedMaxLevel
|
||
"01000000", // u32 choiceCount
|
||
"59657300", // cstring "Yes"
|
||
"99999999", // u32 defaultChoice
|
||
));
|
||
let packet = messages::parse_server(opcode::SMSG_PETITION_QUERY_RESPONSE, &body).unwrap();
|
||
let ServerPacket::PetitionQueryResponse(r) = &packet else {
|
||
panic!("expected PetitionQueryResponse, got {}", packet.name());
|
||
};
|
||
assert_eq!(r.petition_id, 7);
|
||
assert_eq!(r.owner, 0xaa);
|
||
assert_eq!(r.name, "Legacy");
|
||
assert_eq!(r.body_text, "", "vmangos always sends this empty");
|
||
assert_eq!(r.min_signatures, 9);
|
||
assert_eq!(r.max_signatures, 9);
|
||
assert_eq!(r.allowed_gender, 0x6666, "sixteen bits, not thirty-two");
|
||
assert_eq!(r.allowed_min_level, 0x7777_7777);
|
||
assert_eq!(r.allowed_max_level, 0x8888_8888);
|
||
assert_eq!(r.choices, vec!["Yes".to_string()], "the counted tail");
|
||
assert_eq!(r.default_choice, 0x9999_9999);
|
||
|
||
let events = decode(packet);
|
||
assert!(
|
||
matches!(events.as_slice(), [SessionEvent::PetitionQueryResponse(q)] if q.name == "Legacy"),
|
||
"one PetitionQueryResponse event, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// `SMSG_PETITION_SIGN_RESULTS` (`Petition.cpp:69-74`) carries two guids and a code,
|
||
/// `SMSG_TURN_IN_PETITION_RESULTS` (`:76-79`) only the code, from the same `PetitionSigns` enum.
|
||
#[test]
|
||
fn the_two_results_packets_share_an_enum_but_not_a_layout() {
|
||
let body = hx(concat!(
|
||
"0302010000000000", // u64 itemGuid
|
||
"bb00000000000000", // u64 playerGuid: the signer, in both copies of this packet
|
||
"03000000", // u32 result = CANT_SIGN_OWN
|
||
));
|
||
let packet = messages::parse_server(opcode::SMSG_PETITION_SIGN_RESULTS, &body).unwrap();
|
||
let ServerPacket::PetitionSignResults(r) = &packet else {
|
||
panic!("expected PetitionSignResults, got {}", packet.name());
|
||
};
|
||
assert_eq!(r.item, 0x0001_0203);
|
||
assert_eq!(r.player, 0xbb);
|
||
assert_eq!(r.result, petition_result::CANT_SIGN_OWN);
|
||
|
||
let packet = messages::parse_server(
|
||
opcode::SMSG_TURN_IN_PETITION_RESULTS,
|
||
&hx("04000000"), // NEED_MORE
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
matches!(
|
||
packet,
|
||
ServerPacket::TurnInPetitionResults { result } if result == petition_result::NEED_MORE
|
||
),
|
||
"expected TurnInPetitionResults, got {}",
|
||
packet.name()
|
||
);
|
||
let events = decode(packet);
|
||
assert!(
|
||
matches!(
|
||
events.as_slice(),
|
||
[SessionEvent::TurnInPetitionResults { result: 4 }]
|
||
),
|
||
"one TurnInPetitionResults event, got {events:?}"
|
||
);
|
||
}
|
||
|
||
/// `MSG_PETITION_DECLINE` sends the charter's item guid but receives the decliner's player guid,
|
||
/// both eight bytes; `MSG_PETITION_RENAME` is `u64` item and cstring name both ways.
|
||
#[test]
|
||
fn msg_petition_opcodes_read_a_different_body_than_they_write() {
|
||
assert_eq!(
|
||
messages::petition_decline(0x0001_0203),
|
||
hx("0302010000000000"),
|
||
"outbound MSG_PETITION_DECLINE carries the item guid"
|
||
);
|
||
let packet =
|
||
messages::parse_server(opcode::MSG_PETITION_DECLINE, &hx("bb00000000000000")).unwrap();
|
||
assert!(
|
||
matches!(packet, ServerPacket::PetitionDeclined { player } if player == 0xbb),
|
||
"inbound MSG_PETITION_DECLINE carries the player guid, got {}",
|
||
packet.name()
|
||
);
|
||
|
||
// The rename echo arrives only on success.
|
||
let body = hx("03020100000000004c656761637900");
|
||
assert_eq!(
|
||
messages::petition_rename(0x0001_0203, "Legacy"),
|
||
body,
|
||
"outbound MSG_PETITION_RENAME"
|
||
);
|
||
let packet = messages::parse_server(opcode::MSG_PETITION_RENAME, &body).unwrap();
|
||
let ServerPacket::PetitionRenamed(r) = &packet else {
|
||
panic!("expected PetitionRenamed, got {}", packet.name());
|
||
};
|
||
assert_eq!(r.item, 0x0001_0203);
|
||
assert_eq!(r.name, "Legacy");
|
||
|
||
let events = decode(packet);
|
||
assert!(
|
||
matches!(events.as_slice(), [SessionEvent::PetitionRenamed(p)] if p.name == "Legacy"),
|
||
"one PetitionRenamed event, got {events:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_freshly_bought_charter_has_no_signatures() {
|
||
let body = hx(concat!(
|
||
"0302010000000000", // item
|
||
"aa00000000000000", // owner
|
||
"07000000", // petitionId
|
||
"00", // no signatures
|
||
));
|
||
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOW_SIGNATURES, &body).unwrap();
|
||
let ServerPacket::PetitionShowSignatures(sigs) = &packet else {
|
||
panic!("expected PetitionShowSignatures, got {}", packet.name());
|
||
};
|
||
assert!(sigs.signatures.is_empty());
|
||
assert_eq!(sigs.owner, 0xaa, "the owner is still named");
|
||
}
|