- the combat log's last eight wire bodies decode — PARTYKILLLOG, SPELLINSTAKILLLOG, PROCRESIST, SPELLORDAMAGE_IMMUNE, SPELLDISPELLOG, DISPEL_FAILED, ENCHANTMENTLOG, SPELLLOGEXECUTE — each layout read off vmangos's own writer and pinned by a byte-exact golden (DISPEL_FAILED carries no count, the list runs to the end of the body; SPELLLOGEXECUTE's POWER_DRAIN row is (amount, power, multiplier), verified at the client's bytes as well as the server's); with the eleven chat types that were waiting on them, 44 chat types become 55 — 29 sentence families, the death pair and the aura rows riding descriptor diffs because 1.12 has no "unit died" opcode, plus the six trailers ((glancing), (30 blocked), (12 absorbed) and kin) that had been missing from every family; also corrected: the durability-death line was going to the red error frame with hard-coded English, and SMSG_SPELLNONMELEEDAMAGELOG lacked its split-damage branch and its blocked wording
- PlayerModel is its own frame kind: SetUnit, RefreshUnit and SetRotation were published on Model, but they belong to the <PlayerModel> method table (0x84f1fc), and the client's lookup chains derived → base only, so a plain <Model> can never reach them — they got there from a strings scan read as ownership, and a strings hit answers whether a name EXISTS, not who owns it (SetUnit's one pooled string is referenced by the PlayerModel and GameTooltip tables and by no Model entry); the new PlayerModel → Model → Frame → Region chain unblocks CreateFrame("PlayerModel"), which is how pfUI builds every unit-frame portrait — and through pfUI, pfQuest and ShaguDPS
- a nil where a number goes is four shapes, not one law: a census of all 408 widget-registrar entries found 62 numeric positions that validate-or-raise, 43 that gate then stage an alternate, 64 that silently coerce to 0.0 and 5 that gate then store nothing — so SetMaxLetters(nil) completes and stores 0, "no limit" (the argument COUNT is checked exactly, the type is not), and the backdrop colour setters are asymmetric on purpose: a nil r/g/b channel is 0.0 but a nil alpha is OPAQUE, and reading every nil as 0.0 would have turned those borders transparent; both setters now also quantize ×255 into the packed 0xAARRGGBB byte quad the client actually stores, so 0.09 reads back as 23/255
- two more addon-killers: LIGHTYELLOW_FONT_COLOR_CODE was the one missing line of Fonts.xml's seven-line colour block (aux-addon concatenates it at file scope and died on its own first line), and the guard now enumerates the whole block l.4-20 instead of the names an earlier round had noticed absent; and the unit frames publish the reference's names — 1.12 has no *PowerBar anywhere, every bar is ManaBar and the colour table is ManaBarColor (8 of the top-20 addons read it by name), so ManaBarColor is the published table our own bars index into, with 22 more regions renamed to the reference's own
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1305 lines
54 KiB
Rust
1305 lines
54 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()
|
||
);
|
||
}
|
||
|
||
/// **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());
|
||
}
|