Files
benilla-octo/crates/benilla-protocol/tests/simple_packets.rs
T
SamandClaude Fable 5.1 8f0bece3be loot and pet refusals become message ids, and waterfalls flow again
- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:39:11 +07:00

626 lines
26 KiB
Rust

//! Simple bare-scalar server packets — bodies too trivial to need golden hex from a validated
//! implementation: auth challenge/new-world/login-timespeed, the server-pushed sound trio, and
//! weather. Split out of the former `tests/messages.rs` — see `tests/common` for the shared
//! fixtures and methodology note.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::{MoveMode, ServerPacket};
use common::hx;
#[test]
fn simple_server_bodies_parse() {
// SMSG_AUTH_CHALLENGE: just a u32 seed.
match messages::parse_server(messages::opcode::SMSG_AUTH_CHALLENGE, &hx("efbeadde")).unwrap() {
ServerPacket::AuthChallenge { server_seed } => assert_eq!(server_seed, 0xDEAD_BEEF),
_ => panic!("auth challenge"),
}
// SMSG_NEW_WORLD: map u32=1, position (1,2,3), orientation 0.5.
let nw = hx("010000000000803f0000004000004040000000bf");
match messages::parse_server(messages::opcode::SMSG_NEW_WORLD, &nw).unwrap() {
ServerPacket::NewWorld {
map,
position,
orientation,
} => {
assert_eq!(map, 1);
assert_eq!((position.x, position.y, position.z), (1.0, 2.0, 3.0));
assert_eq!(orientation, -0.5);
}
_ => panic!("new world"),
}
// SMSG_LOGIN_SETTIMESPEED: packed DateTime (LSB up: minute:6, hour:5, weekday:3, day:6,
// month:4, year:5) with 14:30 on year 26 / month 6 / day 17; timescale. The day serial
// flattens the date with the packed convention's 31-day months: 26·372 + 6·31 + 17 = 9875.
let datetime: u32 = (26 << 24) | (6 << 20) | (17 << 14) | (14 << 6) | 30;
let mut ts = datetime.to_le_bytes().to_vec();
ts.extend_from_slice(&0.0166_6667f32.to_le_bytes());
match messages::parse_server(messages::opcode::SMSG_LOGIN_SETTIMESPEED, &ts).unwrap() {
ServerPacket::TimeSpeed {
hours,
minutes,
day_serial,
timescale,
} => {
assert_eq!((hours, minutes), (14, 30));
assert_eq!(day_serial, 26 * 372 + 6 * 31 + 17);
assert_eq!(timescale, 0.0166_6667);
}
_ => panic!("settimespeed"),
}
}
/// The server wall clock (`CMSG_QUERY_TIME` 462 / `SMSG_QUERY_TIME_RESPONSE` 463, decision 1150):
/// an empty request body, and a response of one LE `u32` of unix-epoch seconds — the epoch a timed
/// quest's descriptor deadline is written in.
///
/// Byte-exact against the vmangos sender (`WorldSession::SendQueryTimeResponse`,
/// `Handlers/QueryHandler.cpp:418-423` — `packet->time = (uint32)time(nullptr)`, that field and
/// nothing else). The sample is a real present-day stamp with all four bytes distinct, so a byte
/// order or width slip reads as a wildly wrong date rather than a plausible one.
#[test]
fn query_time_round_trips_the_server_wall_clock() {
assert!(
messages::query_time().is_empty(),
"CMSG_QUERY_TIME is a NullClientPacket"
);
// 2026-08-13T12:00:00Z = 1_786_622_400 = 0x6a7db1c0.
assert_eq!(u32::from_le_bytes([0xc0, 0xb1, 0x7d, 0x6a]), 1_786_622_400);
let p = messages::parse_server(messages::opcode::SMSG_QUERY_TIME_RESPONSE, &hx("c0b17d6a"))
.unwrap();
assert!(matches!(
p,
ServerPacket::QueryTimeResponse {
unix_time: 1_786_622_400
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::ServerUnixTime {
unix_time: 1_786_622_400
}]
));
// A short body is an error, not a silent zero: every countdown drawn from this clock would
// otherwise be wrong rather than absent.
assert!(
messages::parse_server(messages::opcode::SMSG_QUERY_TIME_RESPONSE, &hx("c0b17d")).is_err()
);
}
/// The cinematic trigger (opcode 250, body a bare `CinematicSequences.dbc` id u32 per the vmangos
/// `SendCinematicStart` sender) parses and decodes to the event the Net drain must ack — and the
/// char-delete result (opcode 60, one result byte) parses.
#[test]
fn cinematic_and_char_delete_parse_and_decode() {
// SMSG_TRIGGER_CINEMATIC: u32 cinematic id (41 = the dwarf intro, live-captured 2026-07-07).
let body = 41u32.to_le_bytes();
let p = messages::parse_server(messages::opcode::SMSG_TRIGGER_CINEMATIC, &body).unwrap();
assert!(matches!(
p,
ServerPacket::TriggerCinematic { cinematic_id: 41 }
));
assert!(matches!(
decode(p)[..],
[SessionEvent::CinematicTriggered { cinematic_id: 41 }]
));
// SMSG_CHAR_DELETE: one result byte (0x39 = success, live-verified).
let p = messages::parse_server(messages::opcode::SMSG_CHAR_DELETE, &[0x39]).unwrap();
assert!(matches!(p, ServerPacket::CharDelete { result: 0x39 }));
}
/// The server-pushed sound trio (opcodes vmangos `Opcodes_1_12_1.h` 631/632/722; bodies are bare
/// LE scalars per the vmangos senders): each parses to its packet and decodes to one event.
#[test]
fn server_sound_trio_parse_and_decode() {
// SMSG_PLAY_SOUND: u32 soundId.
let body = 8595u32.to_le_bytes();
let p = messages::parse_server(messages::opcode::SMSG_PLAY_SOUND, &body).unwrap();
assert!(matches!(p, ServerPacket::PlaySound { sound_id: 8595 }));
assert!(matches!(
decode(p)[..],
[SessionEvent::PlaySound { sound_id: 8595 }]
));
// SMSG_PLAY_MUSIC: u32 musicId.
let body = 2523u32.to_le_bytes();
let p = messages::parse_server(messages::opcode::SMSG_PLAY_MUSIC, &body).unwrap();
assert!(matches!(
decode(p)[..],
[SessionEvent::PlayMusic { music_id: 2523 }]
));
// SMSG_PLAY_OBJECT_SOUND: u32 soundId + u64 guid (the fishing-bobber splash, vmangos
// GameObject.cpp:373 sends exactly soundId 3355).
let mut body = 3355u32.to_le_bytes().to_vec();
body.extend_from_slice(&0xF110_0000_0000_002Au64.to_le_bytes());
let p = messages::parse_server(messages::opcode::SMSG_PLAY_OBJECT_SOUND, &body).unwrap();
assert!(matches!(
decode(p)[..],
[SessionEvent::PlayObjectSound {
sound_id: 3355,
guid: 0xF110_0000_0000_002A,
}]
));
}
/// SMSG_WEATHER (opcode vmangos 756; body `u32 type, f32 grade, u32 soundId, u8 instant` per
/// `Weather::SendWeatherForPlayersInZone`, 1.12 branch): parses and decodes to one event, with
/// the soundId being a real SoundEntries loop kit (8534 = RainMedium).
#[test]
fn weather_parses_and_decodes() {
let mut body = 1u32.to_le_bytes().to_vec(); // WEATHER_TYPE_RAIN
body.extend_from_slice(&0.7f32.to_le_bytes());
body.extend_from_slice(&8534u32.to_le_bytes());
body.push(0);
let p = messages::parse_server(messages::opcode::SMSG_WEATHER, &body).unwrap();
match decode(p).as_slice() {
[SessionEvent::Weather {
weather_type,
grade,
sound_id,
instant,
}] => {
assert_eq!((*weather_type, *sound_id, *instant), (1, 8534, false));
assert!((grade - 0.7).abs() < 1e-6);
}
other => panic!("weather decode: {} events", other.len()),
}
}
/// `SMSG_SET_PROFICIENCY` (295): u8 itemClass + u32 subclass bitmask (vmangos `Skill.h` /
/// `Skill.cpp AppendBodyTo`) — parses and decodes to the proficiency event the item tooltip's
/// slot-line red reads.
#[test]
fn set_proficiency_parses_and_decodes() {
assert_eq!(messages::opcode::SMSG_SET_PROFICIENCY, 295);
// Weapons (class 2), mask 0x2408F: the fresh-warrior shape.
let body = hx("028f400200");
let p = messages::parse_server(messages::opcode::SMSG_SET_PROFICIENCY, &body).unwrap();
match p {
ServerPacket::SetProficiency {
item_class,
subclass_mask,
} => {
assert_eq!(item_class, 2);
assert_eq!(subclass_mask, 0x0002_408f);
}
_ => panic!("set proficiency"),
}
let p = messages::parse_server(messages::opcode::SMSG_SET_PROFICIENCY, &body).unwrap();
assert!(matches!(
decode(p)[..],
[SessionEvent::Proficiency {
item_class: 2,
subclass_mask: 0x0002_408f
}]
));
}
/// `SMSG_SET_FACTION_STANDING` (292): u32 count + count x (u32 reputationListId, i32 standing)
/// (vmangos `ReputationMgr::SendState` / `SetFactionStanding::AppendBodyTo`) — the mid-session
/// standing delta the reputation red re-ranks from.
#[test]
fn set_faction_standing_parses_and_decodes() {
assert_eq!(messages::opcode::SMSG_SET_FACTION_STANDING, 292);
// Two slots: list 46 -> 3000, list 89 -> -6000 (a standing can go down).
let body = hx("020000002e000000b80b00005900000090e8ffff");
let p = messages::parse_server(messages::opcode::SMSG_SET_FACTION_STANDING, &body).unwrap();
match p {
ServerPacket::SetFactionStanding { ref standings } => {
assert_eq!(standings[..], [(46, 3000), (89, -6000)]);
}
_ => panic!("set faction standing"),
}
assert!(matches!(
decode(p)[..],
[SessionEvent::ReputationDelta { ref standings }]
if standings[..] == [(46, 3000), (89, -6000)]
));
}
/// The dropped-packet tally feed (the wire-coverage instrument): an opcode with no parse arm falls
/// through to `ServerPacket::Other` and MUST decode to a `PacketDropped` event (not silence) so the
/// app can tally the gap — and the generated 1.12.1 name table resolves both known and unassigned
/// opcode numbers.
#[test]
fn unknown_opcode_decodes_to_packet_dropped() {
// 0x021E = SMSG_SET_REST_START — assigned in 1.12.1 but deliberately unparsed by benilla.
// (The sentinel used to be 0x0319 `MSG_MOVE_TIME_SKIPPED`, which decision 1935 gave a parse
// arm; then 0x0324 `SMSG_PET_ACTION_SOUND`, which decision 2039 gave one — the pet's voice
// turned out to drive two real `CreatureSoundData` columns. A sentinel earning a parse arm is
// this test working, not this test breaking.)
let packet = messages::parse_server(0x021E, &hx("0102030405")).unwrap();
assert!(matches!(packet, ServerPacket::Other { opcode: 0x021E }));
match decode(packet).as_slice() {
[SessionEvent::PacketDropped {
opcode: 0x021E,
unparseable: false,
}] => {}
other => panic!("expected one PacketDropped event, got {other:?}"),
}
// The generated name table: a known opcode resolves, an unassigned number doesn't.
assert_eq!(messages::opcode_name(0x021E), Some("SMSG_SET_REST_START"));
assert_eq!(
messages::opcode_name(messages::opcode::SMSG_UPDATE_OBJECT),
Some("SMSG_UPDATE_OBJECT")
);
assert_eq!(messages::opcode_name(0xFFFF), None);
}
/// The death arc's wire family (decision 0308): the corpse query answer (both shapes + the
/// dungeon-entrance map split), the reclaim delay, a resurrect request, the spirit-healer
/// confirm, and the ack'd movement-flag family with its PACKED mover guid — all through
/// `parse_server` and the event decode.
#[test]
fn death_arc_family_parses_and_decodes() {
use benilla_protocol::messages::opcode;
// MSG_CORPSE_QUERY, found: u8(1), i32 map 0, xyz, u32 corpsemap 36 (a dungeon corpse whose
// display coords were entrance-rewritten — the two maps deliberately differ).
let mut body = vec![1u8];
body.extend_from_slice(&0i32.to_le_bytes());
body.extend_from_slice(&(-11209.6f32).to_le_bytes());
body.extend_from_slice(&1666.54f32.to_le_bytes());
body.extend_from_slice(&25.0f32.to_le_bytes());
body.extend_from_slice(&36u32.to_le_bytes());
let pkt = messages::parse_server(opcode::MSG_CORPSE_QUERY, &body).unwrap();
match decode(pkt).as_slice() {
[SessionEvent::CorpseQuery {
found,
display_map,
position,
corpse_map,
}] => {
assert!(found);
assert_eq!(*display_map, 0);
assert_eq!(*corpse_map, 36);
assert_eq!(position[2], 25.0);
}
other => panic!("corpse query decode: {other:?}"),
}
// The unprompted not-found shape (bones conversion): the lone u8(0).
let pkt = messages::parse_server(opcode::MSG_CORPSE_QUERY, &[0u8]).unwrap();
match decode(pkt).as_slice() {
[SessionEvent::CorpseQuery { found: false, .. }] => {}
other => panic!("corpse query not-found decode: {other:?}"),
}
// SMSG_CORPSE_RECLAIM_DELAY: u32 ms.
let pkt = messages::parse_server(opcode::SMSG_CORPSE_RECLAIM_DELAY, &30_000u32.to_le_bytes())
.unwrap();
match decode(pkt).as_slice() {
[SessionEvent::CorpseReclaimDelay { delay_ms: 30_000 }] => {}
other => panic!("reclaim delay decode: {other:?}"),
}
// SMSG_RESURRECT_REQUEST, player caster: guid, u32 len 1, empty cstring, sickness 0, timer 1.
let mut body = 0x2Au64.to_le_bytes().to_vec();
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(&[0, 0, 1]);
let pkt = messages::parse_server(opcode::SMSG_RESURRECT_REQUEST, &body).unwrap();
match decode(pkt).as_slice() {
[SessionEvent::ResurrectRequest {
caster: 0x2A,
name,
sickness: false,
has_timer: true,
}] => assert!(name.is_empty(), "player caster ⇒ empty wire name"),
other => panic!("resurrect request decode: {other:?}"),
}
// SMSG_SPIRIT_HEALER_CONFIRM: one full guid.
let pkt = messages::parse_server(
opcode::SMSG_SPIRIT_HEALER_CONFIRM,
&0xF130_0000_0000_2AB3u64.to_le_bytes(),
)
.unwrap();
match decode(pkt).as_slice() {
[SessionEvent::SpiritHealerConfirm {
npc: 0xF130_0000_0000_2AB3,
}] => {}
other => panic!("spirit healer confirm decode: {other:?}"),
}
// **The whole ack'd movement-mode family, all eight opcodes** (decision 0866): one wire shape,
// PACKED guid + u32 counter. Guid 0x2A packs as mask 0x01 + one byte. Every pair is exercised,
// because the family's whole point is that adding a mode must not need a new lane — and because
// the four that were missing (feather-fall, hover) were missing *silently*.
let body = hx("012a07000000"); // packed(0x2A) + counter 7
for (op, mode, apply) in [
(opcode::SMSG_FORCE_MOVE_ROOT, MoveMode::Root, true),
(opcode::SMSG_FORCE_MOVE_UNROOT, MoveMode::Root, false),
(opcode::SMSG_MOVE_WATER_WALK, MoveMode::WaterWalk, true),
(opcode::SMSG_MOVE_LAND_WALK, MoveMode::WaterWalk, false),
(opcode::SMSG_MOVE_FEATHER_FALL, MoveMode::FeatherFall, true),
(opcode::SMSG_MOVE_NORMAL_FALL, MoveMode::FeatherFall, false),
(opcode::SMSG_MOVE_SET_HOVER, MoveMode::Hover, true),
(opcode::SMSG_MOVE_UNSET_HOVER, MoveMode::Hover, false),
] {
let pkt = messages::parse_server(op, &body).unwrap();
match decode(pkt).as_slice() {
[SessionEvent::MoveMode {
guid: 0x2A,
counter: 7,
mode: got_mode,
apply: got_apply,
}] if *got_mode == mode && *got_apply == apply => {}
other => panic!("move mode decode for opcode {op:#06x}: {other:?}"),
}
}
// The mode→bit map, VERIFIED vmangos `Objects/MovementInfo.h:25-62`. These are the bits the
// mover reads and the ack echoes, so a transcription slip here is silent everywhere else.
assert_eq!(MoveMode::Root.flag(), 0x0000_1000);
assert_eq!(MoveMode::WaterWalk.flag(), 0x1000_0000);
assert_eq!(MoveMode::FeatherFall.flag(), 0x2000_0000);
assert_eq!(MoveMode::Hover.flag(), 0x4000_0000);
// Root is the ONLY mode whose ack opcode differs by direction, and the only one whose ack body
// carries no trailing apply dword — vmangos routes it to `HandleMoveRootAck`, the other three to
// `HandleMovementFlagChangeToggleAck` (`Server/Protocol/Opcodes.cpp:314-816`).
assert_eq!(
MoveMode::Root.ack_opcode(true),
opcode::CMSG_FORCE_MOVE_ROOT_ACK
);
assert_eq!(
MoveMode::Root.ack_opcode(false),
opcode::CMSG_FORCE_MOVE_UNROOT_ACK
);
assert!(!MoveMode::Root.ack_carries_apply());
for mode in [MoveMode::WaterWalk, MoveMode::FeatherFall, MoveMode::Hover] {
assert_eq!(
mode.ack_opcode(true),
mode.ack_opcode(false),
"{mode:?}: one ack opcode both ways"
);
assert!(mode.ack_carries_apply(), "{mode:?}: ack carries apply");
}
}
/// The movement-mode ack bodies (client side): full guid + echoed counter + MovementInfo, with the
/// trailing `u32 apply` on every mode EXCEPT root (vmangos `Movement.cpp:38-59` — `MoveRootAck` has
/// no apply dword). The root ack must be exactly 4 bytes shorter than the others for the same
/// inputs.
#[test]
fn move_flag_ack_bodies_differ_by_the_apply_tail() {
use benilla_protocol::messages::{move_flag_ack, MovementInfo};
use benilla_protocol::wire::Vector3d;
let info = MovementInfo {
flags: 0,
timestamp: 0x1122_3344,
position: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
transport: None,
pitch: 0.0,
fall_time: 0,
jump: None,
};
let root = move_flag_ack(0x2A, 7, &info, None);
let walk = move_flag_ack(0x2A, 7, &info, Some(true));
assert_eq!(root.len() + 4, walk.len());
assert_eq!(&walk[..root.len()], root.as_slice());
assert_eq!(&walk[root.len()..], 1u32.to_le_bytes());
// The full (unpacked) guid + counter head.
assert_eq!(&root[..8], &0x2Au64.to_le_bytes());
assert_eq!(&root[8..12], &7u32.to_le_bytes());
}
/// `SMSG_DURABILITY_DAMAGE_DEATH` (0x2BD): an EMPTY body (vmangos `DurabilityDamageDeath::
/// AppendBodyTo` writes nothing) parsing to its cue event — the red 10%-loss error line.
#[test]
fn durability_damage_death_is_an_empty_body_cue() {
let pkt = messages::parse_server(messages::opcode::SMSG_DURABILITY_DAMAGE_DEATH, &[]).unwrap();
assert!(matches!(pkt, ServerPacket::DurabilityDamageDeath));
assert!(matches!(
decode(pkt).as_slice(),
[SessionEvent::DurabilityDamageDeath]
));
}
/// `SMSG_TRANSFER_PENDING` (0x3F): `u32 mapId` alone for an ordinary far teleport; `+ u32
/// transportEntry + u32 oldMapId` when the player rides a transport through the transfer
/// (VERIFIED vmangos `Misc.cpp:493-501`). The block's presence routes how NEW_WORLD's
/// coordinates are read (decision 0455), so both shapes are pinned — and the abort
/// (`SMSG_TRANSFER_ABORTED`, one reason byte) clears the latch.
#[test]
fn transfer_pending_both_shapes_and_abort_parse_and_decode() {
// Plain: mapId 1 (Kalimdor), no transport block.
let plain =
messages::parse_server(messages::opcode::SMSG_TRANSFER_PENDING, &hx("01000000")).unwrap();
assert!(matches!(
plain,
ServerPacket::TransferPending {
map: 1,
transport: None
}
));
assert!(matches!(
decode(plain).as_slice(),
[SessionEvent::TransferPending {
map_id: 1,
transport_entry: None
}]
));
// Riding: mapId 0 (EK), transportEntry 176310 (0x2B0B6 — the Menethil boat), oldMapId 1.
let mut riding = 0u32.to_le_bytes().to_vec();
riding.extend_from_slice(&176310u32.to_le_bytes());
riding.extend_from_slice(&1u32.to_le_bytes());
let riding = messages::parse_server(messages::opcode::SMSG_TRANSFER_PENDING, &riding).unwrap();
assert!(matches!(
riding,
ServerPacket::TransferPending {
map: 0,
transport: Some((176310, 1))
}
));
assert!(matches!(
decode(riding).as_slice(),
[SessionEvent::TransferPending {
map_id: 0,
transport_entry: Some(176310)
}]
));
// Abort: one reason byte.
let abort = messages::parse_server(messages::opcode::SMSG_TRANSFER_ABORTED, &[2]).unwrap();
assert!(matches!(abort, ServerPacket::TransferAborted { reason: 2 }));
assert!(matches!(
decode(abort).as_slice(),
[SessionEvent::TransferAborted { reason: 2 }]
));
}
/// `SMSG_CLIENT_CONTROL_UPDATE` — the possession handoff. Golden hex is hand-derived from
/// vmangos's builder (`Server/Packets/Misc.cpp:677-682`): `moverGuid.WriteAsPacked()` then
/// `uint8 allowMove`. The packed form is a mask byte whose bit *i* marks a non-zero byte *i* of
/// the little-endian u64, followed by exactly those bytes — so the same guid costs a different
/// number of bytes depending on its value, and getting the mask wrong desynchronises everything
/// after it in the stream.
///
/// **The two cases below are the two the app must tell apart**, and they differ only in a guid:
/// the server grants control by naming somebody *else* and revokes it by naming *us*.
#[test]
fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
// Grant: a creature guid 0xF130000C1A00A2B4 with allowMove = 1. Non-zero LE bytes are
// b4 a2 00 1a 0c 00 30 f1 → indices 0,1,3,4,6,7 → mask 0b1101_1011 = 0xdb.
let grant = hx("dbb4a21a0c30f101");
match messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &grant).unwrap() {
ServerPacket::ClientControlUpdate { mover, allow_move } => {
assert_eq!(mover, 0xF130_000C_1A00_A2B4);
assert!(allow_move);
}
other => panic!("client control update, got {}", other.name()),
}
// Revoke: our own player guid 0x45 (one non-zero byte, index 0 → mask 0x01) with
// allowMove = 0. This is the shape a mind-controlled player receives about themselves, and
// the whole of what stops them walking away — vmangos never roots them.
let revoke = hx("014500");
let revoke = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &revoke)
.expect("revoke parses");
assert!(matches!(
revoke,
ServerPacket::ClientControlUpdate {
mover: 0x45,
allow_move: false
}
));
assert!(matches!(
decode(revoke).as_slice(),
[SessionEvent::ClientControl {
mover: 0x45,
allow_move: false
}]
));
// A zero guid packs to a lone zero mask byte and no guid bytes at all — the degenerate case
// that would read the allow byte AS the guid if the mask were mishandled.
let empty = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &hx("0001"))
.expect("empty guid parses");
assert!(matches!(
empty,
ServerPacket::ClientControlUpdate {
mover: 0,
allow_move: true
}
));
}
/// The hunter pet-feedback family (decision 2039) — the three arms that were name-table-only, on
/// their real bodies.
///
/// The two **empty** bodies are the point of half this test: `SMSG_PET_NAME_INVALID` and
/// `SMSG_PET_BROKEN` carry nothing at all (vmangos's `AppendBodyTo` writes nothing; the
/// reference's handlers read nothing), so "parses" here means "the opcode alone produced the
/// event" — and a body that arrives with junk on it must not change that.
#[test]
fn pet_feedback_family_parses_and_decodes() {
use benilla_protocol::messages::opcode;
// SMSG_PET_TAME_FAILURE: one reason byte. 9 = PETTAME_TOOHIGHLEVEL.
let p = messages::parse_server(opcode::SMSG_PET_TAME_FAILURE, &hx("09")).unwrap();
assert!(matches!(p, ServerPacket::PetTameFailure { reason: 9 }));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetTameFailure { reason: 9 }]
));
// The reason -> GlobalStrings key table, at both ends of the reference's bounds check
// (`0x6e6a20`: `reason - 1` bounded at `0xa`) and in the middle.
assert_eq!(messages::pet_tame_failure_key(1), "PETTAME_INVALIDCREATURE");
assert_eq!(
messages::pet_tame_failure_key(5),
"PETTAME_ANOTHERSUMMONACTIVE"
);
assert_eq!(messages::pet_tame_failure_key(11), "PETTAME_NOTDEAD");
// Out of range on BOTH sides falls to the default arm — including vmangos's own 12th value,
// which is `PETTAME_UNKNOWNERROR` in the enum and reaches the same string by the other road.
assert_eq!(messages::pet_tame_failure_key(0), "PETTAME_UNKNOWNERROR");
assert_eq!(messages::pet_tame_failure_key(12), "PETTAME_UNKNOWNERROR");
assert_eq!(messages::pet_tame_failure_key(200), "PETTAME_UNKNOWNERROR");
// SMSG_PET_NAME_INVALID / SMSG_PET_BROKEN: empty bodies, and unread ones — a body with bytes
// on it still decodes to the bare event, because neither handler reads one.
for body in ["", "deadbeef"] {
let p = messages::parse_server(opcode::SMSG_PET_NAME_INVALID, &hx(body)).unwrap();
assert!(matches!(p, ServerPacket::PetNameInvalid), "body {body:?}");
assert!(matches!(decode(p)[..], [SessionEvent::PetNameInvalid]));
let p = messages::parse_server(opcode::SMSG_PET_BROKEN, &hx(body)).unwrap();
assert!(matches!(p, ServerPacket::PetBroken), "body {body:?}");
assert!(matches!(decode(p)[..], [SessionEvent::PetBroken]));
}
// SMSG_PET_ACTION_SOUND: u64 guid then the u32 talk selector.
let body = hx("2a0000000000401001000000");
let p = messages::parse_server(opcode::SMSG_PET_ACTION_SOUND, &body).unwrap();
assert!(matches!(
p,
ServerPacket::PetActionSound {
pet_guid: 0x1040_0000_0000_002A,
talk: messages::PET_TALK_ATTACK,
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetActionSound {
pet_guid: 0x1040_0000_0000_002A,
talk: 1
}]
));
// SMSG_PET_DISMISS_SOUND: a CreatureModelData id then a raw WoW x/y/z. Model 726, at
// (-8949.95, -132.493, 83.5312) — the human starting point, chosen because its bytes are
// recognisable if the field order ever slips. The handler's `+1.0` on z is a PLAY detail and
// must NOT appear here.
let mut body = hx("d6020000"); // model 726
for f in [-8949.95_f32, -132.493, 83.5312] {
body.extend_from_slice(&f.to_le_bytes());
}
let p = messages::parse_server(opcode::SMSG_PET_DISMISS_SOUND, &body).unwrap();
assert!(matches!(
p,
ServerPacket::PetDismissSound {
model_id: 726,
position: benilla_protocol::wire::Vector3d {
x: -8949.95,
y: -132.493,
z: 83.5312,
},
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetDismissSound {
model_id: 726,
position: [-8949.95, -132.493, 83.5312],
}]
));
}