Files
benilla-octo/crates/benilla-protocol/src/events/decode.rs
T
SamandClaude Fable 5 d16d33dfca world books open, and anvils stop offering a cursor gear
- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:03:35 +07:00

943 lines
36 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]
}
/// 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 } => {
vec![SessionEvent::TextEmote { guid, text_emote }]
}
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::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),
},
}],
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::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: 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) => vec![SessionEvent::SpellGo {
caster: s.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,
item_caster: (s.item_or_caster != s.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::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::QuestGiverInvalid { msg } => {
vec![SessionEvent::QuestGiverInvalid { reason: msg }]
}
ServerPacket::QuestGiverFailed { quest_id, reason } => {
vec![SessionEvent::QuestGiverFailed { quest_id, reason }]
}
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
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::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::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::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::ReadyCheckAnswer { guid, ready } => {
vec![SessionEvent::ReadyCheckAnswer { guid, ready }]
}
ServerPacket::DuelRequested {
arbiter,
challenger,
} => vec![SessionEvent::DuelRequested {
arbiter,
challenger,
}],
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::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::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,
} => vec![SessionEvent::MonsterMove {
guid,
start: v3(start),
spline_id,
path: path.into_iter().map(v3).collect(),
facing,
stop,
duration_ms,
flying,
}],
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::BindPoint { area, .. } => vec![SessionEvent::BindPoint { 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::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,
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.civilian,
i.racial_leader,
),
None => (None, None, None, 0, 0, 0, false, false),
};
vec![SessionEvent::CreatureName {
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
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::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::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::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,
_ => EntityKind::Other,
}
}
/// The display id from a create packet — units, **players**, and GameObjects 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`. Cast from the wire `i32`; `0`/absent →
/// `None`.
fn display_id(t: ObjectType, mask: &ObjectFields) -> Option<u32> {
let raw = match t {
ObjectType::Unit | ObjectType::Player => mask.unit_displayid(),
ObjectType::GameObject => mask.gameobject_displayid(),
_ => None,
}?;
(raw > 0).then_some(raw as u32)
}
/// 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 {
ObjectType::Unit | ObjectType::Player | ObjectType::GameObject => 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
}
}