This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
755 lines
28 KiB
Rust
755 lines
28 KiB
Rust
//! The client-built bodies (auth, char create, chat, emotes, channels, movement, ping) and the
|
||
//! name and creature query replies.
|
||
|
||
mod common;
|
||
|
||
use benilla_protocol::events::{decode, SessionEvent};
|
||
use benilla_protocol::messages::{self, MovementInfo};
|
||
use benilla_protocol::wire::Vector3d;
|
||
use benilla_protocol::ServerPacket;
|
||
use common::hx;
|
||
|
||
/// Alliance races learn Common (spell 668), Horde races Orcish (669) in `playercreateinfo_spell`;
|
||
/// `LANG_COMMON = 7`, `LANG_ORCISH = 1` (vmangos `SharedDefines.h:256-261`).
|
||
#[test]
|
||
fn faction_language_per_race() {
|
||
for race in [1u8, 3, 4, 7] {
|
||
assert_eq!(
|
||
messages::faction_language(race),
|
||
messages::LANGUAGE_COMMON,
|
||
"alliance race {race}"
|
||
);
|
||
}
|
||
for race in [2u8, 5, 6, 8] {
|
||
assert_eq!(
|
||
messages::faction_language(race),
|
||
messages::LANGUAGE_ORCISH,
|
||
"horde race {race}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn client_bodies_golden() {
|
||
let proof: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(3).wrapping_add(7));
|
||
// The tail is the addon block: `u32` 342 uncompressed, then the zlib stream of the stock
|
||
// twelve, the same 130 bytes a retail 1.12.1 client sends.
|
||
assert_eq!(
|
||
messages::auth_session(
|
||
5875,
|
||
"TESTUSER",
|
||
0x1122_3344,
|
||
&proof,
|
||
&messages::STOCK_SECURE_ADDONS
|
||
),
|
||
hx(concat!(
|
||
"f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d40",
|
||
"56010000",
|
||
"789c75ccbd0ec2300c04e0f21ebc0c614095c842c38c4ce2220bc7a98ccb4f9f1e16240673eb777781",
|
||
"695940cb693367a326c7be5bd5c77adf7d12be16c08c7124e41249a8c2e495480ac9c53dd8b67a064b",
|
||
"f8340f15467367bb38cc7ac7978bbddc26ccfe3042d6e6ca01a8b8908051fcb7a45070b812f33f2641",
|
||
"fdb5379019668f",
|
||
)),
|
||
"CMSG_AUTH_SESSION body"
|
||
);
|
||
// No secure addons: the tail is absent, never a zero size, which no real client sends.
|
||
assert_eq!(
|
||
messages::auth_session(5875, "TESTUSER", 0x1122_3344, &proof, &[]),
|
||
hx("f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d40"),
|
||
"CMSG_AUTH_SESSION body with no secure addons"
|
||
);
|
||
// CMSG_CHAR_CREATE in vmangos's read order (`Packets/Character.cpp:4-19`): name, race, class,
|
||
// gender, skin, face, hairStyle, hairColor, facialHair, outfit.
|
||
assert_eq!(
|
||
messages::char_create(&messages::CharCreateReq {
|
||
name: "Benilla".into(),
|
||
race: 1,
|
||
class: 1,
|
||
gender: 0,
|
||
skin: 0,
|
||
face: 0,
|
||
hair_style: 0,
|
||
hair_color: 0,
|
||
facial_hair: 0,
|
||
challenge_mask: None,
|
||
}),
|
||
hx("42656e696c6c6100010100000000000000"),
|
||
"CMSG_CHAR_CREATE body (zero appearance)"
|
||
);
|
||
assert_eq!(
|
||
messages::char_create(&messages::CharCreateReq {
|
||
name: "Benilla".into(),
|
||
race: 1,
|
||
class: 1,
|
||
gender: 0,
|
||
skin: 3,
|
||
face: 4,
|
||
hair_style: 5,
|
||
hair_color: 6,
|
||
facial_hair: 7,
|
||
challenge_mask: None,
|
||
}),
|
||
hx("42656e696c6c6100010100030405060700"),
|
||
"CMSG_CHAR_CREATE body (distinct appearance)"
|
||
);
|
||
// OctoWoW's challenge modes ride one trailing little-endian u32 after `outfit`, which its own
|
||
// patched `WoW.exe` appends from the detour at `0x5aad29` -> `0xd06390` (re-issue the
|
||
// `PutInt8(outfitId)`, then `CDataStore::PutInt32` of the mask global at `0xd062c0`). A realm
|
||
// without a challenge list gets the stock body above, byte for byte.
|
||
assert_eq!(
|
||
messages::char_create(&messages::CharCreateReq {
|
||
name: "Benilla".into(),
|
||
race: 1,
|
||
class: 1,
|
||
gender: 0,
|
||
skin: 0,
|
||
face: 0,
|
||
hair_style: 0,
|
||
hair_color: 0,
|
||
facial_hair: 0,
|
||
// Slow & Steady (2) | War Mode (4) | True Hardcore (4096) = 0x1006.
|
||
challenge_mask: Some(0x1006),
|
||
}),
|
||
hx("42656e696c6c610001010000000000000006100000"),
|
||
"CMSG_CHAR_CREATE body with the challenge tail"
|
||
);
|
||
assert_eq!(
|
||
messages::char_create(&messages::CharCreateReq {
|
||
name: "Benilla".into(),
|
||
race: 1,
|
||
class: 1,
|
||
gender: 0,
|
||
skin: 0,
|
||
face: 0,
|
||
hair_style: 0,
|
||
hair_color: 0,
|
||
facial_hair: 0,
|
||
challenge_mask: Some(0),
|
||
}),
|
||
hx("42656e696c6c610001010000000000000000000000"),
|
||
"a chosen-nothing mask is still four bytes, as the patched client always appends them"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(0, 7, ".tele Westfall"),
|
||
hx("00000000070000002e74656c65205765737466616c6c00"),
|
||
"CMSG_MESSAGECHAT body"
|
||
);
|
||
// CMSG_TEXT_EMOTE (vmangos `Misc.cpp:60-65`): textEmote, emoteNum (0) and the full target
|
||
// guid; the server silently drops a body without the guid.
|
||
assert_eq!(
|
||
messages::text_emote(101, 0x2A),
|
||
hx("65000000000000002a00000000000000"),
|
||
"CMSG_TEXT_EMOTE body"
|
||
);
|
||
assert_eq!(
|
||
messages::stand_state_change(1),
|
||
hx("01000000"),
|
||
"CMSG_STANDSTATECHANGE body: one u32 animState (Misc.cpp:35-38)"
|
||
);
|
||
// The CMSG_MESSAGECHAT type values (vmangos `SharedDefines.h:1194..1202`).
|
||
assert_eq!(messages::CHAT_TYPE_SAY, 0x0, "ChatMsg::CHAT_MSG_SAY");
|
||
assert_eq!(messages::CHAT_TYPE_YELL, 0x5, "ChatMsg::CHAT_MSG_YELL");
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_WHISPER,
|
||
0x6,
|
||
"ChatMsg::CHAT_MSG_WHISPER"
|
||
);
|
||
assert_eq!(messages::CHAT_TYPE_EMOTE, 0x8, "ChatMsg::CHAT_MSG_EMOTE");
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_YELL, 7, "for the horde"),
|
||
hx("0500000007000000666f722074686520686f72646500"),
|
||
"CMSG_MESSAGECHAT (yell) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_EMOTE, 7, "dances"),
|
||
hx("080000000700000064616e63657300"),
|
||
"CMSG_MESSAGECHAT (emote) body"
|
||
);
|
||
// A Horde say speaks Orcish (1): vmangos drops a send in an unknown language at its
|
||
// `KnowsLanguage` gate, dot-commands included, and answers only with SMSG_NOTIFICATION.
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_SAY,
|
||
messages::LANGUAGE_ORCISH,
|
||
"for the horde"
|
||
),
|
||
hx("0000000001000000666f722074686520686f72646500"),
|
||
"CMSG_MESSAGECHAT (say, Orcish) body"
|
||
);
|
||
// Whisper: type, language, then the target and the message cstrings, target first (vmangos
|
||
// `Server/Packets/Chat.cpp:3-12`).
|
||
assert_eq!(
|
||
messages::messagechat_whisper(7, "Bob", "hi there"),
|
||
hx("0600000007000000426f6200686920746865726500"),
|
||
"CMSG_MESSAGECHAT (whisper) body"
|
||
);
|
||
// CMSG_PLAYER_LOGIN, CMSG_SET_ACTIVE_MOVER and CMSG_SET_SELECTION all read a raw `u64` guid,
|
||
// not a packed one.
|
||
assert_eq!(
|
||
messages::full_guid(0x1234_5678_9abc_def0),
|
||
hx("f0debc9a78563412"),
|
||
"full guid body"
|
||
);
|
||
// CMSG_SET_SELECTION is opcode 317 (vmangos `Opcodes_1_12_1.h`).
|
||
assert_eq!(
|
||
messages::opcode::CMSG_SET_SELECTION,
|
||
0x013D,
|
||
"CMSG_SET_SELECTION opcode"
|
||
);
|
||
// CMSG_INSPECT is opcode 276 (vmangos `Opcodes_1_12_1.h`), its body a raw guid, not packed.
|
||
assert_eq!(
|
||
messages::opcode::CMSG_INSPECT,
|
||
0x0114,
|
||
"CMSG_INSPECT opcode"
|
||
);
|
||
assert_eq!(
|
||
messages::full_guid(0x1234_5678_9abc_def0),
|
||
hx("f0debc9a78563412"),
|
||
"CMSG_INSPECT body (a raw guid, same shape as full_guid)"
|
||
);
|
||
let mi = MovementInfo {
|
||
flags: 0x1,
|
||
timestamp: 0x0102_0304,
|
||
position: Vector3d {
|
||
x: -8949.95,
|
||
y: -132.493,
|
||
z: 83.5312,
|
||
},
|
||
orientation: 1.25,
|
||
transport: None,
|
||
pitch: 0.0,
|
||
fall_time: 0,
|
||
jump: None,
|
||
};
|
||
assert_eq!(
|
||
messages::movement(&mi),
|
||
hx("0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"),
|
||
"MSG_MOVE_* body"
|
||
);
|
||
assert_eq!(
|
||
messages::teleport_ack(0x1234_5678_9abc_def0, 7, 0x0102_0304),
|
||
hx("f0debc9a785634120700000004030201"),
|
||
"MSG_MOVE_TELEPORT_ACK body"
|
||
);
|
||
}
|
||
|
||
/// The other sendable `CMSG_MESSAGECHAT` types (vmangos `Handlers/ChatHandler.cpp:253-655`) use
|
||
/// the plain shape, except CHANNEL, which carries its channel name where WHISPER carries its
|
||
/// target (`Server/Packets/Chat.cpp:3-12`).
|
||
#[test]
|
||
fn messagechat_sendable_types_golden() {
|
||
assert_eq!(messages::CHAT_TYPE_PARTY, 0x1, "ChatMsg::CHAT_MSG_PARTY");
|
||
assert_eq!(messages::CHAT_TYPE_RAID, 0x2, "ChatMsg::CHAT_MSG_RAID");
|
||
assert_eq!(messages::CHAT_TYPE_GUILD, 0x3, "ChatMsg::CHAT_MSG_GUILD");
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_OFFICER,
|
||
0x4,
|
||
"ChatMsg::CHAT_MSG_OFFICER"
|
||
);
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_CHANNEL,
|
||
0xE,
|
||
"ChatMsg::CHAT_MSG_CHANNEL"
|
||
);
|
||
assert_eq!(messages::CHAT_TYPE_AFK, 0x14, "ChatMsg::CHAT_MSG_AFK");
|
||
assert_eq!(messages::CHAT_TYPE_DND, 0x15, "ChatMsg::CHAT_MSG_DND");
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_RAID_LEADER,
|
||
0x57,
|
||
"ChatMsg::CHAT_MSG_RAID_LEADER"
|
||
);
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_RAID_WARNING,
|
||
0x58,
|
||
"ChatMsg::CHAT_MSG_RAID_WARNING"
|
||
);
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_BATTLEGROUND,
|
||
0x5C,
|
||
"ChatMsg::CHAT_MSG_BATTLEGROUND"
|
||
);
|
||
assert_eq!(
|
||
messages::CHAT_TYPE_BATTLEGROUND_LEADER,
|
||
0x5D,
|
||
"ChatMsg::CHAT_MSG_BATTLEGROUND_LEADER"
|
||
);
|
||
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_PARTY, 7, "MT is icon 8"),
|
||
hx("01000000070000004d542069732069636f6e203800"),
|
||
"CMSG_MESSAGECHAT (party) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_RAID, 7, "form up"),
|
||
hx("0200000007000000666f726d20757000"),
|
||
"CMSG_MESSAGECHAT (raid) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_GUILD, 7, "hi guild"),
|
||
hx("03000000070000006869206775696c6400"),
|
||
"CMSG_MESSAGECHAT (guild) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_OFFICER, 7, "officers only"),
|
||
hx("04000000070000006f66666963657273206f6e6c7900"),
|
||
"CMSG_MESSAGECHAT (officer) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_RAID_LEADER, 7, "pull in 5"),
|
||
hx("570000000700000070756c6c20696e203500"),
|
||
"CMSG_MESSAGECHAT (raid leader) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_RAID_WARNING, 7, "BLOODLUST NOW"),
|
||
hx("5800000007000000424c4f4f444c555354204e4f5700"),
|
||
"CMSG_MESSAGECHAT (raid warning) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_BATTLEGROUND, 7, "defend flag"),
|
||
hx("5c00000007000000646566656e6420666c616700"),
|
||
"CMSG_MESSAGECHAT (battleground) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_BATTLEGROUND_LEADER, 7, "push mid"),
|
||
hx("5d0000000700000070757368206d696400"),
|
||
"CMSG_MESSAGECHAT (battleground leader) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_AFK, 7, "be back soon"),
|
||
hx("14000000070000006265206261636b20736f6f6e00"),
|
||
"CMSG_MESSAGECHAT (afk) body"
|
||
);
|
||
// A bare `/afk` (no message) still needs the NUL-terminated empty string, not a truncated body.
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_AFK, 7, ""),
|
||
hx("140000000700000000"),
|
||
"CMSG_MESSAGECHAT (afk, empty message) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(messages::CHAT_TYPE_DND, 7, "do not disturb"),
|
||
hx("1500000007000000646f206e6f74206469737475726200"),
|
||
"CMSG_MESSAGECHAT (dnd) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat_channel(0, "General", "wtb boar livers"),
|
||
hx("0e0000000000000047656e6572616c0077746220626f6172206c697665727300"),
|
||
"CMSG_MESSAGECHAT (channel) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat_kind(messages::CHAT_TYPE_SAY, 7, None, "hi"),
|
||
messages::messagechat(messages::CHAT_TYPE_SAY, 7, "hi"),
|
||
"messagechat_kind(None) == messagechat"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat_kind(messages::CHAT_TYPE_WHISPER, 7, Some("Bob"), "hi"),
|
||
messages::messagechat_whisper(7, "Bob", "hi"),
|
||
"messagechat_kind(Some) == messagechat_whisper"
|
||
);
|
||
}
|
||
|
||
/// `SendAddonMessage` (reference `0x49f920`) sends `CMSG_MESSAGECHAT` on one of four lanes: `u32`
|
||
/// type, `u32` language `LANG_ADDON`, then one cstring of prefix, TAB and message (`0x49f9b3`);
|
||
/// there is no prefix or target field. The receiver splits on the first TAB (`0x49a8d0`); vmangos
|
||
/// relays the TAB intact, as it skips `SanitizeChatMessage` for addon chat (`ChatHandler.cpp:49`).
|
||
#[test]
|
||
fn addon_message_bodies_golden() {
|
||
assert_eq!(
|
||
messages::LANGUAGE_ADDON,
|
||
0xFFFF_FFFF,
|
||
"LANG_ADDON (vmangos SharedDefines.h:270)"
|
||
);
|
||
|
||
// oRA2's `SendAddonMessage("CTRA", msg, "RAID")` (`Core.lua:563`).
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_RAID,
|
||
messages::LANGUAGE_ADDON,
|
||
"CTRA\tstatus"
|
||
),
|
||
hx("02000000ffffffff435452410973746174757300"),
|
||
"CMSG_MESSAGECHAT (addon, RAID) body"
|
||
);
|
||
|
||
// The other lanes of the reference's four-lane whitelist (`0x49fa3f`-`0x49fa4e`).
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_PARTY,
|
||
messages::LANGUAGE_ADDON,
|
||
"oRA\thello"
|
||
),
|
||
hx("01000000ffffffff6f52410968656c6c6f00"),
|
||
"CMSG_MESSAGECHAT (addon, PARTY) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_GUILD,
|
||
messages::LANGUAGE_ADDON,
|
||
"oRA\thello"
|
||
),
|
||
hx("03000000ffffffff6f52410968656c6c6f00"),
|
||
"CMSG_MESSAGECHAT (addon, GUILD) body"
|
||
);
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_BATTLEGROUND,
|
||
messages::LANGUAGE_ADDON,
|
||
"oRA\thello"
|
||
),
|
||
hx("5c000000ffffffff6f52410968656c6c6f00"),
|
||
"CMSG_MESSAGECHAT (addon, BATTLEGROUND) body"
|
||
);
|
||
|
||
// An empty message still carries its TAB: the reference composes `"%s\t%s"` unconditionally,
|
||
// so AceEvent-2.0's `SendAddonMessage("LOOT_OPENED", "", "RAID")` ends TAB, NUL.
|
||
assert_eq!(
|
||
messages::messagechat(
|
||
messages::CHAT_TYPE_RAID,
|
||
messages::LANGUAGE_ADDON,
|
||
"LOOT_OPENED\t"
|
||
),
|
||
hx("02000000ffffffff4c4f4f545f4f50454e45440900"),
|
||
"CMSG_MESSAGECHAT (addon, empty message keeps its TAB) body"
|
||
);
|
||
|
||
let addon = messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_ADDON, "x\ty");
|
||
let speech =
|
||
messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_COMMON, "x\ty");
|
||
assert_eq!(addon.len(), speech.len(), "same shape");
|
||
assert_eq!(addon[0..4], speech[0..4], "same chat type");
|
||
assert_eq!(addon[8..], speech[8..], "same payload");
|
||
assert_eq!(&addon[4..8], &[0xFF, 0xFF, 0xFF, 0xFF], "LANG_ADDON");
|
||
assert_eq!(&speech[4..8], &[0x07, 0x00, 0x00, 0x00], "LANG_COMMON");
|
||
}
|
||
|
||
/// The channel CMSG bodies: a channel-name cstring, then optionally a password or target-name
|
||
/// cstring (vmangos `Server/Packets/Channel.cpp`).
|
||
#[test]
|
||
fn channel_client_bodies_golden() {
|
||
assert_eq!(
|
||
messages::join_channel("General", ""),
|
||
hx("47656e6572616c0000"),
|
||
"CMSG_JOIN_CHANNEL body (no password)"
|
||
);
|
||
assert_eq!(
|
||
messages::join_channel("Secret", "hunter2"),
|
||
hx("5365637265740068756e7465723200"),
|
||
"CMSG_JOIN_CHANNEL body (with password)"
|
||
);
|
||
assert_eq!(
|
||
messages::leave_channel("General"),
|
||
hx("47656e6572616c00"),
|
||
"CMSG_LEAVE_CHANNEL body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_list("Trade"),
|
||
hx("547261646500"),
|
||
"CMSG_CHANNEL_LIST body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_password("General", "hunter2"),
|
||
hx("47656e6572616c0068756e7465723200"),
|
||
"CMSG_CHANNEL_PASSWORD body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_set_owner("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_SET_OWNER body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_owner("General"),
|
||
hx("47656e6572616c00"),
|
||
"CMSG_CHANNEL_OWNER body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_moderator("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_MODERATOR body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_unmoderator("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_UNMODERATOR body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_mute("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_MUTE body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_unmute("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_UNMUTE body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_invite("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_INVITE body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_kick("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_KICK body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_ban("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_BAN body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_unban("General", "Bob"),
|
||
hx("47656e6572616c00426f6200"),
|
||
"CMSG_CHANNEL_UNBAN body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_announcements("General"),
|
||
hx("47656e6572616c00"),
|
||
"CMSG_CHANNEL_ANNOUNCEMENTS body"
|
||
);
|
||
assert_eq!(
|
||
messages::channel_moderate("General"),
|
||
hx("47656e6572616c00"),
|
||
"CMSG_CHANNEL_MODERATE body"
|
||
);
|
||
|
||
// The family's opcodes, 151 to 168 (vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`).
|
||
assert_eq!(messages::opcode::CMSG_JOIN_CHANNEL, 0x0097);
|
||
assert_eq!(messages::opcode::CMSG_LEAVE_CHANNEL, 0x0098);
|
||
assert_eq!(messages::opcode::SMSG_CHANNEL_NOTIFY, 0x0099);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_LIST, 0x009A);
|
||
assert_eq!(messages::opcode::SMSG_CHANNEL_LIST, 0x009B);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_PASSWORD, 0x009C);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_SET_OWNER, 0x009D);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_OWNER, 0x009E);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_MODERATOR, 0x009F);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_UNMODERATOR, 0x00A0);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_MUTE, 0x00A1);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_UNMUTE, 0x00A2);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_INVITE, 0x00A3);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_KICK, 0x00A4);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_BAN, 0x00A5);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_UNBAN, 0x00A6);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_ANNOUNCEMENTS, 0x00A7);
|
||
assert_eq!(messages::opcode::CMSG_CHANNEL_MODERATE, 0x00A8);
|
||
}
|
||
|
||
/// `CMSG_CHAT_IGNORED` is a raw guid (vmangos `Server/Packets/Misc.cpp:127-130`),
|
||
/// `CMSG_PLAYED_TIME` is empty, and the `MSG_RANDOM_ROLL` request is `u32` min and max
|
||
/// (`Server/Packets/Group.cpp:39-43`).
|
||
#[test]
|
||
fn chat_ignored_played_time_random_roll_golden() {
|
||
assert_eq!(
|
||
messages::full_guid(0x1234_5678_9abc_def0),
|
||
hx("f0debc9a78563412"),
|
||
"CMSG_CHAT_IGNORED body (a raw guid, same shape as full_guid)"
|
||
);
|
||
assert_eq!(messages::opcode::CMSG_CHAT_IGNORED, 0x0225);
|
||
|
||
assert_eq!(
|
||
messages::played_time(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_PLAYED_TIME body: empty"
|
||
);
|
||
assert_eq!(messages::opcode::CMSG_PLAYED_TIME, 0x01CC);
|
||
assert_eq!(messages::opcode::SMSG_PLAYED_TIME, 0x01CD);
|
||
|
||
assert_eq!(
|
||
messages::random_roll(1, 100),
|
||
hx("0100000064000000"),
|
||
"MSG_RANDOM_ROLL (request) body"
|
||
);
|
||
assert_eq!(messages::opcode::MSG_RANDOM_ROLL, 0x01FB);
|
||
}
|
||
|
||
#[test]
|
||
fn name_and_creature_query_roundtrip() {
|
||
// CMSG_CREATURE_QUERY: `u32` entry and the full guid (vmangos `QueryCreature`).
|
||
assert_eq!(
|
||
messages::creature_query(69, 0x1234_5678_9abc_def0),
|
||
hx("45000000f0debc9a78563412"),
|
||
"CMSG_CREATURE_QUERY body"
|
||
);
|
||
|
||
// CMSG_NAME_QUERY: the full guid (vmangos `QueryPlayerName`), built by `full_guid`.
|
||
assert_eq!(
|
||
messages::full_guid(7),
|
||
hx("0700000000000000"),
|
||
"CMSG_NAME_QUERY body"
|
||
);
|
||
|
||
// SMSG_NAME_QUERY_RESPONSE (vmangos `NameQueryResponse::AppendBodyTo`): guid, name, an empty
|
||
// realm cstring, then race, gender and class as `u32`.
|
||
let body = hx("070000000000000042656e696c6c610000010000000000000001000000");
|
||
match messages::parse_server(messages::opcode::SMSG_NAME_QUERY_RESPONSE, &body).unwrap() {
|
||
ServerPacket::NameQueryResponse {
|
||
guid,
|
||
name,
|
||
race,
|
||
gender,
|
||
class,
|
||
} => {
|
||
assert_eq!(guid, 7);
|
||
assert_eq!(name, "Benilla");
|
||
assert_eq!((race, gender, class), (1, 0, 1));
|
||
}
|
||
_ => panic!("name query response"),
|
||
}
|
||
|
||
// A hit. Family, rank and display id get distinct non-zero values so a one-dword slip between
|
||
// adjacent fields cannot read as plausible data.
|
||
let body = hx(concat!(
|
||
"45000000",
|
||
"596f756e6720576f6c6600", // "Young Wolf"
|
||
"000000", // name2..4 empty
|
||
"5465737400", // subname "Test"
|
||
"10000000", // type_flags = 0x10 (hide-faction-tooltip)
|
||
"01000000", // type = 1 (Beast)
|
||
"01000000", // pet_family = 1 (Wolf)
|
||
"02000000", // rank = 2 (rare elite)
|
||
"0000000000000000", // unk, pet_spell_list_id
|
||
"15020000", // display_id = 533, the model a stabled pet is drawn from
|
||
"0101" // civilian, racial_leader
|
||
));
|
||
match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() {
|
||
ServerPacket::CreatureQueryResponse { entry, info } => {
|
||
assert_eq!(entry, 69);
|
||
assert_eq!(
|
||
info,
|
||
Some(benilla_protocol::messages::CreatureQueryInfo {
|
||
name: "Young Wolf".into(),
|
||
subname: "Test".into(),
|
||
creature_type: 1,
|
||
pet_family: 1,
|
||
rank: 2,
|
||
type_flags: 0x10,
|
||
civilian: true,
|
||
racial_leader: true,
|
||
display_id: 533,
|
||
})
|
||
);
|
||
}
|
||
_ => panic!("creature query response"),
|
||
}
|
||
|
||
// Miss: the lone entry echoed with the top bit set (vmangos "NO CREATURE INFO" branch).
|
||
let body = hx("d2040080");
|
||
match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() {
|
||
ServerPacket::CreatureQueryResponse { entry, info } => {
|
||
assert_eq!(entry, 1234);
|
||
assert_eq!(info, None);
|
||
}
|
||
_ => panic!("creature query miss"),
|
||
}
|
||
}
|
||
|
||
/// An empty wire subname is no subname: vmangos sends "" for a creature without one, and the
|
||
/// reference renders no line for it (`0x608f50`).
|
||
#[test]
|
||
fn creature_query_empty_subname_decodes_to_none() {
|
||
let body = |subname_hex: &str| {
|
||
hx(&format!(
|
||
"45000000{}000000{}{}",
|
||
"4465707574792057696c6c656d00", // "Deputy Willem" (+3 empty names after)
|
||
subname_hex,
|
||
// type_flags, type=7 Humanoid, family..display_id (5 × u32), civilian, racial_leader
|
||
"000000000700000000000000000000000000000000000000000000000000"
|
||
))
|
||
};
|
||
let parse = |b: &[u8]| {
|
||
messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, b).unwrap()
|
||
};
|
||
match decode(parse(&body("00"))).as_slice() {
|
||
[SessionEvent::CreatureName {
|
||
entry,
|
||
name,
|
||
subname,
|
||
..
|
||
}] => {
|
||
assert_eq!(*entry, 69);
|
||
assert_eq!(name.as_deref(), Some("Deputy Willem"));
|
||
assert_eq!(*subname, None, "an empty wire subname is NO subname");
|
||
}
|
||
other => panic!("expected one CreatureName event, got {other:?}"),
|
||
}
|
||
match decode(parse(&body("5465737400"))).as_slice() {
|
||
[SessionEvent::CreatureName { subname, .. }] => {
|
||
assert_eq!(subname.as_deref(), Some("Test"));
|
||
}
|
||
other => panic!("expected one CreatureName event, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `CMSG_PING` is `u32` sequence then `u32` last RTT (reference `0x537e10`, vmangos
|
||
/// `_HandlePing`); `SMSG_PONG` echoes the sequence.
|
||
#[test]
|
||
fn ping_body_golden_and_pong_roundtrip() {
|
||
assert_eq!(
|
||
messages::ping(1, 0),
|
||
hx("0100000000000000"),
|
||
"first ping of a connection: seq 1, no RTT yet"
|
||
);
|
||
assert_eq!(
|
||
messages::ping(0x0A0B_0C0D, 23),
|
||
hx("0d0c0b0a17000000"),
|
||
"CMSG_PING body: sequence then lastRtt, both u32 LE"
|
||
);
|
||
let packet = messages::parse_server(messages::opcode::SMSG_PONG, &hx("0d0c0b0a")).unwrap();
|
||
assert!(matches!(
|
||
packet,
|
||
ServerPacket::Pong {
|
||
sequence: 0x0A0B_0C0D
|
||
}
|
||
));
|
||
match decode(packet).as_slice() {
|
||
[SessionEvent::Pong {
|
||
sequence: 0x0A0B_0C0D,
|
||
}] => {}
|
||
other => panic!("expected one Pong event, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `SMSG_ADDON_INFO` has no count or names: the client reads one record per addon it sent. A
|
||
/// retail capture is 12 records of `{status 2, info 1, key 0, u32 revision 0, url 0}`; status 2
|
||
/// hides the addon from the Lua index (reference `0x51db84`).
|
||
#[test]
|
||
fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||
let capture: Vec<u8> = std::iter::repeat_n(hx("0201000000000000"), 12)
|
||
.flatten()
|
||
.collect();
|
||
assert_eq!(capture.len(), 96, "the capture is 12 x 8 bytes");
|
||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &capture).unwrap();
|
||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||
panic!("expected AddonInfo");
|
||
};
|
||
assert_eq!(statuses, vec![2u8; 12]);
|
||
|
||
// Every stock addon comes back hidden, so the reference's AddOns list shows none of Blizzard's.
|
||
let hidden = messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS);
|
||
assert_eq!(hidden.len(), 12);
|
||
assert!(
|
||
hidden.iter().all(|n| n.starts_with("Blizzard_")),
|
||
"{hidden:?}"
|
||
);
|
||
|
||
// A record with a key and a url is 8 + 256 + 256 bytes; status 1 leaves the addon visible,
|
||
// only status 2 hides it.
|
||
let mut fat = vec![1u8, 1, 1];
|
||
fat.extend(std::iter::repeat_n(0xABu8, 256)); // modulus
|
||
fat.extend([0u8; 4]); // revision
|
||
fat.push(1); // urlProvided
|
||
fat.extend(std::iter::repeat_n(b'x', 256)); // url
|
||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &fat).unwrap();
|
||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||
panic!("expected AddonInfo");
|
||
};
|
||
assert_eq!(statuses, vec![1u8], "the fat record parsed whole");
|
||
assert!(messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS).is_empty());
|
||
|
||
let mut short = hx("0201000000000000").to_vec();
|
||
short.extend([2u8, 1]); // a second record that stops mid-way
|
||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &short).unwrap();
|
||
let ServerPacket::AddonInfo { statuses } = packet else {
|
||
panic!("expected AddonInfo");
|
||
};
|
||
assert_eq!(statuses, vec![2u8], "only the whole record counted");
|
||
}
|