- 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>
170 lines
6.6 KiB
Rust
170 lines
6.6 KiB
Rust
//! `SMSG_CHAR_ENUM` (the character-select roster) + the logout round-trip. The enum body is
|
||
//! hand-built byte-exact to the vmangos serializer (`Player::BuildEnumData`, `Player.cpp:1629`:
|
||
//! guid u64 · name cstring · race/class/gender/skin/face/hairStyle/hairColor/facialHair u8 ·
|
||
//! level u8 · zone u32 · map u32 · x/y/z f32 · guild u32 · flags u32 · firstLogin u8 ·
|
||
//! petDisplay/petLevel/petFamily u32 · 19×(display u32 + invType u8) equipment · first-bag
|
||
//! display u32 + invType u8); two characters in one body exercise the alignment of everything
|
||
//! the parser skips.
|
||
|
||
use benilla_protocol::events::{decode, SessionEvent};
|
||
use benilla_protocol::messages;
|
||
use benilla_protocol::ServerPacket;
|
||
|
||
/// One serialized enum entry, vmangos field order. Non-roster fields get distinct junk values so a
|
||
/// misaligned parse can't accidentally pass.
|
||
fn enum_entry(
|
||
guid: u64,
|
||
name: &str,
|
||
race: u8,
|
||
class: u8,
|
||
gender: u8,
|
||
level: u8,
|
||
map: u32,
|
||
pos: [f32; 3],
|
||
pet_display: u32,
|
||
) -> Vec<u8> {
|
||
let mut b = Vec::new();
|
||
b.extend_from_slice(&guid.to_le_bytes());
|
||
b.extend_from_slice(name.as_bytes());
|
||
b.push(0);
|
||
b.extend_from_slice(&[race, class, gender]);
|
||
b.extend_from_slice(&[3, 4, 5, 6, 7]); // skin/face/hairStyle/hairColor/facialHair
|
||
b.push(level);
|
||
b.extend_from_slice(&12u32.to_le_bytes()); // zone
|
||
b.extend_from_slice(&map.to_le_bytes());
|
||
for c in pos {
|
||
b.extend_from_slice(&c.to_le_bytes());
|
||
}
|
||
b.extend_from_slice(&0u32.to_le_bytes()); // guild id
|
||
b.extend_from_slice(&0x0200_0000u32.to_le_bytes()); // character flags (DECLINED — junk)
|
||
b.push(1); // first login
|
||
b.extend_from_slice(&pet_display.to_le_bytes()); // pet display id
|
||
b.extend_from_slice(&7u32.to_le_bytes()); // pet level
|
||
b.extend_from_slice(&1u32.to_le_bytes()); // pet family
|
||
for slot in 0..19u32 {
|
||
b.extend_from_slice(&(1000 + slot).to_le_bytes()); // equipment display id
|
||
b.push(slot as u8); // inventory type
|
||
}
|
||
b.extend_from_slice(&2000u32.to_le_bytes()); // first bag display id
|
||
b.push(18); // first bag inventory type
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn char_enum_roster_parses_and_decodes() {
|
||
let mut body = vec![2u8]; // count
|
||
body.extend(enum_entry(
|
||
0x0000_0000_0000_0007,
|
||
"Two",
|
||
1, // Human
|
||
1, // Warrior
|
||
0,
|
||
1,
|
||
0,
|
||
[-8949.95, -132.493, 83.5312],
|
||
99, // a pet — the select screen stands one beside this character
|
||
));
|
||
body.extend(enum_entry(
|
||
0x0000_0000_0000_0009,
|
||
"Twomage",
|
||
1, // Human
|
||
8, // Mage
|
||
1,
|
||
60,
|
||
1,
|
||
[1.5, -2.5, 3.5],
|
||
0, // a mage: the server sends the triple zeroed, and no pet renders
|
||
));
|
||
|
||
let p = messages::parse_server(messages::opcode::SMSG_CHAR_ENUM, &body).unwrap();
|
||
let ServerPacket::CharEnum { ref characters } = p else {
|
||
panic!("char enum")
|
||
};
|
||
assert_eq!(characters.len(), 2);
|
||
let c = &characters[0];
|
||
assert_eq!(
|
||
(c.guid, c.name.as_str(), c.race, c.class, c.gender),
|
||
(7, "Two", 1, 1, 0)
|
||
);
|
||
// The appearance bytes + zone + flags land verbatim (decision 0465 — the select screen
|
||
// renders them; they used to be alignment-skips).
|
||
assert_eq!(
|
||
(c.skin, c.face, c.hair_style, c.hair_color, c.facial_hair),
|
||
(3, 4, 5, 6, 7)
|
||
);
|
||
assert_eq!((c.zone, c.flags), (12, 0x0200_0000));
|
||
// The pet triple, which used to be three alignment-skips: it sits between the first-login byte
|
||
// and the equipment array, and reading it *as* the roster's fields is what stands a pet on the
|
||
// select screen. The equipment assertions below are the alignment half of this — a triple read
|
||
// one field wide would slide every display id.
|
||
assert_eq!(
|
||
(c.pet_display_id, c.pet_level, c.pet_family),
|
||
(99, 7, 1),
|
||
"the hunter/warlock pet the select screen renders"
|
||
);
|
||
assert_eq!((c.level, c.map), (1, 0));
|
||
assert_eq!((c.position.x, c.position.y), (-8949.95, -132.493));
|
||
// All 19 equipment pairs, in slot order: display 1000+slot, invType = slot.
|
||
for (slot, item) in c.equipment.iter().enumerate() {
|
||
assert_eq!(
|
||
(item.display_id, item.inventory_type),
|
||
(1000 + slot as u32, slot as u8)
|
||
);
|
||
}
|
||
let c = &characters[1];
|
||
assert_eq!(
|
||
(c.guid, c.name.as_str(), c.race, c.class, c.gender),
|
||
(9, "Twomage", 1, 8, 1)
|
||
);
|
||
assert_eq!((c.level, c.map, c.position.z), (60, 1, 3.5));
|
||
assert_eq!(c.equipment[18].display_id, 1018);
|
||
// No pet: `petDisplayId == 0` is the whole gate — the server zeroes the triple for every
|
||
// character but a living hunter's or warlock's, so the client needs no class test of its own.
|
||
assert_eq!(c.pet_display_id, 0);
|
||
|
||
// Decodes to one CharacterList event carrying the roster through (no realm context on the raw
|
||
// decode path — the IO thread's own emit is what carries the auth realm entry).
|
||
match &decode(p)[..] {
|
||
[SessionEvent::CharacterList { characters, realm }] => {
|
||
assert_eq!(characters.len(), 2);
|
||
assert!(realm.is_none());
|
||
}
|
||
other => panic!("expected CharacterList, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn logout_complete_decodes_to_logged_out() {
|
||
// SMSG_LOGOUT_COMPLETE: empty body.
|
||
let p = messages::parse_server(messages::opcode::SMSG_LOGOUT_COMPLETE, &[]).unwrap();
|
||
assert!(matches!(decode(p)[..], [SessionEvent::LoggedOut]));
|
||
}
|
||
|
||
/// The **refused** pick (`SMSG_CHARACTER_LOGIN_FAILED`, opcode 65): one result byte, straight
|
||
/// through to the event the glue layer answers.
|
||
///
|
||
/// It used to parse into nothing at all — the opcode had a name and no arm — so a server that
|
||
/// refused a character login said it to a client that could not hear it, and the entry the IO
|
||
/// thread had already announced simply never finished. The byte travels **raw**: it is a 1-based
|
||
/// reason index whose meaning is a table in the reference's glue layer, and the wire has no
|
||
/// business holding that table. `0x01` is vmangos's only value; `0x08` is the top of
|
||
/// mangos-classic's `CharLoginFailReasons`, past the client's six-entry switch.
|
||
#[test]
|
||
fn a_refused_character_login_parses_and_decodes() {
|
||
for result in [0x01u8, 0x08] {
|
||
let p = messages::parse_server(
|
||
messages::opcode::SMSG_CHARACTER_LOGIN_FAILED,
|
||
std::slice::from_ref(&result),
|
||
)
|
||
.unwrap();
|
||
assert!(matches!(
|
||
p,
|
||
ServerPacket::CharacterLoginFailed { result: got } if got == result
|
||
));
|
||
assert!(matches!(
|
||
decode(p)[..],
|
||
[SessionEvent::CharacterLoginFailed { result: got }] if got == result
|
||
));
|
||
}
|
||
}
|