Files
benilla-octo/crates/benilla-protocol/tests/spells.rs
T
SamandClaude Fable 5.1 6019703a14 the battle map draws, and the talent modifiers land
- inside a battleground the battle map draws its terrain, overlays, arrow and blips: the world map's selection carries the instance maps as the reference's third cell (2298)
- talent spell modifiers land: the 45 packets a level-60 login threw away fill the reference's two tables, and a spell's power cost reads them, so a cast you can afford is no longer refused (2298)
- a ghost's Accept on the spirit-heal dialog can no longer be eaten by frame order, and a latched spirit guide no longer follows you into the next character's session (2298)
- the battleground probe refuses GM mode, exits when done, and reads the scoreboard before toggling it (2298)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:45:36 +07:00

1439 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::ServerPacket;
use common::hx;
#[test]
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);
// u16 m; m x (u16 spell, u16 item, u16 category, u32 spellCdMs, u32 catCdMs).
let body = hx(concat!(
"00", "0300", "4e000000", // 78 Heroic Strike
"cb190000", // 6603 Attack
"111a0000", // 6673 Battle Shout
"0100", "4e00", "0000", "2301", "88130000",
"dc050000" // 78 on 5000ms cd, category 0x123 1500ms
));
match messages::parse_server(messages::opcode::SMSG_INITIAL_SPELLS, &body).unwrap() {
ServerPacket::InitialSpells {
spell_ids,
cooldowns,
} => {
assert_eq!(spell_ids, vec![78, 6603, 6673]);
assert_eq!(cooldowns.len(), 1);
assert_eq!((cooldowns[0].spell_id, cooldowns[0].category), (78, 0x123));
assert_eq!(
(cooldowns[0].spell_cd_ms, cooldowns[0].category_cd_ms),
(5000, 1500)
);
}
_ => panic!("initial spells"),
}
// 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
body[44..48].copy_from_slice(&0x4000_0003u32.to_le_bytes()); // slot 11: macro 3
let packet = messages::parse_server(messages::opcode::SMSG_ACTION_BUTTONS, &body).unwrap();
let buttons = match &packet {
ServerPacket::ActionButtons { buttons } => buttons.clone(),
_ => panic!("action buttons"),
};
assert_eq!(
buttons,
vec![
ActionButton {
slot: 0,
action: 6673,
kind: 0
},
ActionButton {
slot: 1,
action: 117,
kind: ACTION_KIND_ITEM
},
ActionButton {
slot: 11,
action: 3,
kind: 0x40
},
]
);
// ...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).
assert_eq!(
messages::cast_spell(6673, None),
hx("111a00000000"),
"self cast: u32 id + u16 mask 0"
);
let victim = 0xF130_0000_4500_002Au64; // creature: high 0xF130, entry 69, counter 0x2A
assert_eq!(
messages::cast_spell(5176, Some(victim)),
hx("381400000200c92a4530f1"),
"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.
let enchant_target = 0x4700_0000_0000_0951u64;
assert_eq!(
messages::cast_spell_item(7418, enchant_target),
hx("fa1c0000100083510947"),
"item cast: u32 id + u16 mask 0x10 + packed item guid"
);
// CMSG_ATTACKSWING: one full 8-byte guid.
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).
assert_eq!(
messages::opcode::CMSG_SET_ACTION_BUTTON,
0x0128,
"CMSG_SET_ACTION_BUTTON opcode"
);
assert_eq!(
messages::set_action_button(11, 0x8000_0075),
hx("0b75000080"),
"button 11 (lua slot 12), item 117 (kind 0x80<<24 | action 0x75)"
);
assert_eq!(
messages::set_action_button(0, 0),
hx("0000000000"),
"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.
assert_eq!(
messages::opcode::CMSG_SET_ACTIONBAR_TOGGLES,
0x02BF,
"CMSG_SET_ACTIONBAR_TOGGLES opcode"
);
assert_eq!(
messages::set_actionbar_toggles(0x0b),
hx("0b"),
"bars 1, 2 and 4 shown (bits 0x01|0x02|0x08)"
);
assert_eq!(
messages::set_actionbar_toggles(0),
hx("00"),
"every extra bar hidden — the default, and a real value rather than an empty body"
);
assert_eq!(
messages::set_actionbar_toggles(0x0f),
hx("0f"),
"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,
"CMSG_SETSHEATHED opcode"
);
assert_eq!(messages::set_sheathed(1), hx("01000000"));
// SMSG_CAST_RESULT: ok = status 0 ends the body; fail = status 2 + u8 reason.
match messages::parse_server(messages::opcode::SMSG_CAST_RESULT, &hx("111a000000")).unwrap() {
ServerPacket::CastResult { spell_id, outcome } => {
assert_eq!((spell_id, outcome), (6673, CastOutcome::Ok));
}
_ => panic!("cast ok"),
}
let fail =
messages::parse_server(messages::opcode::SMSG_CAST_RESULT, &hx("111a00000255")).unwrap();
match decode(fail).pop().unwrap() {
SessionEvent::CastResult {
spell_id,
success,
reason,
arg,
} => assert_eq!(
(spell_id, success, reason, arg),
(6673, false, Some(0x55), None),
"a reason carrying no argument ends the body at the reason byte"
),
_ => 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.
let focus = messages::parse_server(
messages::opcode::SMSG_CAST_RESULT,
&hx("111a0000025e0c000000"),
)
.unwrap();
match decode(focus).pop().unwrap() {
SessionEvent::CastResult { reason, arg, .. } => {
assert_eq!((reason, arg), (Some(0x5e), Some(12)));
}
_ => panic!("cast fail event"),
}
let equip = messages::parse_server(
messages::opcode::SMSG_CAST_RESULT,
&hx("111a0000021902000000f3a50200"),
)
.unwrap();
match decode(equip).pop().unwrap() {
SessionEvent::CastResult { reason, arg, .. } => {
assert_eq!(
(reason, arg),
(Some(0x19), Some(2)),
"arg2 read and dropped"
);
}
_ => panic!("cast fail event"),
}
// SMSG_ATTACKSTART: two full guids. SMSG_ATTACKSTOP: two packed guids + u32 isDead.
let body = hx("01000000000000002a000045000030f1");
match messages::parse_server(messages::opcode::SMSG_ATTACKSTART, &body).unwrap() {
ServerPacket::AttackStart {
attacker,
victim: v,
} => {
assert_eq!((attacker, v), (1, victim));
}
_ => panic!("attack start"),
}
let body = hx(concat!("0101", "c92a4530f1", "00000000"));
match messages::parse_server(messages::opcode::SMSG_ATTACKSTOP, &body).unwrap() {
ServerPacket::AttackStop {
attacker,
victim: v,
} => {
assert_eq!((attacker, v), (1, victim));
}
_ => 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.
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
body.extend_from_slice(&42u32.to_le_bytes()); // TotalDamage
body.push(2); // SubDamageCount
body.extend_from_slice(&0u32.to_le_bytes()); // sub 1: school (physical)
body.extend_from_slice(&42f32.to_le_bytes()); // sub 1: damage f32
body.extend_from_slice(&42u32.to_le_bytes()); // sub 1: damage u32
body.extend_from_slice(&5u32.to_le_bytes()); // sub 1: absorb
body.extend_from_slice(&0u32.to_le_bytes()); // sub 1: resist
body.extend_from_slice(&3u32.to_le_bytes()); // sub 2: school (frost)
body.extend_from_slice(&0f32.to_le_bytes()); // sub 2: damage f32
body.extend_from_slice(&0u32.to_le_bytes()); // sub 2: damage u32
body.extend_from_slice(&7u32.to_le_bytes()); // sub 2: absorb
body.extend_from_slice(&0u32.to_le_bytes()); // sub 2: resist
body.extend_from_slice(&1u32.to_le_bytes()); // VictimState: hit
body.extend_from_slice(&0u32.to_le_bytes()); // zero
body.extend_from_slice(&0u32.to_le_bytes()); // spell id
body.extend_from_slice(&15u32.to_le_bytes()); // blocked
match messages::parse_server(messages::opcode::SMSG_ATTACKERSTATEUPDATE, &body).unwrap() {
ServerPacket::AttackerState(s) => {
assert_eq!(s.attacker, 1);
assert_eq!(s.victim, victim);
assert_eq!(s.hit_info, 0x6);
assert_eq!(s.damage, 42);
assert_eq!(s.victim_state, 1);
assert_eq!(
s.absorb, 12,
"absorb summed across both sub-damage blocks (5 + 7)"
);
assert_eq!(s.blocked, 15);
assert_eq!(
s.school, 0,
"the wording's school is the FIRST block's (physical), not the second's frost"
);
}
_ => 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).
let body = hx(concat!("3412000000000000", "02000000"));
let packet = messages::parse_server(messages::opcode::SMSG_AI_REACTION, &body).unwrap();
match &packet {
ServerPacket::AiReaction { unit, reaction } => {
assert_eq!((*unit, *reaction), (0x1234, 2));
}
other => panic!("ai reaction, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::AiReaction { unit, reaction } => assert_eq!((unit, reaction), (0x1234, 2)),
other => panic!("ai reaction event, got {other:?}"),
}
}
/// 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.
#[test]
fn attack_swing_refusal_wire() {
use benilla_protocol::messages::AttackSwingError;
for (op, want) in [
(
messages::opcode::SMSG_ATTACKSWING_NOTINRANGE,
AttackSwingError::NotInRange,
),
(
messages::opcode::SMSG_ATTACKSWING_BADFACING,
AttackSwingError::BadFacing,
),
(
messages::opcode::SMSG_ATTACKSWING_DEADTARGET,
AttackSwingError::DeadOrUnattackable,
),
(
messages::opcode::SMSG_ATTACKSWING_CANT_ATTACK,
AttackSwingError::DeadOrUnattackable,
),
] {
let packet = messages::parse_server(op, &[]).unwrap_or_else(|e| panic!("{op:#x}: {e}"));
match &packet {
ServerPacket::AttackSwingError(got) => assert_eq!(*got, want, "opcode {op:#x}"),
other => panic!("{op:#x} parsed as {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::AttackSwingError(got) => assert_eq!(got, want, "opcode {op:#x} event"),
other => panic!("{op:#x} event, got {other:?}"),
}
}
// The family's fourth arm — a different opcode family, the same empty body and the same act.
assert!(
matches!(
messages::parse_server(messages::opcode::SMSG_CANCEL_COMBAT, &[]).unwrap(),
ServerPacket::CancelCombat
),
"SMSG_CANCEL_COMBAT is registered (`0x5e3308` -> `0x5e7dd0`), just not by the combat TU"
);
assert!(matches!(
decode(ServerPacket::CancelCombat).pop().unwrap(),
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.
assert!(matches!(
messages::parse_server(messages::opcode::SMSG_FEIGN_DEATH_RESISTED, &[]).unwrap(),
ServerPacket::FeignDeathResisted
));
assert!(matches!(
decode(ServerPacket::FeignDeathResisted).pop().unwrap(),
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.
assert!(
matches!(
messages::parse_server(0x0147, &[]).unwrap(),
ServerPacket::Other { opcode: 0x0147 }
),
"0x147 NOTSTANDING must stay unparsed — the reference never registers it"
);
}
/// 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.
#[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)
// 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).
let body = hx(concat!(
"0101", // item_or_caster pguid: mask 1, guid 1
"c92a4530f1", // caster pguid: the creature guid, mask 0xC9
"85000000", // spellId 133
"0200", // castFlags 0x2 (CAST_FLAG_UNKNOWN2)
"b80b0000", // remaining cast time: 3000ms
"0200", // SpellCastTargets mask: TARGET_FLAG_UNIT (0x2)
"0155", // unit target pguid: mask 1, guid 0x55
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).unwrap();
match &packet {
ServerPacket::SpellStart(s) => {
assert_eq!(
s,
&SpellStart {
item_or_caster: 1,
caster: creature,
spell_id: 133,
cast_flags: 0x2,
cast_time_ms: 3000,
targets: SpellCastTargets {
mask: 0x2,
unit_target: Some(0x55),
go_target: None,
dest: None,
},
ammo_display_id: None,
}
);
}
other => panic!("spell start, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellStart {
caster,
spell_id,
cast_flags,
cast_time_ms,
target,
ammo_display_id,
} => assert_eq!(
(
caster,
spell_id,
cast_flags,
cast_time_ms,
target,
ammo_display_id,
),
(creature, 133, 0x2, 3000, Some(0x55), None)
),
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", // 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
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::SpellStart {
caster,
spell_id,
cast_time_ms,
target,
..
} => assert_eq!((caster, spell_id, cast_time_ms, target), (9, 17, 0, None)),
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).
let body = hx(concat!(
"0101", // item_or_caster pguid: guid 1
"c92a4530f1", // caster pguid: the creature
"4b000000", // spellId 75 (Auto Shot)
"2001", // castFlags 0x120 (CAST_FLAG_UNKNOWN9 0x100 | CAST_FLAG_AMMO 0x20)
"02", // hit count 2
"aa00000000000000", // hit guid 0xAA, raw (unpacked)
"bb00000000000000", // hit guid 0xBB, raw
"02", // miss count 2
"cc00000000000000",
"02", // miss guid 0xCC, SPELL_MISS_RESIST (2)
"dd00000000000000",
"0b",
"02", // miss guid 0xDD, SPELL_MISS_REFLECT (11) + reflectResult 2
"0200", // SpellCastTargets mask: TARGET_FLAG_UNIT
"01ee", // unit target pguid: guid 0xEE
"6c170000", // ammo displayId 5996
"12000000", // ammo inventoryType 18 (dropped)
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
match &packet {
ServerPacket::SpellGo(s) => {
assert_eq!(
s,
&SpellGo {
item_or_caster: 1,
caster: creature,
spell_id: 75,
cast_flags: 0x120,
hits: vec![0xAA, 0xBB],
misses: vec![(0xCC, 2), (0xDD, 11)],
targets: SpellCastTargets {
mask: 0x2,
unit_target: Some(0xEE),
go_target: None,
dest: None,
},
ammo_display_id: Some(5996),
}
);
}
other => panic!("spell go, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
caster,
spell_id,
cast_flags,
hits,
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
} => {
assert_eq!(caster, creature);
assert_eq!(spell_id, 75);
assert_eq!(cast_flags, 0x120);
assert_eq!(hits, vec![0xAA, 0xBB]);
assert_eq!(misses, vec![(0xCC, 2), (0xDD, 11)]);
assert_eq!(target, Some(0xEE));
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.
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.
let body = hx(concat!(
"011a", // item_or_caster pguid: guid 0x1a (== caster: a plain spell, no item)
"011a", // caster pguid
"0a000000", // spellId 10 (Blizzard)
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9)
"00", // hit count 0
"00", // miss count 0
"4000", // SpellCastTargets mask: TARGET_FLAG_DEST_LOCATION
"8f300ac6", // dest x −8844.14 (0xc60a308f)
"1f352744", // dest y 668.83 (0x4427351f)
"b89ec342", // dest z 97.81 (0x42c39eb8)
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
caster,
spell_id,
hits,
misses,
target,
dest,
item_caster,
..
} => {
assert_eq!((caster, spell_id), (0x1a, 10));
assert!(hits.is_empty() && misses.is_empty());
assert_eq!(target, None);
assert_eq!(
dest,
Some([
f32::from_bits(0xc60a308f),
f32::from_bits(0x4427351f),
f32::from_bits(0x42c39eb8),
])
);
assert_eq!(item_caster, None, "same guid twice — not an item cast");
}
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
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`
"591b0000", // spellId 7001 (Lightwell Renew)
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9)
"01", // hit count 1
"4500000000000000", // hit: the clicking player (raw u64)
"00", // miss count 0
"0200", // SpellCastTargets mask: TARGET_FLAG_UNIT
"0145", // …the same player, packed
));
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()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
caster,
spell_id,
hits,
target,
item_caster,
..
} => {
assert_eq!(
caster, LIGHTWELL,
"an empty caster slot falls back to slot 1 — the GameObject IS the caster"
);
assert_eq!((spell_id, hits, target), (7001, vec![0x45], Some(0x45)));
assert_eq!(
item_caster, None,
"the object is the caster, not a cast item — the derivation reads the RESOLVED caster"
);
}
other => panic!("lightwell spell go event, got {other:?}"),
}
// SMSG_SPELL_FAILED_OTHER: raw (unpacked) guid + spellId (vmangos `Spell::SendInterrupted`).
let body = hx(concat!("4200000000000000", "85000000"));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_FAILED_OTHER, &body).unwrap();
match &packet {
ServerPacket::SpellFailedOther { caster, spell_id } => {
assert_eq!((*caster, *spell_id), (0x42, 133));
}
other => panic!("spell failed other, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellFailedOther { caster, spell_id } => {
assert_eq!((caster, spell_id), (0x42, 133));
}
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.
let body = hx(concat!("4200000000000000", "f4010000"));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_DELAYED, &body).unwrap();
match &packet {
ServerPacket::SpellDelayed { caster, delay_ms } => {
assert_eq!((*caster, *delay_ms), (0x42, 500));
}
other => panic!("spell delayed, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellDelayed { caster, delay_ms } => {
assert_eq!((caster, delay_ms), (0x42, 500));
}
other => panic!("spell delayed event, got {other:?}"),
}
// SMSG_CANCEL_AUTO_REPEAT: empty body (vmangos `CancelAutoRepeat::AppendBodyTo` writes nothing).
let packet = messages::parse_server(messages::opcode::SMSG_CANCEL_AUTO_REPEAT, &[]).unwrap();
assert!(matches!(packet, ServerPacket::CancelAutoRepeat));
assert!(matches!(
decode(packet).pop().unwrap(),
SessionEvent::CancelAutoRepeat
));
// SMSG_PLAY_SPELL_VISUAL: raw (unpacked) guid + kit id.
let body = hx(concat!("3412000000000000", "39000000"));
let packet = messages::parse_server(messages::opcode::SMSG_PLAY_SPELL_VISUAL, &body).unwrap();
match &packet {
ServerPacket::PlaySpellVisual { unit, kit_id } => {
assert_eq!((*unit, *kit_id), (0x1234, 57));
}
other => panic!("play spell visual, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::PlaySpellVisual { unit, kit_id } => assert_eq!((unit, kit_id), (0x1234, 57)),
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.
let body = hx(concat!("2d2a0000", "70170000"));
let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_START, &body).unwrap();
match &packet {
ServerPacket::ChannelStart {
spell_id,
duration_ms,
} => assert_eq!((*spell_id, *duration_ms), (10797, 6000)),
other => panic!("channel start, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::ChannelStart {
spell_id,
duration_ms,
} => assert_eq!((spell_id, duration_ms), (10797, 6000)),
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.
let body = hx("b80b0000");
let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_UPDATE, &body).unwrap();
match &packet {
ServerPacket::ChannelUpdate { remaining_ms } => assert_eq!(*remaining_ms, 3000),
other => panic!("channel update, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::ChannelUpdate { remaining_ms } => assert_eq!(remaining_ms, 3000),
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",
"85000000",
"0200", // truncated before the u32 cast-time
));
assert!(messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).is_err());
let body = hx(concat!(
"0101",
"c92a4530f1",
"4b000000",
"2001",
"02",
"aa00000000000000", // hit count says 2, only 1 hit guid present
));
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.
#[test]
fn spell_go_surfaces_the_gameobject_target() {
let body = hx(concat!(
"0101", // item_or_caster pguid: guid 1
"0107", // caster pguid: guid 7
"250d0000", // spellId 3365 ("Opening")
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9, no ammo)
"00", // hit count 0
"00", // miss count 0
"0008", // SpellCastTargets mask: TARGET_FLAG_GAMEOBJECT (0x0800)
"033412", // GO target pguid: guid 0x1234
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
match &packet {
ServerPacket::SpellGo(s) => {
assert_eq!(s.targets.go_target, Some(0x1234));
assert_eq!(s.targets.unit_target, None);
}
other => panic!("spell go, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
spell_id,
go_target,
target,
..
} => {
assert_eq!(spell_id, 3365);
assert_eq!(go_target, Some(0x1234)); // the chest the open-lock cast will pop
assert_eq!(target, None);
}
other => panic!("spell go event, got {other:?}"),
}
}
/// 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.
#[test]
fn combat_log_wire_golden() {
use benilla_protocol::messages::{
DamageShield, EnvironmentalDamageLog, ExplorationXp, LevelUpInfo, PeriodicAuraLog,
PeriodicTick, SpellDamageLog, SpellLogMiss, XpGain,
};
let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere)
// 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
"85000000", // spellId 133
"f4010000", // damage 500
"03", // school 3 (frost)
"32000000", // absorbed 50
"ecffffff", // resist -20
"00", // periodicLog: false (direct, not a DoT tick)
"00", // unused
"0a000000", // blocked 10
"02000000", // hitInfo 0x2 (crit)
"00", // extendedData (always 0, dropped)
));
let packet =
messages::parse_server(messages::opcode::SMSG_SPELLNONMELEEDAMAGELOG, &body).unwrap();
match &packet {
ServerPacket::SpellDamageLog(s) => {
assert_eq!(
s,
&SpellDamageLog {
target: creature,
attacker: 1,
spell_id: 133,
damage: 500,
school: 3,
absorb: 50,
resist: -20,
periodic: false,
blocked: 10,
hit_info: 0x2,
}
);
}
other => panic!("spell damage log, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellDamageLog(s) => assert_eq!(s.hit_info & 0x2, 0x2, "crit bit"),
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.
let body = hx(concat!(
"c92a4530f1", // target pguid: the creature
"0101", // caster pguid: guid 1
"ac000000", // spellId 172 (Corruption)
"01000000", // count 1
"03000000", // auraType 3 (PERIODIC_DAMAGE)
"58000000", // damage 88
"06000000", // school 6 (shadow)
"0c000000", // absorb 12
"fbffffff", // resist -5
));
let packet = messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).unwrap();
match &packet {
ServerPacket::PeriodicAuraLog(l) => {
assert_eq!(
l,
&PeriodicAuraLog {
target: creature,
caster: 1,
spell_id: 172,
ticks: vec![PeriodicTick::Damage {
amount: 88,
school: 6,
absorb: 12,
resist: -5,
}],
}
);
}
other => panic!("periodic aura log (damage), got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::PeriodicAuraLog(l) => assert_eq!(l.ticks.len(), 1),
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)
"59000000", // spellId 89 (Renew)
"01000000", // count 1
"08000000", // auraType 8 (PERIODIC_HEAL)
"2d000000", // amount 45
));
let packet = messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).unwrap();
match &packet {
ServerPacket::PeriodicAuraLog(l) => {
assert_eq!(
l,
&PeriodicAuraLog {
target: 1,
caster: 1,
spell_id: 89,
ticks: vec![PeriodicTick::Heal { amount: 45 }],
}
);
}
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.
let body = hx(concat!(
"0101", // target
"0101", // caster
"01000000", // spellId 1
"01000000", // count 1
"04000000", // auraType 4 (unhandled)
));
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.
let body = hx(concat!(
"1111000000000000", // victim raw guid 0x1111
"2222000000000000", // attacker raw guid 0x2222
"21000000", // damage 33
"04000000", // school 4 (nature)
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELLDAMAGESHIELD, &body).unwrap();
match &packet {
ServerPacket::DamageShield(s) => {
assert_eq!(
s,
&DamageShield {
victim: 0x1111,
attacker: 0x2222,
damage: 33,
school: 4,
}
);
}
other => panic!("damage shield, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::DamageShield(s) => assert_eq!((s.victim, s.attacker), (0x1111, 0x2222)),
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.
let body = hx(concat!(
"0300000000000000", // victim raw guid 3
"02", // damageType 2 (DAMAGE_FALL)
"17000000", // damage 23
"05000000", // absorbed 5
"ffffffff", // resist -1
));
let packet =
messages::parse_server(messages::opcode::SMSG_ENVIRONMENTALDAMAGELOG, &body).unwrap();
match &packet {
ServerPacket::EnvironmentalDamageLog(e) => {
assert_eq!(
e,
&EnvironmentalDamageLog {
victim: 3,
damage_type: 2,
damage: 23,
absorb: 5,
resist: -1,
}
);
}
other => panic!("environmental damage log, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::EnvironmentalDamageLog(e) => {
assert_eq!((e.victim, e.damage_type, e.damage), (3, 2, 23));
}
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.
let body = hx(concat!(
"85000000", // spellId 133
"4200000000000000", // caster raw guid 0x42
"00", // useExtended: 0
"02000000", // count 2
"aa00000000000000", // target 0xAA
"03", // missInfo 3 (DODGE)
"bb00000000000000", // target 0xBB
"0b", // missInfo 11 (REFLECT)
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELLLOGMISS, &body).unwrap();
match &packet {
ServerPacket::SpellLogMiss(s) => {
assert_eq!(
s,
&SpellLogMiss {
spell_id: 133,
caster: 0x42,
misses: vec![(0xAA, 3), (0xBB, 11)],
}
);
}
other => panic!("spell log miss, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellLogMiss(s) => assert_eq!(s.misses, vec![(0xAA, 3), (0xBB, 11)]),
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
body.extend_from_slice(&200u32.to_le_bytes()); // base xp (kept: rested bonus = total − base)
body.extend_from_slice(&1.25f32.to_le_bytes()); // group bonus (dropped)
let packet = messages::parse_server(messages::opcode::SMSG_LOG_XPGAIN, &body).unwrap();
match &packet {
ServerPacket::XpGain(x) => {
assert_eq!(
x,
&XpGain {
victim: 0xCC,
total: 250,
base: 200,
kill: true,
}
);
}
other => panic!("xp gain (kill), got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::XpGain(x) => assert_eq!((x.victim, x.total, x.kill), (0xCC, 250, true)),
other => panic!("xp gain event, got {other:?}"),
}
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
let packet = messages::parse_server(messages::opcode::SMSG_LOG_XPGAIN, &body).unwrap();
match &packet {
ServerPacket::XpGain(x) => {
assert_eq!(
x,
&XpGain {
victim: 0,
total: 50,
base: 50, // non-kill carries no base on the wire; base = total
kill: false,
}
);
}
other => panic!("xp gain (non-kill), got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::XpGain(x) => assert_eq!((x.victim, x.total, x.kill), (0, 50, false)),
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.
let mut body = 40u32.to_le_bytes().to_vec();
body.extend_from_slice(&85u32.to_le_bytes());
assert_eq!(body.len(), 8);
let packet =
messages::parse_server(messages::opcode::SMSG_EXPLORATION_EXPERIENCE, &body).unwrap();
match &packet {
ServerPacket::ExplorationXp(x) => {
assert_eq!(
x,
&ExplorationXp {
area_id: 40,
xp: 85,
}
);
}
other => panic!("exploration xp, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::ExplorationXp(x) => assert_eq!((x.area_id, x.xp), (40, 85)),
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.
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());
}
assert_eq!(body.len(), 48);
let packet = messages::parse_server(messages::opcode::SMSG_LEVELUP_INFO, &body).unwrap();
match &packet {
ServerPacket::LevelUp(l) => {
assert_eq!(
l,
&LevelUpInfo {
level: 7,
health: 22,
powers: [15, 0, 0, 0, 0],
stats: [1, 0, 1, 2, 1],
}
);
}
other => panic!("level up, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, 22, 15)),
other => panic!("level up event, got {other:?}"),
}
}
/// `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.
#[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)
"b1020000", // spellId 689 (Drain Life)
"01000000", // count 1 — a u32, not the u8 the GO lists use
"aa00000000000000", // target guid 0xAA, raw
));
let packet =
messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap();
match &packet {
ServerPacket::SpellChainTargets(c) => assert_eq!(
c,
&SpellChainTargets {
caster: CASTER,
spell_id: 689,
targets: vec![0xAA],
}
),
other => panic!("chain targets, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellChainTargets {
caster,
spell_id,
targets,
} => assert_eq!((caster, spell_id, targets), (CASTER, 689, vec![0xAA])),
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)
"03000000",
"aa00000000000000",
"bb00000000000000",
"cc00000000000000",
));
match messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap()
{
ServerPacket::SpellChainTargets(c) => {
assert_eq!(c.targets, vec![0xAA, 0xBB, 0xCC], "wire order is hop order");
}
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.
let body = hx(concat!("0100000000000000", "a5010000", "00000000"));
match messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap()
{
ServerPacket::SpellChainTargets(c) => assert!(c.targets.is_empty()),
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.
let body = hx(concat!(
"0100000000000000",
"a5010000",
"02000000",
"aa00000000000000",
));
assert!(
messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).is_err()
);
}
/// **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.
#[test]
fn combat_log_completeness_wire() {
use benilla_protocol::messages::{
DispelFailed, EnchantmentLog, ExecuteLog, PartyKillLog, SpellDispelLog, SpellInstaKillLog,
SpellLogExecute, SpellOutcomeLog,
};
// SMSG_PARTYKILLLOG: killer raw guid, victim raw guid
// (`WorldPackets::Combat::PartyKillLog::AppendBodyTo`, Combat.cpp:52-56).
let body = hx(concat!(
"0700000000000000", // killer 7
"2a00000000000000", // victim 42
));
match messages::parse_server(messages::opcode::SMSG_PARTYKILLLOG, &body).unwrap() {
ServerPacket::PartyKillLog(p) => assert_eq!(
p,
PartyKillLog {
killer: 7,
victim: 42
}
),
other => panic!("party kill log, got {}", other.name()),
}
// SMSG_SPELLINSTAKILLLOG: victim raw guid, spellId (`Spell::EffectInstaKill`,
// 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!(
p,
SpellInstaKillLog {
victim: 9,
spell_id: 9997
}
),
other => panic!("instakill log, got {}", other.name()),
}
// SMSG_PROCRESIST / SMSG_SPELLORDAMAGE_IMMUNE: one body, two opcodes
// (`WorldPackets::Spell::ProcResist`/`SpellOrDamageImmune`, Spell.cpp:88-102).
let body = hx(concat!(
"0100000000000000", // caster 1
"0200000000000000", // target 2
"39300000", // spellId 12345
"01", // logFormat 1 (the reference's "is periodic" flag)
));
let expect = SpellOutcomeLog {
caster: 1,
target: 2,
spell_id: 12345,
log_format: 1,
};
match messages::parse_server(messages::opcode::SMSG_PROCRESIST, &body).unwrap() {
ServerPacket::ProcResist(p) => assert_eq!(p, expect),
other => panic!("proc resist, got {}", other.name()),
}
match messages::parse_server(messages::opcode::SMSG_SPELLORDAMAGE_IMMUNE, &body).unwrap() {
ServerPacket::SpellOrDamageImmune(p) => assert_eq!(p, expect),
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).
let body = hx(concat!(
"0105", // packed victim: mask 0x01, byte 0x05 -> 5
"0206", // packed caster: mask 0x02, byte 0x06 -> 0x600
"02000000", // count 2
"0a000000", // 10
"14000000", // 20
));
match messages::parse_server(messages::opcode::SMSG_SPELLDISPELLOG, &body).unwrap() {
ServerPacket::SpellDispelLog(p) => assert_eq!(
p,
SpellDispelLog {
victim: 5,
caster: 0x600,
spell_ids: vec![10, 20],
}
),
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).
let body = hx(concat!(
"0300000000000000",
"0400000000000000",
"63000000",
"64000000",
));
match messages::parse_server(messages::opcode::SMSG_DISPEL_FAILED, &body).unwrap() {
ServerPacket::DispelFailed(p) => assert_eq!(
p,
DispelFailed {
caster: 3,
victim: 4,
spell_ids: vec![99, 100],
}
),
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).
let body = hx(concat!(
"0000000000000000", // caster 0 -> a fade
"0b00000000000000", // owner 11
"d2040000", // itemEntry 1234
"39050000", // spellId 1337
"00", // showAffiliation false
));
match messages::parse_server(messages::opcode::SMSG_ENCHANTMENTLOG, &body).unwrap() {
ServerPacket::EnchantmentLog(p) => assert_eq!(
p,
EnchantmentLog {
caster: 0,
owner: 11,
item_entry: 1234,
spell_id: 1337,
show_affiliation: false,
}
),
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`).
let body = hx(concat!(
"0107", // packed caster 7
"e8030000", // spellId 1000
"03000000", // 3 groups
// group 1: effect 8 POWER_DRAIN, one row
"08000000",
"01000000",
"1400000000000000", // target 20
"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
"18000000",
"01000000",
"b80b0000", // itemEntry 3000
// group 3: effect 102 DISMISS_PET, one row — the guid-only tail of the switch
"66000000",
"01000000",
"1e00000000000000", // target 30
));
match messages::parse_server(messages::opcode::SMSG_SPELLLOGEXECUTE, &body).unwrap() {
ServerPacket::SpellLogExecute(p) => assert_eq!(
p,
SpellLogExecute {
caster: 7,
spell_id: 1000,
effects: vec![
(
8,
vec![ExecuteLog::PowerDrain {
target: 20,
amount: 300,
power: 0,
multiplier: 1.0,
}]
),
(24, vec![ExecuteLog::CreateItem { item_entry: 3000 }]),
(102, vec![ExecuteLog::Target { target: 30 }]),
],
}
),
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.
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.
#[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.
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 {
"SMSG_SET_FLAT_SPELL_MODIFIER"
} else {
"SMSG_SET_PCT_SPELL_MODIFIER"
}
);
match &packet {
ServerPacket::SpellModifier {
flat,
mask_bit,
op,
value,
} => assert_eq!((*flat, *mask_bit, *op, *value), (is_flat, 35, 14, -30)),
other => panic!("spell modifier, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellModifier {
flat,
mask_bit,
op,
value,
} => assert_eq!((flat, mask_bit, op, value), (is_flat, 35, 14, -30)),
_ => panic!("spell modifier event"),
}
}
// 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")
)
.is_err());
}