comments: protocol, the packet, spell, stable, taxi, trainer and update-object tests
514 comment lines become 183; the code is unchanged. Three comments said something other than their bytes: - SMSG_NEW_WORLD's orientation is -0.5, not 0.5; - transport 176495 is the Grom'Gol-Undercity zeppelin, not the Menethil-Theramore ferry; - 176244 is the Moonspray, the Auberdine-Rut'theran boat, not the Orgrimmar-Undercity zeppelin. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
//! 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.
|
||||
//! Server packets with small fixed bodies, parsed and decoded against hand-built bytes.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -12,12 +9,11 @@ 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.
|
||||
// SMSG_NEW_WORLD: u32 map, f32 x/y/z, f32 orientation.
|
||||
let nw = hx("010000000000803f0000004000004040000000bf");
|
||||
match messages::parse_server(messages::opcode::SMSG_NEW_WORLD, &nw).unwrap() {
|
||||
ServerPacket::NewWorld {
|
||||
@@ -31,9 +27,8 @@ fn simple_server_bodies_parse() {
|
||||
}
|
||||
_ => 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.
|
||||
// SMSG_LOGIN_SETTIMESPEED: packed date (LSB up: minute:6, hour:5, weekday:3, day:6, month:4,
|
||||
// year:5) then f32 timescale. The day serial counts 31-day months: year·372 + month·31 + day.
|
||||
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());
|
||||
@@ -52,14 +47,7 @@ fn simple_server_bodies_parse() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The response is one u32 of unix-epoch seconds (vmangos `Handlers/QueryHandler.cpp:418-423`).
|
||||
#[test]
|
||||
fn query_time_round_trips_the_server_wall_clock() {
|
||||
assert!(
|
||||
@@ -67,7 +55,7 @@ fn query_time_round_trips_the_server_wall_clock() {
|
||||
"CMSG_QUERY_TIME is a NullClientPacket"
|
||||
);
|
||||
|
||||
// 2026-08-13T12:00:00Z = 1_786_622_400 = 0x6a7db1c0.
|
||||
// 2026-08-13T12:00:00Z = 1_786_622_400 = 0x6a7db1c0: four distinct bytes.
|
||||
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();
|
||||
@@ -84,19 +72,15 @@ fn query_time_round_trips_the_server_wall_clock() {
|
||||
}]
|
||||
));
|
||||
|
||||
// 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.
|
||||
/// `SMSG_TRIGGER_CINEMATIC` is one u32 `CinematicSequences.dbc` id; `SMSG_CHAR_DELETE` one byte.
|
||||
#[test]
|
||||
fn cinematic_and_char_delete_parse_and_decode() {
|
||||
// SMSG_TRIGGER_CINEMATIC: u32 cinematic id (41 = the dwarf intro, live-captured 2026-07-07).
|
||||
// 41 = the dwarf intro.
|
||||
let body = 41u32.to_le_bytes();
|
||||
let p = messages::parse_server(messages::opcode::SMSG_TRIGGER_CINEMATIC, &body).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -108,16 +92,13 @@ fn cinematic_and_char_delete_parse_and_decode() {
|
||||
[SessionEvent::CinematicTriggered { cinematic_id: 41 }]
|
||||
));
|
||||
|
||||
// SMSG_CHAR_DELETE: one result byte (0x39 = success, live-verified).
|
||||
// 0x39 = CHAR_DELETE_SUCCESS.
|
||||
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 }));
|
||||
@@ -126,7 +107,6 @@ fn server_sound_trio_parse_and_decode() {
|
||||
[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!(
|
||||
@@ -134,8 +114,7 @@ fn server_sound_trio_parse_and_decode() {
|
||||
[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).
|
||||
// 3355: the fishing-bobber splash (vmangos `GameObject.cpp:373`).
|
||||
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();
|
||||
@@ -148,9 +127,7 @@ fn server_sound_trio_parse_and_decode() {
|
||||
));
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Body per vmangos `Weather::SendWeatherForPlayersInZone`; 8534 is the RainMedium sound loop.
|
||||
#[test]
|
||||
fn weather_parses_and_decodes() {
|
||||
let mut body = 1u32.to_le_bytes().to_vec(); // WEATHER_TYPE_RAIN
|
||||
@@ -172,13 +149,11 @@ fn weather_parses_and_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// u8 itemClass + u32 subclass bitmask, per vmangos `Skill.cpp` `AppendBodyTo`.
|
||||
#[test]
|
||||
fn set_proficiency_parses_and_decodes() {
|
||||
assert_eq!(messages::opcode::SMSG_SET_PROFICIENCY, 295);
|
||||
// Weapons (class 2), mask 0x2408F: the fresh-warrior shape.
|
||||
// Weapons (class 2), mask 0x2408F: what a fresh warrior gets.
|
||||
let body = hx("028f400200");
|
||||
let p = messages::parse_server(messages::opcode::SMSG_SET_PROFICIENCY, &body).unwrap();
|
||||
match p {
|
||||
@@ -201,13 +176,11 @@ fn set_proficiency_parses_and_decodes() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// u32 count + count x (u32 reputationListId, i32 standing), per vmangos `SetFactionStanding`.
|
||||
#[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).
|
||||
// Two slots: list 46 -> 3000, list 89 -> -6000 (standing is signed).
|
||||
let body = hx("020000002e000000b80b00005900000090e8ffff");
|
||||
let p = messages::parse_server(messages::opcode::SMSG_SET_FACTION_STANDING, &body).unwrap();
|
||||
match p {
|
||||
@@ -223,17 +196,10 @@ fn set_faction_standing_parses_and_decodes() {
|
||||
));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// An opcode with no parse arm decodes to `PacketDropped`, not silence, so the app can tally it.
|
||||
#[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.)
|
||||
// Sentinel: SMSG_SET_REST_START (0x021E) has no parse arm; swap in another if it gains one.
|
||||
let packet = messages::parse_server(0x021E, &hx("0102030405")).unwrap();
|
||||
assert!(matches!(packet, ServerPacket::Other { opcode: 0x021E }));
|
||||
match decode(packet).as_slice() {
|
||||
@@ -243,7 +209,6 @@ fn unknown_opcode_decodes_to_packet_dropped() {
|
||||
}] => {}
|
||||
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),
|
||||
@@ -252,16 +217,11 @@ fn unknown_opcode_decodes_to_packet_dropped() {
|
||||
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).
|
||||
// Found: a dungeon corpse reports its entrance's map and coords, so the two maps differ.
|
||||
let mut body = vec![1u8];
|
||||
body.extend_from_slice(&0i32.to_le_bytes());
|
||||
body.extend_from_slice(&(-11209.6f32).to_le_bytes());
|
||||
@@ -283,14 +243,13 @@ fn death_arc_family_parses_and_decodes() {
|
||||
}
|
||||
other => panic!("corpse query decode: {other:?}"),
|
||||
}
|
||||
// The unprompted not-found shape (bones conversion): the lone u8(0).
|
||||
// Not found, also sent unprompted when the corpse turns to bones: a 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() {
|
||||
@@ -313,7 +272,6 @@ fn death_arc_family_parses_and_decodes() {
|
||||
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(),
|
||||
@@ -326,10 +284,6 @@ fn death_arc_family_parses_and_decodes() {
|
||||
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),
|
||||
@@ -353,16 +307,13 @@ fn death_arc_family_parses_and_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The movement-flag bits (vmangos `Objects/MovementInfo.h:25-62`).
|
||||
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`).
|
||||
// Root alone acks per direction, with no apply dword (vmangos `HandleMoveRootAck`).
|
||||
assert_eq!(
|
||||
MoveMode::Root.ack_opcode(true),
|
||||
opcode::CMSG_FORCE_MOVE_ROOT_ACK
|
||||
@@ -382,10 +333,7 @@ fn death_arc_family_parses_and_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Root's ack lacks the trailing u32 apply the other modes carry (vmangos `Movement.cpp:38-59`).
|
||||
#[test]
|
||||
fn move_flag_ack_bodies_differ_by_the_apply_tail() {
|
||||
use benilla_protocol::messages::{move_flag_ack, MovementInfo};
|
||||
@@ -410,13 +358,12 @@ fn move_flag_ack_bodies_differ_by_the_apply_tail() {
|
||||
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.
|
||||
// The ack's guid is full, not packed.
|
||||
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.
|
||||
/// `SMSG_DURABILITY_DAMAGE_DEATH` (0x2BD): empty body; the cue for the 10% durability-loss line.
|
||||
#[test]
|
||||
fn durability_damage_death_is_an_empty_body_cue() {
|
||||
let pkt = messages::parse_server(messages::opcode::SMSG_DURABILITY_DAMAGE_DEATH, &[]).unwrap();
|
||||
@@ -427,14 +374,10 @@ fn durability_damage_death_is_an_empty_body_cue() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// u32 mapId, then u32 transportEntry + u32 oldMapId when the player rides a transport through
|
||||
/// (vmangos `Misc.cpp:493-501`).
|
||||
#[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!(
|
||||
@@ -451,7 +394,7 @@ fn transfer_pending_both_shapes_and_abort_parse_and_decode() {
|
||||
transport_entry: None
|
||||
}]
|
||||
));
|
||||
// Riding: mapId 0 (EK), transportEntry 176310 (0x2B0B6 — the Menethil boat), oldMapId 1.
|
||||
// Riding: 176310 is the Menethil boat, arriving in EK (0) from Kalimdor (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());
|
||||
@@ -470,7 +413,6 @@ fn transfer_pending_both_shapes_and_abort_parse_and_decode() {
|
||||
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!(
|
||||
@@ -479,19 +421,11 @@ fn transfer_pending_both_shapes_and_abort_parse_and_decode() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `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*.
|
||||
/// Packed mover guid then u8 allowMove (vmangos `Server/Packets/Misc.cpp:677-682`).
|
||||
#[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.
|
||||
// Grant: creature 0xF130000C1A00A2B4, allowMove 1. Mask 0xdb marks its non-zero LE bytes
|
||||
// (b4 a2 00 1a 0c 00 30 f1 → indices 0,1,3,4,6,7), which follow in order.
|
||||
let grant = hx("dbb4a21a0c30f101");
|
||||
match messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &grant).unwrap() {
|
||||
ServerPacket::ClientControlUpdate { mover, allow_move } => {
|
||||
@@ -500,9 +434,7 @@ fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
|
||||
}
|
||||
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.
|
||||
// Revoke: our own guid 0x45, allowMove 0; vmangos never roots a mind-controlled player.
|
||||
let revoke = hx("014500");
|
||||
let revoke = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &revoke)
|
||||
.expect("revoke parses");
|
||||
@@ -520,8 +452,7 @@ fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
|
||||
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.
|
||||
// A zero guid packs to a lone zero mask byte, so the very next byte is allowMove.
|
||||
let empty = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &hx("0001"))
|
||||
.expect("empty guid parses");
|
||||
assert!(matches!(
|
||||
@@ -533,40 +464,30 @@ fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
|
||||
));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// 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.
|
||||
// Reason to GlobalStrings key: the reference bounds `reason - 1` at 0xa (`0x6e6a20`).
|
||||
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.
|
||||
// Out of range on either side, vmangos's 12th value included, falls to the default arm.
|
||||
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.
|
||||
// The reference reads no body here (vmangos writes none), so stray bytes change nothing.
|
||||
for body in ["", "deadbeef"] {
|
||||
let p = messages::parse_server(opcode::SMSG_PET_NAME_INVALID, &hx(body)).unwrap();
|
||||
assert!(matches!(p, ServerPacket::PetNameInvalid), "body {body:?}");
|
||||
@@ -595,10 +516,8 @@ fn pet_feedback_family_parses_and_decodes() {
|
||||
}]
|
||||
));
|
||||
|
||||
// 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.
|
||||
// SMSG_PET_DISMISS_SOUND: u32 CreatureModelData id then raw WoW x/y/z (the human start).
|
||||
// The reference handler's +1.0 on z belongs to playback, not to the decode.
|
||||
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());
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
//! Spell/action/attack + spell-visual wire tests (mirrors `src/messages/spells.rs`): initial spells,
|
||||
//! action buttons, cast/attack swing, cast result, attacker state, plus the spell-visual pipeline
|
||||
//! (`SMSG_SPELL_START`/`GO`, `SMSG_SPELL_FAILED_OTHER`, `SMSG_CANCEL_AUTO_REPEAT`,
|
||||
//! `SMSG_PLAY_SPELL_VISUAL`, decision 0099 phase 1). Split out of the former `tests/messages.rs` —
|
||||
//! see `tests/common` for the shared fixtures and methodology note.
|
||||
//! Wire tests for spells, action buttons, melee and the combat log (`src/messages/spells.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -15,7 +11,7 @@ use common::hx;
|
||||
fn spell_and_action_wire() {
|
||||
use benilla_protocol::messages::{ActionButton, CastOutcome, ACTION_KIND_ITEM};
|
||||
|
||||
// SMSG_INITIAL_SPELLS (vmangos Player::SendInitialSpells): u8 0; u16 n; n x (u16 id, u16 0);
|
||||
// SMSG_INITIAL_SPELLS (vmangos `Player::SendInitialSpells`): u8 0; u16 n; n x (u16 id, u16 0);
|
||||
// u16 m; m x (u16 spell, u16 item, u16 category, u32 spellCdMs, u32 catCdMs).
|
||||
let body = hx(concat!(
|
||||
"00", "0300", "4e000000", // 78 Heroic Strike
|
||||
@@ -40,7 +36,7 @@ fn spell_and_action_wire() {
|
||||
_ => panic!("initial spells"),
|
||||
}
|
||||
|
||||
// SMSG_ACTION_BUTTONS: 120 packed u32s — action bits 0-23, kind bits 24-31, 0 = empty.
|
||||
// SMSG_ACTION_BUTTONS: 120 packed u32s, action bits 0-23, kind bits 24-31, 0 = empty.
|
||||
let mut body = vec![0u8; 120 * 4];
|
||||
body[0..4].copy_from_slice(&0x0000_1A11u32.to_le_bytes()); // slot 0: spell 6673
|
||||
body[4..8].copy_from_slice(&0x8000_0075u32.to_le_bytes()); // slot 1: item 117
|
||||
@@ -70,14 +66,12 @@ fn spell_and_action_wire() {
|
||||
},
|
||||
]
|
||||
);
|
||||
// ...and the decode passes them through as the ActionButtons event.
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::ActionButtons { buttons: b } => assert_eq!(b, buttons),
|
||||
_ => panic!("action buttons event"),
|
||||
}
|
||||
|
||||
// CMSG_CAST_SPELL golden bytes (vmangos SpellCastTargets::read): self cast = mask 0, nothing
|
||||
// follows; unit cast = mask 2 + PACKED guid (build > 1.8.4 reads targets packed).
|
||||
// CMSG_CAST_SPELL targets per vmangos `SpellCastTargets::read`: packed guids after 1.8.4.
|
||||
assert_eq!(
|
||||
messages::cast_spell(6673, None),
|
||||
hx("111a00000000"),
|
||||
@@ -90,8 +84,7 @@ fn spell_and_action_wire() {
|
||||
"unit cast: u32 id + u16 mask 2 + packed guid (mask 0xC9)"
|
||||
);
|
||||
|
||||
// CMSG_CAST_SPELL at an ITEM (0437 phase 3 — the enchant pick): mask 0x0010 + packed guid.
|
||||
// Item guid 0x4700_0000_0000_0951: present bytes 0 (0x51), 1 (0x09), 7 (0x47) → mask 0x83.
|
||||
// Guid 0x4700_0000_0000_0951 has non-zero bytes 0 (0x51), 1 (0x09), 7 (0x47) → mask 0x83.
|
||||
let enchant_target = 0x4700_0000_0000_0951u64;
|
||||
assert_eq!(
|
||||
messages::cast_spell_item(7418, enchant_target),
|
||||
@@ -99,11 +92,10 @@ fn spell_and_action_wire() {
|
||||
"item cast: u32 id + u16 mask 0x10 + packed item guid"
|
||||
);
|
||||
|
||||
// CMSG_ATTACKSWING: one full 8-byte guid.
|
||||
// CMSG_ATTACKSWING: one full 8-byte guid, not packed.
|
||||
assert_eq!(messages::attack_swing(victim), hx("2a000045000030f1"));
|
||||
|
||||
// CMSG_SET_ACTION_BUTTON opcode (296 / 0x0128 — vmangos Opcodes_1_12_1.h) + body: button u8 +
|
||||
// packed u32 (WorldPackets::Misc::SetActionButton::ReadFromWorldPacket, Misc.cpp:87-90).
|
||||
// CMSG_SET_ACTION_BUTTON: u8 button + packed u32 action (vmangos `Misc.cpp:87-90`).
|
||||
assert_eq!(
|
||||
messages::opcode::CMSG_SET_ACTION_BUTTON,
|
||||
0x0128,
|
||||
@@ -120,10 +112,7 @@ fn spell_and_action_wire() {
|
||||
"packed 0 clears the slot"
|
||||
);
|
||||
|
||||
// CMSG_SET_ACTIONBAR_TOGGLES opcode (703 / 0x02BF) + body: ONE u8 and nothing else — VERIFIED
|
||||
// at the bytes, wow-re `system/ui/scratch/action-bar-toggles.md` §3 (`0x4e771d push 0x2bf`,
|
||||
// the one emitter image-wide; PutUInt32 opcode + PutUInt8 byte; NetClient::Send computes the
|
||||
// payload as 5 = 4 + 1). vmangos corroborates: `Misc.cpp:150-153` reads a single uint8.
|
||||
// CMSG_SET_ACTIONBAR_TOGGLES: one u8 (reference `0x4e771d`, vmangos `Misc.cpp:150-153`).
|
||||
assert_eq!(
|
||||
messages::opcode::CMSG_SET_ACTIONBAR_TOGGLES,
|
||||
0x02BF,
|
||||
@@ -145,7 +134,6 @@ fn spell_and_action_wire() {
|
||||
"all four — the largest value the reference binding can ever accumulate (§2)"
|
||||
);
|
||||
|
||||
// CMSG_SETSHEATHED opcode (480 / 0x01E0 — vmangos Opcodes_1_12_1.h) + body: one u32 state.
|
||||
assert_eq!(
|
||||
messages::opcode::CMSG_SETSHEATHED,
|
||||
0x01E0,
|
||||
@@ -175,11 +163,8 @@ fn spell_and_action_wire() {
|
||||
),
|
||||
_ => panic!("cast fail event"),
|
||||
}
|
||||
// The reason-specific argument words (`CastResult::AppendBodyTo`) are POSITIONAL — read by
|
||||
// what is left in the body, never keyed off the reason. One word for 0x5e
|
||||
// REQUIRES_SPELL_FOCUS (the `SpellFocusObject.dbc` id: 12 = "Starbreeze Village Moonwell",
|
||||
// the `%s` of "Requires %s"); two for the 0x19 EQUIPPED_ITEM_CLASS family (class + subclass
|
||||
// mask), whose second is read and dropped.
|
||||
// Argument words are read by body length, not by reason (vmangos `CastResult::AppendBodyTo`).
|
||||
// 12 = Starbreeze Village Moonwell (`SpellFocusObject.dbc`); 0x19's subclass mask is dropped.
|
||||
let focus = messages::parse_server(
|
||||
messages::opcode::SMSG_CAST_RESULT,
|
||||
&hx("111a0000025e0c000000"),
|
||||
@@ -229,10 +214,7 @@ fn spell_and_action_wire() {
|
||||
_ => panic!("attack stop"),
|
||||
}
|
||||
|
||||
// SMSG_ATTACKERSTATEUPDATE (vmangos Unit::SendAttackStateUpdate, Unit.cpp:4572-4605 — decision
|
||||
// 0073): HitInfo, attacker PackGUID **first**, victim PackGUID, TotalDamage, *two* sub-damage
|
||||
// blocks (absorb summed across both — decision 0137 phase 2's floating combat text feed),
|
||||
// VictimState, two u32s (zero + spell id), blocked. The offhand bit (0x4) rides HitInfo.
|
||||
// SMSG_ATTACKERSTATEUPDATE, per vmangos `Unit.cpp:4572-4605`; the attacker guid comes first.
|
||||
let mut body = 0x6u32.to_le_bytes().to_vec(); // HitInfo: 0x2 | 0x4 (offhand)
|
||||
body.extend_from_slice(&hx("0101")); // attacker packed guid = 1
|
||||
body.extend_from_slice(&hx("c92a4530f1")); // victim packed guid
|
||||
@@ -272,8 +254,7 @@ fn spell_and_action_wire() {
|
||||
_ => panic!("attacker state"),
|
||||
}
|
||||
|
||||
// SMSG_AI_REACTION (vmangos Creature::SendAIReaction → Misc.cpp:445-449): raw (unpacked)
|
||||
// guid + reaction u32 — 2 HOSTILE (aggro), 0 ALERT (stealth pre-aggro).
|
||||
// SMSG_AI_REACTION: raw guid + u32 reaction, 2 = HOSTILE (vmangos `Misc.cpp:445-449`).
|
||||
let body = hx(concat!("3412000000000000", "02000000"));
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_AI_REACTION, &body).unwrap();
|
||||
match &packet {
|
||||
@@ -288,19 +269,8 @@ fn spell_and_action_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The melee swing family's SIX opcodes and FOUR arms — plus the one that must stay unparsed.
|
||||
///
|
||||
/// Every body is empty (vmangos `Server/Packets/Combat.cpp`: `AttackSwingNotInRange`,
|
||||
/// `AttackSwingBadFacing`, `AttackSwingDeadTarget`, `AttackSwingCantAttack` all have a no-op
|
||||
/// `AppendBodyTo`), so there are no bytes to pin — what needs pinning is the **arm map**, which is
|
||||
/// the reference's and not the wire's:
|
||||
///
|
||||
/// - `0x148` DEADTARGET and `0x149` CANT_ATTACK both reach arm 4 (`0x625ab8`) verbatim, so the
|
||||
/// client cannot tell them apart and neither can we;
|
||||
/// - `0x147` NOTSTANDING is **never registered** (`0x6255b0`'s table `0x625aec` has it on the
|
||||
/// default arm `0x625ade`) and vmangos never sends it
|
||||
/// (`Player::SendAttackSwingNotStanding` has zero callers), so it must stay an unknown opcode
|
||||
/// here. A future session adding it "for completeness" fails this test, which is the point.
|
||||
/// Every swing refusal has an empty body (vmangos `Server/Packets/Combat.cpp`). DEADTARGET (0x148)
|
||||
/// and CANT_ATTACK (0x149) share the reference's arm `0x625ab8`, so they decode alike.
|
||||
#[test]
|
||||
fn attack_swing_refusal_wire() {
|
||||
use benilla_protocol::messages::AttackSwingError;
|
||||
@@ -334,7 +304,7 @@ fn attack_swing_refusal_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
// The family's fourth arm — a different opcode family, the same empty body and the same act.
|
||||
// SMSG_CANCEL_COMBAT is the fourth arm: another opcode family, same empty body and effect.
|
||||
assert!(
|
||||
matches!(
|
||||
messages::parse_server(messages::opcode::SMSG_CANCEL_COMBAT, &[]).unwrap(),
|
||||
@@ -347,8 +317,7 @@ fn attack_swing_refusal_wire() {
|
||||
SessionEvent::CancelCombat
|
||||
));
|
||||
|
||||
// The family's other empty-bodied sibling, sent from the same vmangos site as CANCEL_COMBAT —
|
||||
// but a bare `DisplayError(421)` in the reference (`0x6e9800`), with no latch behind it.
|
||||
// SMSG_FEIGN_DEATH_RESISTED: in the reference only a `DisplayError(421)` (`0x6e9800`).
|
||||
assert!(matches!(
|
||||
messages::parse_server(messages::opcode::SMSG_FEIGN_DEATH_RESISTED, &[]).unwrap(),
|
||||
ServerPacket::FeignDeathResisted
|
||||
@@ -358,9 +327,8 @@ fn attack_swing_refusal_wire() {
|
||||
SessionEvent::FeignDeathResisted
|
||||
));
|
||||
|
||||
// 0x147 SMSG_ATTACKSWING_NOTSTANDING — unregistered in the reference, unsent by vmangos, and
|
||||
// therefore an UNKNOWN opcode here: it falls to the `Other` arm, which is exactly what
|
||||
// `0x6255b0`'s default arm `0x625ade` does with it.
|
||||
// 0x147 SMSG_ATTACKSWING_NOTSTANDING stays unknown: the reference never registers it (default
|
||||
// arm `0x625ade`) and vmangos never sends it.
|
||||
assert!(
|
||||
matches!(
|
||||
messages::parse_server(0x0147, &[]).unwrap(),
|
||||
@@ -370,20 +338,16 @@ fn attack_swing_refusal_wire() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The spell-visual pipeline wire (decision 0099 phase 1): `SMSG_SPELL_START`/`GO`,
|
||||
/// `SMSG_SPELL_FAILED_OTHER`, `SMSG_CANCEL_AUTO_REPEAT`, `SMSG_PLAY_SPELL_VISUAL`. Golden bytes are
|
||||
/// hand-built from the vmangos writers (`Spell.cpp:4468-4659`, `SpellCastTargetsInfo.cpp:180-234`,
|
||||
/// `Spell.cpp:4780-4789`, `Server/Packets/Misc.cpp:548-550`, `Server/Packets/Spell.cpp:54-58`) —
|
||||
/// there is no existing oracle for these opcodes.
|
||||
/// Spell-visual bodies per vmangos `Spell.cpp:4468-4659` and `4780-4789`,
|
||||
/// `SpellCastTargetsInfo.cpp:180-234`, `Server/Packets/Misc.cpp:548-550` and
|
||||
/// `Server/Packets/Spell.cpp:54-58`.
|
||||
#[test]
|
||||
fn spell_visual_wire_golden() {
|
||||
use benilla_protocol::messages::{SpellCastTargets, SpellGo, SpellStart};
|
||||
|
||||
let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere)
|
||||
let creature = 0xF130_0000_4500_002Au64; // packs with mask 0xC9
|
||||
|
||||
// SMSG_SPELL_START, a Fireball-style cast: item_or_caster pguid (no cast item, guid 1) + caster
|
||||
// pguid (the creature) + spellId + castFlags (CAST_FLAG_UNKNOWN2 only) + remaining cast-time ms
|
||||
// + a unit-target SpellCastTargets block. No ammo (castFlags doesn't carry CAST_FLAG_AMMO).
|
||||
// SMSG_SPELL_START, a Fireball-style cast at a unit. The ammo block rides only CAST_FLAG_AMMO.
|
||||
let body = hx(concat!(
|
||||
"0101", // item_or_caster pguid: mask 1, guid 1
|
||||
"c92a4530f1", // caster pguid: the creature guid, mask 0xC9
|
||||
@@ -438,16 +402,13 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("spell start event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELL_START, an instant self-cast: cast time 0 (never gets a `Casting` component — the
|
||||
// net bridge's insert gate is `cast_time_ms > 0`), target mask 0 (TARGET_FLAG_SELF) — nothing
|
||||
// follows the mask.
|
||||
let body = hx(concat!(
|
||||
"0109", // item_or_caster pguid: mask 1, guid 9 (no cast item — this is the caster itself)
|
||||
"0109", // item_or_caster pguid: mask 1, guid 9 (no cast item: the caster itself)
|
||||
"0109", // caster pguid: same object, guid 9
|
||||
"11000000", // spellId 17
|
||||
"0200", // castFlags 0x2
|
||||
"00000000", // remaining cast time: 0 (instant)
|
||||
"0000", // SpellCastTargets mask: 0 (SELF) — nothing follows
|
||||
"0000", // SpellCastTargets mask: 0 (SELF), nothing follows
|
||||
));
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
@@ -461,10 +422,7 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("instant spell start event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELL_GO, a ranged Auto Shot: the same guid pair + spellId + castFlags (UNKNOWN9 |
|
||||
// AMMO) + a 2-hit/2-miss target list (one plain miss, one SPELL_MISS_REFLECT with its trailing
|
||||
// reflectResult byte) + a unit-target SpellCastTargets block + the ammo block (displayId
|
||||
// 5996, inventoryType dropped).
|
||||
// SMSG_SPELL_GO: only a SPELL_MISS_REFLECT miss carries the extra reflectResult byte.
|
||||
let body = hx(concat!(
|
||||
"0101", // item_or_caster pguid: guid 1
|
||||
"c92a4530f1", // caster pguid: the creature
|
||||
@@ -530,18 +488,13 @@ fn spell_visual_wire_golden() {
|
||||
assert_eq!(go_target, None); // a unit-targeted cast leaves the GO target empty
|
||||
assert_eq!(dest, None); // …and the ground point empty (mask 0x2, no 0x40)
|
||||
assert_eq!(ammo_display_id, Some(5996));
|
||||
// The packet's first guid (1) differs from the caster — an item cast, surfaced for
|
||||
// the item-use cooldown key.
|
||||
// A first guid that differs from the caster marks an item cast.
|
||||
assert_eq!(item_caster, Some(1));
|
||||
}
|
||||
other => panic!("spell go event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELL_GO for a GROUND cast (the B132 follow-up): empty hit/miss lists, mask 0x0040 +
|
||||
// the dest Vector3d — the exact shape the live vmangos Blizzard capture produced
|
||||
// (2026-07-30; the f32 bit patterns below are the captured DYNAMICOBJECT_POS values). The
|
||||
// event layer must surface the point — it is the only launch-side record of where the
|
||||
// spell went.
|
||||
// A ground cast (Blizzard): mask 0x0040 + dest, a real cast's DYNAMICOBJECT_POS.
|
||||
let body = hx(concat!(
|
||||
"011a", // item_or_caster pguid: guid 0x1a (== caster: a plain spell, no item)
|
||||
"011a", // caster pguid
|
||||
@@ -582,18 +535,12 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("ground spell go event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELL_GO for a **GameObject caster** — the Priest's Lightwell (GO entry 181102) casting
|
||||
// spell 7001 "Lightwell Renew" on the player who clicked it. `GameObject::Use` keeps
|
||||
// `spellCaster = this` for `GAMEOBJECT_TYPE_SPELLCASTER` (22) and builds the GameObject
|
||||
// overload, whose ctor leaves `Unit* const m_casterUnit = nullptr` standing (`Spell.cpp:102`,
|
||||
// `Spell.h:368`) — so `SendSpellGo`'s slot-2 write `WriteGuidHelper(data, m_casterUnit)`
|
||||
// (`Spell.cpp:4513`) emits `ObjectGuid().WriteAsPacked()`: a lone zero mask byte, guid 0.
|
||||
// Slot 1 still carries the object (no cast item → `m_caster`). The event layer must resolve the
|
||||
// pair, or the cast has no caster the world index can ever hold and loses its whole visual body.
|
||||
const LIGHTWELL: u64 = 0xF110_02C3_6E00_0123; // HIGHGUID_GAMEOBJECT | entry 181102 << 24 | 0x123
|
||||
// A GameObject caster (Lightwell 181102, spell 7001): vmangos writes the null `m_casterUnit`
|
||||
// as a lone zero mask byte (`Spell.cpp:4513`), so the object rides only slot 1.
|
||||
const LIGHTWELL: u64 = 0xF110_02C3_6E00_0123; // GO high guid, entry 181102 << 24, counter 0x123
|
||||
let body = hx(concat!(
|
||||
"fb23016ec30210f1", // item_or_caster pguid: the GameObject (mask 0xfb — byte 2 is zero)
|
||||
"00", // caster pguid: EMPTY — vmangos wrote a null `m_casterUnit`
|
||||
"fb23016ec30210f1", // item_or_caster pguid: the GameObject (mask 0xfb: byte 2 is zero)
|
||||
"00", // caster pguid: empty, vmangos writes a null `m_casterUnit`
|
||||
"591b0000", // spellId 7001 (Lightwell Renew)
|
||||
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9)
|
||||
"01", // hit count 1
|
||||
@@ -605,7 +552,6 @@ fn spell_visual_wire_golden() {
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
|
||||
match &packet {
|
||||
ServerPacket::SpellGo(g) => {
|
||||
// The message layer stays a faithful decode: slot 2 really is empty on the wire.
|
||||
assert_eq!((g.item_or_caster, g.caster), (LIGHTWELL, 0));
|
||||
}
|
||||
other => panic!("lightwell spell go, got {}", other.name()),
|
||||
@@ -648,8 +594,7 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("spell failed other event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELL_DELAYED: raw (unpacked) guid + pushback ms (vmangos `Spell::Delayed` — `data <<
|
||||
// ObjectGuid` is a raw u64, then `u32` delaytime). 500ms = 0x1F4.
|
||||
// SMSG_SPELL_DELAYED: raw guid + u32 pushback ms (vmangos `Spell::Delayed`).
|
||||
let body = hx(concat!("4200000000000000", "f4010000"));
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_DELAYED, &body).unwrap();
|
||||
match &packet {
|
||||
@@ -665,7 +610,7 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("spell delayed event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_CANCEL_AUTO_REPEAT: empty body (vmangos `CancelAutoRepeat::AppendBodyTo` writes nothing).
|
||||
// SMSG_CANCEL_AUTO_REPEAT: empty body (vmangos `CancelAutoRepeat::AppendBodyTo`).
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_CANCEL_AUTO_REPEAT, &[]).unwrap();
|
||||
assert!(matches!(packet, ServerPacket::CancelAutoRepeat));
|
||||
assert!(matches!(
|
||||
@@ -687,8 +632,8 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("play spell visual event, got {other:?}"),
|
||||
}
|
||||
|
||||
// MSG_CHANNEL_START: spellId + duration, both u32 — self-only, no guid on the wire (vmangos
|
||||
// `Spell::SendChannelStart`, `Spell.cpp:4951-4954`; decision 0137). 10797 Starshards, 6s.
|
||||
// MSG_CHANNEL_START: u32 spellId + u32 duration, no guid since only the caster gets it
|
||||
// (vmangos `Spell.cpp:4951-4954`). 10797 is Starshards.
|
||||
let body = hx(concat!("2d2a0000", "70170000"));
|
||||
let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_START, &body).unwrap();
|
||||
match &packet {
|
||||
@@ -706,8 +651,7 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("channel start event, got {other:?}"),
|
||||
}
|
||||
|
||||
// MSG_CHANNEL_UPDATE: one u32 time-left (vmangos `Player::SendChannelUpdate`,
|
||||
// `Player.cpp:21106-21110`); 0 = the channel is over.
|
||||
// MSG_CHANNEL_UPDATE: u32 ms left, 0 = over (vmangos `Player.cpp:21106-21110`).
|
||||
let body = hx("b80b0000");
|
||||
let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_UPDATE, &body).unwrap();
|
||||
match &packet {
|
||||
@@ -719,8 +663,6 @@ fn spell_visual_wire_golden() {
|
||||
other => panic!("channel update event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Truncated buffers fail cleanly rather than silently short-reading (the codec's usual
|
||||
// contract — every reader is `Read::read_exact`-backed, which errors on a short buffer).
|
||||
let body = hx(concat!(
|
||||
"0101",
|
||||
"c92a4530f1",
|
||||
@@ -739,10 +681,7 @@ fn spell_visual_wire_golden() {
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).is_err());
|
||||
}
|
||||
|
||||
/// An open-lock cast on a chest / locked door: `SMSG_SPELL_GO` whose `SpellCastTargets` names a
|
||||
/// GameObject (`TARGET_FLAG_GAMEOBJECT`, no unit target). The decoder must surface the GO guid so the
|
||||
/// lid/door animation can open it (decision 2271) — the guid the older decode read for alignment and
|
||||
/// dropped.
|
||||
/// An open-lock cast names its chest or door as a `TARGET_FLAG_GAMEOBJECT` target, not a unit.
|
||||
#[test]
|
||||
fn spell_go_surfaces_the_gameobject_target() {
|
||||
let body = hx(concat!(
|
||||
@@ -778,12 +717,8 @@ fn spell_go_surfaces_the_gameobject_target() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The combat-log wire (decision 0137 phase 2's floating-combat-text data feed):
|
||||
/// `SMSG_SPELLNONMELEEDAMAGELOG`, `SMSG_PERIODICAURALOG`, `SMSG_SPELLDAMAGESHIELD`,
|
||||
/// `SMSG_SPELLLOGMISS`, `SMSG_LOG_XPGAIN`, `SMSG_LEVELUP_INFO`. Golden bytes are hand-built from
|
||||
/// the vmangos writers (`Server/Packets/Spell.cpp:68-86,124-140`, `Unit.cpp:4395-4443`,
|
||||
/// `Server/Packets/Combat.cpp:73-79`, `Server/Packets/Misc.cpp:512-532`) — there is no existing
|
||||
/// oracle for these opcodes.
|
||||
/// Combat-log bodies per vmangos `Server/Packets/Spell.cpp:68-86,124-140`, `Unit.cpp:4395-4443`,
|
||||
/// `Server/Packets/Combat.cpp:73-79` and `Server/Packets/Misc.cpp:512-532`.
|
||||
#[test]
|
||||
fn combat_log_wire_golden() {
|
||||
use benilla_protocol::messages::{
|
||||
@@ -791,11 +726,8 @@ fn combat_log_wire_golden() {
|
||||
PeriodicTick, SpellDamageLog, SpellLogMiss, XpGain,
|
||||
};
|
||||
|
||||
let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere)
|
||||
let creature = 0xF130_0000_4500_002Au64; // packs with mask 0xC9
|
||||
|
||||
// SMSG_SPELLNONMELEEDAMAGELOG: target PackedGuid, attacker PackedGuid, spellId, damage, school
|
||||
// u8, absorbed, resist i32, periodicLog u8 (bool), unused u8, blocked, hitInfo, extendedData u8
|
||||
// (always 0, dropped). hitInfo 0x2 = SPELL_HIT_TYPE_CRIT.
|
||||
let body = hx(concat!(
|
||||
"c92a4530f1", // target pguid: the creature
|
||||
"0101", // attacker pguid: guid 1
|
||||
@@ -807,7 +739,7 @@ fn combat_log_wire_golden() {
|
||||
"00", // periodicLog: false (direct, not a DoT tick)
|
||||
"00", // unused
|
||||
"0a000000", // blocked 10
|
||||
"02000000", // hitInfo 0x2 (crit)
|
||||
"02000000", // hitInfo 0x2 (SPELL_HIT_TYPE_CRIT)
|
||||
"00", // extendedData (always 0, dropped)
|
||||
));
|
||||
let packet =
|
||||
@@ -837,8 +769,7 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("spell damage log event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_PERIODICAURALOG: target PackedGuid, caster PackedGuid, spellId, count, then `count` x
|
||||
// {auraType u32, payload}. First vector: one PERIODIC_DAMAGE (3) tick.
|
||||
// SMSG_PERIODICAURALOG: target, caster, spellId, then count x {u32 auraType, payload}.
|
||||
let body = hx(concat!(
|
||||
"c92a4530f1", // target pguid: the creature
|
||||
"0101", // caster pguid: guid 1
|
||||
@@ -875,7 +806,6 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("periodic aura log event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Second vector: one PERIODIC_HEAL (8) tick — a self-heal (target == caster).
|
||||
let body = hx(concat!(
|
||||
"0101", // target pguid: guid 1
|
||||
"0101", // caster pguid: guid 1 (self)
|
||||
@@ -900,8 +830,7 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("periodic aura log (heal), got {}", other.name()),
|
||||
}
|
||||
|
||||
// Third vector: an unrecognized auraType (4) cannot be skipped without desyncing the stream —
|
||||
// decode must fail rather than guess a payload width.
|
||||
// An unhandled auraType has no known payload width, so the decode fails rather than desync.
|
||||
let body = hx(concat!(
|
||||
"0101", // target
|
||||
"0101", // caster
|
||||
@@ -911,8 +840,7 @@ fn combat_log_wire_golden() {
|
||||
));
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).is_err());
|
||||
|
||||
// SMSG_SPELLDAMAGESHIELD: victim raw guid, attacker raw guid, damage, school — all four fields
|
||||
// distinct. `victim` is the shield's bearer; `attacker` receives the damage back.
|
||||
// SMSG_SPELLDAMAGESHIELD: `victim` bears the shield; `attacker` takes the damage back.
|
||||
let body = hx(concat!(
|
||||
"1111000000000000", // victim raw guid 0x1111
|
||||
"2222000000000000", // attacker raw guid 0x2222
|
||||
@@ -939,9 +867,8 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("damage shield event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_ENVIRONMENTALDAMAGELOG: victim raw guid (vmangos `ObjectGuid.cpp:174` streams the raw
|
||||
// u64, NOT a PackedGuid), damageType u8, damage, absorbed, resist i32 (the > 1.6.1 tail,
|
||||
// `Server/Packets/Combat.cpp:58-67`). Type 2 = fall.
|
||||
// SMSG_ENVIRONMENTALDAMAGELOG: a raw victim guid (vmangos `ObjectGuid.cpp:174`); absorbed and
|
||||
// resist are the post-1.6.1 tail (`Server/Packets/Combat.cpp:58-67`).
|
||||
let body = hx(concat!(
|
||||
"0300000000000000", // victim raw guid 3
|
||||
"02", // damageType 2 (DAMAGE_FALL)
|
||||
@@ -973,9 +900,8 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("environmental damage log event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SPELLLOGMISS: spellId, caster raw guid, useExtended u8 (0 here — no trailing 2xf32 per
|
||||
// entry), count, then `count` x {target raw guid, missInfo u8}. Two entries: DODGE (3) and
|
||||
// REFLECT (11) — the reflect trailing byte only rides SMSG_SPELL_GO's miss list, not this one.
|
||||
// SMSG_SPELLLOGMISS: count x {raw target guid, u8 missInfo}; useExtended 1 adds 2 x f32 per
|
||||
// entry. REFLECT's extra byte rides only SMSG_SPELL_GO's miss list, not this one.
|
||||
let body = hx(concat!(
|
||||
"85000000", // spellId 133
|
||||
"4200000000000000", // caster raw guid 0x42
|
||||
@@ -1005,9 +931,6 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("spell log miss event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_LOG_XPGAIN: victim raw guid (0 for non-kill) + total + xpType (0 kill, carries trailing
|
||||
// base+bonus; 1 non-kill, nothing trailing). Both vectors consume exactly their own body — a
|
||||
// kill's trailing base/bonus is present, a non-kill's is absent.
|
||||
let mut body = 0xCCu64.to_le_bytes().to_vec(); // victim
|
||||
body.extend_from_slice(&250u32.to_le_bytes()); // total
|
||||
body.push(0); // xpType: kill
|
||||
@@ -1035,7 +958,7 @@ fn combat_log_wire_golden() {
|
||||
|
||||
let mut body = 0u64.to_le_bytes().to_vec(); // victim: 0 (non-kill xp has no victim)
|
||||
body.extend_from_slice(&50u32.to_le_bytes()); // total
|
||||
body.push(1); // xpType: non-kill — nothing trails
|
||||
body.push(1); // xpType: non-kill, nothing trails
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_LOG_XPGAIN, &body).unwrap();
|
||||
match &packet {
|
||||
ServerPacket::XpGain(x) => {
|
||||
@@ -1056,9 +979,8 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("xp gain event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_EXPLORATION_EXPERIENCE: areaId u32 (an AreaTable.dbc row id) + xp u32 — exactly
|
||||
// 8 bytes (VERIFIED vmangos Misc.cpp:552-556; sent even when xp is 0). The vector is
|
||||
// Westfall (area 40) worth 85 xp.
|
||||
// SMSG_EXPLORATION_EXPERIENCE: u32 AreaTable.dbc id (40 = Westfall) + u32 xp, sent even when
|
||||
// xp is 0 (vmangos `Misc.cpp:552-556`).
|
||||
let mut body = 40u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&85u32.to_le_bytes());
|
||||
assert_eq!(body.len(), 8);
|
||||
@@ -1081,9 +1003,8 @@ fn combat_log_wire_golden() {
|
||||
other => panic!("exploration xp event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_LEVELUP_INFO: twelve u32 — level, healthGain, powerGains[5] (mana..happiness),
|
||||
// statGains[5] (str..spirit). VERIFIED vmangos Misc.cpp:524-532; exactly 48 bytes, no guid
|
||||
// (self-addressed). The vector is a caster-flavored ding: mana + int/spirit heavy.
|
||||
// SMSG_LEVELUP_INFO: twelve u32, level, healthGain, powerGains[5] (mana..happiness) and
|
||||
// statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`).
|
||||
let mut body = Vec::new();
|
||||
for v in [7u32, 22, 15, 0, 0, 0, 0, 1, 0, 1, 2, 1] {
|
||||
body.extend_from_slice(&v.to_le_bytes());
|
||||
@@ -1110,25 +1031,18 @@ fn combat_log_wire_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS` — the beam's hop list (decision 0955). Byte-exact against
|
||||
/// vmangos `Spell::SendChannelStart` (`Spell.cpp:4970-4997`), which writes an `ObjectGuid` (raw
|
||||
/// `u64`, never packed), then `u32 spellId`, then a `u32` count, then that many raw guids. The
|
||||
/// client's handler `0x6e9820` decodes exactly that shape.
|
||||
///
|
||||
/// This is the packet the whole beam system hangs off: it is the **only** producer of the client's
|
||||
/// chain-target array (`unit+0xd44`), so a decode slip here reads as "the beams came back wrong"
|
||||
/// rather than as a parse error.
|
||||
/// Raw caster guid, u32 spellId, u32 count, raw guids (vmangos `Spell.cpp:4970-4997`); in the
|
||||
/// reference (`0x6e9820`) the only writer of the beams' chain-target array (`unit+0xd44`).
|
||||
#[test]
|
||||
fn spell_chain_targets_wire() {
|
||||
use benilla_protocol::messages::SpellChainTargets;
|
||||
|
||||
const CASTER: u64 = 0x0000_01f1_3045_2ac9;
|
||||
|
||||
// Drain Life (689) channelled from a creature onto one target — the common case, count 1.
|
||||
let body = hx(concat!(
|
||||
"c92a4530f1010000", // caster guid, RAW u64 (not packed)
|
||||
"c92a4530f1010000", // caster guid, raw u64 (not packed)
|
||||
"b1020000", // spellId 689 (Drain Life)
|
||||
"01000000", // count 1 — a u32, not the u8 the GO lists use
|
||||
"01000000", // count 1: a u32, not the u8 the GO lists use
|
||||
"aa00000000000000", // target guid 0xAA, raw
|
||||
));
|
||||
let packet =
|
||||
@@ -1153,7 +1067,6 @@ fn spell_chain_targets_wire() {
|
||||
other => panic!("chain targets event, got {other:?}"),
|
||||
}
|
||||
|
||||
// A three-hop chain, hop order preserved: the beam runs caster -> t1 -> t2 -> t3.
|
||||
let body = hx(concat!(
|
||||
"0100000000000000",
|
||||
"a5010000", // spellId 421 (Chain Lightning)
|
||||
@@ -1170,8 +1083,7 @@ fn spell_chain_targets_wire() {
|
||||
other => panic!("chain targets, got {}", other.name()),
|
||||
}
|
||||
|
||||
// An empty list is legal on the wire (the server only *sends* when it has hits, but the shape
|
||||
// is a count) and must decode rather than error.
|
||||
// An empty list is legal on the wire, though the server only sends the packet with hits.
|
||||
let body = hx(concat!("0100000000000000", "a5010000", "00000000"));
|
||||
match messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap()
|
||||
{
|
||||
@@ -1179,7 +1091,7 @@ fn spell_chain_targets_wire() {
|
||||
other => panic!("chain targets, got {}", other.name()),
|
||||
}
|
||||
|
||||
// A truncated body is an error, not a silent short list — the count says 2, one guid follows.
|
||||
// Count 2 with one guid following is an error, not a short list.
|
||||
let body = hx(concat!(
|
||||
"0100000000000000",
|
||||
"a5010000",
|
||||
@@ -1191,10 +1103,7 @@ fn spell_chain_targets_wire() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The combat-log completeness wire** (decision 1703) — the eight bodies 1571 §5 named as
|
||||
/// "deliberately out because their wire sources are undecoded". Byte-exact against vmangos's own
|
||||
/// writers, cited per packet, because a wrong field order here is a wrong *sentence* in the chat
|
||||
/// log and no gate downstream can see it.
|
||||
/// Eight more combat-log bodies, each checked against the vmangos writer cited at it.
|
||||
#[test]
|
||||
fn combat_log_completeness_wire() {
|
||||
use benilla_protocol::messages::{
|
||||
@@ -1202,8 +1111,7 @@ fn combat_log_completeness_wire() {
|
||||
SpellLogExecute, SpellOutcomeLog,
|
||||
};
|
||||
|
||||
// SMSG_PARTYKILLLOG: killer raw guid, victim raw guid
|
||||
// (`WorldPackets::Combat::PartyKillLog::AppendBodyTo`, Combat.cpp:52-56).
|
||||
// SMSG_PARTYKILLLOG: raw killer + raw victim guid (vmangos `Server/Packets/Combat.cpp:52-56`).
|
||||
let body = hx(concat!(
|
||||
"0700000000000000", // killer 7
|
||||
"2a00000000000000", // victim 42
|
||||
@@ -1219,8 +1127,7 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("party kill log, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_SPELLINSTAKILLLOG: victim raw guid, spellId (`Spell::EffectInstaKill`,
|
||||
// SpellEffects.cpp:274-279).
|
||||
// SMSG_SPELLINSTAKILLLOG: raw victim guid, u32 spellId (vmangos `SpellEffects.cpp:274-279`).
|
||||
let body = hx(concat!("0900000000000000", "0d270000"));
|
||||
match messages::parse_server(messages::opcode::SMSG_SPELLINSTAKILLLOG, &body).unwrap() {
|
||||
ServerPacket::SpellInstaKillLog(p) => assert_eq!(
|
||||
@@ -1233,8 +1140,7 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("instakill log, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_PROCRESIST / SMSG_SPELLORDAMAGE_IMMUNE: one body, two opcodes
|
||||
// (`WorldPackets::Spell::ProcResist`/`SpellOrDamageImmune`, Spell.cpp:88-102).
|
||||
// One body, two opcodes (vmangos `Server/Packets/Spell.cpp:88-102`).
|
||||
let body = hx(concat!(
|
||||
"0100000000000000", // caster 1
|
||||
"0200000000000000", // target 2
|
||||
@@ -1256,8 +1162,7 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("spell-or-damage immune, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_SPELLDISPELLOG: victim PACKED guid, caster PACKED guid, count, count x spellId
|
||||
// (`Spell::EffectDispel`, SpellEffects.cpp:2524-2539 — the >= 1.12.1 branch, which is ours).
|
||||
// SMSG_SPELLDISPELLOG, per vmangos `SpellEffects.cpp:2524-2539` (its 1.12.1 branch).
|
||||
let body = hx(concat!(
|
||||
"0105", // packed victim: mask 0x01, byte 0x05 -> 5
|
||||
"0206", // packed caster: mask 0x02, byte 0x06 -> 0x600
|
||||
@@ -1277,8 +1182,8 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("spell dispel log, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_DISPEL_FAILED: caster raw guid, victim raw guid, then spell ids TO THE END OF THE BODY —
|
||||
// vmangos writes no count (`Spell::EffectDispel`, SpellEffects.cpp:2549-2555).
|
||||
// SMSG_DISPEL_FAILED: raw caster, raw victim, then spell ids to the end of the body, with no
|
||||
// count (vmangos `SpellEffects.cpp:2549-2555`).
|
||||
let body = hx(concat!(
|
||||
"0300000000000000",
|
||||
"0400000000000000",
|
||||
@@ -1296,18 +1201,15 @@ fn combat_log_completeness_wire() {
|
||||
),
|
||||
other => panic!("dispel failed, got {}", other.name()),
|
||||
}
|
||||
// The same packet with no failures at all is a legal two-guid body, not a parse error.
|
||||
let body = hx(concat!("0300000000000000", "0400000000000000"));
|
||||
match messages::parse_server(messages::opcode::SMSG_DISPEL_FAILED, &body).unwrap() {
|
||||
ServerPacket::DispelFailed(p) => assert!(p.spell_ids.is_empty()),
|
||||
other => panic!("dispel failed, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_ENCHANTMENTLOG: caster raw guid, owner raw guid, itemEntry, spellId, showAffiliation
|
||||
// (`WorldPackets::Item::EnchantmentLog::AppendBodyTo`, Item.cpp:235-242). An EMPTY caster is
|
||||
// how the server says the enchant FADED (`Player::SendEnchantmentLog`'s own comment).
|
||||
// SMSG_ENCHANTMENTLOG, per vmangos `Server/Packets/Item.cpp:235-242`.
|
||||
let body = hx(concat!(
|
||||
"0000000000000000", // caster 0 -> a fade
|
||||
"0000000000000000", // caster 0: the enchant faded
|
||||
"0b00000000000000", // owner 11
|
||||
"d2040000", // itemEntry 1234
|
||||
"39050000", // spellId 1337
|
||||
@@ -1327,11 +1229,7 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("enchantment log, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_SPELLLOGEXECUTE: caster PACKED guid, spellId, groupCount, then per group
|
||||
// {effect, rowCount, rows} (`Spell::SendLogExecute`, Spell.cpp:4662-4778). Three groups here
|
||||
// cover the three payload shapes that are not a bare guid, including POWER_DRAIN's
|
||||
// (amount, power, multiplier) — the order verified at the client's own bytes, not only at
|
||||
// vmangos's (see `ExecuteLog::PowerDrain`).
|
||||
// SMSG_SPELLLOGEXECUTE (vmangos `Spell.cpp:4662-4778`): row shape varies by effect.
|
||||
let body = hx(concat!(
|
||||
"0107", // packed caster 7
|
||||
"e8030000", // spellId 1000
|
||||
@@ -1343,11 +1241,11 @@ fn combat_log_completeness_wire() {
|
||||
"2c010000", // amount 300
|
||||
"00000000", // power 0 (mana)
|
||||
"0000803f", // multiplier 1.0
|
||||
// group 2: effect 24 CREATE_ITEM, one row — an item entry and nothing else
|
||||
// group 2: effect 24 CREATE_ITEM, one row of just an item entry
|
||||
"18000000",
|
||||
"01000000",
|
||||
"b80b0000", // itemEntry 3000
|
||||
// group 3: effect 102 DISMISS_PET, one row — the guid-only tail of the switch
|
||||
// group 3: effect 102 DISMISS_PET, one row of just a guid
|
||||
"66000000",
|
||||
"01000000",
|
||||
"1e00000000000000", // target 30
|
||||
@@ -1376,30 +1274,25 @@ fn combat_log_completeness_wire() {
|
||||
other => panic!("spell log execute, got {}", other.name()),
|
||||
}
|
||||
|
||||
// An effect id vmangos has no case for cannot be skipped — its row width is not on the wire —
|
||||
// so the body errors rather than desyncing. vmangos never sends one: its own switch returns
|
||||
// before `SendMessageToSet`, so an unlisted effect means no packet, never a truncated one.
|
||||
// An effect vmangos has no case for has no known row width, so the body errors; vmangos never
|
||||
// sends one, since its switch returns before `SendMessageToSet`.
|
||||
let body = hx(concat!(
|
||||
"0107", "e8030000", "01000000", "77000000", "01000000"
|
||||
));
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_SPELLLOGEXECUTE, &body).is_err());
|
||||
}
|
||||
|
||||
/// The talent spell-modifier pair, end to end: both opcodes through `parse_server` and on into a
|
||||
/// [`SessionEvent`]. One handler, one body, and the opcode as the only discriminant — so the two
|
||||
/// bodies here are byte-identical and only the `flat` flag differs.
|
||||
/// The flat and pct spell-modifier opcodes share one body; only the opcode sets `flat`.
|
||||
#[test]
|
||||
fn spell_modifier_wire() {
|
||||
// mask_bit 35 · op 14 (SPELLMOD_COST) · value -30. Bit 35 is deliberately past the low dword:
|
||||
// it is a real shipped value (Cure Poison 526's only bit) and it is the half a 32-bit mask
|
||||
// would quietly drop.
|
||||
// u8 mask_bit 35, u8 op 14 (SPELLMOD_COST), i32 value -30. Bit 35, Cure Poison 526's only
|
||||
// bit, sits past the low dword that a 32-bit mask would keep.
|
||||
let body = hx("230ee2ffffff");
|
||||
for (wire_op, is_flat) in [
|
||||
(messages::opcode::SMSG_SET_FLAT_SPELL_MODIFIER, true),
|
||||
(messages::opcode::SMSG_SET_PCT_SPELL_MODIFIER, false),
|
||||
] {
|
||||
let packet = messages::parse_server(wire_op, &body).unwrap();
|
||||
// The name recovers the opcode, which the collapsed-arm families cannot.
|
||||
assert_eq!(
|
||||
packet.name(),
|
||||
if is_flat {
|
||||
@@ -1428,8 +1321,6 @@ fn spell_modifier_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
// Five bytes is not a body: the value is a dword, not the three bytes a "u8 u8 u8 i16" misread
|
||||
// would take.
|
||||
assert!(messages::parse_server(
|
||||
messages::opcode::SMSG_SET_FLAT_SPELL_MODIFIER,
|
||||
&hx("230ee2ffff")
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
//! Oracle-free golden tests for the stable-master arc's protocol layer (decision 1676): the five
|
||||
//! send verbs, the `MSG_LIST_STABLED_PETS` list, and the `SMSG_STABLE_RESULT` byte. Same idioms as
|
||||
//! `trainer.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies round-tripped through
|
||||
//! `parse_server`.
|
||||
//!
|
||||
//! Every layout here is VERIFIED against vmangos: the send bodies from
|
||||
//! `Server/Packets/Npc.cpp:51-76` (`ReadFromWorldPacket` for each `ClientPacket`), the list from
|
||||
//! `WorldSession::SendStablePet` (`Handlers/NPCHandler.cpp:522-575`), and the result byte from
|
||||
//! `StableResult::AppendBodyTo` (`Npc.cpp:99-102`) with the codes from that file's
|
||||
//! `StableResultCode` enum (`NPCHandler.cpp:40-47`).
|
||||
//! Stable-master wire: send bodies per vmangos `Server/Packets/Npc.cpp:51-76`, the pet list per
|
||||
//! `Handlers/NPCHandler.cpp:522-575` and the result byte per `Npc.cpp:99-102`.
|
||||
|
||||
use benilla_protocol::messages::{self, stable_result, StabledPet};
|
||||
use benilla_protocol::ServerPacket;
|
||||
@@ -24,10 +16,7 @@ const NPC_HEX: &str = "f0debc9a78563412";
|
||||
|
||||
#[test]
|
||||
fn stable_send_bodies_golden() {
|
||||
// The three guid-only verbs. They are byte-identical bodies on three different opcodes — the
|
||||
// opcode IS the verb — so each is asserted separately rather than through a shared helper: a
|
||||
// writer that sent `stable_pet`'s body under `CMSG_BUY_STABLE_SLOT` would still match a shared
|
||||
// assertion.
|
||||
// Three guid-only verbs with identical bodies: only the opcode tells them apart.
|
||||
assert_eq!(
|
||||
messages::list_stabled_pets(NPC),
|
||||
hx(NPC_HEX),
|
||||
@@ -44,9 +33,7 @@ fn stable_send_bodies_golden() {
|
||||
"CMSG_BUY_STABLE_SLOT body"
|
||||
);
|
||||
|
||||
// The two pet-number verbs: u64 npcGuid, u32 petNumber. The number is the pet's OWN id
|
||||
// (`character_pet.id`), never its slot — a body carrying a slot index would name the wrong pet
|
||||
// for every hunter whose stable is not in id order.
|
||||
// u64 npcGuid + u32 petNumber, the pet's own id (`character_pet.id`), never its slot.
|
||||
assert_eq!(
|
||||
messages::unstable_pet(NPC, 42),
|
||||
hx(&format!("{NPC_HEX}2a000000")),
|
||||
@@ -59,9 +46,7 @@ fn stable_send_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Append one variable-length pet record to a `MSG_LIST_STABLED_PETS` body, in wire order:
|
||||
/// `u32 petNumber, u32 creatureEntry, u32 level, cstring name, u32 loyalty, u8 slot` — where
|
||||
/// `slot` is the **wire** value (1-based), not the client index the decode produces.
|
||||
/// Appends one pet record in wire order; `wire_slot` is 1-based, unlike the decoded index.
|
||||
fn push_pet(
|
||||
body: &mut Vec<u8>,
|
||||
pet_number: u32,
|
||||
@@ -101,11 +86,8 @@ fn parse_list(body: &[u8]) -> (u64, u8, Vec<StabledPet>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A full hunter: a pet at their side and both stable slots occupied. **The slot rebasing is the
|
||||
/// assertion that matters** — the wire is 1-based (`SendStablePet` writes a literal `0x01` for the
|
||||
/// current pet and `it->slot + 1` for a stabled one) and the client is 0-based, so a decode that
|
||||
/// forwarded the wire byte would put the current pet in stable slot 1 and drop the last pet off the
|
||||
/// end of a 3-slot window.
|
||||
/// Wire slots are 1-based (`SendStablePet` writes 1 for the current pet and `slot + 1` for a
|
||||
/// stabled one); decoded slots are 0-based.
|
||||
#[test]
|
||||
fn a_full_stable_decodes_with_client_slot_indices() {
|
||||
let body = list_body(
|
||||
@@ -135,16 +117,13 @@ fn a_full_stable_decodes_with_client_slot_indices() {
|
||||
);
|
||||
assert_eq!(pets[1].slot, 1);
|
||||
assert_eq!(pets[2].slot, 2);
|
||||
// The name is a cstring mid-record, so every field after it depends on having consumed it
|
||||
// exactly: a mis-read name shifts loyalty and slot together.
|
||||
// The name is a cstring mid-record; every later field depends on consuming it exactly.
|
||||
assert_eq!(pets[2].name, "Nibbles");
|
||||
assert_eq!((pets[2].loyalty, pets[2].creature_entry), (1, 883));
|
||||
}
|
||||
|
||||
/// A hunter with **no current pet** — the row for slot 0 is simply absent (vmangos emits it only
|
||||
/// for a live `HUNTER_PET` or a cached one). The rows must therefore be read BY SLOT: a consumer
|
||||
/// that treated `pets[0]` as the current pet would show a stabled wolf standing at the player's
|
||||
/// side.
|
||||
/// vmangos sends the slot-0 row only for a live or cached `HUNTER_PET`, so rows are read by slot,
|
||||
/// never by position.
|
||||
#[test]
|
||||
fn an_absent_current_pet_leaves_no_slot_zero_row() {
|
||||
let body = list_body(1, &[(8, 1126, 38, "Bruiser", 4, 2)]);
|
||||
@@ -158,24 +137,19 @@ fn an_absent_current_pet_leaves_no_slot_zero_row() {
|
||||
assert!(!pets.iter().any(|p| p.slot == 0));
|
||||
}
|
||||
|
||||
/// A hunter who has bought a slot and stabled nothing: an empty list with a non-zero slot count.
|
||||
/// This must decode to an empty vec — the count and the rows are independent numbers, and treating
|
||||
/// `num_stable_slots` as a row count would over-read the body.
|
||||
/// The purchased slot count and the row count are independent numbers.
|
||||
#[test]
|
||||
fn an_empty_list_still_carries_the_purchased_slot_count() {
|
||||
let (_, slots, pets) = parse_list(&list_body(1, &[]));
|
||||
assert_eq!(slots, 1);
|
||||
assert!(pets.is_empty());
|
||||
|
||||
// And the true zero state: nothing bought, nothing stabled.
|
||||
let (_, slots, pets) = parse_list(&list_body(0, &[]));
|
||||
assert_eq!(slots, 0);
|
||||
assert!(pets.is_empty());
|
||||
}
|
||||
|
||||
/// Every `StableResultCode` vmangos can send, round-tripped. The success codes are distinct
|
||||
/// numbers but `SUCCESS_UNSTABLE` answers BOTH a plain unstable and a swap, so the reply alone
|
||||
/// never identifies which verb ran.
|
||||
/// `SUCCESS_UNSTABLE` answers both an unstable and a swap, so the reply never names the verb.
|
||||
#[test]
|
||||
fn stable_result_codes_round_trip() {
|
||||
for code in [
|
||||
@@ -191,8 +165,7 @@ fn stable_result_codes_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
// The values themselves, against vmangos's enum — a renumbering here would silently turn a
|
||||
// refusal into a success in the app's match.
|
||||
// vmangos's `StableResultCode` values (`NPCHandler.cpp:40-47`).
|
||||
assert_eq!(
|
||||
[
|
||||
stable_result::ERR_MONEY,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! Oracle-free golden tests for the taxi/flight-master arc's protocol layer (decision 0484 phase
|
||||
//! 1): the four `CMSG_TAXI*` send verbs, the `SMSG_SHOWTAXINODES` map + `TaxiMask` bit law, the
|
||||
//! `SMSG_TAXINODE_STATUS` status/learn packet, `SMSG_ACTIVATETAXIREPLY`'s verdict codes, and the
|
||||
//! empty `SMSG_NEW_TAXI_PATH` ack. Same idioms as `trainer.rs`/`gossip_vendor.rs` — `hx(...)`
|
||||
//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`, each SMSG's
|
||||
//! `decode()` bridge exercised too.
|
||||
//! Flight-master wire: the taxi send verbs, the node map and its mask, status, reply and path ack.
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages::{self, taxi_reply, TaxiMask};
|
||||
@@ -18,8 +13,7 @@ fn hx(s: &str) -> Vec<u8> {
|
||||
|
||||
#[test]
|
||||
fn taxi_send_bodies_golden() {
|
||||
// CMSG_TAXINODE_STATUS_QUERY / CMSG_TAXIQUERYAVAILABLENODES (vmangos Packets/Taxi.cpp): both
|
||||
// a single full 8-byte guid — same shape as CMSG_GOSSIP_HELLO / CMSG_TRAINER_LIST.
|
||||
// Both queries are one full 8-byte guid (vmangos `Packets/Taxi.cpp`).
|
||||
assert_eq!(
|
||||
messages::taxi_node_status_query(0x1234_5678_9abc_def0),
|
||||
hx("f0debc9a78563412"),
|
||||
@@ -31,16 +25,14 @@ fn taxi_send_bodies_golden() {
|
||||
"CMSG_TAXIQUERYAVAILABLENODES body"
|
||||
);
|
||||
|
||||
// CMSG_ACTIVATETAXI (vmangos ActivateTaxi::ReadFromWorldPacket): guid, node1, node2 — a
|
||||
// single-hop flight, Stormwind (2) -> Sentinel Hill (4).
|
||||
// CMSG_ACTIVATETAXI: guid, from node, to node; Stormwind (2) -> Sentinel Hill (4).
|
||||
assert_eq!(
|
||||
messages::activate_taxi(0x1234_5678_9abc_def0, 2, 4),
|
||||
hx(concat!("f0debc9a78563412", "02000000", "04000000")),
|
||||
"CMSG_ACTIVATETAXI body"
|
||||
);
|
||||
|
||||
// CMSG_ACTIVATETAXIEXPRESS (vmangos ActivateTaxiExpress::ReadFromWorldPacket): guid,
|
||||
// totalcost, nodeCount, nodeCount x node — a 3-node chain at a combined 110-copper fare.
|
||||
// CMSG_ACTIVATETAXIEXPRESS: guid, u32 total cost, u32 count, count x u32 node.
|
||||
assert_eq!(
|
||||
messages::activate_taxi_express(0x1234_5678_9abc_def0, 110, &[2, 3, 4]),
|
||||
hx(concat!(
|
||||
@@ -53,7 +45,6 @@ fn taxi_send_bodies_golden() {
|
||||
)),
|
||||
"CMSG_ACTIVATETAXIEXPRESS body"
|
||||
);
|
||||
// An empty node list still writes a zero count, not a truncated body.
|
||||
assert_eq!(
|
||||
messages::activate_taxi_express(0x1234_5678_9abc_def0, 0, &[]),
|
||||
hx(concat!("f0debc9a78563412", "00000000", "00000000")),
|
||||
@@ -63,9 +54,7 @@ fn taxi_send_bodies_golden() {
|
||||
|
||||
#[test]
|
||||
fn taxi_mask_bit_law() {
|
||||
// Bit law (vmangos PlayerTaxi.h IsTaximaskNodeKnown/SetTaximaskNode): 1-based node id,
|
||||
// field = (id-1)/32, bit = (id-1)%32. Node 2 -> field 0 bit 1 (0x2); node 4 -> field 0 bit 3
|
||||
// (0x8); node 33 -> field 1 bit 0 (0x1) — the first id that crosses into the second word.
|
||||
// Node ids are 1-based: field (id-1)/32, bit (id-1)%32 (vmangos `PlayerTaxi.h`).
|
||||
let mask = TaxiMask([0x0000_000A, 0x0000_0001, 0, 0, 0, 0, 0, 0]);
|
||||
assert!(mask.is_known(2));
|
||||
assert!(mask.is_known(4));
|
||||
@@ -77,9 +66,7 @@ fn taxi_mask_bit_law() {
|
||||
|
||||
#[test]
|
||||
fn show_taxi_nodes_wire() {
|
||||
// SMSG_SHOWTAXINODES (vmangos SendTaxiMenu, TaxiHandler.cpp:82-96): u32 windowConstant
|
||||
// (always 1), u64 flightmasterGuid (PLAIN, not packed), u32 nearestNode, u32[8] knownMask.
|
||||
// 48 bytes fixed. Mask carries node 2 (Stormwind) and node 33 (crosses into the second word).
|
||||
// SMSG_SHOWTAXINODES (vmangos `TaxiHandler.cpp:82-96`): window always 1, guid not packed.
|
||||
let mut body = 1u32.to_le_bytes().to_vec(); // window constant
|
||||
body.extend_from_slice(&0xCCu64.to_le_bytes()); // flightmaster guid
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // nearest node
|
||||
@@ -105,7 +92,6 @@ fn show_taxi_nodes_wire() {
|
||||
other => panic!("show taxi nodes, got {}", other.name()),
|
||||
}
|
||||
|
||||
// The decode() bridge drops the gate word and carries the rest straight through.
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SHOWTAXINODES, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::TaxiNodesShown {
|
||||
@@ -119,9 +105,7 @@ fn show_taxi_nodes_wire() {
|
||||
other => panic!("taxi nodes shown event, got {other:?}"),
|
||||
}
|
||||
|
||||
// The leading word is the client parser's own conditional gate (0496 §claim-1): a zero gate
|
||||
// ends the body right there — 4 bytes total, and no event out of decode. vmangos never sends
|
||||
// it; the parse is faithful anyway.
|
||||
// The leading word gates the reference's parse: 0 ends the body (vmangos never sends 0).
|
||||
let gated = 0u32.to_le_bytes().to_vec();
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SHOWTAXINODES, &gated).unwrap();
|
||||
assert!(
|
||||
@@ -132,8 +116,7 @@ fn show_taxi_nodes_wire() {
|
||||
|
||||
#[test]
|
||||
fn taxi_node_status_wire() {
|
||||
// SMSG_TAXINODE_STATUS (vmangos TaxiNodeStatus::AppendBodyTo): u64 guid (PLAIN), u8 known
|
||||
// (a C++ bool — one byte). Exercise both known and unknown.
|
||||
// SMSG_TAXINODE_STATUS (vmangos `TaxiNodeStatus::AppendBodyTo`): raw u64 guid + u8 known.
|
||||
let mut known_body = 0xCCu64.to_le_bytes().to_vec();
|
||||
known_body.push(1);
|
||||
match messages::parse_server(messages::opcode::SMSG_TAXINODE_STATUS, &known_body).unwrap() {
|
||||
@@ -157,7 +140,7 @@ fn taxi_node_status_wire() {
|
||||
|
||||
#[test]
|
||||
fn activate_taxi_reply_and_new_taxi_path_wire() {
|
||||
// SMSG_ACTIVATETAXIREPLY (vmangos ActivateTaxiReply::AppendBodyTo): one u32 TaxiError code.
|
||||
// SMSG_ACTIVATETAXIREPLY (vmangos `ActivateTaxiReply::AppendBodyTo`): one u32 TaxiError code.
|
||||
let body = taxi_reply::NOT_ENOUGH_MONEY.to_le_bytes().to_vec();
|
||||
match messages::parse_server(messages::opcode::SMSG_ACTIVATETAXIREPLY, &body).unwrap() {
|
||||
ServerPacket::ActivateTaxiReply { code } => {
|
||||
@@ -173,11 +156,8 @@ fn activate_taxi_reply_and_new_taxi_path_wire() {
|
||||
other => panic!("activate taxi reply event, got {other:?}"),
|
||||
}
|
||||
|
||||
// TaxiError values (vmangos Objects/Player.h:404-416) — pinned so a future edit can't drift
|
||||
// them. Codes actually emitted by Player::ActivateTaxiPathTo (this session's grep of
|
||||
// Player.cpp:18008-18252): OK, UNSPECIFIED_SERVER_ERROR, NO_SUCH_PATH, NOT_ENOUGH_MONEY,
|
||||
// TOO_FAR, BUSY, ALREADY_MOUNTED, SHAPESHIFTED. The rest are defined but never sent.
|
||||
// (13-wide, so an array rather than a tuple — Rust's tuple trait impls stop at 12.)
|
||||
// vmangos `TaxiError` (`Objects/Player.h:404-416`); `ActivateTaxiPathTo` only ever sends 0-4
|
||||
// and 7-9 (`Player.cpp:18008-18252`).
|
||||
assert_eq!(
|
||||
[
|
||||
taxi_reply::OK,
|
||||
@@ -197,7 +177,6 @@ fn activate_taxi_reply_and_new_taxi_path_wire() {
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
);
|
||||
|
||||
// SMSG_NEW_TAXI_PATH — empty body (vmangos NewTaxiPath::AppendBodyTo writes nothing).
|
||||
match messages::parse_server(messages::opcode::SMSG_NEW_TAXI_PATH, &[]).unwrap() {
|
||||
ServerPacket::NewTaxiPath => {}
|
||||
other => panic!("new taxi path, got {}", other.name()),
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
//! Oracle-free golden tests for the training arc's protocol layer (decision 0237): the
|
||||
//! `CMSG_TRAINER_LIST`/`CMSG_TRAINER_BUY_SPELL` send verbs, the `SMSG_TRAINER_LIST` service list and
|
||||
//! the buy succeeded/failed results, and the incremental spell-learn pair
|
||||
//! (`SMSG_LEARNED_SPELL`/`SMSG_SUPERCEDED_SPELL`). Same idioms as `gossip_vendor.rs` — `hx(...)`
|
||||
//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`. The learn pair
|
||||
//! also asserts its `decode()` bridge (phase 2 wired those events); the trainer list/buy variants
|
||||
//! stay consumer-free until phases 3–4 wire the `TrainerState` events + `ClassTrainerFrame`.
|
||||
//! Trainer wire: the list and buy send verbs, the service list, buy results and spell learning.
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages::{self, train_fail, trainer_spell_state, TrainerSpell};
|
||||
@@ -19,15 +13,14 @@ fn hx(s: &str) -> Vec<u8> {
|
||||
|
||||
#[test]
|
||||
fn trainer_send_bodies_golden() {
|
||||
// CMSG_TRAINER_LIST (vmangos Npc.cpp, TrainerList::Read): one full trainer guid — same shape as
|
||||
// CMSG_GOSSIP_HELLO / CMSG_LIST_INVENTORY.
|
||||
// CMSG_TRAINER_LIST: one full trainer guid (vmangos `Npc.cpp` `TrainerList::Read`).
|
||||
assert_eq!(
|
||||
messages::trainer_list(0x1234_5678_9abc_def0),
|
||||
hx("f0debc9a78563412"),
|
||||
"CMSG_TRAINER_LIST body"
|
||||
);
|
||||
|
||||
// CMSG_TRAINER_BUY_SPELL (vmangos Npc.cpp, TrainerBuySpell::Read): u64 trainerGuid, u32 spellId.
|
||||
// CMSG_TRAINER_BUY_SPELL: u64 trainerGuid + u32 spellId (vmangos `TrainerBuySpell::Read`).
|
||||
assert_eq!(
|
||||
messages::trainer_buy_spell(0x1234_5678_9abc_def0, 78),
|
||||
hx(concat!("f0debc9a78563412", "4e000000")),
|
||||
@@ -35,9 +28,7 @@ fn trainer_send_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Append one 38-byte service record (vmangos SendTrainerSpellHelper, NPCHandler.cpp:97-139) to a
|
||||
/// SMSG_TRAINER_LIST body: spell u32, state u8, cost u32, canLearnPrimaryProf u32, isPrimaryProfFirstRank
|
||||
/// u32, reqLevel u8, reqSkill u32, reqSkillValue u32, then three prerequisite-spell u32s.
|
||||
/// Appends one 38-byte service record in wire order (vmangos `NPCHandler.cpp:97-139`).
|
||||
fn push_service(
|
||||
body: &mut Vec<u8>,
|
||||
spell: u32,
|
||||
@@ -65,10 +56,7 @@ fn push_service(
|
||||
|
||||
#[test]
|
||||
fn trainer_list_wire() {
|
||||
// SMSG_TRAINER_LIST (vmangos SendTrainerList, NPCHandler.cpp:141-241): u64 trainerGuid, u32
|
||||
// trainerType, u32 count, count x 38-byte service, cstr title. Two services: a green (learnable)
|
||||
// class spell with a level gate, and a gray (already-known) primary-profession first rank with a
|
||||
// skill gate + one prerequisite spell — exercising both prof flag fields and the req_spells array.
|
||||
// SMSG_TRAINER_LIST, per vmangos `SendTrainerList` (`NPCHandler.cpp:141-241`).
|
||||
let mut body = 0xCCu64.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // trainerType 0 = class
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // count
|
||||
@@ -116,7 +104,6 @@ fn trainer_list_wire() {
|
||||
other => panic!("trainer list, got {}", other.name()),
|
||||
}
|
||||
|
||||
// Empty trainer (count 0): the title still follows immediately — no service records to read.
|
||||
let mut empty = 0xDDu64.to_le_bytes().to_vec();
|
||||
empty.extend_from_slice(&2u32.to_le_bytes()); // trainerType 2 = tradeskill
|
||||
empty.extend_from_slice(&0u32.to_le_bytes()); // count
|
||||
@@ -135,7 +122,6 @@ fn trainer_list_wire() {
|
||||
other => panic!("empty trainer list, got {}", other.name()),
|
||||
}
|
||||
|
||||
// The decode() bridge maps title → greeting and carries the wire services through (phase 3).
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_TRAINER_LIST, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::TrainerList {
|
||||
@@ -153,7 +139,7 @@ fn trainer_list_wire() {
|
||||
|
||||
#[test]
|
||||
fn trainer_buy_result_wire() {
|
||||
// SMSG_TRAINER_BUY_SUCCEEDED (vmangos TrainerBuySucceeded::AppendBodyTo): u64 trainerGuid, u32 spellId.
|
||||
// SMSG_TRAINER_BUY_SUCCEEDED (vmangos `TrainerBuySucceeded`): u64 trainerGuid, u32 spellId.
|
||||
let mut ok = 0xCCu64.to_le_bytes().to_vec();
|
||||
ok.extend_from_slice(&78u32.to_le_bytes());
|
||||
match messages::parse_server(messages::opcode::SMSG_TRAINER_BUY_SUCCEEDED, &ok).unwrap() {
|
||||
@@ -163,8 +149,7 @@ fn trainer_buy_result_wire() {
|
||||
other => panic!("trainer buy succeeded, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_TRAINER_BUY_FAILED (vmangos TrainerBuyFailed::AppendBodyTo): u64 trainerGuid, u32 serviceId,
|
||||
// u32 errorCode.
|
||||
// SMSG_TRAINER_BUY_FAILED (vmangos `TrainerBuyFailed`): u64 guid, u32 serviceId, u32 error.
|
||||
let mut failed = 0xCCu64.to_le_bytes().to_vec();
|
||||
failed.extend_from_slice(&78u32.to_le_bytes());
|
||||
failed.extend_from_slice(&train_fail::NOT_ENOUGH_MONEY.to_le_bytes());
|
||||
@@ -182,8 +167,7 @@ fn trainer_buy_result_wire() {
|
||||
other => panic!("trainer buy failed, got {}", other.name()),
|
||||
}
|
||||
|
||||
// Wire enum values pinned so a future edit can't drift them (vmangos Player.h:119-122,
|
||||
// SharedDefines.h:1120-1122).
|
||||
// vmangos values (`Player.h:119-122`, `SharedDefines.h:1120-1122`).
|
||||
assert_eq!(
|
||||
(
|
||||
trainer_spell_state::GREEN,
|
||||
@@ -204,14 +188,12 @@ fn trainer_buy_result_wire() {
|
||||
|
||||
#[test]
|
||||
fn learned_and_superceded_spell_wire() {
|
||||
// SMSG_LEARNED_SPELL (vmangos LearnedSpell::AppendBodyTo, Spell.cpp:175-179): u16 spellId, u16
|
||||
// actionBarSlot. The slot is unused on the client and dropped — a nonzero slot here must not leak
|
||||
// into spell_id (proving it's read as two u16s, not one u32).
|
||||
// SMSG_LEARNED_SPELL: u16 spellId + u16 actionBarSlot, the slot unused by the client and
|
||||
// dropped (vmangos `Spell.cpp:175-179`).
|
||||
match messages::parse_server(messages::opcode::SMSG_LEARNED_SPELL, &hx("cb19aaaa")).unwrap() {
|
||||
ServerPacket::LearnedSpell { spell_id } => assert_eq!(spell_id, 6603),
|
||||
other => panic!("learned spell, got {}", other.name()),
|
||||
}
|
||||
// The bridge widens the wire u16 to the store's u32 (phase 2).
|
||||
let packet =
|
||||
messages::parse_server(messages::opcode::SMSG_LEARNED_SPELL, &hx("cb19aaaa")).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
@@ -219,8 +201,7 @@ fn learned_and_superceded_spell_wire() {
|
||||
other => panic!("spell learned event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SUPERCEDED_SPELL (vmangos SupercededSpell::AppendBodyTo, Spell.cpp:169-173): u16 oldSpellId,
|
||||
// u16 newSpellId.
|
||||
// SMSG_SUPERCEDED_SPELL: u16 oldSpellId + u16 newSpellId (vmangos `Spell.cpp:169-173`).
|
||||
match messages::parse_server(messages::opcode::SMSG_SUPERCEDED_SPELL, &hx("cb19cc19")).unwrap()
|
||||
{
|
||||
ServerPacket::SupercededSpell {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
//! `SMSG_UPDATE_OBJECT` / `SMSG_DESTROY_OBJECT` wire tests (mirrors `src/messages/update_object.rs`):
|
||||
//! object creates (unit/gameobject/item/container), out-of-range removal, destroy, and the sparse
|
||||
//! `ObjectFields` Values-update decode (unit state/power/customization, virtual items + sheath,
|
||||
//! inventory slots). Split out of the former `tests/messages.rs` — see `tests/common` for the shared
|
||||
//! fixtures and methodology note.
|
||||
//! `SMSG_UPDATE_OBJECT` and `SMSG_DESTROY_OBJECT` wire tests (`src/messages/update_object.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -13,9 +9,7 @@ use common::hx;
|
||||
|
||||
#[test]
|
||||
fn update_object_fixture_decodes() {
|
||||
// A real serialized SMSG_UPDATE_OBJECT body: Unit create (guid 0xAA, displayid 50, scale 1.5, a
|
||||
// living position) + GameObject create (guid 0xBB, displayid 99, scale 2.0, pos 100/200/300) +
|
||||
// out-of-range [0x10, 0x20].
|
||||
// A unit create (0xAA), a gameobject create (0xBB) and an out-of-range block [0x10, 0x20].
|
||||
let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05000117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||||
let events = decode(packet);
|
||||
@@ -74,9 +68,7 @@ fn update_object_fixture_decodes() {
|
||||
|
||||
#[test]
|
||||
fn destroy_object_decodes_to_object_destroyed() {
|
||||
// SMSG_DESTROY_OBJECT: a plain u64 guid (vmangos `DestroyObject::AppendBodyTo` streams the raw
|
||||
// ObjectGuid) — the corpse-decay / despawn teardown. Its own event, distinct from the OutOfRange
|
||||
// update block: destroyed objects pop instantly, out-of-range ones fade.
|
||||
// SMSG_DESTROY_OBJECT: a raw u64 guid (vmangos `DestroyObject::AppendBodyTo`).
|
||||
let body = hx("efbeadde01000000");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_DESTROY_OBJECT, &body).unwrap();
|
||||
assert_eq!(packet.name(), "SMSG_DESTROY_OBJECT");
|
||||
@@ -92,10 +84,8 @@ fn destroy_object_decodes_to_object_destroyed() {
|
||||
|
||||
#[test]
|
||||
fn values_update_decodes_unit_state() {
|
||||
// A hand-built Values-only update (update_type 0) for guid 0xAA carrying the unit descriptor
|
||||
// fields HEALTH=22 (=100), MAXHEALTH=28 (=120), LEVEL=34 (=5), BYTES_0=36 (race 1/class 1/gender 0)
|
||||
// — bits in 2 mask blocks, values in ascending field order. Verifies the `Object::Values` path
|
||||
// (previously dropped) emits an ObjectValues delta whose fields decode via the named accessors.
|
||||
// Values update for 0xAA: fields 22 HEALTH 100, 28 MAXHEALTH 120, 34 LEVEL 5 and 36 BYTES_0
|
||||
// (race 1, class 1, gender 0), in 2 mask blocks with values in ascending field order.
|
||||
let body = hx("01000000000001aa02000040101400000064000000780000000500000001010001");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||||
let fields = decode(packet)
|
||||
@@ -116,17 +106,14 @@ fn values_update_decodes_unit_state() {
|
||||
),
|
||||
(Some(100), Some(120), Some(5), Some(1), Some(1), Some(0))
|
||||
);
|
||||
// A non-player unit carries no PLAYER block, so the customization accessors stay `None`.
|
||||
assert_eq!(fields.player_skin(), None);
|
||||
assert_eq!(fields.player_facial_hair(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_update_decodes_player_customization() {
|
||||
// A Values-only update for player guid 0xBB carrying PLAYER_BYTES=193 (skin 0x10 / face 0x11 /
|
||||
// hairStyle 0x12 / hairColor 0x13, packed b0..b3) and PLAYER_BYTES_2=194 (facialHair 0x14, b0).
|
||||
// Field 193 lives in mask block 6 (bit 1), 194 in block 6 (bit 2) → 7 blocks, the first six empty.
|
||||
// Pins the RF-0056 field indices + byte order against the decode.
|
||||
// Player 0xBB: PLAYER_BYTES (193) = skin, face, hairStyle, hairColor in b0..b3; PLAYER_BYTES_2
|
||||
// (194) b0 = facialHair. Both sit in mask block 6 (bits 1, 2), after six empty blocks.
|
||||
let body = hx(
|
||||
"01000000000001bb07000000000000000000000000000000000000000000000000060000001011121314000000",
|
||||
);
|
||||
@@ -152,9 +139,8 @@ fn values_update_decodes_player_customization() {
|
||||
|
||||
#[test]
|
||||
fn values_update_decodes_unit_power() {
|
||||
// A Values-only update for guid 0xAA carrying POWER1=23 (40), MAXPOWER1=29 (60), BYTES_0=36
|
||||
// (race 1 / class 1 / gender 0 / power type 0 = mana). Pins the 1.12.1 power field indices
|
||||
// (vmangos UpdateFields_1_12_1.h) + the bytes_0 power-type byte against the decode.
|
||||
// Fields 23 POWER1 = 40, 29 MAXPOWER1 = 60, 36 BYTES_0 with power type 0 (mana) in b3 (vmangos
|
||||
// `UpdateFields_1_12_1.h`).
|
||||
let body = hx("01000000000001aa020000802010000000280000003c00000001010000");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||||
let fields = decode(packet)
|
||||
@@ -167,19 +153,15 @@ fn values_update_decodes_unit_power() {
|
||||
assert_eq!(fields.unit_power_type(), 0);
|
||||
assert_eq!(fields.unit_power(0), Some(40));
|
||||
assert_eq!(fields.unit_max_power(0), Some(60));
|
||||
// Other slots are absent; out-of-range slots are None by contract.
|
||||
assert_eq!(fields.unit_power(1), None);
|
||||
assert_eq!(fields.unit_max_power(5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_update_decodes_unit_virtual_items_and_sheath() {
|
||||
// A Values-only update for creature guid 0xCC carrying UNIT_VIRTUAL_ITEM_SLOT_DISPLAY slot 0
|
||||
// (field 37) = 1234, UNIT_VIRTUAL_ITEM_INFO slot 0's pair (fields 40/41): dword0 packs
|
||||
// class=2/subclass=7/material=1/invType=21, dword1 byte0 = sheath 3; and UNIT_FIELD_BYTES_2
|
||||
// (field 164) byte0 = sheath state 1 (melee drawn). Field -> (block, bit): 37 -> (1,5),
|
||||
// 40 -> (1,8), 41 -> (1,9), 164 -> (5,4) -- 6 mask blocks.
|
||||
let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, packed guid 0xCC, 6 blocks
|
||||
// Fields 37 (virtual item display 0), 40/41 (its info pair, sheath 3 in 41's b0) and 164
|
||||
// (UNIT_FIELD_BYTES_2, b0 = sheath state 1, melee drawn).
|
||||
let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, guid 0xCC, 6 blocks
|
||||
let mut masks = [0u32; 6];
|
||||
masks[1] = (1 << 5) | (1 << 8) | (1 << 9);
|
||||
masks[5] = 1 << 4;
|
||||
@@ -219,12 +201,9 @@ fn values_update_decodes_unit_virtual_items_and_sheath() {
|
||||
|
||||
#[test]
|
||||
fn values_update_decodes_inventory_slots() {
|
||||
// A Values-only update for guid 1 carrying PLAYER_FIELD_INV_SLOT 16 (the offhand, fields
|
||||
// 518/519 — live-verified: One's shield guid landed exactly there) = guid 0xAB and
|
||||
// PLAYER_FIELD_PACK_SLOT 0 (fields 532/533, live-verified) = guid 0xCD. Pins the CORRECTED
|
||||
// 1.12.1 bases (vmangos enum arithmetic, NOT its stale hex comments: INV_SLOT_HEAD 486,
|
||||
// PACK 532). 17 mask blocks; block 16 = bits 6,7 (518,519) + 20,21 (532,533).
|
||||
let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, packed guid 1, 17 blocks
|
||||
// INV_SLOT 16, the offhand (fields 518/519) = 0xAB; PACK_SLOT 0 (532/533) = 0xCD. The bases
|
||||
// (INV_SLOT_HEAD 486, PACK 532) follow vmangos's enum arithmetic, not its stale hex comments.
|
||||
let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, guid 1, 17 blocks
|
||||
for block in 0..17u32 {
|
||||
let mask: u32 = if block == 16 { 0x0030_00C0 } else { 0 };
|
||||
body.extend_from_slice(&mask.to_le_bytes());
|
||||
@@ -249,19 +228,15 @@ fn values_update_decodes_inventory_slots() {
|
||||
|
||||
#[test]
|
||||
fn item_and_container_creates_decode() {
|
||||
// Two position-less creates in one packet — an item and a bag — exactly as vmangos streams our
|
||||
// inventory at login: CREATE_OBJECT, packed guid, TypeId 1/2, movement block = UPDATEFLAG_ALL
|
||||
// (0x10) + its constant u32 1 (wire-verified `Object::BuildMovementUpdate`), then the mask.
|
||||
// These used to be dropped whole by the create placement gate; now they're ItemCreate events.
|
||||
// Container fields pin the CORRECTED bases (enum arithmetic, not the stale hex comments —
|
||||
// the same 6-low drift as the PLAYER block): NUM_SLOTS = ITEM_END = 48, SLOT_1 = 50.
|
||||
// Item and bag creates as vmangos streams them at login (movement block 0x10 + u32 1). Bases
|
||||
// follow vmangos's enum arithmetic, not its stale hex comments: NUM_SLOTS 48, SLOT_1 50.
|
||||
let mut body = hx("0200000000");
|
||||
// Item guid 0x42, entry 4660 (field 3), stack count 5 (field 14): 1 mask block, bits 3+14.
|
||||
body.extend_from_slice(&hx("0201420110010000000108400000"));
|
||||
body.extend_from_slice(&4660u32.to_le_bytes());
|
||||
body.extend_from_slice(&5u32.to_le_bytes());
|
||||
// Container guid 0x43, entry 828 (field 3), num-slots 6 (field 48), slot-0 guid 0x42
|
||||
// (fields 50/51): 2 mask blocks — block0 bit 3, block1 bits 16/18/19.
|
||||
// (fields 50/51): 2 mask blocks, block0 bit 3, block1 bits 16/18/19.
|
||||
body.extend_from_slice(&hx("020143021001000000020800000000000d00"));
|
||||
for v in [828u32, 6, 0x42, 0] {
|
||||
body.extend_from_slice(&v.to_le_bytes());
|
||||
@@ -295,17 +270,12 @@ fn item_and_container_creates_decode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport GameObject's create (decision 0438 "the wire"): `UPDATE_FLAG_HAS_POSITION` (0x40) +
|
||||
/// `UPDATE_FLAG_TRANSPORT` (0x02), the latter's tail being the server's pathProgress ms anchor
|
||||
/// (vmangos `Object.cpp:590-605`). The mask carries `GAMEOBJECT_DISPLAYID` only — **no**
|
||||
/// `GAMEOBJECT_POS_*` fields, exactly what vmangos sends for a boat/zeppelin (it puts the stationary
|
||||
/// spawn point in the movement block's `HAS_POSITION` slot instead) — pinning both the new
|
||||
/// `transport_progress` surfacing AND the create-placement fallback to the movement block's position
|
||||
/// (`create_placement`, `events/decode.rs`) in one fixture.
|
||||
/// A boat's create: `HAS_POSITION` (0x40) + `TRANSPORT` (0x02), whose u32 tail is pathProgress ms
|
||||
/// (vmangos `Object.cpp:590-605`); vmangos sends no `GAMEOBJECT_POS_*`, only the movement pose.
|
||||
#[test]
|
||||
fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
|
||||
// HIGH_MO_TRANSPORT (0x1FC0) with the Menethil-Theramore ferry's real template entry (176495)
|
||||
// riding the full low 32 bits (guid.rs `entry()` — MO_TRANSPORT has no 24-bit entry slot).
|
||||
// HIGH_MO_TRANSPORT (0x1FC0) guids hold the entry in the full low 32 bits; 176495 is the
|
||||
// Grom'Gol-Undercity zeppelin.
|
||||
let guid: u64 = 176_495 | (0x1FC0u64 << 48);
|
||||
|
||||
let mut body = 1u32.to_le_bytes().to_vec(); // amount_of_objects
|
||||
@@ -314,13 +284,12 @@ fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
|
||||
write_packed_guid(guid, &mut body).unwrap();
|
||||
body.push(5); // TypeId::GameObject
|
||||
|
||||
// Movement block: HAS_POSITION | TRANSPORT.
|
||||
body.push(0x40 | 0x02);
|
||||
body.extend_from_slice(&5000.0f32.to_le_bytes()); // stationary pos.x
|
||||
body.extend_from_slice(&6000.0f32.to_le_bytes()); // stationary pos.y
|
||||
body.extend_from_slice(&15.0f32.to_le_bytes()); // stationary pos.z
|
||||
body.extend_from_slice(&2.0f32.to_le_bytes()); // orientation
|
||||
body.extend_from_slice(&123_456u32.to_le_bytes()); // UPDATE_FLAG_TRANSPORT tail: pathProgress ms
|
||||
body.extend_from_slice(&123_456u32.to_le_bytes()); // TRANSPORT tail: pathProgress ms
|
||||
|
||||
// Mask: 1 block, GAMEOBJECT_DISPLAYID (field 8) only.
|
||||
body.push(1);
|
||||
@@ -368,15 +337,11 @@ fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit's `LIVING` block with `MOVEFLAG_ON_TRANSPORT` (0x0200_0000): the rider pose tail
|
||||
/// `[u64 transport guid][local x,y,z][local o]` (`update_object/movement.rs`'s `MovementBlock::read`,
|
||||
/// mirroring `read_movement_info`'s relay parse). VERIFIES the tail lands in `ObjectCreate::transport`
|
||||
/// and the 6 movement speeds that follow still parse from the right offset (the alignment the old
|
||||
/// discard-to-stay-aligned code depended on, now proven by consuming the values instead of dropping
|
||||
/// them).
|
||||
/// `MOVEFLAG_ON_TRANSPORT` (0x0200_0000) adds a rider tail to the `LIVING` block: u64 transport
|
||||
/// guid, then local x, y, z and orientation.
|
||||
#[test]
|
||||
fn unit_living_block_surfaces_on_transport_rider_pose() {
|
||||
// The Org-UC zeppelin's template entry (176244), same MO_TRANSPORT composition as the ferry above.
|
||||
// 176244 is the Moonspray, the Auberdine-Rut'theran boat.
|
||||
let transport_guid: u64 = 176_244 | (0x1FC0u64 << 48);
|
||||
|
||||
let mut body = 1u32.to_le_bytes().to_vec();
|
||||
@@ -389,18 +354,18 @@ fn unit_living_block_surfaces_on_transport_rider_pose() {
|
||||
let flags: u32 = 0x0200_0000; // MOVEFLAG_ON_TRANSPORT (1.12 bit 25, vmangos MovementInfo.h)
|
||||
body.extend_from_slice(&flags.to_le_bytes());
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||||
body.extend_from_slice(&100.0f32.to_le_bytes()); // living pos.x (world, NOT local)
|
||||
body.extend_from_slice(&100.0f32.to_le_bytes()); // living pos.x (world, not local)
|
||||
body.extend_from_slice(&200.0f32.to_le_bytes()); // living pos.y
|
||||
body.extend_from_slice(&5.0f32.to_le_bytes()); // living pos.z
|
||||
body.extend_from_slice(&1.0f32.to_le_bytes()); // living orientation
|
||||
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid
|
||||
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: full u64 guid
|
||||
body.extend_from_slice(&2.0f32.to_le_bytes()); // local x
|
||||
body.extend_from_slice(&3.0f32.to_le_bytes()); // local y
|
||||
body.extend_from_slice(&0.5f32.to_le_bytes()); // local z
|
||||
body.extend_from_slice(&0.25f32.to_le_bytes()); // local o
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time (not swimming/jumping, no other tails)
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time (no swim or jump tails)
|
||||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must still land right after the tail
|
||||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds, right after the tail
|
||||
}
|
||||
|
||||
body.push(0); // mask: 0 blocks (nothing to interpret beyond the pose)
|
||||
@@ -447,17 +412,8 @@ fn unit_living_block_surfaces_on_transport_rider_pose() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player who streams in **already swimming**: the `LIVING` block's `MOVEFLAG_SWIMMING` word and
|
||||
/// the swim-pitch float that rides it both reach [`SessionEvent::ObjectCreate::mover`], and the six
|
||||
/// speeds after the tail still parse from the right offset.
|
||||
///
|
||||
/// This is the byte the client threw away for as long as observed swimming has existed
|
||||
/// (`let _pitch = read_f32_le(r)?;`). Its cost was the whole body-pitch render law being
|
||||
/// unreachable at create: the app's only source of a mover's flags + pitch was a relayed
|
||||
/// `MSG_MOVE_*`, so a player who swam into view rendered LEVEL until their next packet — and an
|
||||
/// idle floater, who sends none while unmoving, stayed level for as long as they floated. The
|
||||
/// reference re-authors `CMovement`'s live flags word from this same block (wow-5875-re
|
||||
/// `system/collision/collision.md`, the create-block apply's `0x75a07dff` merge).
|
||||
/// A swimming create's flags word and swim pitch both reach [`SessionEvent::ObjectCreate::mover`];
|
||||
/// the reference re-authors the mover's live flags from this block (the `0x75a07dff` merge).
|
||||
#[test]
|
||||
fn unit_living_block_surfaces_the_swim_it_is_already_in() {
|
||||
let mut body = 1u32.to_le_bytes().to_vec();
|
||||
@@ -474,11 +430,10 @@ fn unit_living_block_surfaces_the_swim_it_is_already_in() {
|
||||
body.extend_from_slice(&(-566.0f32).to_le_bytes()); // living pos.y
|
||||
body.extend_from_slice(&(-3.25f32).to_le_bytes()); // living pos.z (under the surface)
|
||||
body.extend_from_slice(&2.0f32.to_le_bytes()); // living orientation
|
||||
// The SWIMMING tail: one f32, no ON_TRANSPORT pose before it, `fall_time` right after.
|
||||
body.extend_from_slice(&(-0.4f32).to_le_bytes()); // swim pitch — nose DOWN
|
||||
body.extend_from_slice(&(-0.4f32).to_le_bytes()); // the SWIMMING tail: pitch, nose down
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
|
||||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must land right after the tail
|
||||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds, right after the tail
|
||||
}
|
||||
|
||||
body.push(0); // mask: 0 blocks
|
||||
@@ -509,10 +464,7 @@ fn unit_living_block_surfaces_the_swim_it_is_already_in() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A **dry** create carries no pitch tail at all — the four bytes are absent, so a parse that read
|
||||
/// one unconditionally would swallow `fall_time` and misalign every speed after it. The mover state
|
||||
/// still surfaces (the flags word is not conditional); its pitch is a level `0.0`, which is what the
|
||||
/// render law's own SWIMMING gate then reads.
|
||||
/// Without SWIMMING there is no pitch tail; the mover still surfaces, with a level 0.0 pitch.
|
||||
#[test]
|
||||
fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
|
||||
let mut body = 1u32.to_le_bytes().to_vec();
|
||||
@@ -528,7 +480,7 @@ fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
|
||||
for v in [10.0f32, 20.0, 30.0, 0.5] {
|
||||
body.extend_from_slice(&v.to_le_bytes()); // pos + orientation
|
||||
}
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time — NO pitch tail before it
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time, no pitch tail before it
|
||||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||||
body.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
@@ -552,22 +504,11 @@ fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit that is **already walking** when it streams in: the `LIVING` block's
|
||||
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) tail, byte-shaped exactly as vmangos writes it
|
||||
/// (`PacketBuilder::WriteCreate`, `packet_builder.cpp:152`). Two things are verified, and both are
|
||||
/// what decision 0708 turns on:
|
||||
///
|
||||
/// 1. The node array is the server's **internal** control array — `[phantom, p₀…pₙ, tail]`, since
|
||||
/// vmangos builds even a linear spline through `InitCatmullRom` (`spline.cpp:52`) — so the decoded
|
||||
/// `path` must be that array with its two virtual points trimmed, not the raw nodes.
|
||||
/// 2. The descriptor mask *after* the spline still parses: a mis-sized spline read (a missed node, a
|
||||
/// forgotten final destination) silently shifts every field that follows, which is precisely how a
|
||||
/// parse-and-discard block hides its own bugs.
|
||||
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) adds a spline tail (vmangos `packet_builder.cpp:152`).
|
||||
/// Its nodes are the internal control array `[phantom, p₀…pₙ, tail]`, since vmangos builds even a
|
||||
/// linear spline through `InitCatmullRom` (`spline.cpp:52`); the decoded path drops the two ends.
|
||||
#[test]
|
||||
fn unit_living_block_surfaces_the_walk_it_is_already_on() {
|
||||
// The real path: an L, 10 yd east then 20 yd north. What the server actually serializes is that
|
||||
// path wrapped in its two virtual points: a phantom head at `p₀.lerp(p₁, -1)` = 2p₀ − p₁ (one
|
||||
// segment *behind* the start) and a tail duplicating the destination.
|
||||
let path = [
|
||||
[10.0f32, 20.0, 30.0],
|
||||
[20.0, 20.0, 30.0],
|
||||
@@ -591,10 +532,8 @@ fn unit_living_block_surfaces_the_walk_it_is_already_on() {
|
||||
let flags: u32 = 0x0040_0000; // MOVEFLAG_SPLINE_ENABLED (vmangos MovementInfo.h:53)
|
||||
body.extend_from_slice(&flags.to_le_bytes());
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||||
// The unit's stored pose — vmangos re-syncs it to the spline only every 400 ms, so it trails the
|
||||
// true position; the spline below is what actually places the unit.
|
||||
for v in [14.0f32, 20.0, 30.0, 0.0] {
|
||||
body.extend_from_slice(&v.to_le_bytes()); // living pos + orientation
|
||||
body.extend_from_slice(&v.to_le_bytes()); // stored pose, synced to the spline every 400 ms
|
||||
}
|
||||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
|
||||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||||
@@ -658,8 +597,6 @@ fn unit_living_block_surfaces_the_walk_it_is_already_on() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit standing still carries no spline tail at all — the flag gates it, so `spline` is `None` and
|
||||
/// nothing downstream tries to walk it.
|
||||
#[test]
|
||||
fn unit_living_block_without_the_spline_flag_carries_no_walk() {
|
||||
let mut body = 1u32.to_le_bytes().to_vec();
|
||||
@@ -686,15 +623,8 @@ fn unit_living_block_without_the_spline_flag_carries_no_walk() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PLAYER_VISIBLE_ITEM_<n>_0` across the slot range — the PUBLIC entry the inspect window's whole
|
||||
/// paper doll is built from (decision 0631) and the same field equipment rendering resolves other
|
||||
/// players' gear through. Untested until now, and an off-by-one in its `258 + 2 + 12i` stride would
|
||||
/// put an inspected player's rings on their feet: the stride is exercised at both ends and in the
|
||||
/// middle, not just at slot 0 (which any wrong-but-anchored formula would also get right).
|
||||
///
|
||||
/// Field index = `PLAYER_VISIBLE_ITEM_1_CREATOR` (258, a 2-field guid) + 2 + 12·slot, so
|
||||
/// slot 0 → 260 (block 8 bit 4), slot 1 → 272 (block 8 bit 16), slot 14 → 428 (block 13 bit 12),
|
||||
/// slot 18 → 476 (block 14 bit 28). 15 mask blocks cover it.
|
||||
/// `PLAYER_VISIBLE_ITEM_<n>_0`, the public entry other players' gear renders from, sits at field
|
||||
/// 258 (`PLAYER_VISIBLE_ITEM_1_CREATOR`, a 2-field guid) + 2 + 12·slot: 260, 272, 428, 476 here.
|
||||
#[test]
|
||||
fn values_update_decodes_player_visible_item_entries() {
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
@@ -710,7 +640,6 @@ fn values_update_decodes_player_visible_item_entries() {
|
||||
for m in masks {
|
||||
body.extend_from_slice(&m.to_le_bytes());
|
||||
}
|
||||
// Ascending field order: 260, 272, 428, 476.
|
||||
for v in [7365u32, 2196u32, 15406u32, 2504u32] {
|
||||
body.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
@@ -727,8 +656,7 @@ fn values_update_decodes_player_visible_item_entries() {
|
||||
assert_eq!(fields.player_visible_item_entry(1), Some(2196), "NECK");
|
||||
assert_eq!(fields.player_visible_item_entry(14), Some(15406), "BACK");
|
||||
assert_eq!(fields.player_visible_item_entry(18), Some(2504), "TABARD");
|
||||
// An unsent slot and the sentinel are both None — an empty equipment slot must not read as
|
||||
// "item 0" (the feed keys "is there gear here" on exactly this).
|
||||
// An unsent slot and an out-of-range one both read None, never item 0.
|
||||
assert_eq!(fields.player_visible_item_entry(2), None, "slot 2 unsent");
|
||||
assert_eq!(
|
||||
fields.player_visible_item_entry(19),
|
||||
|
||||
Reference in New Issue
Block a user