- 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>
943 lines
36 KiB
Rust
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
|
|
}
|
|
}
|