Files
benilla-octo/crates/benilla-protocol/src/events/decode.rs
T
SamandClaude Fable 5 b8758d0b21 the server's broadcasts land, and a ghost walks through closed doors
- the four world broadcasts decode and land — a shutdown countdown, the civilian-kill alarm, a defense-tower capture and the whisper-cap refusal all reached benilla as unhandled opcodes; `SMSG_SERVER_MESSAGE` carries a row id and a fill, never a finished line, so `ServerMessages.dbc` ships (the five sentences a shutdown has, plus the client's own `[%d]: %s` row-missing fallback, which is mechanism, not an error path); the two defense broadcasts are CHANNEL lines, not system lines — delivered once per joined channel whose row carries DEFENSE, the LocalDefense copy zone-matched against the PARENT area while the text names the packet's own — so a character in neither channel sees nothing, faithfully, and since nothing auto-joins WorldDefense in 1.12 an Eastern Plaguelands tower capture reaches only players standing there unless they typed /join; the wow-re cross-check then made the length prefix load-bearing — the client borrows a pointer into the receive buffer and composes NULL on an overrun, so an overrunning length is a refusal here too: the packet drops with a log line, nothing displayed
- `BankButtonIDToInvSlotID` is carved: `isBag` is a TYPE test, not truthiness — `lua_isnumber` refuses every non-number tag without ever loading the payload, so `true` takes the same arm as `nil` while `0` and `"1"` take the bag arm — the function is TOTAL (our out-of-range 0 was an invention; the only compares in all 118 bytes are on `lua_isnumber`'s return), and the +59/+39 arithmetic stands confirmed, byte-identical to `ContainerIDToInventoryID`'s second arm
- a deliberate right-drag turn no longer stands you while seated — the observation was right and our attribution was wrong: the body-facing commit is refused by two independent gates and the stand arm is skipped outright while the button is held; what stands you is the RELEASE, a press under 200 ms with under 2.25 degrees of yaw dispatched as a right-CLICK whose interact reaches `SetStandState(0)` — this lands the deletion half, with the click's own stand held for one more wow-re answer
- a ghost walks through closed doors — the whole mechanism is one instruction image-wide, reading bit 15 through the sign flag to answer "not a collision candidate" for a DOOR when the traced body is a player in ghost form; the bit is a property of whose trace it is, not of the door, so the exclusion rides the two mover-only casts and the set is empty on every living frame — the particle snap, the precipitation probe, the mouse pick and the creature conform keep the door's collider untouched, exactly as the binary leaves them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:19:32 +07:00

1218 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.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::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
}
}