diff --git a/crates/benilla-protocol/tests/simple_packets.rs b/crates/benilla-protocol/tests/simple_packets.rs index b19b4e22..1a7811c5 100644 --- a/crates/benilla-protocol/tests/simple_packets.rs +++ b/crates/benilla-protocol/tests/simple_packets.rs @@ -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()); diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index d1782449..92407eda 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -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") diff --git a/crates/benilla-protocol/tests/stable.rs b/crates/benilla-protocol/tests/stable.rs index 1c7a9db0..e9b771e5 100644 --- a/crates/benilla-protocol/tests/stable.rs +++ b/crates/benilla-protocol/tests/stable.rs @@ -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, pet_number: u32, @@ -101,11 +86,8 @@ fn parse_list(body: &[u8]) -> (u64, u8, Vec) { } } -/// 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, diff --git a/crates/benilla-protocol/tests/taxi.rs b/crates/benilla-protocol/tests/taxi.rs index 5ab454cc..80d3fe09 100644 --- a/crates/benilla-protocol/tests/taxi.rs +++ b/crates/benilla-protocol/tests/taxi.rs @@ -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 { #[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()), diff --git a/crates/benilla-protocol/tests/trainer.rs b/crates/benilla-protocol/tests/trainer.rs index 904e1bae..b8319686 100644 --- a/crates/benilla-protocol/tests/trainer.rs +++ b/crates/benilla-protocol/tests/trainer.rs @@ -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 { #[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, 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 { diff --git a/crates/benilla-protocol/tests/update_object.rs b/crates/benilla-protocol/tests/update_object.rs index 5361a3f5..1d770b17 100644 --- a/crates/benilla-protocol/tests/update_object.rs +++ b/crates/benilla-protocol/tests/update_object.rs @@ -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__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__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 = 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),