Files
benilla-octo/crates/benilla-protocol/src/events/decode.rs
T
SamandClaude Fable 5.1 e7aa04f41c the buff bar goes stock, and gossip quests show their level
- the buff bar is the reference's own `BuffFrame.xml`, and the swap found an engine defect: an empty `<OnLoad>` in 1.12's XML is a BLANK that takes an inherited handler away again (seven such sites across four stock files), which our loader read as "none given" — so the two enchant slots would have painted the player's first buff in the corner and cancelled it on right-click; the first fix then had to be corrected at the bytes (`SetScript 0x7025c0` tests only NULL and empty, and nothing trims, so `<OnLoad>↵</OnLoad>` compiles to a valid empty function rather than storing nil, which any addon calling `GetScript` can observe); `SHOW_BUFF_DURATIONS` is applied once post-load because 1.12 leaves that to `UIOptionsFrame.lua`, which benilla does not build — without it the bar ships a 10px pitch error on every buff
- gossip quests come through the reference's four verbs — `GetGossipAvailableQuests`/`GetGossipActiveQuests` returning (title, level) PAIRS, with the two `Select*` verbs indexing into that list — replacing three invented names that sat on the beyond-1.12 exception list; the quest LEVEL was on the wire, parsed and dropped because the invented verb had nowhere to put it, and the reference's order (available before active, whatever the packet interleaved) comes with them
- eight more files off the chain as groundwork: `LocaleProperties.lua` (the gendered/plural `GetText` selector that eleven stock call sites read their words through — fifty lines of FrameXML that looked like an engine binding), `CinematicFrame.xml`, the UIMenu kit, the `UnitFrame.lua`/`CombatFeedback.lua` kits (the only two names blocking the stock player, party, target and pet frames), the money-input kit, and `ItemButtonTemplate.xml` — the base every bag, bank, merchant, loot, trade and mail slot inherits; `GetMerchantItemMaxStack` (on the wire the whole time, dropped) and the two trade item-link verbs join the engine
- two instruments for the migration: a gap report that reads a stock window's call sites against a LOADED VM and says which engine verbs it still needs (28 windows need none, the rest name 1–5), and the readiness probe's documented false positive — loading a stock window on top of our same-named one leaves `getglobal(name)` pointing at ours while the stock handlers run against theirs, so a failure inside a colliding name is a question, not an answer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:58:47 +07:00

1221 lines
49 KiB
Rust

//! The [`ServerPacket`]→[`SessionEvent`] mapping: [`decode`]'s per-packet fan-out plus the
//! object-list interpretation ([`decode_objects`] and the spawn-identity readers). Pure functions
//! of the packet — no I/O, no state; the running world model stays the app's.
use crate::messages::{CastOutcome, MovementBlock, Object, ObjectFields, ObjectType, ServerPacket};
use crate::wire::Vector3d;
use super::{EntityKind, MoveSpeeds, SessionEvent};
fn v3(v: Vector3d) -> [f32; 3] {
[v.x, v.y, v.z]
}
/// **Who cast it** — the caster slot of `SMSG_SPELL_START`/`SMSG_SPELL_GO`, corrected for the one
/// case where vmangos leaves that slot EMPTY.
///
/// Both packets carry a guid pair: slot 1 is *the cast item's guid when one is in play, else the
/// caster's own* (`WriteGuidHelper(data, m_CastItem)` / `else … m_caster`, `Spell.cpp:4475-4477` and
/// `4509-4511`), slot 2 is the caster. But slot 2 is written from **`m_casterUnit`**
/// (`Spell.cpp:4479`, `Spell.cpp:4513`) — and `m_casterUnit` is a `Unit*` that the GameObject
/// constructor never sets: `Spell::Spell(GameObject*, …)` initialises `m_caster(caster),
/// m_casterGo(caster)` and leaves the default `Unit* const m_casterUnit = nullptr` (`Spell.cpp:102`,
/// `Spell.h:368`) standing. `WriteGuidHelper(data, nullptr)` writes `ObjectGuid().WriteAsPacked()`
/// (`Spell.cpp:4441-4453`) — a lone zero mask byte — so **every spell a GameObject casts arrives
/// with caster guid 0**.
///
/// That is not a rare shape. `GameObject::Use` keeps `WorldObject* spellCaster = this` for a
/// `GAMEOBJECT_TYPE_SPELLCASTER` (22) and casts through the GameObject overload
/// (`GameObject.cpp`, `spellCaster->ToGameObject()` → `new Spell(pGo, …)`): the Priest's
/// **Lightwell** (GO 181102, spell 7001 "Lightwell Renew" on the clicker) is the canonical one, and
/// a guid the index can never hold cost that cast its whole visual body — the impact kit, its sound
/// and its effect model — because [`crate::events::SessionEvent::SpellGo`]'s consumer resolves the
/// caster before it plays anything. Slot 1 already carries the answer whenever slot 2 is empty:
/// with no cast item it IS `m_caster`, GameObject included. So an empty caster slot falls back to
/// it, which also stops the object being mistaken for a **cast item** by the `item_caster`
/// derivation below (they are equal, so nothing is derived).
///
/// A Unit caster can never reach this: `Spell::Spell(Unit*, …)` sets `m_casterUnit(caster)`
/// (`Spell.cpp:60`) and a live `Unit*` has a non-zero guid.
fn spell_caster(item_or_caster: u64, caster_slot: u64) -> u64 {
if caster_slot == 0 {
item_or_caster
} else {
caster_slot
}
}
/// Decode one server packet into zero or more [`SessionEvent`]s. Pure: no I/O, no state. Packets the
/// client doesn't model yield an empty list.
pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
match packet {
ServerPacket::UpdateObject { objects } => decode_objects(objects),
// A batch is only a transport: unwrap it in order and decode each move exactly as if it had
// arrived on its own (decision 0624). Order matters — the replay queue in
// `net::motion` reconstructs a mover's arc from packet order.
ServerPacket::CompressedMoves { packets } => packets.into_iter().flat_map(decode).collect(),
ServerPacket::CharEnum { characters } => vec![SessionEvent::CharacterList {
characters,
realm: None,
}],
ServerPacket::LogoutComplete => vec![SessionEvent::LoggedOut],
ServerPacket::LogoutResponse { reason, instant } => {
vec![SessionEvent::LogoutResponse { reason, instant }]
}
ServerPacket::LogoutCancelAck => vec![SessionEvent::LogoutCancelled],
ServerPacket::PlaySound { sound_id } => vec![SessionEvent::PlaySound { sound_id }],
ServerPacket::PlayMusic { music_id } => vec![SessionEvent::PlayMusic { music_id }],
ServerPacket::PlayObjectSound { sound_id, guid } => {
vec![SessionEvent::PlayObjectSound { sound_id, guid }]
}
ServerPacket::Weather {
weather_type,
grade,
sound_id,
instant,
} => vec![SessionEvent::Weather {
weather_type,
grade,
sound_id,
instant,
}],
ServerPacket::TextEmote {
guid,
text_emote,
target_name,
} => {
vec![SessionEvent::TextEmote {
guid,
text_emote,
target_name,
}]
}
ServerPacket::Emote { guid, emote_id } => vec![SessionEvent::Emote { guid, emote_id }],
ServerPacket::InitialSpells {
spell_ids,
cooldowns,
} => vec![SessionEvent::SpellBook {
spell_ids: spell_ids.into_iter().map(u32::from).collect(),
cooldowns,
}],
ServerPacket::ActionButtons { buttons } => vec![SessionEvent::ActionButtons { buttons }],
ServerPacket::LearnedSpell { spell_id } => vec![SessionEvent::SpellLearned {
spell_id: u32::from(spell_id),
}],
ServerPacket::RemovedSpell { spell_id } => vec![SessionEvent::SpellRemoved {
spell_id: u32::from(spell_id),
}],
ServerPacket::SupercededSpell {
old_spell_id,
new_spell_id,
} => vec![SessionEvent::SpellSuperceded {
old_spell_id: u32::from(old_spell_id),
new_spell_id: u32::from(new_spell_id),
}],
ServerPacket::CastResult { spell_id, outcome } => vec![SessionEvent::CastResult {
spell_id,
success: outcome == CastOutcome::Ok,
reason: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { reason, .. } => Some(reason),
},
arg: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { arg, .. } => arg,
},
}],
ServerPacket::PetSpells(spells) => vec![SessionEvent::PetSpells(Box::new(spells))],
ServerPacket::PetMode(mode) => vec![SessionEvent::PetMode(mode)],
ServerPacket::PetActionFeedback { reason } => {
vec![SessionEvent::PetActionFeedback { reason }]
}
ServerPacket::PetCastFailed { spell_id, outcome } => vec![SessionEvent::PetCastFailed {
spell_id,
reason: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { reason, .. } => Some(reason),
},
}],
ServerPacket::ItemQueryResponse { entry, info } => {
vec![SessionEvent::ItemTemplate { entry, info }]
}
ServerPacket::MessageChat(m) => vec![SessionEvent::Chat(m)],
ServerPacket::ChannelNotify(n) => vec![SessionEvent::ChannelNotify {
notice: n.notice,
channel: n.channel,
tail: n.tail,
}],
ServerPacket::ChannelList {
channel,
flags,
members,
} => vec![SessionEvent::ChannelList {
channel,
flags,
members,
}],
ServerPacket::ChatPlayerNotFound { name } => {
vec![SessionEvent::ChatPlayerNotFound { name }]
}
ServerPacket::ChatWrongFaction => vec![SessionEvent::ChatWrongFaction],
ServerPacket::Notification { text } => vec![SessionEvent::Notification { text }],
ServerPacket::AreaTriggerMessage { text } => {
vec![SessionEvent::AreaTriggerMessage { text }]
}
ServerPacket::ServerMessage { message_type, text } => {
vec![SessionEvent::ServerMessage { message_type, text }]
}
ServerPacket::ZoneUnderAttack { area_id } => {
vec![SessionEvent::ZoneUnderAttack { area_id }]
}
ServerPacket::DefenseMessage { zone_id, text } => {
vec![SessionEvent::DefenseMessage { zone_id, text }]
}
ServerPacket::ChatRestricted => vec![SessionEvent::ChatRestricted],
ServerPacket::PlayedTime { total, level } => {
vec![SessionEvent::PlayedTime { total, level }]
}
ServerPacket::RandomRoll {
min,
max,
roll,
guid,
} => vec![SessionEvent::RandomRoll {
min,
max,
roll,
guid,
}],
ServerPacket::InventoryChangeFailure {
reason,
required_level,
item_guid,
bag_slot,
} if reason != 0 => {
vec![SessionEvent::InventoryFailure {
reason,
required_level,
item_guid,
bag_slot,
}]
}
ServerPacket::AttackStart { attacker, victim } => {
vec![SessionEvent::AttackStart { attacker, victim }]
}
ServerPacket::AttackStop { attacker, victim } => {
vec![SessionEvent::AttackStop { attacker, victim }]
}
ServerPacket::AttackerState(s) => vec![SessionEvent::AttackerState(s)],
ServerPacket::AiReaction { unit, reaction } => {
vec![SessionEvent::AiReaction { unit, reaction }]
}
ServerPacket::SpellStart(s) => vec![SessionEvent::SpellStart {
caster: spell_caster(s.item_or_caster, s.caster),
spell_id: s.spell_id,
cast_flags: s.cast_flags,
cast_time_ms: s.cast_time_ms,
target: s.targets.unit_target,
ammo_display_id: s.ammo_display_id,
}],
ServerPacket::SpellGo(s) => {
let caster = spell_caster(s.item_or_caster, s.caster);
vec![SessionEvent::SpellGo {
caster,
spell_id: s.spell_id,
cast_flags: s.cast_flags,
hits: s.hits,
misses: s.misses,
target: s.targets.unit_target,
go_target: s.targets.go_target,
dest: s.targets.dest.map(|d| [d.x, d.y, d.z]),
ammo_display_id: s.ammo_display_id,
// Against the RESOLVED caster, not the raw slot: a GameObject's cast puts its own
// guid in slot 1, and comparing that with the empty slot 2 would label the object
// an item and route the cast down the item-use lane.
item_caster: (s.item_or_caster != caster).then_some(s.item_or_caster),
}]
}
ServerPacket::SpellChainTargets(c) => vec![SessionEvent::SpellChainTargets {
caster: c.caster,
spell_id: c.spell_id,
targets: c.targets,
}],
ServerPacket::SpellDelayed { caster, delay_ms } => {
vec![SessionEvent::SpellDelayed { caster, delay_ms }]
}
ServerPacket::SpellFailedOther { caster, spell_id } => {
vec![SessionEvent::SpellFailedOther { caster, spell_id }]
}
ServerPacket::CancelAutoRepeat => vec![SessionEvent::CancelAutoRepeat],
ServerPacket::SpellCooldownList { caster, cooldowns } => {
vec![SessionEvent::SpellCooldowns { caster, cooldowns }]
}
ServerPacket::ItemCooldown {
item_guid,
spell_id,
} => vec![SessionEvent::ItemCooldown {
item_guid,
spell_id,
}],
ServerPacket::ItemEnchantTime {
item_guid,
slot,
seconds,
} => vec![SessionEvent::ItemEnchantTime {
item_guid,
slot,
seconds,
}],
ServerPacket::CooldownEvent { spell_id, caster } => {
vec![SessionEvent::CooldownEvent { spell_id, caster }]
}
ServerPacket::ClearCooldown { spell_id, caster } => {
vec![SessionEvent::ClearCooldown { spell_id, caster }]
}
ServerPacket::CooldownCheat { caster } => vec![SessionEvent::CooldownCheat { caster }],
ServerPacket::ChannelStart {
spell_id,
duration_ms,
} => vec![SessionEvent::ChannelStart {
spell_id,
duration_ms,
}],
ServerPacket::ChannelUpdate { remaining_ms } => {
vec![SessionEvent::ChannelUpdate { remaining_ms }]
}
ServerPacket::UpdateAuraDuration { slot, remaining_ms } => {
vec![SessionEvent::AuraDuration { slot, remaining_ms }]
}
ServerPacket::PlaySpellVisual { unit, kit_id } => {
vec![SessionEvent::PlaySpellVisual { unit, kit_id }]
}
ServerPacket::SpellDamageLog(s) => vec![SessionEvent::SpellDamageLog(s)],
ServerPacket::PeriodicAuraLog(s) => vec![SessionEvent::PeriodicAuraLog(s)],
ServerPacket::SpellHealLog(s) => vec![SessionEvent::SpellHealLog(s)],
ServerPacket::SpellEnergizeLog(s) => vec![SessionEvent::SpellEnergizeLog(s)],
ServerPacket::DamageShield(s) => vec![SessionEvent::DamageShield(s)],
ServerPacket::EnvironmentalDamageLog(s) => vec![SessionEvent::EnvironmentalDamageLog(s)],
ServerPacket::SpellLogMiss(s) => vec![SessionEvent::SpellLogMiss(s)],
ServerPacket::PartyKillLog(s) => vec![SessionEvent::PartyKillLog(s)],
ServerPacket::SpellInstaKillLog(s) => vec![SessionEvent::SpellInstaKillLog(s)],
ServerPacket::ProcResist(s) => vec![SessionEvent::ProcResist(s)],
ServerPacket::SpellOrDamageImmune(s) => vec![SessionEvent::SpellOrDamageImmune(s)],
ServerPacket::SpellDispelLog(s) => vec![SessionEvent::SpellDispelLog(s)],
ServerPacket::DispelFailed(s) => vec![SessionEvent::DispelFailed(s)],
ServerPacket::EnchantmentLog(s) => vec![SessionEvent::EnchantmentLog(s)],
ServerPacket::SpellLogExecute(s) => vec![SessionEvent::SpellLogExecute(s)],
ServerPacket::XpGain(s) => vec![SessionEvent::XpGain(s)],
ServerPacket::ExplorationXp(s) => vec![SessionEvent::ExplorationXp(s)],
ServerPacket::LevelUp(l) => vec![SessionEvent::LevelUp(l)],
ServerPacket::GossipMessage {
npc,
text_id,
options,
quests,
} => vec![SessionEvent::GossipMenu {
npc,
text_id,
options,
quests: quests
.into_iter()
.map(|q| (q.quest_id, q.icon, q.level, q.title))
.collect(),
}],
ServerPacket::QuestGiverStatus { npc, status } => {
vec![SessionEvent::QuestGiverStatus { npc, status }]
}
ServerPacket::QuestGiverQuestList(l) => vec![SessionEvent::QuestGreeting(l)],
ServerPacket::QuestGiverDetails(d) => vec![SessionEvent::QuestDetail(d)],
ServerPacket::QuestGiverRequestItems(p) => vec![SessionEvent::QuestProgress(p)],
ServerPacket::QuestGiverOfferReward(o) => vec![SessionEvent::QuestOffer(o)],
ServerPacket::QuestGiverComplete(c) => vec![SessionEvent::QuestComplete(c)],
ServerPacket::QuestQueryResponse(t) => vec![SessionEvent::QuestTemplate(t)],
ServerPacket::QuestUpdateAddKill {
quest_id,
entry,
count,
required,
guid: _, // the killed unit's guid — no consumer (the toast keys on the entry)
} => vec![SessionEvent::QuestObjectiveKill {
quest_id,
entry,
count,
required,
}],
ServerPacket::QuestUpdateAddItem { item_id, count } => {
vec![SessionEvent::QuestObjectiveItem { item_id, count }]
}
ServerPacket::QuestUpdateComplete { quest_id } => {
vec![SessionEvent::QuestObjectivesComplete { quest_id }]
}
ServerPacket::QuestUpdateFailed { quest_id } => vec![SessionEvent::QuestFailed {
quest_id,
timed: false,
}],
ServerPacket::QuestUpdateFailedTimer { quest_id } => vec![SessionEvent::QuestFailed {
quest_id,
timed: true,
}],
ServerPacket::QuestLogFull => vec![SessionEvent::QuestLogFull],
ServerPacket::QuestPushResult(r) => vec![SessionEvent::QuestPushResult {
member: r.member,
msg: r.msg,
}],
ServerPacket::QuestConfirmAccept(c) => vec![SessionEvent::QuestConfirmAccept(c)],
ServerPacket::QuestGiverInvalid { msg } => {
vec![SessionEvent::QuestGiverInvalid { reason: msg }]
}
ServerPacket::QuestGiverFailed { quest_id, reason } => {
vec![SessionEvent::QuestGiverFailed { quest_id, reason }]
}
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
ServerPacket::GossipPoi(poi) => vec![SessionEvent::GossipPoi(poi)],
ServerPacket::NpcText { text_id, blocks } => {
vec![SessionEvent::NpcGreeting { text_id, blocks }]
}
ServerPacket::VendorList { vendor, items } => {
vec![SessionEvent::VendorInventory { vendor, items }]
}
ServerPacket::TrainerList {
trainer,
trainer_type,
services,
title,
} => vec![SessionEvent::TrainerList {
trainer,
trainer_type,
services,
greeting: title,
}],
ServerPacket::InvalidatePlayer { guid } => vec![SessionEvent::InvalidatePlayer { guid }],
ServerPacket::ListStabledPets {
npc,
num_stable_slots,
pets,
} => vec![SessionEvent::ListStabledPets {
npc,
num_stable_slots,
pets,
}],
ServerPacket::StableResult { result } => vec![SessionEvent::StableResult { result }],
ServerPacket::TrainerBuySucceeded { trainer, spell_id } => {
vec![SessionEvent::TrainerBuySucceeded { trainer, spell_id }]
}
ServerPacket::TrainerBuyFailed {
trainer,
spell_id,
error,
} => vec![SessionEvent::TrainerBuyFailed {
trainer,
spell_id,
error,
}],
ServerPacket::BuyItem {
vendor,
slot,
new_count,
purchase_count,
} => vec![SessionEvent::VendorBuyResult {
vendor,
slot,
new_count,
purchase_count,
}],
ServerPacket::SellItemResult {
vendor,
item_guid,
reason,
} => vec![SessionEvent::VendorSellFailed {
vendor,
item_guid,
reason,
}],
ServerPacket::BuyFailed {
vendor,
item_entry,
reason,
} => vec![SessionEvent::VendorBuyFailed {
vendor,
item_entry,
reason,
}],
ServerPacket::ShowBank { banker } => vec![SessionEvent::ShowBank { banker }],
ServerPacket::BuyBankSlotResult { result } => {
vec![SessionEvent::BuyBankSlotResult { result }]
}
ServerPacket::LootResponse {
guid,
loot_type,
gold,
items,
} => vec![SessionEvent::LootResponse {
guid,
loot_type,
gold,
items,
}],
ServerPacket::LootError { guid, error } => vec![SessionEvent::LootError { guid, error }],
ServerPacket::LootReleaseResponse { guid, .. } => {
vec![SessionEvent::LootReleaseResponse { guid }]
}
ServerPacket::LootRemoved { slot } => vec![SessionEvent::LootRemoved { slot }],
ServerPacket::LootMoneyNotify { amount } => {
vec![SessionEvent::LootMoneyNotify { amount }]
}
ServerPacket::LootClearMoney => vec![SessionEvent::LootClearMoney],
ServerPacket::LootStartRoll(p) => vec![SessionEvent::LootStartRoll(p)],
ServerPacket::LootRoll(p) => vec![SessionEvent::LootRoll(p)],
ServerPacket::LootRollWon(p) => vec![SessionEvent::LootRollWon(p)],
ServerPacket::LootAllPassed(p) => vec![SessionEvent::LootAllPassed(p)],
ServerPacket::LootMasterList { candidates } => {
vec![SessionEvent::LootMasterList { candidates }]
}
ServerPacket::ItemPushResult(p) => vec![SessionEvent::ItemPushResult(p)],
ServerPacket::CorpseQuery(loc) => vec![SessionEvent::CorpseQuery {
found: loc.found,
display_map: loc.display_map,
position: loc.position,
corpse_map: loc.corpse_map,
}],
ServerPacket::CorpseReclaimDelay { delay_ms } => {
vec![SessionEvent::CorpseReclaimDelay { delay_ms }]
}
ServerPacket::DurabilityDamageDeath => vec![SessionEvent::DurabilityDamageDeath],
ServerPacket::ResurrectRequest(r) => vec![SessionEvent::ResurrectRequest {
caster: r.caster,
name: r.name,
sickness: r.sickness,
has_timer: r.has_timer,
}],
ServerPacket::SpiritHealerConfirm { npc } => {
vec![SessionEvent::SpiritHealerConfirm { npc }]
}
ServerPacket::MoveMode {
guid,
counter,
mode,
apply,
} => vec![SessionEvent::MoveMode {
guid,
counter,
mode,
apply,
}],
ServerPacket::SplineMoveMode { guid, mode, apply } => {
vec![SessionEvent::SplineMoveMode { guid, mode, apply }]
}
ServerPacket::KnockBack {
guid,
counter,
launch,
} => vec![SessionEvent::KnockBack {
guid,
counter,
launch,
}],
ServerPacket::GroupInvite { inviter } => vec![SessionEvent::GroupInvite { inviter }],
ServerPacket::GroupDecline { name } => vec![SessionEvent::GroupDecline { name }],
ServerPacket::GroupUninvited => vec![SessionEvent::GroupUninvited],
ServerPacket::GroupLeaderChanged { name } => {
vec![SessionEvent::GroupLeaderChanged { name }]
}
ServerPacket::GroupDestroyed => vec![SessionEvent::GroupDestroyed],
ServerPacket::GroupList {
group_type,
own_flags,
members,
leader,
loot,
} => vec![SessionEvent::GroupList {
group_type,
own_flags,
members,
leader,
loot,
}],
ServerPacket::PartyCommandResult {
operation,
member,
result,
} => vec![SessionEvent::PartyCommandResult {
operation,
member,
result,
}],
ServerPacket::PartyMemberStats { guid, full, info } => {
vec![SessionEvent::PartyMemberStats { guid, full, info }]
}
ServerPacket::MinimapPing { guid, x, y } => {
vec![SessionEvent::MinimapPing { guid, x, y }]
}
ServerPacket::RaidTargetSet { icon, guid } => {
vec![SessionEvent::RaidTargetSet { icon, guid }]
}
ServerPacket::RaidTargetList { entries } => {
vec![SessionEvent::RaidTargetList { entries }]
}
ServerPacket::ReadyCheckRequest => vec![SessionEvent::ReadyCheckRequest],
ServerPacket::RaidInstanceInfo { entries } => {
vec![SessionEvent::RaidInstanceInfo { entries }]
}
ServerPacket::ReadyCheckAnswer { guid, ready } => {
vec![SessionEvent::ReadyCheckAnswer { guid, ready }]
}
ServerPacket::DuelRequested {
arbiter,
challenger,
} => vec![SessionEvent::DuelRequested {
arbiter,
challenger,
}],
// The instance/raid lockout family (decision 1748) — straight relays; the ownership flag
// is narrowed to a bool here because the reference's own reader is a `test eax,eax`.
ServerPacket::RaidInstanceMessage { message } => {
vec![SessionEvent::RaidInstanceMessage { message }]
}
ServerPacket::InstanceSaveCreated { flag } => {
vec![SessionEvent::InstanceSaveCreated { flag }]
}
ServerPacket::InstanceReset { map } => vec![SessionEvent::InstanceReset { map }],
ServerPacket::InstanceResetFailed { failure } => {
vec![SessionEvent::InstanceResetFailed { failure }]
}
ServerPacket::UpdateLastInstance { map } => vec![SessionEvent::UpdateLastInstance { map }],
ServerPacket::UpdateInstanceOwnership { owns } => {
vec![SessionEvent::UpdateInstanceOwnership { owns: owns != 0 }]
}
ServerPacket::DuelOutOfBounds => vec![SessionEvent::DuelOutOfBounds],
ServerPacket::DuelInBounds => vec![SessionEvent::DuelInBounds],
ServerPacket::DuelComplete { started } => vec![SessionEvent::DuelComplete { started }],
ServerPacket::DuelWinner {
fled,
winner,
loser,
} => vec![SessionEvent::DuelWinner {
fled,
winner,
loser,
}],
ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }],
ServerPacket::InspectHonorStats(stats) => vec![SessionEvent::InspectHonorStats(stats)],
ServerPacket::PvpCredit(credit) => vec![SessionEvent::PvpCredit(credit)],
ServerPacket::MirrorTimerStart(start) => vec![SessionEvent::MirrorTimerStart(start)],
ServerPacket::MirrorTimerPause { kind, paused } => {
vec![SessionEvent::MirrorTimerPause { kind, paused }]
}
ServerPacket::MirrorTimerStop { kind } => vec![SessionEvent::MirrorTimerStop { kind }],
ServerPacket::FriendList { friends } => vec![SessionEvent::FriendList { friends }],
ServerPacket::IgnoreList { guids } => vec![SessionEvent::IgnoreList { guids }],
ServerPacket::FriendStatus(status) => vec![SessionEvent::FriendStatus(status)],
ServerPacket::WhoResults(results) => vec![SessionEvent::WhoResults(results)],
ServerPacket::GuildQueryResponse(response) => {
vec![SessionEvent::GuildQueryResponse(response)]
}
ServerPacket::GuildRoster(roster) => vec![SessionEvent::GuildRoster(roster)],
ServerPacket::GuildEvent(notice) => vec![SessionEvent::GuildEvent(notice)],
ServerPacket::GuildCommandResult(result) => vec![SessionEvent::GuildCommandResult(result)],
ServerPacket::GuildInvite { inviter, guild } => {
vec![SessionEvent::GuildInvite { inviter, guild }]
}
ServerPacket::GuildDecline { name } => vec![SessionEvent::GuildDecline { name }],
ServerPacket::GuildInfo(info) => vec![SessionEvent::GuildInfo(info)],
ServerPacket::PetitionShowList(list) => vec![SessionEvent::PetitionShowList(list)],
ServerPacket::PetitionShowSignatures(sigs) => {
vec![SessionEvent::PetitionShowSignatures(sigs)]
}
ServerPacket::PetitionSignResults(results) => {
vec![SessionEvent::PetitionSignResults(results)]
}
ServerPacket::PetitionQueryResponse(response) => {
vec![SessionEvent::PetitionQueryResponse(response)]
}
ServerPacket::TurnInPetitionResults { result } => {
vec![SessionEvent::TurnInPetitionResults { result }]
}
ServerPacket::PetitionDeclined { player } => {
vec![SessionEvent::PetitionDeclined { player }]
}
ServerPacket::PetitionRenamed(rename) => vec![SessionEvent::PetitionRenamed(rename)],
ServerPacket::DestroyObject { guid } => vec![SessionEvent::ObjectDestroyed(guid)],
ServerPacket::TriggerCinematic { cinematic_id } => {
vec![SessionEvent::CinematicTriggered { cinematic_id }]
}
ServerPacket::MonsterMove {
guid,
start,
spline_id,
path,
facing,
stop,
duration_ms,
flying,
run_mode,
} => vec![SessionEvent::MonsterMove {
guid,
start: v3(start),
spline_id,
path: path.into_iter().map(v3).collect(),
facing,
stop,
duration_ms,
flying,
run_mode,
}],
ServerPacket::PlayerMove {
guid,
opcode,
flags,
position,
orientation,
pitch,
time,
fall_time,
jump,
transport,
} => vec![SessionEvent::UnitMove {
guid,
position: v3(position),
orientation,
flags,
pitch,
time,
heartbeat: opcode == crate::messages::opcode::MSG_MOVE_HEARTBEAT,
fall_time,
jump,
transport,
}],
ServerPacket::Teleport {
guid,
counter,
position,
orientation,
} => vec![SessionEvent::Teleport {
guid,
counter,
position: v3(position),
orientation,
}],
ServerPacket::NewWorld {
map,
position,
orientation,
} => vec![SessionEvent::Worldport {
map_id: map,
position: v3(position),
orientation,
needs_ack: true,
}],
ServerPacket::LoginVerifyWorld {
map,
position,
orientation,
} => vec![SessionEvent::Worldport {
map_id: map,
position: v3(position),
orientation,
needs_ack: false,
}],
ServerPacket::TransferPending { map, transport } => vec![SessionEvent::TransferPending {
map_id: map,
transport_entry: transport.map(|(entry, _old_map)| entry),
}],
ServerPacket::TransferAborted { reason } => {
vec![SessionEvent::TransferAborted { reason }]
}
ServerPacket::TimeSpeed {
hours,
minutes,
day_serial,
timescale,
} => vec![SessionEvent::TimeSpeed {
hours,
minutes,
day_serial,
timescale,
}],
ServerPacket::QueryTimeResponse { unix_time } => {
vec![SessionEvent::ServerUnixTime { unix_time }]
}
ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }],
ServerPacket::GmTicketAnswer { ticket } => vec![SessionEvent::GmTicket { ticket }],
ServerPacket::GmTicketCreated { response } => {
vec![SessionEvent::GmTicketCreated { response }]
}
ServerPacket::GmTicketUpdated { response } => {
vec![SessionEvent::GmTicketUpdated { response }]
}
ServerPacket::GmTicketDeleted { response } => {
vec![SessionEvent::GmTicketDeleted { response }]
}
ServerPacket::GmTicketSystemStatus { status } => {
vec![SessionEvent::GmTicketSystemStatus { status }]
}
ServerPacket::GmTicketStatusUpdate { status } => {
vec![SessionEvent::GmTicketStatusUpdate { status }]
}
ServerPacket::BinderConfirm { binder } => vec![SessionEvent::BinderConfirm { binder }],
ServerPacket::SummonRequest {
summoner,
zone,
delay_ms,
} => vec![SessionEvent::SummonRequest {
summoner,
zone,
delay_ms,
}],
ServerPacket::TalentWipeConfirm { trainer, cost } => {
vec![SessionEvent::TalentWipeConfirm { trainer, cost }]
}
ServerPacket::PlayerBound { binder, area } => {
vec![SessionEvent::PlayerBound { binder, area }]
}
ServerPacket::SetProficiency {
item_class,
subclass_mask,
} => vec![SessionEvent::Proficiency {
item_class: u32::from(item_class),
subclass_mask,
}],
ServerPacket::InitializeFactions { standings } => {
vec![SessionEvent::Reputations { standings }]
}
ServerPacket::SetFactionStanding { standings } => {
vec![SessionEvent::ReputationDelta { standings }]
}
ServerPacket::SetFactionVisible { list_id } => {
vec![SessionEvent::ReputationVisible { list_id }]
}
ServerPacket::NameQueryResponse {
guid,
name,
race,
gender,
class,
} => vec![SessionEvent::PlayerName {
guid,
name,
race,
gender,
class,
}],
ServerPacket::PetNameQueryResponse { pet_number, name } => {
vec![SessionEvent::PetName { pet_number, name }]
}
ServerPacket::CreatureQueryResponse { entry, info } => {
let (
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
display_id,
civilian,
racial_leader,
) = match info {
Some(i) => (
Some(i.name),
// An EMPTY wire subname is NO subname (vmangos sends "" for creatures
// without one; the client renders no line for it — the nameplate
// builder's verified shape, 0x608f50). Mapped here, once, so no consumer
// ever sees a present-but-empty tooltip/nameplate line.
Some(i.subname).filter(|s| !s.is_empty()),
Some(i.creature_type),
// NOT wrapped in an `Option` like the type beside it: family `0` already
// means "this template has no family", which is exactly what a miss means
// too, so both answer the same `0` rather than two shapes for one state
// (`CreatureFamily.dbc` has no row 0 — decision 1062).
i.pet_family,
i.rank,
i.type_flags,
i.display_id,
i.civilian,
i.racial_leader,
),
None => (None, None, None, 0, 0, 0, 0, false, false),
};
vec![SessionEvent::CreatureName {
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
display_id,
civilian,
racial_leader,
}]
}
ServerPacket::PageTextQueryResponse {
page_id,
text,
next_page_id,
} => vec![SessionEvent::PageText {
page_id,
text,
next_page_id,
}],
ServerPacket::GameObjectQueryResponse { entry, info } => {
let event = match info {
Some(i) => SessionEvent::GameObjectInfo {
entry,
type_id: i.type_id,
display_id: i.display_id,
name: i.name,
data: i.data,
},
None => SessionEvent::GameObjectInfo {
entry,
type_id: 0,
display_id: 0,
name: String::new(),
data: [0; 24],
},
};
vec![event]
}
ServerPacket::GameObjectCustomAnim { guid, anim_id } => {
vec![SessionEvent::GameObjectCustomAnim { guid, anim_id }]
}
ServerPacket::GameObjectDespawnAnim { guid } => {
vec![SessionEvent::GameObjectDespawnAnim { guid }]
}
ServerPacket::FishNotHooked => vec![SessionEvent::FishNotHooked],
ServerPacket::FishEscaped => vec![SessionEvent::FishEscaped],
// The keepalive echo: the io layer matches the sequence against its ping clock to compute
// the round-trip time (the codec is stateless, so the timing lives with the sender).
ServerPacket::Pong { sequence } => vec![SessionEvent::Pong { sequence }],
ServerPacket::ForceSpeedChange {
guid,
kind,
counter,
speed,
} => vec![SessionEvent::ForceSpeedChange {
guid,
kind,
counter,
speed,
}],
ServerPacket::SplineSpeedChange { guid, kind, speed } => {
vec![SessionEvent::SpeedChanged { guid, kind, speed }]
}
// The MOVE_SET flavour is a speed change AND a fresh authoritative pose in one packet
// (decision 0441) — surface both; they land in the same drain, so extrapolation resumes
// from the new pose at the new speed either way.
ServerPacket::MoveSetSpeed {
guid,
kind,
flags,
position,
orientation,
pitch,
time,
fall_time,
jump,
transport,
speed,
} => vec![
SessionEvent::UnitMove {
guid,
position: v3(position),
orientation,
flags,
pitch,
time,
heartbeat: false,
fall_time,
jump,
transport,
},
SessionEvent::SpeedChanged { guid, kind, speed },
],
ServerPacket::MountResult { mount, code } => {
vec![SessionEvent::MountResult { mount, code }]
}
ServerPacket::MountSpecialAnim { guid } => {
vec![SessionEvent::MountSpecial { guid }]
}
ServerPacket::ClientControlUpdate { mover, allow_move } => {
vec![SessionEvent::ClientControl { mover, allow_move }]
}
ServerPacket::ShowTaxiNodes {
window,
flightmaster,
nearest_node,
known,
} => {
// The leading word is the client parser's own conditional gate (0496 §claim-1): a
// zero-gated body carries no menu — no event. vmangos always sends 1.
if window == 0 {
vec![]
} else {
vec![SessionEvent::TaxiNodesShown {
flightmaster,
nearest_node,
known_mask: known,
}]
}
}
ServerPacket::TaxiNodeStatus { guid, known } => {
vec![SessionEvent::TaxiNodeStatus { guid, known }]
}
ServerPacket::ActivateTaxiReply { code } => {
vec![SessionEvent::ActivateTaxiReply { code }]
}
ServerPacket::NewTaxiPath => vec![SessionEvent::NewTaxiPath],
ServerPacket::MailList { mails } => vec![SessionEvent::MailList { mails }],
ServerPacket::SendMailResult {
mail_id,
action,
error,
equip_error,
item,
} => vec![SessionEvent::SendMailResult {
mail_id,
action,
error,
equip_error,
item,
}],
ServerPacket::ItemTextQueryResponse { text_id, text } => {
vec![SessionEvent::MailItemText { text_id, text }]
}
ServerPacket::ReceivedMail { seconds } => vec![SessionEvent::ReceivedMail { seconds }],
ServerPacket::NextMailTime { seconds } => {
vec![SessionEvent::NextMailTime { seconds }]
}
ServerPacket::AuctionHello {
auctioneer,
house_id,
} => vec![SessionEvent::AuctionHello {
auctioneer,
house_id,
}],
ServerPacket::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => vec![SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
}],
ServerPacket::AuctionListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionListResult {
auctions,
total_count,
}],
ServerPacket::AuctionOwnerListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionOwnerListResult {
auctions,
total_count,
}],
ServerPacket::AuctionBidderListResult {
auctions,
total_count,
} => vec![SessionEvent::AuctionBidderListResult {
auctions,
total_count,
}],
ServerPacket::AuctionBidderNotification(n) => {
vec![SessionEvent::AuctionBidderNotification(n)]
}
ServerPacket::AuctionOwnerNotification(n) => {
vec![SessionEvent::AuctionOwnerNotification(n)]
}
ServerPacket::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
} => vec![SessionEvent::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
}],
ServerPacket::TradeStatus { status } => vec![SessionEvent::TradeStatus { status }],
ServerPacket::TradeStatusExtended { state } => {
vec![SessionEvent::TradeStatusExtended { state }]
}
ServerPacket::InitWorldStates(w) => vec![SessionEvent::WorldStates {
scope: Some((w.map, w.zone)),
states: w.states,
}],
ServerPacket::UpdateWorldState { id, value } => vec![SessionEvent::WorldStates {
scope: None,
states: vec![(id, value)],
}],
// An opcode with NO parse arm at all — surface it so the app can tally the coverage gap
// (the debug panel's dropped-opcode instrument); the silent fall-through hid whole wire
// families. Parsed-but-unmodelled packets (the `_` below) stay deliberate no-ops.
ServerPacket::Other { opcode } => vec![SessionEvent::PacketDropped {
opcode,
unparseable: false,
}],
_ => Vec::new(),
}
}
/// Fan one object-update packet's object list out into create/move/remove/values events. Consumes the
/// list so each object's descriptor `mask` moves into its event as the ECS store seed/delta (decision
/// 0061) — no clone.
fn decode_objects(objects: Vec<Object>) -> Vec<SessionEvent> {
let mut out = Vec::new();
for object in objects {
match object {
Object::Create {
guid,
object_type,
movement,
mask,
} => {
// Items/containers are descriptor-only creates — no pose to interpret, no scene
// presence. Route them whole into the item event before the placement gate below
// (which would otherwise silently drop a position-less create, fields and all).
if matches!(object_type, ObjectType::Item | ObjectType::Container) {
out.push(SessionEvent::ItemCreate {
guid,
container: object_type == ObjectType::Container,
fields: mask,
});
continue;
}
// Interpret the spawn identity + placement from the mask (borrow), then move the whole
// mask into the create event as the object's initial descriptor store.
if let Some((position, orientation)) =
create_placement(object_type, &mask, &movement)
{
let display = display_id(object_type, &mask);
let scale = object_scale(object_type, &mask);
let speeds = movement.speeds.map(MoveSpeeds::from_wire);
out.push(SessionEvent::ObjectCreate {
guid,
kind: entity_kind(object_type),
display_id: display,
position: v3(position),
orientation,
scale,
speeds,
transport_progress: movement.transport_progress,
transport: movement.transport,
spline: movement.spline,
fields: mask,
});
}
}
Object::Movement { guid, movement } => {
if let Some((position, orientation)) = movement.position {
out.push(SessionEvent::ObjectMove {
guid,
position: v3(position),
orientation,
});
}
}
Object::OutOfRange { guids } => {
out.push(SessionEvent::ObjectsRemoved(guids));
}
// `Values` carries UpdateFields-only changes (no position) — descriptor deltas (health on a
// hit, an appearance byte, a door's state). Emit the whole non-empty delta for the ECS to
// merge into the object's store; interpretation is the consumer's, per named accessor.
Object::Values { guid, mask } => {
if !mask.is_empty() {
out.push(SessionEvent::ObjectValues { guid, fields: mask });
}
}
// `Near` is a guid pre-announce; no state.
Object::Near { .. } => {}
}
}
out
}
/// Coarse classification of a decoded object from its `TypeId`.
fn entity_kind(t: ObjectType) -> EntityKind {
match t {
ObjectType::Player => EntityKind::Player,
ObjectType::Unit => EntityKind::Unit,
ObjectType::GameObject => EntityKind::GameObject,
ObjectType::DynamicObject => EntityKind::DynamicObject,
ObjectType::Corpse => EntityKind::Corpse,
_ => EntityKind::Other,
}
}
/// The display id from a create packet — units, **players**, GameObjects **and corpses** all carry
/// one. A player's body model resolves through the same CreatureDisplayInfo→CreatureModelData chain
/// as a creature's: the 1.12 client has no race/sex→path resolver, CGPlayer inherits CGUnit's
/// displayId model-build (decision 0041), so we route `Player` alongside `Unit`. A corpse's
/// `CORPSE_FIELD_DISPLAY_ID` is the dead player's own body display (vmangos writes
/// `GetNativeDisplayId()` straight in), so it resolves down that identical chain — the reference's
/// `0x5d6700` does exactly this lookup (decision 1706).
///
/// **A bone pile's display id is deliberately still reported here.** The client ignores it (it
/// builds `<Race><Sex>DeathSkeleton` from the BYTES instead), but that is a *model-resolution* law,
/// not a wire fact — the field is present and it is what it is, and the fork lives where models are
/// resolved (`entities::corpse`). Reporting `None` here would also blind the live-display differ to
/// a flesh→bones flag flip, which the reference reacts to with a full model reload.
///
/// Cast from the wire `i32`; `0`/absent → `None`.
fn display_id(t: ObjectType, mask: &ObjectFields) -> Option<u32> {
match t {
ObjectType::Unit | ObjectType::Player => {
mask.unit_displayid().filter(|&d| d > 0).map(|d| d as u32)
}
ObjectType::GameObject => mask
.gameobject_displayid()
.filter(|&d| d > 0)
.map(|d| d as u32),
ObjectType::Corpse => mask.corpse_display_id(),
_ => None,
}
}
/// The per-object scale (`OBJECT_FIELD_SCALE_X`) units/players/GameObjects carry — the *complete*
/// render scale that multiplies the raw model's native size. The real client sizes any object by this
/// field alone (verified `world_model_scale` `0x613ef0`); for a unit the server has already folded the
/// CreatureDisplayInfo/CreatureModelData scale into it (vmangos `Unit::GetScaleForDisplayId`), so it is
/// *not* multiplied again client-side. Defaults to `1.0` when absent or non-positive (the server always
/// sends a positive value on create).
fn object_scale(t: ObjectType, mask: &ObjectFields) -> f32 {
let raw = match t {
// A corpse joins them: `OBJECT_FIELD_SCALE_X` is an OBJECT-block field and vmangos sets it
// (`Corpse::Create` → `SetObjectScale(DEFAULT_OBJECT_SCALE)`), so the same one-field law
// sizes it (decision 1706).
ObjectType::Unit | ObjectType::Player | ObjectType::GameObject | ObjectType::Corpse => {
mask.object_scale_x()
}
_ => None,
};
raw.filter(|s| *s > 0.0).unwrap_or(1.0)
}
/// Where a freshly-created object sits: GameObjects from UpdateFields (`GAMEOBJECT_POS_*`) — falling
/// back to the movement block's `HAS_POSITION` pose when those fields were never sent at all — everything
/// else straight from the movement block.
///
/// The fallback exists for transports: vmangos never sets `GAMEOBJECT_POS_*` on a boat/zeppelin/elevator
/// (it sends the stationary spawn point in the movement block's `HAS_POSITION` slot instead — decision
/// 0438 "the wire"). Gated on [`ObjectFields::gameobject_pos_sent`] rather than
/// `gameobject_position().or(..)`: a create block's absent-is-zero fold
/// (`ObjectFields`'s "created" semantics) means `gameobject_position()` reads `Some((0,0,0), 0)` even
/// when nothing was sent, so a plain `.or` never falls through. An ordinary GameObject sends at least
/// one non-zero `GAMEOBJECT_POS_*` axis virtually always, so this never changes its placement.
fn create_placement(
object_type: ObjectType,
mask: &ObjectFields,
movement: &MovementBlock,
) -> Option<(Vector3d, f32)> {
if object_type == ObjectType::GameObject && mask.gameobject_pos_sent() {
mask.gameobject_position()
} else {
movement.position
}
}