- the world camera's pick regression: its off-screen render target carried `scale_factor: 1.0`, so on a Retina display the camera reported physical pixels as logical and a centred cursor cast its pick ray into the upper-left quadrant — unit and GameObject picks, the occlusion ray, chat bubbles and `world_to_viewport` all broke at once; the target now carries the window's real scale factor, re-stamped when dragging between displays changes it mid-session — and the perf pill's overlap dodge had the same disease in miniature (`GetBottom()` answers in WoW UI units, the pill lays out in window px), so it now answers a fraction of the screen and works at any window height - `boothHalfRate` defaults back to half-rate: a body pane's off-screen booth pass rendered every frame reads +7.6 ms of GPU at 4K (~1.6 ms at 1600×900), on exactly the weak GPUs that felt the last snapshot — the doll's item effects keep full speed, since they key on the pacing regime rather than the camera's active bit, so only the 30 fps animation cadence returns, and full-rate stays one `SetCVar` away - a spell cast by a GameObject arrives with caster guid 0 — vmangos writes the caster slot from a `Unit*` its GameObject constructor never sets — and the whole visual body was dropped at the guid lookup; the decode now reads an empty caster slot as the packet's first guid, which with no cast item IS the caster, so the Lightwell's click plays its impact kit (sound 3155 and the head flash) instead of only the aura glow that hid the miss, and the object stops being mislabelled a cast item - the world map's black hairlines: a `SetTexCoord` crop is not a texture, and a POI icon magnified to 2.08 px per texel sampled half a texel past its crop into the neighbouring atlas cell — an opaque black row bleeding over a cell that is fully transparent (the empty cell is the reference's own zone-level substitution); `UiQuad::uv_clamp` now insets the sampling window half a texel per axis, so a crop renders as if its sub-rect were its own clamped texture, and `WOW_UI_PICK=x,y` names every quad under a pixel — it found this one in a single run Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1111 lines
46 KiB
Rust
1111 lines
46 KiB
Rust
//! 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 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 0250) — 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()
|
||
);
|
||
}
|