From f20f77d6950e173f6cbe8a68537b49c76096696c Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 12:04:14 +0700 Subject: [PATCH] every packet has a handler, and the drain is a dispatcher - the pet, the group, the death arc, the animation layer, the combat log and the chat window register their own packet handlers (2321-2323) - the spell gets a home of its own; the world feed, the name queries and the session register theirs, and the broadcast list retires (2324-2326) - the object layer goes last: every packet the client decodes has a handler, and the dispatch match and its three staging maps are gone (2327) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../combat_chat.rs => combat_log/chat.rs} | 14 +- crates/benilla-app/src/combat_log/mod.rs | 335 ++++ .../combat_log.rs => combat_log/text.rs} | 18 +- crates/benilla-app/src/creature_anim.rs | 2 + crates/benilla-app/src/creature_anim/net.rs | 471 ++++++ .../src/{death.rs => death/mod.rs} | 3 + .../src/{net/apply/death.rs => death/net.rs} | 128 +- crates/benilla-app/src/entities/missile.rs | 2 +- crates/benilla-app/src/game_plugins.rs | 7 +- crates/benilla-app/src/lib.rs | 2 + crates/benilla-app/src/names.rs | 138 ++ crates/benilla-app/src/net.rs | 9 + crates/benilla-app/src/net/apply.rs | 1361 +---------------- crates/benilla-app/src/net/apply/anim.rs | 98 -- crates/benilla-app/src/net/apply/combat.rs | 218 --- crates/benilla-app/src/net/apply/mount.rs | 46 - crates/benilla-app/src/net/apply/names.rs | 70 - crates/benilla-app/src/net/apply/params.rs | 283 ---- .../benilla-app/src/net/apply/seam_tests.rs | 160 +- crates/benilla-app/src/net/apply/world.rs | 83 - crates/benilla-app/src/net/handlers.rs | 191 +-- .../src/net/{apply => }/objects.rs | 729 +++++---- .../src/net/{apply => }/session.rs | 402 ++++- crates/benilla-app/src/net/world.rs | 80 + crates/benilla-app/src/sound/mod.rs | 2 + crates/benilla-app/src/sound/net.rs | 64 + crates/benilla-app/src/spell/mod.rs | 18 + .../src/{net/apply/spells.rs => spell/net.rs} | 491 +++++- crates/benilla-app/src/tutorial.rs | 21 + crates/benilla-app/src/ui_action/mod.rs | 2 + crates/benilla-app/src/ui_action/net.rs | 34 + crates/benilla-app/src/ui_auction/net.rs | 13 +- crates/benilla-app/src/ui_bank/net.rs | 2 +- crates/benilla-app/src/ui_chat.rs | 2 + crates/benilla-app/src/ui_chat/combat.rs | 16 - .../src/{net/apply/chat.rs => ui_chat/net.rs} | 154 +- crates/benilla-app/src/ui_duel.rs | 2 +- crates/benilla-app/src/ui_gm_ticket.rs | 2 +- crates/benilla-app/src/ui_gossip/net.rs | 2 +- crates/benilla-app/src/ui_guild/mod.rs | 2 +- crates/benilla-app/src/ui_logout.rs | 28 + crates/benilla-app/src/ui_loot/net.rs | 2 +- crates/benilla-app/src/ui_loot_roll.rs | 2 +- crates/benilla-app/src/ui_mail/net.rs | 10 +- crates/benilla-app/src/ui_merchant/net.rs | 2 +- crates/benilla-app/src/ui_party/mod.rs | 2 + .../{net/apply/group.rs => ui_party/net.rs} | 212 ++- crates/benilla-app/src/ui_pet/mod.rs | 2 + .../src/{net/apply/pet.rs => ui_pet/net.rs} | 101 +- crates/benilla-app/src/ui_quest/net.rs | 2 +- crates/benilla-app/src/ui_quest_log.rs | 2 +- crates/benilla-app/src/ui_quest_share.rs | 2 +- crates/benilla-app/src/ui_social/mod.rs | 2 +- crates/benilla-app/src/ui_taxi/net.rs | 2 +- crates/benilla-app/src/ui_trade/net.rs | 8 +- crates/benilla-app/src/ui_trainer/net.rs | 2 +- 56 files changed, 3203 insertions(+), 2855 deletions(-) rename crates/benilla-app/src/{net/apply/combat_chat.rs => combat_log/chat.rs} (99%) create mode 100644 crates/benilla-app/src/combat_log/mod.rs rename crates/benilla-app/src/{net/apply/combat_log.rs => combat_log/text.rs} (98%) create mode 100644 crates/benilla-app/src/creature_anim/net.rs rename crates/benilla-app/src/{death.rs => death/mod.rs} (99%) rename crates/benilla-app/src/{net/apply/death.rs => death/net.rs} (63%) delete mode 100644 crates/benilla-app/src/net/apply/anim.rs delete mode 100644 crates/benilla-app/src/net/apply/combat.rs delete mode 100644 crates/benilla-app/src/net/apply/mount.rs delete mode 100644 crates/benilla-app/src/net/apply/names.rs delete mode 100644 crates/benilla-app/src/net/apply/params.rs delete mode 100644 crates/benilla-app/src/net/apply/world.rs rename crates/benilla-app/src/net/{apply => }/objects.rs (77%) rename crates/benilla-app/src/net/{apply => }/session.rs (58%) create mode 100644 crates/benilla-app/src/net/world.rs create mode 100644 crates/benilla-app/src/sound/net.rs create mode 100644 crates/benilla-app/src/spell/mod.rs rename crates/benilla-app/src/{net/apply/spells.rs => spell/net.rs} (89%) create mode 100644 crates/benilla-app/src/ui_action/net.rs rename crates/benilla-app/src/{net/apply/chat.rs => ui_chat/net.rs} (79%) rename crates/benilla-app/src/{net/apply/group.rs => ui_party/net.rs} (76%) rename crates/benilla-app/src/{net/apply/pet.rs => ui_pet/net.rs} (84%) diff --git a/crates/benilla-app/src/net/apply/combat_chat.rs b/crates/benilla-app/src/combat_log/chat.rs similarity index 99% rename from crates/benilla-app/src/net/apply/combat_chat.rs rename to crates/benilla-app/src/combat_log/chat.rs index 793e8c89..c90d0429 100644 --- a/crates/benilla-app/src/net/apply/combat_chat.rs +++ b/crates/benilla-app/src/combat_log/chat.rs @@ -1,5 +1,5 @@ -//! The combat-log **chat** arms (B297) — the second consumer of the packets -//! [`super::combat_log`] and [`super::combat`] already read for the floating numbers. +//! The combat log's **chat line** leg (B297) — one of the two consumers of every combat packet; +//! the floating number is [`super::text`]'s, and [`super`]'s handlers call this leg first. //! //! The split is the reference's own. `0x629b60` is the display dispatcher: one classification of //! both endpoints, then a branch per outcome into a formatter that emits *text*. The floating @@ -25,11 +25,11 @@ use benilla_protocol::messages::{ use crate::ui_chat::combat::{self, Family, Fills, UnitClass}; use crate::ui_chat::ChatLog; -use super::super::{GuidIndex, ObjectStore, Reputations, SelfGuid}; +use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid}; -/// The classification inputs every line needs, bundled so seven arms do not each grow six -/// parameters. Assembled once per drain in [`super::apply_net_updates`]. -pub(super) struct ChatCtx<'a> { +/// The classification inputs every line needs, bundled so seven legs do not each grow six +/// parameters. Assembled per packet by [`super`]'s handlers from [`super::Log`]. +pub(crate) struct ChatCtx<'a> { pub self_guid: &'a SelfGuid, pub group: Option<&'a crate::ui_party::GroupState>, pub index: &'a GuidIndex, @@ -1355,7 +1355,7 @@ pub(super) fn environmental_damage_log( /// /// The wire's `reputationListId` is `Faction.dbc`'s `rep_index`, not a faction id; the name comes /// off the row that carries that index. -pub(super) fn faction_standing( +pub(crate) fn faction_standing( deltas: &[(u32, i32)], reputations: &Reputations, factions: Option<&crate::target::ring::Factions>, diff --git a/crates/benilla-app/src/combat_log/mod.rs b/crates/benilla-app/src/combat_log/mod.rs new file mode 100644 index 00000000..4b123b73 --- /dev/null +++ b/crates/benilla-app/src/combat_log/mod.rs @@ -0,0 +1,335 @@ +//! **The combat log** — the reference's own handler cluster for the combat packets (in the net +//! handler table since 2323, moved out of the drain's `combat_chat` and `combat_log` arm files). +//! Every combat packet has two consumers, and the reference emits both from one handler: the +//! **chat line** ([`chat`], the `0x629b60` display dispatcher's text) and the **floating +//! number** with the portrait's `UNIT_COMBAT` flash ([`text`], the `0x625010` world-anchored +//! spawn). They are gated by different CVars and classify by different laws, which is why the +//! legs are two files; they answer one packet, which is why the handler is one — the line first, +//! the number second, as the drain's match ran them. +//! +//! Two packets have a third consumer: the completed swing and the environmental-damage log also +//! drive the animation layer, whose handlers ([`crate::creature_anim::net`]) are registered after +//! these, so a kind with two handlers runs the line, then the animation. + +use benilla_protocol::messages::SpellOutcomeLog; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use crate::combat_text::{CombatTextSpawn, DamageTextGates}; +use crate::names::NameCache; +use crate::net::{ + GuidIndex, NetCommands, NetHandlerApp, ObjectStore, Reputations, SelfGuid, ServerSoundMessage, +}; +use crate::ui_chat::combat::{CombatLogRanges, LogPeriodicSpells}; +use crate::ui_chat::ChatLog; +use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback}; + +pub(crate) mod chat; +pub(crate) mod text; + +/// What the chat line classifies against — [`chat::ChatCtx`]'s inputs, built per packet by +/// [`Ctx::ctx`]. Read-only, so a handler can hold it beside the mutable [`Sinks`]. +#[derive(SystemParam)] +pub(crate) struct Ctx<'w> { + self_guid: Res<'w, SelfGuid>, + group: Res<'w, crate::ui_party::GroupState>, + index: Res<'w, GuidIndex>, + factions: Option>, + reputations: Res<'w, Reputations>, + spells: Option>, + /// The eight display ranges. + ranges: Res<'w, CombatLogRanges>, + /// `CombatLogPeriodicSpells`. + periodic: Res<'w, LogPeriodicSpells>, +} + +impl Ctx<'_> { + fn ctx(&self) -> chat::ChatCtx<'_> { + chat::ChatCtx { + self_guid: &self.self_guid, + group: Some(&self.group), + index: &self.index, + factions: self.factions.as_deref(), + reputations: &self.reputations, + spells: self.spells.as_deref(), + ranges: &self.ranges, + periodic: self.periodic.0, + } + } +} + +/// What the two legs read from the world and write to: the object stores and poses both legs +/// resolve endpoints through, the chat log, the number's gates and its three sinks. +#[derive(SystemParam)] +pub(crate) struct Sinks<'w, 's> { + /// `CombatDamage` + the two `Pet*` sub-gates. + damage_text: Res<'w, DamageTextGates>, + stores: Query<'w, 's, &'static mut ObjectStore>, + poses: Query<'w, 's, &'static mut Transform>, + log: ResMut<'w, ChatLog>, + names: Res<'w, NameCache>, + net: Res<'w, NetCommands>, + text: MessageWriter<'w, CombatTextSpawn>, + feedback: MessageWriter<'w, UnitCombatFeedback>, + center: MessageWriter<'w, CombatTextEvent>, +} + +/// The combat log's plugin — registration only; the two legs' state is the chat window's and the +/// combat text's. Added ahead of [`crate::creature_anim::CreatureAnimPlugin`] so the two kinds +/// both answer run the line before the animation. +pub(crate) struct CombatLogPlugin; + +impl Plugin for CombatLogPlugin { + fn build(&self, app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::AttackerState, on_attacker_state) + .net_handler(K::SpellDamageLog, on_spell_damage_log) + .net_handler(K::PeriodicAuraLog, on_periodic_aura_log) + .net_handler(K::SpellHealLog, on_spell_heal_log) + .net_handler(K::SpellEnergizeLog, on_spell_energize_log) + .net_handler(K::DamageShield, on_damage_shield) + .net_handler(K::SpellLogMiss, on_spell_log_miss) + .net_handler(K::PartyKillLog, on_party_kill_log) + .net_handler(K::SpellInstaKillLog, on_spell_insta_kill_log) + .net_handler(K::ProcResist, on_spell_outcome_log) + .net_handler(K::SpellOrDamageImmune, on_spell_outcome_log) + .net_handler(K::SpellDispelLog, on_spell_dispel_log) + .net_handler(K::DispelFailed, on_dispel_failed) + .net_handler(K::EnchantmentLog, on_enchantment_log) + .net_handler(K::SpellLogExecute, on_spell_log_execute) + .net_handler(K::EnvironmentalDamageLog, on_environmental_damage_log) + .net_handler(K::XpGain, on_xp_gain) + .net_handler(K::ExplorationXp, on_exploration_xp) + .net_handler(K::LevelUp, on_level_up) + .net_handler(K::PvpCredit, on_pvp_credit); + } +} + +fn on_attacker_state(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::AttackerState(s) = ev { + let ctx = c.ctx(); + chat::attacker_state(s, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_spell_damage_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellDamageLog(s) = ev { + let ctx = c.ctx(); + chat::spell_damage_log(s, &ctx, &l.stores, &l.poses, &mut l.log); + text::spell_damage_log( + s, + &c.index, + &c.self_guid, + &l.stores, + c.spells.as_deref(), + *l.damage_text, + &mut l.text, + &mut l.feedback, + &mut l.center, + ); + } +} + +/// **`CombatLogPeriodicSpells` gates the WHOLE packet body, and this handler is where that is +/// expressible.** The reference's read site `0x626dee` is the first thing the handler `0x626dd0` +/// does, and a zero jumps to the bare epilogue `0x6271b4`: no chat line, no floating tick number, +/// no periodic miss word. Gating inside either leg would model it as two filters; it is one gate +/// over both. +fn on_periodic_aura_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::PeriodicAuraLog(s) = ev { + if !c.periodic.0 { + return; + } + let ctx = c.ctx(); + chat::periodic_aura_log(&s, &ctx, &l.stores, &l.poses, &mut l.log); + text::periodic_aura_log( + s, + &c.index, + &c.self_guid, + &l.stores, + c.spells.as_deref(), + *l.damage_text, + &mut l.text, + &mut l.feedback, + &mut l.center, + &l.names, + &l.net, + ); + } +} + +fn on_spell_heal_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellHealLog(s) = ev { + let ctx = c.ctx(); + chat::spell_heal_log(s, &ctx, &l.stores, &l.poses, &mut l.log); + text::spell_heal_log( + s, + &c.index, + &c.self_guid, + &mut l.feedback, + &mut l.center, + &l.names, + &l.net, + ); + } +} + +fn on_spell_energize_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellEnergizeLog(s) = ev { + let ctx = c.ctx(); + chat::spell_energize_log(s, &ctx, &l.stores, &l.poses, &mut l.log); + text::spell_energize_log(s, &c.self_guid, &mut l.center); + } +} + +fn on_damage_shield(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::DamageShield(s) = ev { + let ctx = c.ctx(); + chat::damage_shield(s, &ctx, &l.stores, &l.poses, &mut l.log); + text::damage_shield( + s, + &c.index, + &c.self_guid, + &l.stores, + *l.damage_text, + &mut l.text, + &mut l.feedback, + ); + } +} + +fn on_spell_log_miss(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellLogMiss(s) = ev { + let ctx = c.ctx(); + chat::spell_log_miss(&s, &ctx, &l.stores, &l.poses, &mut l.log); + text::spell_log_miss( + s, + &c.index, + &c.self_guid, + &l.stores, + c.spells.as_deref(), + *l.damage_text, + &mut l.text, + &mut l.feedback, + &mut l.center, + ); + } +} + +// ── the combat log's completeness block (1703): every one of these is chat-only — they carry +// no damage number, so unlike their neighbours above they have no floating-text leg to call ── + +fn on_party_kill_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::PartyKillLog(k) = ev { + let ctx = c.ctx(); + chat::party_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_spell_insta_kill_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellInstaKillLog(k) = ev { + let ctx = c.ctx(); + chat::spell_insta_kill_log(k, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_spell_outcome_log(In(ev): In, c: Ctx, mut l: Sinks) { + let (o, immune): (SpellOutcomeLog, bool) = match ev { + SessionEvent::ProcResist(o) => (o, false), + SessionEvent::SpellOrDamageImmune(o) => (o, true), + _ => return, + }; + let ctx = c.ctx(); + chat::spell_outcome_log(o, immune, &ctx, &l.stores, &l.poses, &mut l.log); +} + +fn on_spell_dispel_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellDispelLog(d) = ev { + let ctx = c.ctx(); + chat::spell_dispel_log(&d, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_dispel_failed(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::DispelFailed(d) = ev { + let ctx = c.ctx(); + chat::dispel_failed(&d, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_enchantment_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::EnchantmentLog(e) = ev { + let ctx = c.ctx(); + chat::enchantment_log(e, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_spell_log_execute(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::SpellLogExecute(x) = ev { + let ctx = c.ctx(); + chat::spell_log_execute(&x, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_environmental_damage_log(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::EnvironmentalDamageLog(e) = ev { + let ctx = c.ctx(); + chat::environmental_damage_log(e, &ctx, &l.stores, &l.poses, &mut l.log); + } +} + +fn on_xp_gain(In(ev): In, c: Ctx, mut l: Sinks) { + if let SessionEvent::XpGain(x) = ev { + text::xp_gain(x, &c.index, &c.self_guid, &mut l.text, &mut l.log); + } +} + +fn on_exploration_xp( + In(ev): In, + c: Ctx, + mut l: Sinks, + area_table: Option>, + exploration_sounds: Option>, + mut server_sounds: MessageWriter, +) { + if let SessionEvent::ExplorationXp(x) = ev { + text::exploration_xp( + x, + area_table.as_deref(), + exploration_sounds.as_deref(), + &c.index, + &c.self_guid, + &l.stores, + &mut server_sounds, + &mut l.log, + ); + } +} + +fn on_level_up(In(ev): In, mut l: Sinks) { + if let SessionEvent::LevelUp(lv) = ev { + text::level_up(lv, &mut l.log); + } +} + +/// An honor award (decision 1512 — the arc's other inbound message, the inspect reply, is +/// `ui_honor`'s own handler): the combat-log line (name-resolved, so it queues) and the floating +/// number, which are two different surfaces of one packet and are both the reference's. A +/// DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text takes it +/// signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a "+" only when the +/// number is positive and therefore already expects the other case. +fn on_pvp_credit(In(ev): In, mut l: Sinks) { + if let SessionEvent::PvpCredit(credit) = ev { + l.log.push_pvp_credit( + credit.honor, + credit.victim_guid, + u8::try_from(credit.victim_rank).unwrap_or(0), + ); + l.center.write(CombatTextEvent { + message_type: "HONOR_GAINED", + data: Some(credit.honor.to_string()), + extra: None, + }); + } +} diff --git a/crates/benilla-app/src/net/apply/combat_log.rs b/crates/benilla-app/src/combat_log/text.rs similarity index 98% rename from crates/benilla-app/src/net/apply/combat_log.rs rename to crates/benilla-app/src/combat_log/text.rs index c480f5ef..1d44b69f 100644 --- a/crates/benilla-app/src/net/apply/combat_log.rs +++ b/crates/benilla-app/src/combat_log/text.rs @@ -1,6 +1,6 @@ -//! Combat-log SMSG arm bodies for [`super::apply_net_updates`]'s dispatch match — the floating -//! combat-text **spawn table** (decision 0137 phase 2; wow-re `worldtext-spawn-and-law.md`). One -//! `pub(super)` fn per arm, mirroring the client's handler → emitter structure: each classifies +//! The combat log's **floating number** leg — the combat-text **spawn table** (decision 0137 +//! phase 2; wow-re `worldtext-spawn-and-law.md`). One fn per packet, mirroring the client's +//! handler → emitter structure; [`super`]'s handlers call it after the chat line: each classifies //! the damage **source** (the color law's `K` — self / owned-by-me / anything else SUPPRESSED), //! resolves the outcome **recipient**, applies Gate A (self-anchored damage text is //! unconditionally suppressed — outgoing damage floats over the victim, incoming never floats @@ -28,7 +28,7 @@ use crate::names::NameCache; use crate::ui_chat::ChatLog; use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback}; -use super::super::{GuidIndex, NetCommands, ObjectStore, SelfGuid}; +use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfGuid}; /// Gate A + anchor resolution: the recipient's entity, unless the recipient is us (`0x607140`/ /// `0x6128b0` compare the anchor guid to the active-player cache and return before submitting). @@ -43,7 +43,7 @@ fn gated_anchor(guid: u64, index: &GuidIndex, self_guid: &SelfGuid) -> Option, - sound_out: &mut MessageWriter, + sound_out: &mut MessageWriter, chat_log: &mut ChatLog, ) { let race = self_guid @@ -634,8 +634,8 @@ pub(super) fn exploration_xp( .and_then(|s| s.0.unit_race()); let kit = race.and_then(|r| sounds.and_then(|s| s.0.kit(u32::from(r)))); if let Some(kit) = kit { - sound_out.write(super::super::ServerSoundMessage { - kind: super::super::ServerSoundKind::Sound2d, + sound_out.write(crate::net::ServerSoundMessage { + kind: crate::net::ServerSoundKind::Sound2d, sound_id: kit, source: None, }); diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index fcfec173..e448d4cb 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -847,6 +847,7 @@ pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, Sw /// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]). mod blood; mod env_damage; +pub(crate) mod net; pub(crate) mod spell_visual; use blood::{blood_spurts, load_blood_tables}; use env_damage::{hard_landing_dust, load_env_damage_table}; @@ -1165,6 +1166,7 @@ pub(crate) struct CreatureAnimPlugin; impl Plugin for CreatureAnimPlugin { fn build(&self, app: &mut App) { + net::register(app); twist::plugin(app); lod::plugin(app); breath::register(app); diff --git a/crates/benilla-app/src/creature_anim/net.rs b/crates/benilla-app/src/creature_anim/net.rs new file mode 100644 index 00000000..1813bb08 --- /dev/null +++ b/crates/benilla-app/src/creature_anim/net.rs @@ -0,0 +1,471 @@ +//! The animation layer's packet handlers (in the net handler table since 2322, moved out of the +//! drain's combat, anim and mount arm files) — the server packets whose whole content is *a thing +//! to play on a streamed unit*: the melee engagement brackets, the aggro/alert flare, the swing +//! refusals, the two emote relays, the spell-visual kit push (decision 0280), a rider's flourish. +//! Each resolves the guid through the index and writes one message; the animation law itself +//! lives in [`super`]. Two kinds here have a second handler: the completed-swing record +//! ([`attacker_state`], with its client-side full-block synthesis) and the environmental-damage +//! kit ([`environmental_damage_log`]) also feed [`crate::combat_log`]'s chat line, which is +//! registered ahead of these and so runs first, as the drain's match ran them. + +use benilla_protocol::messages::{AttackSwingError, AttackerState, EnvironmentalDamageLog}; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::prelude::*; + +use super::{ + Engaged, EnvDamageTable, KitPush, MountFlourish, PlaySeq, RangedHold, SheathRequest, + SwingFlush, SwingImpact, SwingMessage, +}; +use crate::net::{AiReactionMessage, EmoteKind, EmoteMessage, GuidIndex, NetHandlerApp, SelfGuid}; +use crate::swing_refusal::SwingRefusalEdge; +use crate::ui_action::{UiError, UiErrorKeys}; +use crate::ui_unit::CombatTextEvent; + +/// Register the layer's handlers — called from [`super::CreatureAnimPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::AttackStart, on_attack_start) + .net_handler(K::AttackStop, on_attack_stop) + .net_handler(K::AiReaction, on_ai_reaction) + .net_handler(K::AttackSwingError, on_swing_refusal) + .net_handler(K::CancelCombat, on_swing_refusal) + .net_handler(K::FeignDeathResisted, on_feign_death_resisted) + .net_handler(K::MountSpecial, on_mount_special) + .net_handler(K::TextEmote, on_text_emote) + .net_handler(K::Emote, on_emote) + .net_handler(K::PlaySpellVisual, on_play_spell_visual) + .net_handler(K::AttackerState, on_attacker_state) + .net_handler(K::EnvironmentalDamageLog, on_environmental_damage_log); +} + +fn on_attack_start(In(ev): In, mut commands: Commands, index: Res) { + if let SessionEvent::AttackStart { attacker, victim } = ev { + attack_start(attacker, victim, &mut commands, &index); + } +} + +fn on_attack_stop( + In(ev): In, + mut commands: Commands, + index: Res, + mut flushes: MessageWriter, +) { + if let SessionEvent::AttackStop { attacker, victim } = ev { + attack_stop(attacker, victim, &mut commands, &index, &mut flushes); + } +} + +fn on_ai_reaction( + In(ev): In, + index: Res, + mut reactions: MessageWriter, +) { + if let SessionEvent::AiReaction { unit, reaction } = ev { + ai_reaction(unit, reaction, &index, &mut reactions); + } +} + +fn on_swing_refusal(In(ev): In, mut edges: MessageWriter) { + match ev { + SessionEvent::AttackSwingError(e) => attack_swing_error(e, &mut edges), + SessionEvent::CancelCombat => cancel_combat(&mut edges), + _ => {} + } +} + +fn on_feign_death_resisted(In(ev): In, mut errors: ResMut) { + if let SessionEvent::FeignDeathResisted = ev { + feign_death_resisted(&mut errors); + } +} + +fn on_mount_special( + In(ev): In, + self_guid: Res, + index: Res, + mut out: MessageWriter, +) { + if let SessionEvent::MountSpecial { guid } = ev { + mount_special(guid, &self_guid, &index, &mut out); + } +} + +fn on_text_emote( + In(ev): In, + index: Res, + mut out: MessageWriter, + mut chat_log: ResMut, +) { + if let SessionEvent::TextEmote { + guid, + text_emote, + target_name, + } = ev + { + self::text_emote( + guid, + text_emote, + target_name, + &index, + &mut out, + &mut chat_log, + ); + } +} + +fn on_emote(In(ev): In, index: Res, mut out: MessageWriter) { + if let SessionEvent::Emote { guid, emote_id } = ev { + emote(guid, emote_id, &index, &mut out); + } +} + +fn on_attacker_state( + In(ev): In, + index: Res, + self_guid: Res, + mut swings: MessageWriter, + mut impacts: MessageWriter, + mut center: MessageWriter, + mut sheaths: MessageWriter, + mut edges: MessageWriter, + stores: Query<&mut crate::net::ObjectStore>, + mut play_seq: ResMut, +) { + if let SessionEvent::AttackerState(s) = ev { + attacker_state( + s, + &index, + &self_guid, + &mut swings, + &mut impacts, + &mut center, + &mut sheaths, + &mut edges, + &stores, + play_seq.next(), + ); + } +} + +fn on_environmental_damage_log( + In(ev): In, + index: Res, + table: Option>, + mut play_seq: ResMut, + mut out: MessageWriter, +) { + if let SessionEvent::EnvironmentalDamageLog(e) = ev { + environmental_damage_log(e, &index, table.as_deref(), &mut play_seq, &mut out); + } +} + +fn on_play_spell_visual( + In(ev): In, + index: Res, + mut play_seq: ResMut, + mut out: MessageWriter, +) { + if let SessionEvent::PlaySpellVisual { unit, kit_id } = ev { + play_spell_visual(unit, kit_id, &index, &mut play_seq, &mut out); + } +} + +/// A unit began melee auto-attack (`SMSG_ATTACKSTART`, including our own echo). +fn attack_start(attacker: u64, victim: u64, commands: &mut Commands, index: &GuidIndex) { + // Engagement brackets (decision 0073): the standing Ready idle rides this window — + // the client's gate is the auto-attack-target GUID being set, mirrored here as a + // marker component on the attacker (including our own echo). + debug!("net: attack start {attacker:#x} → {victim:#x}"); + if let Some(&e) = index.0.get(&attacker) { + // Melee-start drops the `0x400` weapon-visual hold unconditionally (the client's + // `0x60fc50` sibling clear) — a shooter that closes to melee leaves the drawn idle. + // The LOCAL player's melee paths additionally run the full cancel funnel at send. + commands + .entity(e) + .insert(Engaged(victim)) + .remove::(); + } +} + +/// A unit stopped melee auto-attack (`SMSG_ATTACKSTOP`). +fn attack_stop( + attacker: u64, + victim: u64, + commands: &mut Commands, + index: &GuidIndex, + flushes: &mut MessageWriter, +) { + debug!("net: attack stop {attacker:#x} → {victim:#x}"); + if let Some(&e) = index.0.get(&attacker) { + commands.entity(e).remove::(); + // The client's `0x624e40` (death/stun arrive as this packet too): a pending + // swing record flushes text-only and clears. + flushes.write(SwingFlush(e)); + } +} + +/// The server refused our melee swing (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/ +/// `_CANT_ATTACK`) — forwarded verbatim to [`crate::swing_refusal`], which owns the latch, the 4 s +/// repeat, and arm 4's silent StopAttack. Nothing is decided here: the arms differ only in what +/// that module does with them, and it holds the write set for all three. +fn attack_swing_error(error: AttackSwingError, edges: &mut MessageWriter) { + edges.write(SwingRefusalEdge::Refused(error)); +} + +/// `SMSG_CANCEL_COMBAT` — the server forced our attack to stop. The swing family's fourth arm, +/// and the same act as `0x148`/`0x149`: the reference's handler `0x5e7dd0` is arm 4's body +/// verbatim. +fn cancel_combat(edges: &mut MessageWriter) { + edges.write(SwingRefusalEdge::CombatCancelled); +} + +/// `SMSG_FEIGN_DEATH_RESISTED` — the target shrugged off our Feign Death. +/// +/// One red line and nothing else: the reference's handler `0x6e9800` is `push 0x1a5; call +/// 0x496720`, a bare `DisplayError(421)` with no latch, no cooldown and no state — the opposite of +/// its sibling above, and the reason the two do not share a path. Catalog row 421 is +/// `ERR_FEIGN_DEATH_RESISTED`, whose 1.12 string is the single word "Resisted". +/// +/// It lives beside the swing arms because vmangos sends it in the same breath as +/// `SMSG_CANCEL_COMBAT` (`Objects/Unit.cpp:9445-9451`: a resisted feign death cancels the attack +/// and says so), and finding one without the other is how this family stayed half-built. +fn feign_death_resisted(errors: &mut UiErrorKeys) { + debug!("net: feign death resisted"); + errors.0.push(UiError::key("ERR_FEIGN_DEATH_RESISTED")); +} + +/// A creature flared aggro or a stealth pre-aggro alert (`SMSG_AI_REACTION`). +fn ai_reaction( + unit: u64, + reaction: u32, + index: &GuidIndex, + reactions: &mut MessageWriter, +) { + // Aggro (2 HOSTILE) / stealth alert (0 ALERT) flare — pure audio, byte-verified + // (`0x6056e0` is an exact two-way branch; any other value no-ops, and neither leg + // touches animation/nameplate/UI — decision 0280). Vocals: `sound::creature`. + debug!("net: ai reaction {reaction} on {unit:#x}"); + if matches!(reaction, 0 | 2) { + if let Some(&e) = index.0.get(&unit) { + reactions.write(AiReactionMessage { + unit: e, + hostile: reaction == 2, + }); + } + } +} + +/// One completed melee swing (`SMSG_ATTACKERSTATEUPDATE`, decision 0073): the attacker's swing +/// anim starts NOW; the victim feedback (blood/flinch/text/impact sounds) defers to the swing +/// clip's attack-hit keyframe (`creature_anim::impact`, the client's `0x6247d0` router) — EXCEPT +/// the center combat text, which the client fires **synchronously at packet parse** +/// (`0x6255b0 → 0x629d30 → 0x703f50`, one call stack — §5-verified, wow-re +/// `combat-text-update-emission-law.md`; decision 0580's fold-back). +fn attacker_state( + mut s: AttackerState, + index: &GuidIndex, + self_guid: &SelfGuid, + swings: &mut MessageWriter, + impacts: &mut MessageWriter, + center: &mut MessageWriter, + sheaths: &mut MessageWriter, + edges: &mut MessageWriter, + stores: &Query<&mut crate::net::ObjectStore>, + seq: u64, +) { + let victim = index.0.get(&s.victim).copied(); + // Arm 5's `0x6259b6 call 0x5ea800`, whose first act is the swing-refusal latch clear + // (`0x5ecdb0(0)`) — gated exactly as the reference gates it: the attacker IS the active player + // (`0x5fa6d0`, a guid compare) and the victim resolves as a streamed unit. Written from here + // rather than computed downstream because this is the only place holding both guids, and it + // keeps the clear in packet order with the refusals (`crate::swing_refusal`). + if self_guid.0 == Some(s.attacker) && victim.is_some() { + edges.write(SwingRefusalEdge::Landed); + } + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fct", + &format!( + "recv swing atk={:#x} victim={:#x} dmg={} vstate={} hit={:#x}", + s.attacker, s.victim, s.damage, s.victim_state, s.hit_info + ), + ); + } + // The client-side FULL-BLOCK synthesis (`0x625e20`, decision 0279): a resolvable + // victim + zero damage + a nonzero blocked amount rewrites the state to BLOCKS(5) + // before any consumer sees the record — the only thing the wire's blocked_amount + // ever does (a PARTIAL block stays state 1, indistinguishable from a plain hit). + if victim.is_some() && s.damage == 0 && s.blocked != 0 { + s.victim_state = 5; + } + // The center combat text (decision 0578/0580): self victim, at receive, AFTER the full-block + // synthesis (so a full block reads BLOCK, not MISS) — the packet's absorb/resist/blocked + // sums feed the confirmed helper-B partial trailers. + if self_guid.0 == Some(s.victim) { + if let Some((message_type, data, extra)) = crate::combat_log::text::melee_center_text( + s.hit_info, + s.victim_state, + s.damage, + s.absorb, + s.resist, + s.blocked, + ) { + center.write(CombatTextEvent { + message_type, + data, + extra, + }); + } + } + let swing = SwingMessage { + attacker: Entity::PLACEHOLDER, // filled per branch below + victim, + hit_info: s.hit_info, + victim_state: s.victim_state, + damage: s.damage, + displayed: s.displayed(), + seq, + }; + if let Some(&e) = index.0.get(&s.attacker) { + // The **observed attacker auto-draws melee** — the ref's SECOND melee draw, independent + // of the attack-start one, and the reason a swing is never delivered in the wrong stance: + // `0x625829 cmp [attacker+0xd40],1; jne` → `SetSheatheState(1, bInstant=1, bFireEvent=1)` + // at `0x62583a`, byte-read here, tabulated in wow-re `sheath-policy.md` §1. It sits + // immediately after the attacker resolve and **before** any hit-info handling, so even a + // swing whose animation is suppressed (`HitInfo & 0x10000`) still draws. Nothing else in + // the policy can do this job: the per-animation reconcile's melee force is gated to + // `CUR != 2` (`0x5fe0f9`/`0x5fe13b`), so a unit swinging with a bow drawn — a ranged + // stance a shot left behind — would otherwise keep swinging with the bow forever. The + // setter's own idempotency is the `cmp`: a request equal to the committed state is + // refused there, so this is free on every swing after the first. + sheaths.write(SheathRequest { + entity: e, + state: 1, + ceremony: false, + }); + swings.write(SwingMessage { + attacker: e, + ..swing + }); + } else if swing.victim.is_some_and(|v| { + // The receive-time arm goes THROUGH the gated dispatcher — `0x625823 je 0x625a3e` takes + // the unresolved-attacker leg, which resolves the victim and calls `0x625a6d call + // 0x624530`, so the LOOTABLE front gate applies here exactly as it does to the tag path + // (`creature_anim::impact::lootable_victim`). It calls no consequence directly. + !stores.get(v).is_ok_and(|s| s.0.unit_lootable()) + }) { + // The client's SMSG-arm fallback: an attacker we can't resolve (out of range) + // can't animate a swing — its victim feedback fires immediately and in FULL + // (`0x625a6d`, the only receive-time victim dispatch). The PLACEHOLDER + // attacker resolves nowhere downstream (blood defaults front). + impacts.write(SwingImpact { + swing, + text_only: false, + natural: None, + // The receive-time arm: no tag fired, so the reference has no event point either — + // the consumer falls back to the victim, the only anchor the packet leaves us. + pos: None, + }); + } +} + +/// `SMSG_TEXT_EMOTE` — someone performed a `/`-emote (the TextEmote.dbc id; the anim, if any, is +/// the emote row's). +/// +/// **Two consequences, and they are independent** (decision 1274): the anim + voice ride the +/// [`EmoteMessage`] and need the performer *streamed*; the chat sentence is queued for +/// [`crate::ui_chat`]'s composer and needs only the performer's *name*. An emote from someone +/// off-screen therefore still prints its line, which is the reference's shape — `0x49dbe0` +/// resolves a name and never touches the object manager. +fn text_emote( + guid: u64, + text_emote: u32, + target_name: String, + index: &GuidIndex, + out: &mut MessageWriter, + chat_log: &mut crate::ui_chat::ChatLog, +) { + out.write(EmoteMessage { + source: index.0.get(&guid).copied(), + kind: EmoteKind::Text(text_emote), + }); + chat_log.push_text_emote(guid, text_emote, target_name); +} + +/// `SMSG_EMOTE` — a bare Emotes.dbc anim id on a unit (the server-driven one-shot: NPC scripts, +/// the `/`-emote's own anim leg). +fn emote(guid: u64, emote_id: u32, index: &GuidIndex, out: &mut MessageWriter) { + out.write(EmoteMessage { + source: index.0.get(&guid).copied(), + kind: EmoteKind::Anim(emote_id), + }); +} + +/// `SMSG_PLAY_SPELL_VISUAL` — the kit-push opcode (decision 0280): a stage-0 play on the unit, the +/// eat/drink kit cadence and mid-channel swaps. Consumer: `creature_anim::spell_visual`. The +/// [`PlaySeq`] stamp is taken only when the unit is streamed in, so an unstreamed guid never +/// advances the call-order counter. +fn play_spell_visual( + unit: u64, + kit_id: u32, + index: &GuidIndex, + play_seq: &mut PlaySeq, + out: &mut MessageWriter, +) { + if let Some(&e) = index.0.get(&unit) { + out.write(KitPush { + entity: e, + kit_id, + seq: play_seq.next(), + }); + } +} + +/// `SMSG_ENVIRONMENTALDAMAGELOG`'s consequence (wow-re `sound/scratch/uisound-tables.md`: reader +/// `0x624fcc` inside `0x624f30`): the EnvironmentalDamage.dbc 6-slot table picks the damage type's +/// SpellVisualKit — fall's is the DustCloud_Land puff — played on the victim through the ordinary +/// discrete kit play (`0x60edf0`), the same leg the kit-push opcode rides. The pain vocal's exact +/// trigger is a dispatched wow-re §5 (in flight) — it folds in as its own edge when the verdict +/// lands. +fn environmental_damage_log( + e: EnvironmentalDamageLog, + index: &GuidIndex, + table: Option<&EnvDamageTable>, + play_seq: &mut PlaySeq, + out: &mut MessageWriter, +) { + if let Some(&ent) = index.0.get(&e.victim) { + if let Some(kit_id) = table.and_then(|t| t.0.kit_id(e.damage_type)) { + debug!( + "net: environmental damage on {:#x} (type {}, {} dmg) → kit {kit_id}", + e.victim, e.damage_type, e.damage + ); + out.write(KitPush { + entity: ent, + kit_id, + seq: play_seq.next(), + }); + } + } +} + +/// `SMSG_MOUNTSPECIAL_ANIM` — a nearby rider's flourish: rear their mount (MountSpecial 94 on the +/// mount child; the hop happens in `creature_anim::flourish_to_anim`). +/// +/// Our OWN guid is dropped: we played it locally at send time, and whether the sender gets the +/// SMSG echoed back is a server-config detail (LIVE-VERIFIED 2026-07-17, double-flourish probe: +/// vmangos's `SendMovementMessageToSet(.., false)` only cheat-logs on the flag — the +/// non-broadcaster delivery hardcodes self=true, so our deployment echoes; the optional per-player +/// broadcaster honors it and would not). Self-suppression on receive is correct under both configs. +fn mount_special( + guid: u64, + self_guid: &SelfGuid, + index: &GuidIndex, + out: &mut MessageWriter, +) { + if self_guid.0 != Some(guid) { + if let Some(&e) = index.0.get(&guid) { + out.write(MountFlourish { unit: e }); + } + } +} diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death/mod.rs similarity index 99% rename from crates/benilla-app/src/death.rs rename to crates/benilla-app/src/death/mod.rs index 91d16481..5ff3bedf 100644 --- a/crates/benilla-app/src/death.rs +++ b/crates/benilla-app/src/death/mod.rs @@ -18,6 +18,8 @@ use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, S use crate::ui_action::Spells; use crate::ui_script::{UiFeed, UiInput}; +pub(crate) mod net; + /// Where our corpse is — the `MSG_CORPSE_QUERY` answer (decision 0308 §5). Raw WoW coords. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct CorpsePoint { @@ -713,6 +715,7 @@ pub(crate) struct DeathPlugin; impl Plugin for DeathPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/net/apply/death.rs b/crates/benilla-app/src/death/net.rs similarity index 63% rename from crates/benilla-app/src/net/apply/death.rs rename to crates/benilla-app/src/death/net.rs index dffb184f..d35f2636 100644 --- a/crates/benilla-app/src/net/apply/death.rs +++ b/crates/benilla-app/src/death/net.rs @@ -1,17 +1,107 @@ -//! Death-arc arm bodies for [`super::apply_net_updates`]'s dispatch match (decision 0308) — the -//! wire-fed [`DeathNet`] stores (the corpse marker and its guid latch, the reclaim clock, the -//! resurrect offer, the spirit-healer confirm, the death durability notice) plus the granted -//! movement-mode forward the server addresses to our own mover. Each `pub(super)` fn here is -//! exactly one arm's body — except the two corpse-guid latches, which ride the object-lifecycle -//! arms; the match at the call site stays the dispatcher, one call per arm. +//! The death arc's packet handlers (decision 0308; in the net handler table since 2322, moved out +//! of the drain's death arm file) — the wire-fed [`DeathNet`] stores (the corpse marker and its +//! guid latch, the reclaim clock, the resurrect offer, the spirit-healer confirm, the death +//! durability notice) plus the granted movement-mode forward the server addresses to our own +//! mover. Three bodies here are not packet handlers but the corpse-guid latch's hooks on the +//! object lifecycle ([`note_corpse`], [`recheck_corpse`], [`forget_corpse`]); the drain's object +//! arms still call them, and they become listeners on the object kinds once those are peeled. -use benilla_protocol::{EntityKind, MoveMode, ObjectFields}; +use benilla_protocol::{EntityKind, MoveMode, ObjectFields, SessionEvent, SessionEventKind}; use bevy::prelude::*; -use crate::death::{CorpsePoint, DeathNet, ResurrectOffer}; -use crate::net::MoveModeMessage; +use super::{CorpsePoint, DeathNet, ResurrectOffer}; +use crate::net::{MoveModeMessage, NetHandlerApp, SelfGuid}; -use super::super::SelfGuid; +/// Register the death handlers — called from [`super::DeathPlugin`]. One per kind, plus the +/// session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::CorpseQuery, on_corpse_query) + .net_handler(K::CorpseReclaimDelay, on_corpse_reclaim_delay) + .net_handler(K::ResurrectRequest, on_resurrect_request) + .net_handler(K::SpiritHealerConfirm, on_spirit_healer_confirm) + .net_handler(K::DurabilityDamageDeath, on_durability_damage_death) + .net_handler(K::MoveMode, on_move_mode) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_corpse_query(In(ev): In, mut death_net: ResMut) { + if let SessionEvent::CorpseQuery { + found, + display_map, + position, + corpse_map, + } = ev + { + corpse_query(found, display_map, position, corpse_map, &mut death_net); + } +} + +fn on_corpse_reclaim_delay( + In(ev): In, + mut death_net: ResMut, + real_clock: Res>, +) { + if let SessionEvent::CorpseReclaimDelay { delay_ms } = ev { + corpse_reclaim_delay(delay_ms, real_clock.elapsed_secs_f64(), &mut death_net); + } +} + +fn on_resurrect_request(In(ev): In, mut death_net: ResMut) { + if let SessionEvent::ResurrectRequest { + caster, + name, + sickness, + has_timer, + } = ev + { + resurrect_request(caster, name, sickness, has_timer, &mut death_net); + } +} + +fn on_spirit_healer_confirm(In(ev): In, mut death_net: ResMut) { + if let SessionEvent::SpiritHealerConfirm { npc } = ev { + spirit_healer_confirm(npc, &mut death_net); + } +} + +fn on_durability_damage_death(In(ev): In, mut log: ResMut) { + if let SessionEvent::DurabilityDamageDeath = ev { + durability_damage_death(&mut log); + } +} + +fn on_move_mode( + In(ev): In, + self_guid: Res, + mut death_net: ResMut, + mut out: MessageWriter, +) { + if let SessionEvent::MoveMode { + guid, + counter, + mode, + apply, + } = ev + { + move_mode( + guid, + counter, + mode, + apply, + &self_guid, + &mut death_net, + &mut out, + ); + } +} + +/// The death stores are session-scoped: a reclaim expiry, resurrect offer, or corpse marker must +/// not survive the socket (the reconnect re-sends the reclaim delay when dead). A listener on +/// the session end (a second handler on the kind, after the bridge's own teardown). +fn on_session_end(In(_): In, mut death_net: ResMut) { + *death_net = DeathNet::default(); +} /// OUR corpse streaming into range (a `TYPEID_CORPSE` create whose owner is us): remember its guid /// for the reclaim send (decision 0308 §5). The kind is exact ([`EntityKind::Corpse`] since 1706 — @@ -26,7 +116,7 @@ use super::super::SelfGuid; /// bones never arms it. That matters because `RetrieveCorpse` itself carries **no guard of any /// kind** — whatever is in the latch is what goes out on `CMSG_RECLAIM_CORPSE`. The bones gate is /// the only thing standing between a converted corpse and a reclaim send naming it. -pub(super) fn note_corpse( +pub(crate) fn note_corpse( guid: u64, kind: EntityKind, fields: &ObjectFields, @@ -49,7 +139,7 @@ pub(super) fn note_corpse( /// construction: its three latch writers include the `FLAGS` mirror handler `0x5d6d60`, so the /// gate is re-asked on every change of the very field that carries the bones bit. Ours asks it /// here, on the same edge. Rides the `ObjectValues` arm. -pub(super) fn recheck_corpse( +pub(crate) fn recheck_corpse( guid: u64, fields: &ObjectFields, self_guid: &SelfGuid, @@ -77,7 +167,7 @@ pub(super) fn recheck_corpse( /// The corpse-to-bones swap destroys the corpse object under its guid (0308 §1); a stale guid must /// not ride a later reclaim. Rides the `ObjectDestroyed` arm. -pub(super) fn forget_corpse(guid: u64, death_net: &mut DeathNet) { +pub(crate) fn forget_corpse(guid: u64, death_net: &mut DeathNet) { if death_net.corpse_guid == Some(guid) { death_net.corpse_guid = None; } @@ -85,7 +175,7 @@ pub(super) fn forget_corpse(guid: u64, death_net: &mut DeathNet) { /// `MSG_CORPSE_QUERY`'s answer — where the corpse marker goes. A not-found (reactive, or the /// server's unprompted bones-conversion push) drops the marker. -pub(super) fn corpse_query( +fn corpse_query( found: bool, display_map: i32, position: [f32; 3], @@ -103,13 +193,13 @@ pub(super) fn corpse_query( /// `Time::elapsed_secs_f64` the feed reads back). The client's `0x269` handler re-fires the /// corpse-range events through its latch (wow-re death-ui.md §4), so the feed re-announces on the /// generation bump. -pub(super) fn corpse_reclaim_delay(delay_ms: u32, now_secs: f64, death_net: &mut DeathNet) { +fn corpse_reclaim_delay(delay_ms: u32, now_secs: f64, death_net: &mut DeathNet) { death_net.reclaim_at = Some(now_secs + f64::from(delay_ms) / 1000.0); death_net.reclaim_generation = death_net.reclaim_generation.wrapping_add(1); } /// `SMSG_RESURRECT_REQUEST` — the RESURRECT popup's data. -pub(super) fn resurrect_request( +fn resurrect_request( caster: u64, name: String, sickness: bool, @@ -128,7 +218,7 @@ pub(super) fn resurrect_request( /// IS the announce (decision 1068): the healer's gossip re-sends it on every ask, and the /// reference fires `CONFIRM_XP_LOSS` per arrival — so the generation bump is what re-shows a /// cancelled confirm, exactly the `SMSG_CORPSE_RECLAIM_DELAY` re-fire pattern above. -pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) { +fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) { // Through [`DeathNet::ask_spirit_healer`], which the right-click's own bit-5 arm also calls — // the reference raises this dialog client-side and vmangos also pushes it, and one entry // point is what keeps the two roads saying the same thing (decision 1861). @@ -143,7 +233,7 @@ pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) { /// fields are ignored. Routing it to `UIErrorsFrame` put it in the wrong frame and, worse, meant /// hard-coding Blizzard's English sentence in our source; as a combat-log family it resolves /// `DURABILITYDAMAGE_DEATH` out of the player's own `GlobalStrings.lua` like every other line. -pub(super) fn durability_damage_death(log: &mut crate::ui_chat::ChatLog) { +fn durability_damage_death(log: &mut crate::ui_chat::ChatLog) { log.push_combat(crate::ui_chat::combat::PendingCombat { kind: crate::ui_chat::ChatEventKind::CombatMiscInfo, family: crate::ui_chat::combat::DURABILITYDAMAGE_DEATH, @@ -161,7 +251,7 @@ pub(super) fn durability_damage_death(log: &mut crate::ui_chat::ChatLog) { /// harmless. Water-walk is additionally mirrored into [`DeathNet`], which reads it as a ghost-form /// cue — but the *mover* effect of every mode, this one included, is the controller's /// ([`crate::player::wire_in`]). -pub(super) fn move_mode( +fn move_mode( guid: u64, counter: u32, mode: MoveMode, diff --git a/crates/benilla-app/src/entities/missile.rs b/crates/benilla-app/src/entities/missile.rs index a152cda9..da0af1f7 100644 --- a/crates/benilla-app/src/entities/missile.rs +++ b/crates/benilla-app/src/entities/missile.rs @@ -436,7 +436,7 @@ fn miss_defense_state(code: u8) -> Option { /// Bevy's 16-`SystemParam` ceiling, and these three are one concern anyway: everything a /// projectile emits at the moment it lands — the router's impact/ground event, the victim's /// defense clip, and the deferred outcome word. Bundled for the same reason -/// [`crate::ui_chat::combat::CombatFeedbackCvars`] is, so the call sites read `out.words` rather +/// the combat log's CVar context ([`crate::combat_log::Ctx`]) is, so the call sites read `out.words` rather /// than a positional tuple field. #[derive(bevy::ecs::system::SystemParam)] pub(super) struct ArrivalOut<'w> { diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 76c0a8cb..33cd1ae2 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -130,6 +130,9 @@ impl PluginGroup for GamePlugins { // Streamed world entities: cube assets + display catalogs at startup, sync each frame. .add(EntitiesPlugin) // Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C). + // The combat log — every combat packet's chat line and floating number, one handler each + // (decision 2323); ahead of the animation layer, whose two shared kinds run second. + .add(crate::combat_log::CombatLogPlugin) .add(CreatureAnimPlugin) // The unit blob shadow: the dark ground oval under every unit, sized from the playing // animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same @@ -319,6 +322,8 @@ impl PluginGroup for GamePlugins { // feed, whose shape it shares (intents in, a booth look out). .add(crate::ui_dressup::DressUpUiPlugin) .add(UiActionPlugin) + // The spell — the cast lifecycle's packet handlers (decision 2324; 2265 §A7's owner). + .add(crate::spell::SpellPlugin) // The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the // self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and // drains the right-click cancels. After UiActionPlugin (shares its `Spells` catalog). @@ -1381,7 +1386,7 @@ pub(crate) mod schedule_tests { "`seen_generation` is a `VmMemo`: a new VM reads `None`, rebuilds and re-fires UPDATE_BINDINGS"), ("capture/probe_bg.rs", "bg_probe", Because::SelfHealing, "the battleground probe: dev-only (`WOW_PROBE_BG`, `cfg(feature = \"dev\")`) so it is not in a player build at all, and its `mem::take` is of its OWN pending-events string, not a queue anything else fills. Its one real VM dependency is the Lua event tap, which self-heals: `EVENT_DRAIN` returns a `` sentinel when the tap's globals are missing — the case this window causes, since a tap installed in the boot VM is discarded when `mint_entry_vm` builds the interface — and the probe re-installs on the next frame"), - ("death.rs", "feed_death", Because::MemoLatched, + ("death/mod.rs", "feed_death", Because::MemoLatched, "`feed.vm: VmMemo`: a fresh memo makes the first snapshot an edge and re-announces a held offer, confirm and corpse range"), ("screenshot.rs", "ask_for_captures", Because::FilledByVm, "`pending` holds only the VM's own `Screenshot()` asks, and the take spawns a capture, publishing nothing"), diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 67a5107c..bab9536a 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -57,6 +57,7 @@ mod char_select; mod chat_bubble; mod chr_classes; mod cinematic; +mod combat_log; mod combat_text; mod console; mod cooldowns; @@ -122,6 +123,7 @@ mod smart_rect; mod sound; /// The two talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`) and the read that /// puts them on a number. +mod spell; mod spell_mods; /// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`). mod swing_refusal; diff --git a/crates/benilla-app/src/names.rs b/crates/benilla-app/src/names.rs index 84e9ed3d..11ea64c7 100644 --- a/crates/benilla-app/src/names.rs +++ b/crates/benilla-app/src/names.rs @@ -556,6 +556,144 @@ impl NameCache { } } +/// The name-query answers (in the net handler table since 2325, moved out of the drain's names +/// arm file) — the three `*_QUERY_RESPONSE` packets that fill the ask-once cache +/// (`NameCache::resolve` is what asked for them), and the invalidation that unsticks a player's +/// name. Registered from [`crate::net::NetPlugin`], which initialises the cache. +pub(crate) mod net { + use benilla_protocol::{SessionEvent, SessionEventKind}; + use bevy::prelude::*; + + use super::{CreatureRecord, NameCache}; + use crate::net::NetHandlerApp; + + /// Register the four handlers. + pub(crate) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::PlayerName, on_player_name) + .net_handler(K::PetName, on_pet_name) + .net_handler(K::CreatureName, on_creature_name) + .net_handler(K::InvalidatePlayer, on_invalidate_player); + } + + fn on_player_name(In(ev): In, mut names: ResMut) { + if let SessionEvent::PlayerName { + guid, + name, + race, + class, + gender, + } = ev + { + player_name(guid, name, race, class, gender, &mut names); + } + } + + fn on_pet_name(In(ev): In, mut names: ResMut) { + if let SessionEvent::PetName { pet_number, name } = ev { + pet_name(pet_number, name, &mut names); + } + } + + fn on_creature_name(In(ev): In, mut names: ResMut) { + if let SessionEvent::CreatureName { + entry, + name, + subname, + creature_type, + pet_family, + rank, + type_flags, + display_id, + civilian, + racial_leader, + } = ev + { + creature_name( + entry, + name, + subname, + creature_type, + pet_family, + rank, + type_flags, + civilian, + racial_leader, + display_id, + &mut names, + ); + } + } + + fn on_invalidate_player(In(ev): In, mut names: ResMut) { + if let SessionEvent::InvalidatePlayer { guid } = ev { + invalidate_player(guid, &mut names); + } + } + + /// `SMSG_NAME_QUERY_RESPONSE` — a player's name, plus the race/gender/class that ride the same + /// answer. Those three now have a consumer: the `$`-macro expander's subject fallback for a player + /// who isn't streamed ([`crate::npc_text::subject_for_guid`]). + fn player_name( + guid: u64, + name: String, + race: u32, + class: u32, + gender: u32, + names: &mut NameCache, + ) { + names.insert_player(guid, name, Some((race as u8, class as u8, gender as u8))); + } + + /// `SMSG_PET_NAME_QUERY_RESPONSE` — keyed by pet *number*, not by template entry. + fn pet_name(pet_number: u32, name: String, names: &mut NameCache) { + names.insert_pet(pet_number, name); + } + + /// `SMSG_INVALIDATE_PLAYER` — drop this guid so the next resolve re-asks (decision 1689). + /// + /// The name cache has **no TTL** (wow-re `dbcache.md`: eviction is explicit only), so this is the + /// one thing that unsticks a player's name. It matters more to benilla than it did before the + /// cache persisted: in memory a stale name lasted a session, on disk it lasts until something + /// evicts it. + fn invalidate_player(guid: u64, names: &mut NameCache) { + debug!("net: invalidating cached name for player {guid:#x}"); + names.invalidate_player(guid); + } + + /// `SMSG_CREATURE_QUERY_RESPONSE` — the template's name plus the hover line's fields. A `None` name + /// is the server's "no such entry", cached as such so the ask never repeats; the remaining fields + /// then carry their miss defaults. + fn creature_name( + entry: u32, + name: Option, + subname: Option, + creature_type: Option, + pet_family: u32, + rank: u32, + type_flags: u32, + civilian: bool, + racial_leader: bool, + display_id: u32, + names: &mut NameCache, + ) { + names.insert_creature( + entry, + name.map(|n| CreatureRecord { + name: n, + subname, + creature_type: creature_type.unwrap_or(0), + pet_family, + rank, + type_flags, + civilian, + racial_leader, + display_id, + }), + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index b4b8b961..9f0f7def 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -30,6 +30,9 @@ mod apply; pub(crate) mod handlers; pub(crate) mod io; mod motion; +mod objects; +mod session; +mod world; pub(crate) use apply::apply_net_updates; use apply::tag_self_player; @@ -95,6 +98,12 @@ impl Plugin for NetPlugin { Update, publish_world_time.in_set(benilla_world::schedule::WorldStage::Net), ); + // The bridge's own handlers (decision 2325): the world feed it forwards, the name cache it + // initialises. + world::register(app); + session::register(app); + objects::register(app); + crate::names::net::register(app); app.insert_resource(NetEvents(handles.events)) .insert_resource(NetCommands(handles.commands)) .insert_resource(CharPick(handles.pick)) diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index f25facb1..ad1e5a7e 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -1,1363 +1,28 @@ //! The per-frame wire→ECS bridge systems: [`apply_net_updates`] drains the inbound -//! [`SessionEvent`] channel into real entities (spawn/move/despawn, descriptor merges, splines, -//! teleports, clock), and [`tag_self_player`] marks our own streamed entity. The parent module -//! owns the channel/type surface; this module owns the event application. +//! [`SessionEvent`] channel through the handler table, and [`tag_self_player`] marks our own +//! streamed entity. The parent module owns the channel/type surface and, since 2327, every +//! packet's handler is a subsystem's own or the bridge's own ([`super::session`], +//! [`super::objects`], [`super::world`]); nothing is applied here. -use std::collections::HashMap; - -use benilla_protocol::{ObjectFields, SessionEvent}; +use benilla_protocol::SessionEvent; use bevy::prelude::*; -use super::{ - Guid, GuidIndex, NetCommands, NetEvents, NetStatus, ObjectStore, Reputations, SelfGuid, - SelfPlayer, -}; +use super::{Guid, NetEvents, SelfGuid, SelfPlayer}; -mod anim; -mod chat; -mod combat; -mod combat_chat; -mod combat_log; -mod death; -mod group; -mod mount; -mod names; -mod objects; -mod params; -mod pet; #[cfg(test)] mod seam_tests; -mod session; -mod spells; -mod world; - -use params::{ActionStores, AnimWriters, Catalogs, Clocks, ObjectQueries, Session, WindowStores}; - -// The arm families, split out of the dispatch match below (each `pub(super)` fn is one arm's -// body; the match stays the dispatcher, one call per arm — see the child modules). -use spells::{ - action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update, - clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, removed_spell, - set_spell_modifier, spell_book, spell_chain_targets, spell_cooldowns, spell_delayed, - spell_failed_other, spell_go, spell_start, superceded_spell, -}; - -/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982). -/// -/// All four of them (`SMSG_SPELL_COOLDOWN`, `_COOLDOWN_EVENT`, `_CLEAR_COOLDOWN`, -/// `_COOLDOWN_CHEAT`) carry a caster guid, and until the pet bar existed all four answered it the -/// same way: "is it us? then apply, else drop" — four copies of a self-only assumption, each -/// inside its own arm. Since the server sends a pet's cooldowns on the pet's guid, that -/// assumption silently discarded every one of them. Resolving the guid ONCE, here, is what let the -/// pet bar sweep for real without a second copy of any arm; it also matches the reference, whose -/// `SMSG_COOLDOWN_CHEAT` handler wipes "the self/pet cooldown list" off exactly this test. -/// -/// `None` = a guid we hold no store for (another player's pet, a stale packet): dropped, as the -/// client drops an unknown guid. -fn addressed_store<'a>( - caster: u64, - self_guid: &SelfGuid, - player: &'a mut crate::cooldowns::Cooldowns, - pet: &'a mut crate::ui_pet::PetBar, -) -> Option<&'a mut crate::cooldowns::Cooldowns> { - if self_guid.0 == Some(caster) { - Some(player) - } else if pet.has_bar() && pet.spells.pet_guid == caster { - Some(&mut pet.cooldowns) - } else { - None - } -} // ── The per-frame bridge systems ───────────────────────────────────────────────────────────────── -/// The drain: take this frame's events off the channel and run them **in wire order** — a run of -/// kinds the `match` below still owns goes through [`apply_unpeeled`] as one batch, a kind a -/// subsystem has claimed in the handler table ([`super::handlers`]) runs its handlers in place, -/// each a one-shot system over this exclusive world (decisions 2305, 2306). One frame, packet -/// order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the property -/// 0006 built and 2265 said every split must keep. +/// The drain: take this frame's events off the channel and run each through the handler table +/// ([`super::handlers`]) **in wire order**, one after another, each handler a one-shot system +/// over this exclusive world with its commands applied before the next (decisions 2305, 2306, +/// 2327). One frame, packet order, before anything else in +/// [`benilla_world::schedule::WorldStage::Net`] runs: the property 0006 built and 2265 said +/// every split must keep. pub(crate) fn apply_net_updates(world: &mut World) { let events: Vec = world.resource::().0.try_iter().collect(); - super::handlers::dispatch(world, events, |world, unclaimed| { - if let Err(e) = world.run_system_cached_with(apply_unpeeled, unclaimed) { - panic!("the drain's dispatch match did not run: {e}"); - } - }); -} - -/// The dispatch `match` — every kind no subsystem has claimed yet (2305's migration runs one -/// window family at a time out of here into its own module's handlers). Mutates real ECS -/// entities: spawn on create, move existing, despawn on remove, attach/clear movement splines, -/// and surface teleport/worldport/clock changes; parks the rest into window state. -fn apply_unpeeled( - In(events): In>, - mut commands: Commands, - net_commands: Res, - mut index: ResMut, - mut self_guid: ResMut, - mut status: ResMut, - mut reputations: ResMut, - // The families, by name (`params`): the drain destructures each one below so every arm - // reads a plain local. - clocks: Clocks, - objects: ObjectQueries, - session: Session, - windows: WindowStores, - actions: ActionStores, - anim: AnimWriters, - catalogs: Catalogs, -) { - let Clocks { - mut server_time, - mut wall_clock, - real: real_clock, - } = clocks; - let ObjectQueries { - mut transforms, - mut stores, - remote: mut remote_motion, - modes: mut unit_modes, - mut riders, - speeds: unit_speeds, - transports, - casting: casting_units, - engaged_self, - mut field_changes, - } = objects; - let ActionStores { - mut player_actions, - mut cast_errors, - mut mount_errors, - mut chain_casts, - mut pet_tame_failures, - mut learned_in_tab, - mut cast_bar, - mut pending_cast, - cooldowns: mut cooldown_store, - mut auto_repeat, - mut active_channel, - mut ui_error_texts, - mut queued_melee, - mut aura_durations, - mut spell_mods, - } = actions; - let AnimWriters { - mut server_sounds, - weather: mut weather_msgs, - mut emotes, - mut swings, - mut cast_events, - mut spell_go_targets, - combat_text: mut combat_text_spawns, - mut swing_impacts, - mut swing_flushes, - go_lid_open: mut go_lid_opens, - mut ai_reactions, - mut kit_pushes, - mut hard_landings, - mut mount_flourishes, - unit_combat: mut unit_combat_feedback, - mut combat_text_events, - mut sheath_requests, - go_custom_anim: mut go_custom_anims, - pet_talk: mut pet_talks, - pet_dismiss: mut pet_dismiss_sounds, - swing_refusals: mut swing_refusal_edges, - mut play_seq, - } = anim; - let Catalogs { - factions: faction_catalog, - spells: spell_catalog, - combat_cvars, - env_damage: env_damage_table, - area_table, - exploration_sounds, - } = catalogs; - // A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp - // it *conditionally* can take it by reference and only advance it when they emit. - let play_seq: &mut crate::creature_anim::PlaySeq = &mut play_seq; - let WindowStores { - mut names, - mut items, - mut loot_latch, - mut chat_log, - mut quest, - mut go_templates, - mut home_bind, - mut proficiencies, - mut dropped, - mut death_net, - mut group, - mut world_states, - social, - mut logout, - mut pet_bar, - mut ui_error_keys, - mut played_time_answer, - mut tutorials, - } = windows; - let Session { - mut teleports, - mut worldports, - mut char_lists, - mut realm_lists, - mut char_actions, - mut char_login_failures, - mut entered_world, - mut addon_reply, - mut logged_out, - mut speed_changes, - mut move_modes, - mut knockbacks, - mut login_stages, - mut login_queued, - mut login_failures, - mut disconnects, - mut server_said, - mut self_moves, - mut client_control, - mut cinematics, - transfer: mut pending_transfer, - } = session; - // Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their - // entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end. - // This also removes a latent clobber: a plain per-delta `insert` on a not-yet-spawned entity would - // overwrite an earlier partial rather than merge it (decision 0061). - let mut pending: HashMap = HashMap::new(); - // The drain's staged [`UnitMoveModes`] grants — see [`objects::StagedModes`]. A grant and the - // `SMSG_MONSTER_MOVE` it refuses can land in the same drain, and the refusal has to see it. - let mut staged_modes = objects::StagedModes::new(); - // The same deferral trap for movers' speeds, and the one B213 fell into: a create's - // `UnitSpeeds` insert is a Command, so a `SMSG_FORCE_*_SPEED_CHANGE` arriving later in this - // same drain could not land on top of it. Both stage here in packet order (decision 1478). - let mut speed_stage = objects::SpeedStage::default(); - // The combat log's classification inputs (B297). Built per use rather than once: the arms - // around these ones take `&mut` to `index`, `group` and `reputations`, so a borrow held across - // the whole drain would not compile. `macro_rules!` here is hygienic against the locals it - // names because it is defined after them, so this is one expression in seven call sites rather - // than seven copies of six fields. - macro_rules! chat_ctx { - () => { - combat_chat::ChatCtx { - self_guid: &self_guid, - group: Some(&group), - index: &index, - factions: faction_catalog.as_deref(), - reputations: &reputations, - spells: spell_catalog.as_deref(), - ranges: &combat_cvars.ranges, - periodic: combat_cvars.periodic.0, - } - }; - } - for ev in events { - match ev { - SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages), - SessionEvent::LoginQueued { position, realm } => { - login_queued.write(crate::net::LoginQueuedMessage { position, realm }); - } - SessionEvent::LoginFailed { - refusal, - reason, - terminal, - dial, - } => session::login_failed(refusal, reason, terminal, dial, &mut login_failures), - SessionEvent::RealmList { realms } => { - realm_lists.write(crate::net::RealmListMessage { realms }); - } - SessionEvent::CharacterList { characters, realm } => { - session::character_list(characters, realm, &mut status, &mut char_lists) - } - SessionEvent::CharActionResult { action, code } => { - session::char_action_result(action, code, &mut char_actions) - } - SessionEvent::CharacterLoginFailed { result } => { - session::character_login_failed(result, &mut char_login_failures) - } - SessionEvent::CinematicTriggered { cinematic_id } => { - session::cinematic_triggered(cinematic_id, &mut cinematics) - } - SessionEvent::Connected { - self_guid: guid, - name, - billing_time_rested, - tutorial_flags, - addon_info, - } => session::connected( - guid, - name, - billing_time_rested, - tutorial_flags, - addon_info, - &mut addon_reply, - &mut self_guid, - &mut status, - &mut names, - &mut entered_world, - ), - SessionEvent::LoggedOut => { - session::logged_out(&mut commands, &mut index, &mut self_guid, &mut logged_out) - } - // The logout arc's two narration packets (decision 0674) — `crate::ui_logout` owns the - // decision table; this is only the hand-off. - SessionEvent::LogoutResponse { reason, instant } => { - logout.apply_response(reason, instant) - } - SessionEvent::LogoutCancelled => logout.apply_cancelled(), - SessionEvent::Disconnected { reason, end } => { - session::disconnected( - reason, - end, - &mut commands, - &mut index, - &mut self_guid, - &mut status, - &mut names, - &mut items, - &mut chat_log, - &mut death_net, - &mut group, - &mut cooldown_store, - &mut pending_transfer, - &mut disconnects, - ); - } - SessionEvent::ObjectCreate { - guid, - kind, - display_id, - position, - orientation, - scale, - speeds, - mover, - transport_progress, - transport, - spline, - fields, - } => { - death::note_corpse(guid, kind, &fields, &self_guid, &mut death_net); - objects::object_create( - guid, - kind, - display_id, - position, - orientation, - scale, - speeds, - mover, - transport_progress, - transport, - spline, - fields, - &mut commands, - &mut index, - &mut transforms, - &mut stores, - &mut pending, - &mut field_changes, - &mut speed_stage, - &names, - &go_templates, - &net_commands, - ) - } - SessionEvent::ItemCreate { - guid, - container, - fields, - } => objects::item_create(guid, container, fields, &mut items), - SessionEvent::ObjectMove { - guid, - position, - orientation, - } => objects::object_move( - guid, - position, - orientation, - &mut commands, - &index, - &mut transforms, - ), - // **An observed mover skipped time** (decision 1935). No pose moved — only that - // unit's clock ran on — so this touches its relay chain and nothing else, which is - // the whole of what the reference's handler does (`0x603b40` → `0x61ab90`: - // `[CMovement+0xac] += lag`). A guid we do not hold is dropped, faithfully: the - // reference resolves under `TYPEMASK_UNIT` and returns on a miss. - SessionEvent::MoveTimeSkipped { guid, lag_ms } => { - if let Some(mut m) = index - .0 - .get(&guid) - .and_then(|&e| remote_motion.get_mut(e).ok()) - { - m.relay.skip_time(lag_ms); - } - } - SessionEvent::UnitMove { - guid, - position, - orientation, - flags, - pitch, - time, - verb, - fall_time, - jump, - transport, - } => { - // The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own - // replay chain over this packet's wire stamp to get its client fire-time, then - // applies it now if due, else queues it on the unit for `drain_pending_moves`. - let now_ms = real_clock.elapsed_secs_f64() * 1000.0; - objects::unit_move( - guid, - crate::net::motion::RelayMove { - wire_ms: time, - position, - orientation, - flags, - pitch, - fall_time, - jump, - transport, - verb, - }, - now_ms, - &mut commands, - &index, - &self_guid, - &mut remote_motion, - &mut transforms, - &mut hard_landings, - &mut self_moves, - ); - } - SessionEvent::ObjectValues { guid, fields } => { - // Our corpse's own `CORPSE_FIELD_FLAGS` can flip to BONES under a live guid; the - // reclaim latch is re-asked on that edge, as the reference's `FLAGS` mirror - // handler `0x5d6d60` does (1729). - death::recheck_corpse(guid, &fields, &self_guid, &mut death_net); - objects::object_values( - guid, - fields, - &index, - &mut stores, - &mut pending, - &mut field_changes, - &mut items, - ) - } - SessionEvent::ObjectDestroyed(guid) => { - death::forget_corpse(guid, &mut death_net); - // The party hook runs FIRST and on the same edge the reference takes it: the - // deactivate virtual reads the descriptor that is about to go (decision 1640). - let store = index.0.get(&guid).and_then(|e| stores.get(*e).ok()); - group::member_deactivated(guid, &mut group, store, &net_commands); - objects::object_destroyed(guid, &mut commands, &mut index, &mut items) - } - SessionEvent::ObjectsRemoved(guids) => { - // OUT_OF_RANGE and DESTROY take the same virtual in the reference — so the - // snapshot + `CMSG_REQUEST_PARTY_MEMBER_STATS` fire here too, which is the edge - // report B334 is actually about: a member walking over the hill. - for guid in &guids { - let store = index.0.get(guid).and_then(|e| stores.get(*e).ok()); - group::member_deactivated(*guid, &mut group, store, &net_commands); - } - objects::objects_removed(guids, &mut commands, &mut index) - } - SessionEvent::MonsterMove { - guid, - transport, - start, - spline_id, - path, - facing, - stop, - duration_ms, - flying, - run_mode, - } => objects::monster_move( - guid, - transport, - start, - spline_id, - path, - facing, - stop, - duration_ms, - flying, - run_mode, - objects::modes_of(guid, &index, &unit_modes, &staged_modes).rooted(), - &mut commands, - &index, - &mut transforms, - &mut riders, - ), - SessionEvent::Teleport { - guid, - counter, - position, - orientation, - } => session::teleport( - guid, - counter, - position, - orientation, - &self_guid, - &mut teleports, - ), - SessionEvent::Worldport { - map_id, - position, - orientation, - needs_ack, - } => { - // Every streamed roster member's object is about to be purged — the same - // deactivation the reference runs one object at a time (decision 1640). - group::roster_deactivated(&mut group, &index, &stores, &net_commands); - session::worldport( - map_id, - position, - orientation, - needs_ack, - &mut commands, - &mut index, - &mut pending_transfer, - &transports, - &mut worldports, - ) - } - SessionEvent::TransferPending { - map_id, - transport_entry, - } => session::transfer_pending(map_id, transport_entry, &mut pending_transfer), - SessionEvent::TransferAborted { reason } => { - session::transfer_aborted(reason, &mut pending_transfer) - } - SessionEvent::TimeSpeed { - hours, - minutes, - day_serial, - timescale, - } => session::time_speed(hours, minutes, day_serial, timescale, &mut server_time), - SessionEvent::ServerUnixTime { unix_time } => { - session::server_unix_time(unix_time, &mut wall_clock) - } - SessionEvent::Reputations { standings } => { - session::reputations(standings, &mut reputations) - } - SessionEvent::ReputationDelta { standings } => { - // The chat line reads the deltas against the store, so it runs BEFORE the - // overwrite — after it, every delta is zero. - combat_chat::faction_standing( - &standings, - &reputations, - faction_catalog.as_deref(), - &mut chat_log, - ); - session::reputation_delta(standings, &mut reputations, &mut quest) - } - SessionEvent::ReputationVisible { list_id } => { - session::reputation_visible(list_id, &mut reputations) - } - SessionEvent::BindPoint { area } => home_bind.0 = Some(area), - // An honor award (decision 1512 — the arc's other inbound message, the inspect reply, - // is `ui_honor`'s own handler): the combat-log line (name-resolved, so it queues) and the floating - // number, which are two different surfaces of one packet and are both the reference's. - // A DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text - // takes it signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a - // "+" only when the number is positive and therefore already expects the other case. - SessionEvent::PvpCredit(credit) => { - chat_log.push_pvp_credit( - credit.honor, - credit.victim_guid, - u8::try_from(credit.victim_rank).unwrap_or(0), - ); - combat_text_events.write(crate::ui_unit::CombatTextEvent { - message_type: "HONOR_GAINED", - data: Some(credit.honor.to_string()), - extra: None, - }); - } - SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes), - SessionEvent::Proficiency { - item_class, - subclass_mask, - } => { - proficiencies.0.insert(item_class, subclass_mask); - } - SessionEvent::PlayerName { - guid, - name, - race, - class, - gender, - } => names::player_name(guid, name, race, class, gender, &mut names), - SessionEvent::PetName { pet_number, name } => { - names::pet_name(pet_number, name, &mut names) - } - SessionEvent::CreatureName { - entry, - name, - subname, - creature_type, - pet_family, - rank, - type_flags, - display_id, - civilian, - racial_leader, - } => names::creature_name( - entry, - name, - subname, - creature_type, - pet_family, - rank, - type_flags, - civilian, - racial_leader, - display_id, - &mut names, - ), - SessionEvent::GameObjectInfo { - entry, - type_id, - display_id, - name, - data, - } => { - objects::gameobject_info(entry, type_id, display_id, name, &data, &mut go_templates) - } - SessionEvent::GameObjectCustomAnim { guid, anim_id } => { - objects::gameobject_custom_anim(guid, anim_id, &mut go_custom_anims) - } - SessionEvent::GameObjectDespawnAnim { guid } => { - objects::gameobject_despawn_anim(guid, &mut commands, &index) - } - SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut server_sounds), - SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut server_sounds), - SessionEvent::PlayObjectSound { sound_id, guid } => { - world::play_object_sound(sound_id, guid, &index, &mut server_sounds) - } - SessionEvent::Weather { - weather_type, - grade, - sound_id, - instant, - } => world::weather(weather_type, grade, sound_id, instant, &mut weather_msgs), - SessionEvent::TextEmote { - guid, - text_emote, - target_name, - } => anim::text_emote( - guid, - text_emote, - target_name, - &index, - &mut emotes, - &mut chat_log, - ), - SessionEvent::Emote { guid, emote_id } => { - anim::emote(guid, emote_id, &index, &mut emotes) - } - // The spell-book/action-bar pair → the action store the UI feed reads - // (`crate::ui_action`), sent once at login (and the bar again on server-side edits). - SessionEvent::SpellBook { - spell_ids, - cooldowns, - } => spell_book( - spell_ids, - cooldowns, - &mut player_actions, - &mut cooldown_store, - ), - SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut player_actions), - SessionEvent::SpellLearned { spell_id } => learned_spell( - spell_id, - &mut player_actions, - spell_catalog.as_deref(), - &mut ui_error_keys, - &mut learned_in_tab, - ), - SessionEvent::SpellRemoved { spell_id } => removed_spell( - spell_id, - &mut player_actions, - spell_catalog.as_deref(), - &mut ui_error_keys, - ), - SessionEvent::SpellSuperceded { - old_spell_id, - new_spell_id, - } => superceded_spell( - old_spell_id, - new_spell_id, - &mut player_actions, - spell_catalog.as_deref(), - &mut ui_error_keys, - &mut learned_in_tab, - ), - SessionEvent::CastResult { - spell_id, - success, - reason, - arg, - } => cast_result( - spell_id, - success, - reason, - arg, - &mut commands, - &self_guid, - &index, - &mut cast_errors, - &casting_units, - &mut cast_events, - &mut cast_bar, - &mut pending_cast, - &mut queued_melee, - &mut cooldown_store, - &mut auto_repeat, - spell_catalog.as_deref(), - &net_commands, - &mut chain_casts, - play_seq.next(), - ), - SessionEvent::Chat(m) => { - chat::chat(m, &mut chat_log, &social, &net_commands, &mut server_said) - } - SessionEvent::ChannelNotify { - notice, - channel, - tail, - } => chat_log.push_channel_notice(notice, channel, &tail), - SessionEvent::ChannelList { - channel, members, .. - } => chat::channel_list(channel, &members, &mut chat_log), - SessionEvent::ChatPlayerNotFound { name } => { - chat::chat_player_not_found(&name, &mut ui_error_keys) - } - SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut ui_error_keys), - // The four world broadcasts — parked for `ui_chat::broadcast`'s resolve pass, which - // owns the AreaTable/ServerMessages lookups and the joined-defense-channel walk. - SessionEvent::ZoneUnderAttack { area_id } => chat::broadcast( - crate::ui_chat::Broadcast::ZoneUnderAttack { area_id }, - &mut chat_log, - ), - SessionEvent::DefenseMessage { zone_id, text } => chat::broadcast( - crate::ui_chat::Broadcast::Defense { zone_id, text }, - &mut chat_log, - ), - SessionEvent::ServerMessage { message_type, text } => chat::broadcast( - crate::ui_chat::Broadcast::Server { message_type, text }, - &mut chat_log, - ), - SessionEvent::ChatRestricted => { - chat::broadcast(crate::ui_chat::Broadcast::ChatRestricted, &mut chat_log) - } - SessionEvent::Notification { text } => chat::notification(text, &mut ui_error_texts), - SessionEvent::AreaTriggerMessage { text } => { - chat::area_trigger_message(text, &mut ui_error_texts) - } - SessionEvent::PlayedTime { total, level } => { - // BOTH halves, and they are not redundant. The chat breakdown is our stand-in for - // the reference's `ChatFrame_DisplayTimePlayed`, which we do not ship; the mailbox - // is what becomes `TIME_PLAYED_MSG(total, level)` for an addon that asked. - played_time_answer.0 = Some((total, level)); - chat::played_time(total, level, &mut chat_log) - } - SessionEvent::RandomRoll { - min, - max, - roll, - guid, - } => chat_log.push_roll(min, max, roll, guid), - // ── The group/party family (decision 0434 §D2, superseded by 0440) — arm bodies in - // `group` ── - SessionEvent::GroupInvite { inviter } => { - group::invited(&mut group, &mut ui_error_keys, &inviter) - } - SessionEvent::GroupDecline { name } => { - group::declined(&mut group, &mut ui_error_keys, &name) - } - SessionEvent::GroupUninvited => group::uninvited(&mut group, &mut ui_error_keys), - SessionEvent::GroupLeaderChanged { name } => group::leader_changed( - &mut group, - &mut ui_error_keys, - &name, - &self_guid, - &names, - &net_commands, - ), - SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut ui_error_keys), - SessionEvent::GroupList { - group_type, - own_flags, - members, - leader, - loot, - } => group::list( - &mut group, - &mut ui_error_keys, - &mut quest, - group_type, - own_flags, - members, - leader, - loot, - &names, - &index, - &net_commands, - ), - SessionEvent::PartyCommandResult { - operation, - member, - result, - } => group::command_result(&mut group, &mut ui_error_keys, operation, &member, result), - SessionEvent::PartyMemberStats { guid, full, info } => { - group.apply_stats(guid, full, *info) - } - SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid), - SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries), - // The ready check (decision 1549, completed by 1989): the open form takes the - // leader arm or the popup arm by our guid; the ANSWER form — forwarded to the leader - // alone — is logged for the engine's flags, which the timeout tick sums up after 30 s - // (1.12 has no per-member answer surface, only the AFK summary line). - SessionEvent::ReadyCheckRequest => { - group::ready_check_request(&mut group, &mut ui_error_keys, &self_guid) - } - SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries), - SessionEvent::ReadyCheckAnswer { guid, ready } => { - group.apply_ready_check_answer(guid, ready != 0) - } - // ── The death arc (decision 0308) — arm bodies in `death` ───────────────────────── - SessionEvent::CorpseQuery { - found, - display_map, - position, - corpse_map, - } => death::corpse_query(found, display_map, position, corpse_map, &mut death_net), - SessionEvent::CorpseReclaimDelay { delay_ms } => { - death::corpse_reclaim_delay(delay_ms, real_clock.elapsed_secs_f64(), &mut death_net) - } - SessionEvent::ResurrectRequest { - caster, - name, - sickness, - has_timer, - } => death::resurrect_request(caster, name, sickness, has_timer, &mut death_net), - SessionEvent::SpiritHealerConfirm { npc } => { - death::spirit_healer_confirm(npc, &mut death_net) - } - SessionEvent::DurabilityDamageDeath => death::durability_damage_death(&mut chat_log), - SessionEvent::MoveMode { - guid, - counter, - mode, - apply, - } => death::move_mode( - guid, - counter, - mode, - apply, - &self_guid, - &mut death_net, - &mut move_modes, - ), - // ── The observer movement-mode family (decision 1780) — the same modes, on a body - // somebody else is driving. No ack, so this arm ends the packet. - SessionEvent::SplineMoveMode { guid, mode, apply } => objects::spline_move_mode( - guid, - mode, - apply, - &mut commands, - &index, - &mut unit_modes, - &mut remote_motion, - &mut staged_modes, - ), - SessionEvent::KnockBack { - guid, - counter, - launch, - } => session::knock_back(guid, counter, launch, &self_guid, &mut knockbacks), - // An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our - // `CMSG_ITEM_QUERY_SINGLE`): fill the ask-once template cache (decisions 0068/0072 — - // one cache serves held-item resolution and the container layer); a server miss - // records `None` so the entry is never re-asked. Consumers re-read it next frame. - SessionEvent::ItemTemplate { entry, info } => { - let info = info.map(|b| *b); - debug!("net: item template {entry} → {info:?}"); - items.insert_template(entry, info); - } - SessionEvent::AttackStart { attacker, victim } => { - combat::attack_start(attacker, victim, &mut commands, &index) - } - SessionEvent::AttackStop { attacker, victim } => { - combat::attack_stop(attacker, victim, &mut commands, &index, &mut swing_flushes) - } - SessionEvent::AiReaction { unit, reaction } => { - combat::ai_reaction(unit, reaction, &index, &mut ai_reactions) - } - SessionEvent::AttackerState(s) => { - combat_chat::attacker_state(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); - combat::attacker_state( - s, - &index, - &self_guid, - &mut swings, - &mut swing_impacts, - &mut combat_text_events, - &mut sheath_requests, - &mut swing_refusal_edges, - &stores, - play_seq.next(), - ) - } - SessionEvent::AttackSwingError(e) => { - combat::attack_swing_error(e, &mut swing_refusal_edges) - } - SessionEvent::CancelCombat => combat::cancel_combat(&mut swing_refusal_edges), - SessionEvent::FeignDeathResisted => combat::feign_death_resisted(&mut ui_error_keys), - SessionEvent::SpellDamageLog(s) => { - combat_chat::spell_damage_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); - combat_log::spell_damage_log( - s, - &index, - &self_guid, - &stores, - spell_catalog.as_deref(), - *combat_cvars.damage_text, - &mut combat_text_spawns, - &mut unit_combat_feedback, - &mut combat_text_events, - ) - } - SessionEvent::PeriodicAuraLog(s) => { - // **`CombatLogPeriodicSpells` gates the WHOLE packet body, and this arm is where - // that is expressible.** The reference's read site `0x626dee` is the first thing - // the handler `0x626dd0` does, and a zero jumps to the bare epilogue `0x6271b4`: - // no chat line, no floating tick number, no periodic miss word. Gating inside - // either half below would model it as two filters; it is one gate over both. - if !combat_cvars.periodic.0 { - continue; - } - combat_chat::periodic_aura_log( - &s, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ); - combat_log::periodic_aura_log( - s, - &index, - &self_guid, - &stores, - spell_catalog.as_deref(), - *combat_cvars.damage_text, - &mut combat_text_spawns, - &mut unit_combat_feedback, - &mut combat_text_events, - &names, - &net_commands, - ) - } - SessionEvent::SpellHealLog(s) => { - combat_chat::spell_heal_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); - combat_log::spell_heal_log( - s, - &index, - &self_guid, - &mut unit_combat_feedback, - &mut combat_text_events, - &names, - &net_commands, - ) - } - SessionEvent::SpellEnergizeLog(s) => { - combat_chat::spell_energize_log( - s, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ); - combat_log::spell_energize_log(s, &self_guid, &mut combat_text_events) - } - SessionEvent::DamageShield(s) => { - combat_chat::damage_shield(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); - combat_log::damage_shield( - s, - &index, - &self_guid, - &stores, - *combat_cvars.damage_text, - &mut combat_text_spawns, - &mut unit_combat_feedback, - ) - } - SessionEvent::SpellLogMiss(s) => { - combat_chat::spell_log_miss(&s, &chat_ctx!(), &stores, &transforms, &mut chat_log); - combat_log::spell_log_miss( - s, - &index, - &self_guid, - &stores, - spell_catalog.as_deref(), - *combat_cvars.damage_text, - &mut combat_text_spawns, - &mut unit_combat_feedback, - &mut combat_text_events, - ) - } - // ── the combat log's completeness block (1703) ──────────────────────────────── - // Every one of these is chat-only: they carry no damage number, so unlike their - // neighbours above they have no floating-text twin to call. - SessionEvent::PartyKillLog(k) => { - combat_chat::party_kill_log(k, &chat_ctx!(), &stores, &transforms, &mut chat_log) - } - SessionEvent::SpellInstaKillLog(k) => combat_chat::spell_insta_kill_log( - k, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ), - SessionEvent::ProcResist(o) => combat_chat::spell_outcome_log( - o, - false, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ), - SessionEvent::SpellOrDamageImmune(o) => combat_chat::spell_outcome_log( - o, - true, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ), - SessionEvent::SpellDispelLog(d) => { - combat_chat::spell_dispel_log(&d, &chat_ctx!(), &stores, &transforms, &mut chat_log) - } - SessionEvent::DispelFailed(d) => { - combat_chat::dispel_failed(&d, &chat_ctx!(), &stores, &transforms, &mut chat_log) - } - SessionEvent::EnchantmentLog(e) => { - combat_chat::enchantment_log(e, &chat_ctx!(), &stores, &transforms, &mut chat_log) - } - SessionEvent::SpellLogExecute(x) => combat_chat::spell_log_execute( - &x, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ), - SessionEvent::XpGain(x) => combat_log::xp_gain( - x, - &index, - &self_guid, - &mut combat_text_spawns, - &mut chat_log, - ), - SessionEvent::ExplorationXp(x) => combat_log::exploration_xp( - x, - area_table.as_deref(), - exploration_sounds.as_deref(), - &index, - &self_guid, - &stores, - &mut server_sounds, - &mut chat_log, - ), - SessionEvent::LevelUp(l) => combat_log::level_up(l, &mut chat_log), - SessionEvent::SpellStart { - caster, - spell_id, - cast_flags, - cast_time_ms, - target, - ammo_display_id, - } => spell_start( - caster, - spell_id, - cast_flags, - cast_time_ms, - target, - ammo_display_id, - &mut commands, - &index, - &mut cast_events, - &self_guid, - &mut cast_bar, - &mut pending_cast, - spell_catalog.as_deref(), - play_seq.next(), - ), - SessionEvent::SpellGo { - caster, - spell_id, - cast_flags, - hits, - misses, - target, - go_target, - dest, - ammo_display_id, - item_caster, - } => spell_go( - caster, - spell_id, - cast_flags, - hits, - misses, - target, - go_target, - dest, - ammo_display_id, - item_caster, - &mut commands, - &index, - &casting_units, - &mut cast_events, - &mut spell_go_targets, - &self_guid, - &stores, - &mut cast_bar, - &mut pending_cast, - &mut queued_melee, - &mut combat_text_spawns, - *combat_cvars.damage_text, - &mut go_lid_opens, - &mut loot_latch, - ( - &mut cooldown_store, - spell_catalog.as_deref(), - &mut items, - &net_commands, - &mut pet_bar, - ), - ( - &mut auto_repeat, - &mut sheath_requests, - !engaged_self.is_empty(), - ), - play_seq.next(), - ), - SessionEvent::SpellChainTargets { - caster, - spell_id, - targets, - } => spell_chain_targets(caster, spell_id, targets, &mut commands, &index), - SessionEvent::SpellFailedOther { caster, spell_id } => spell_failed_other( - caster, - spell_id, - &mut commands, - &index, - &casting_units, - &mut cast_events, - &self_guid, - &mut cast_bar, - &mut pending_cast, - &mut queued_melee, - play_seq.next(), - ), - SessionEvent::SpellDelayed { caster, delay_ms } => spell_delayed( - caster, - delay_ms, - &self_guid, - &mut cast_bar, - &mut pending_cast, - ), - SessionEvent::CancelAutoRepeat => cancel_auto_repeat( - &mut auto_repeat, - &self_guid, - &index, - &mut commands, - &net_commands, - ), - SessionEvent::SpellCooldowns { caster, cooldowns } => { - if let Some(store) = - addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) - { - spell_cooldowns(caster, cooldowns, spell_catalog.as_deref(), store); - } - } - SessionEvent::ItemCooldown { - item_guid, - spell_id, - } => item_cooldown(item_guid, spell_id, &items, &mut cooldown_store), - // The item-lifetime countdown's ONLY feed (decision 1933): park the deadline on the - // item store, exactly as the enchant timer below does — vmangos's own writer says the - // `ITEM_FIELD_DURATION` field the client also holds is not what it displays from. - SessionEvent::ItemTime { item_guid, seconds } => { - items.set_item_duration(item_guid, seconds) - } - // The temporary-enchant countdown's ONLY feed (decision 0920): park the deadline on the - // item store, which every tooltip surface reads back through `enchant_remaining_ms`. - SessionEvent::ItemEnchantTime { - item_guid, - slot, - seconds, - } => items.set_enchant_deadline(item_guid, slot, seconds), - SessionEvent::CooldownEvent { spell_id, caster } => { - if let Some(store) = - addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) - { - cooldown_event(spell_id, caster, store); - } - } - SessionEvent::ClearCooldown { spell_id, caster } => { - if let Some(store) = - addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) - { - clear_cooldown(spell_id, caster, store); - } - } - SessionEvent::CooldownCheat { caster } => { - if let Some(store) = - addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) - { - cooldown_cheat(caster, store); - } - } - // The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a - // wholesale replace and its zero-guid form is the teardown. - SessionEvent::PetSpells(spells) => { - pet::pet_spells(*spells, spell_catalog.as_deref(), &mut pet_bar) - } - SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar), - SessionEvent::PetActionFeedback { reason } => { - pet::pet_action_feedback(reason, &mut ui_error_keys) - } - SessionEvent::PetCastFailed { spell_id, reason } => { - pet::pet_cast_failed(spell_id, reason, &mut cast_errors) - } - // The three pet-feedback arms and the pet's voice (decision 2039). Each was - // name-table-only until then; each is something the reference visibly does. - SessionEvent::PetTameFailure { reason } => { - pet::pet_tame_failure(reason, &mut pet_tame_failures) - } - SessionEvent::PetNameInvalid => pet::pet_name_invalid(&mut ui_error_keys), - SessionEvent::PetBroken => pet::pet_broken(&mut ui_error_keys), - SessionEvent::PetActionSound { pet_guid, talk } => { - pet::pet_action_sound(pet_guid, talk, &index, &mut pet_talks) - } - SessionEvent::PetDismissSound { model_id, position } => { - pet::pet_dismiss_sound(model_id, position, &mut pet_dismiss_sounds) - } - SessionEvent::ChannelStart { - spell_id, - duration_ms, - } => channel_start(spell_id, duration_ms, &mut active_channel, &mut cast_bar), - SessionEvent::ChannelUpdate { remaining_ms } => { - channel_update(remaining_ms, &mut active_channel, &mut cast_bar) - } - SessionEvent::AuraDuration { slot, remaining_ms } => aura_duration( - slot, - remaining_ms, - &mut aura_durations, - real_clock.elapsed_secs_f64(), - ), - SessionEvent::SpellModifier { - flat, - mask_bit, - op, - value, - } => set_spell_modifier(flat, mask_bit, op, value, &mut spell_mods), - SessionEvent::PlaySpellVisual { unit, kit_id } => { - anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut kit_pushes) - } - SessionEvent::EnvironmentalDamageLog(e) => { - combat_chat::environmental_damage_log( - e, - &chat_ctx!(), - &stores, - &transforms, - &mut chat_log, - ); - anim::environmental_damage_log( - e, - &index, - env_damage_table.as_deref(), - play_seq, - &mut kit_pushes, - ) - } - SessionEvent::InvalidatePlayer { guid } => names::invalidate_player(guid, &mut names), - SessionEvent::ForceSpeedChange { - guid, - kind, - counter, - speed, - } => objects::force_speed_change( - guid, - kind, - counter, - speed, - &index, - &unit_speeds, - &mut speed_stage, - &self_guid, - &mut speed_changes, - ), - SessionEvent::SpeedChanged { guid, kind, speed } => { - objects::speed_changed(guid, kind, speed, &index, &unit_speeds, &mut speed_stage) - } - // ── The mount arc (decision 0441) — arm bodies in `mount` ───────────────────────── - SessionEvent::MountResult { mount, code } => { - mount::mount_result(mount, code, &mut mount_errors) - } - SessionEvent::MountSpecial { guid } => { - mount::mount_special(guid, &self_guid, &index, &mut mount_flourishes) - } - // Possession's control half (B211). Forwarded whole and unjudged: the guid may name us - // (a revoke) or somebody else (a grant), and only the controller can act on either — - // it owns the pose to park and the mover claim to send. - SessionEvent::ClientControl { mover, allow_move } => { - client_control.write(super::ClientControlMessage { mover, allow_move }); - } - // **The pong never gets here** — the read thread measures it against the ping clock - // the instant it lands and stops it, the way the reference's `OnData 0x537b10` hands - // `SMSG_PONG` to `HandlePong 0x537d60` inline instead of queueing it (`net::io`). - // Reaching this arm means that bypass was undone and every latency reading is a - // client frame too slow again, which is B346 exactly — so it says so out loud rather - // than measuring here and hiding it. - SessionEvent::Pong { sequence } => { - warn!("net: pong seq={sequence} reached the drain — the read thread's RTT bypass is gone (B346)"); - } - SessionEvent::PacketDropped { - opcode, - unparseable, - } => session::packet_dropped(opcode, unparseable, &mut dropped), - SessionEvent::WorldStates { scope, states } => { - world::world_states(scope, states, &mut world_states) - } - // A kind with no arm here is one a subsystem has claimed in the handler table - // (decision 2305) — routed there by the drain, so it never reaches this match — or a - // new kind nobody handles, which `every_session_event_kind_has_one_owner` names at - // test time and this names at run time: the reference discards an unregistered - // opcode in silence; this client says so. - other => error!( - "net: {:?} reached the dispatch match with no arm — neither peeled nor handled", - benilla_protocol::SessionEventKind::from(&other) - ), - } - } - // Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the - // above Commands) — one insert each, fully merged, so no partial delta clobbers another. - for (guid, fields) in pending { - if let Some(&e) = index.0.get(&guid) { - commands.entity(e).insert(ObjectStore(fields)); - } - } - // And the movers' speed sets, for the same reason (decision 1478) — one insert each, carrying - // the create block and every force-change this drain saw, folded in the order they arrived. - speed_stage.flush(&mut commands, &index); + super::handlers::dispatch(world, events); } /// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the diff --git a/crates/benilla-app/src/net/apply/anim.rs b/crates/benilla-app/src/net/apply/anim.rs deleted file mode 100644 index 705b0027..00000000 --- a/crates/benilla-app/src/net/apply/anim.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! "Play this on that unit" arm bodies for [`super::apply_net_updates`]'s dispatch match — the -//! server packets whose whole content is *an animation to run on a streamed unit*: the two emote -//! relays, the spell-visual kit push (decision 0280), and the environmental-damage kit (the -//! fall-landing dust puff). Each resolves the guid through the index and writes one message; the -//! animation law itself lives in [`crate::creature_anim`]. Each `pub(super)` fn here is exactly one -//! arm's body; the match at the call site stays the dispatcher, one call per arm. - -use benilla_protocol::messages::EnvironmentalDamageLog; -use bevy::prelude::*; - -use crate::creature_anim::{EnvDamageTable, KitPush, PlaySeq}; - -use super::super::{EmoteKind, EmoteMessage, GuidIndex}; - -/// `SMSG_TEXT_EMOTE` — someone performed a `/`-emote (the TextEmote.dbc id; the anim, if any, is -/// the emote row's). -/// -/// **Two consequences, and they are independent** (decision 1274): the anim + voice ride the -/// [`EmoteMessage`] and need the performer *streamed*; the chat sentence is queued for -/// [`crate::ui_chat`]'s composer and needs only the performer's *name*. An emote from someone -/// off-screen therefore still prints its line, which is the reference's shape — `0x49dbe0` -/// resolves a name and never touches the object manager. -pub(super) fn text_emote( - guid: u64, - text_emote: u32, - target_name: String, - index: &GuidIndex, - out: &mut MessageWriter, - chat_log: &mut crate::ui_chat::ChatLog, -) { - out.write(EmoteMessage { - source: index.0.get(&guid).copied(), - kind: EmoteKind::Text(text_emote), - }); - chat_log.push_text_emote(guid, text_emote, target_name); -} - -/// `SMSG_EMOTE` — a bare Emotes.dbc anim id on a unit (the server-driven one-shot: NPC scripts, -/// the `/`-emote's own anim leg). -pub(super) fn emote( - guid: u64, - emote_id: u32, - index: &GuidIndex, - out: &mut MessageWriter, -) { - out.write(EmoteMessage { - source: index.0.get(&guid).copied(), - kind: EmoteKind::Anim(emote_id), - }); -} - -/// `SMSG_PLAY_SPELL_VISUAL` — the kit-push opcode (decision 0280): a stage-0 play on the unit, the -/// eat/drink kit cadence and mid-channel swaps. Consumer: `creature_anim::spell_visual`. The -/// [`PlaySeq`] stamp is taken only when the unit is streamed in, so an unstreamed guid never -/// advances the call-order counter. -pub(super) fn play_spell_visual( - unit: u64, - kit_id: u32, - index: &GuidIndex, - play_seq: &mut PlaySeq, - out: &mut MessageWriter, -) { - if let Some(&e) = index.0.get(&unit) { - out.write(KitPush { - entity: e, - kit_id, - seq: play_seq.next(), - }); - } -} - -/// `SMSG_ENVIRONMENTALDAMAGELOG`'s consequence (wow-re `sound/scratch/uisound-tables.md`: reader -/// `0x624fcc` inside `0x624f30`): the EnvironmentalDamage.dbc 6-slot table picks the damage type's -/// SpellVisualKit — fall's is the DustCloud_Land puff — played on the victim through the ordinary -/// discrete kit play (`0x60edf0`), the same leg the kit-push opcode rides. The pain vocal's exact -/// trigger is a dispatched wow-re §5 (in flight) — it folds in as its own edge when the verdict -/// lands. -pub(super) fn environmental_damage_log( - e: EnvironmentalDamageLog, - index: &GuidIndex, - table: Option<&EnvDamageTable>, - play_seq: &mut PlaySeq, - out: &mut MessageWriter, -) { - if let Some(&ent) = index.0.get(&e.victim) { - if let Some(kit_id) = table.and_then(|t| t.0.kit_id(e.damage_type)) { - debug!( - "net: environmental damage on {:#x} (type {}, {} dmg) → kit {kit_id}", - e.victim, e.damage_type, e.damage - ); - out.write(KitPush { - entity: ent, - kit_id, - seq: play_seq.next(), - }); - } - } -} diff --git a/crates/benilla-app/src/net/apply/combat.rs b/crates/benilla-app/src/net/apply/combat.rs deleted file mode 100644 index 14648ace..00000000 --- a/crates/benilla-app/src/net/apply/combat.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Melee-engagement arm bodies for [`super::apply_net_updates`]'s dispatch match — the attack -//! start/stop brackets, the aggro/alert flare, and the completed-swing record with its client-side -//! full-block synthesis. Each `pub(super)` fn here is exactly one arm's body; the match at the -//! call site stays the dispatcher, one call per arm. - -use benilla_protocol::messages::{AttackSwingError, AttackerState}; -use bevy::prelude::*; - -use crate::creature_anim::{ - Engaged, RangedHold, SheathRequest, SwingFlush, SwingImpact, SwingMessage, -}; -use crate::swing_refusal::SwingRefusalEdge; -use crate::ui_action::{UiError, UiErrorKeys}; -use crate::ui_unit::CombatTextEvent; - -use super::super::{AiReactionMessage, GuidIndex, SelfGuid}; - -/// A unit began melee auto-attack (`SMSG_ATTACKSTART`, including our own echo). -pub(super) fn attack_start(attacker: u64, victim: u64, commands: &mut Commands, index: &GuidIndex) { - // Engagement brackets (decision 0073): the standing Ready idle rides this window — - // the client's gate is the auto-attack-target GUID being set, mirrored here as a - // marker component on the attacker (including our own echo). - debug!("net: attack start {attacker:#x} → {victim:#x}"); - if let Some(&e) = index.0.get(&attacker) { - // Melee-start drops the `0x400` weapon-visual hold unconditionally (the client's - // `0x60fc50` sibling clear) — a shooter that closes to melee leaves the drawn idle. - // The LOCAL player's melee paths additionally run the full cancel funnel at send. - commands - .entity(e) - .insert(Engaged(victim)) - .remove::(); - } -} - -/// A unit stopped melee auto-attack (`SMSG_ATTACKSTOP`). -pub(super) fn attack_stop( - attacker: u64, - victim: u64, - commands: &mut Commands, - index: &GuidIndex, - flushes: &mut MessageWriter, -) { - debug!("net: attack stop {attacker:#x} → {victim:#x}"); - if let Some(&e) = index.0.get(&attacker) { - commands.entity(e).remove::(); - // The client's `0x624e40` (death/stun arrive as this packet too): a pending - // swing record flushes text-only and clears. - flushes.write(SwingFlush(e)); - } -} - -/// The server refused our melee swing (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/ -/// `_CANT_ATTACK`) — forwarded verbatim to [`crate::swing_refusal`], which owns the latch, the 4 s -/// repeat, and arm 4's silent StopAttack. Nothing is decided here: the arms differ only in what -/// that module does with them, and it holds the write set for all three. -pub(super) fn attack_swing_error( - error: AttackSwingError, - edges: &mut MessageWriter, -) { - edges.write(SwingRefusalEdge::Refused(error)); -} - -/// `SMSG_CANCEL_COMBAT` — the server forced our attack to stop. The swing family's fourth arm, -/// and the same act as `0x148`/`0x149`: the reference's handler `0x5e7dd0` is arm 4's body -/// verbatim. -pub(super) fn cancel_combat(edges: &mut MessageWriter) { - edges.write(SwingRefusalEdge::CombatCancelled); -} - -/// `SMSG_FEIGN_DEATH_RESISTED` — the target shrugged off our Feign Death. -/// -/// One red line and nothing else: the reference's handler `0x6e9800` is `push 0x1a5; call -/// 0x496720`, a bare `DisplayError(421)` with no latch, no cooldown and no state — the opposite of -/// its sibling above, and the reason the two do not share a path. Catalog row 421 is -/// `ERR_FEIGN_DEATH_RESISTED`, whose 1.12 string is the single word "Resisted". -/// -/// It lives beside the swing arms because vmangos sends it in the same breath as -/// `SMSG_CANCEL_COMBAT` (`Objects/Unit.cpp:9445-9451`: a resisted feign death cancels the attack -/// and says so), and finding one without the other is how this family stayed half-built. -pub(super) fn feign_death_resisted(errors: &mut UiErrorKeys) { - debug!("net: feign death resisted"); - errors.0.push(UiError::key("ERR_FEIGN_DEATH_RESISTED")); -} - -/// A creature flared aggro or a stealth pre-aggro alert (`SMSG_AI_REACTION`). -pub(super) fn ai_reaction( - unit: u64, - reaction: u32, - index: &GuidIndex, - reactions: &mut MessageWriter, -) { - // Aggro (2 HOSTILE) / stealth alert (0 ALERT) flare — pure audio, byte-verified - // (`0x6056e0` is an exact two-way branch; any other value no-ops, and neither leg - // touches animation/nameplate/UI — decision 0280). Vocals: `sound::creature`. - debug!("net: ai reaction {reaction} on {unit:#x}"); - if matches!(reaction, 0 | 2) { - if let Some(&e) = index.0.get(&unit) { - reactions.write(AiReactionMessage { - unit: e, - hostile: reaction == 2, - }); - } - } -} - -/// One completed melee swing (`SMSG_ATTACKERSTATEUPDATE`, decision 0073): the attacker's swing -/// anim starts NOW; the victim feedback (blood/flinch/text/impact sounds) defers to the swing -/// clip's attack-hit keyframe (`creature_anim::impact`, the client's `0x6247d0` router) — EXCEPT -/// the center combat text, which the client fires **synchronously at packet parse** -/// (`0x6255b0 → 0x629d30 → 0x703f50`, one call stack — §5-verified, wow-re -/// `combat-text-update-emission-law.md`; decision 0580's fold-back). -pub(super) fn attacker_state( - mut s: AttackerState, - index: &GuidIndex, - self_guid: &SelfGuid, - swings: &mut MessageWriter, - impacts: &mut MessageWriter, - center: &mut MessageWriter, - sheaths: &mut MessageWriter, - edges: &mut MessageWriter, - stores: &Query<&mut crate::net::ObjectStore>, - seq: u64, -) { - let victim = index.0.get(&s.victim).copied(); - // Arm 5's `0x6259b6 call 0x5ea800`, whose first act is the swing-refusal latch clear - // (`0x5ecdb0(0)`) — gated exactly as the reference gates it: the attacker IS the active player - // (`0x5fa6d0`, a guid compare) and the victim resolves as a streamed unit. Written from here - // rather than computed downstream because this is the only place holding both guids, and it - // keeps the clear in packet order with the refusals (`crate::swing_refusal`). - if self_guid.0 == Some(s.attacker) && victim.is_some() { - edges.write(SwingRefusalEdge::Landed); - } - if benilla_assets::trace::enabled() { - benilla_assets::trace::line( - "fct", - &format!( - "recv swing atk={:#x} victim={:#x} dmg={} vstate={} hit={:#x}", - s.attacker, s.victim, s.damage, s.victim_state, s.hit_info - ), - ); - } - // The client-side FULL-BLOCK synthesis (`0x625e20`, decision 0279): a resolvable - // victim + zero damage + a nonzero blocked amount rewrites the state to BLOCKS(5) - // before any consumer sees the record — the only thing the wire's blocked_amount - // ever does (a PARTIAL block stays state 1, indistinguishable from a plain hit). - if victim.is_some() && s.damage == 0 && s.blocked != 0 { - s.victim_state = 5; - } - // The center combat text (decision 0578/0580): self victim, at receive, AFTER the full-block - // synthesis (so a full block reads BLOCK, not MISS) — the packet's absorb/resist/blocked - // sums feed the confirmed helper-B partial trailers. - if self_guid.0 == Some(s.victim) { - if let Some((message_type, data, extra)) = super::combat_log::melee_center_text( - s.hit_info, - s.victim_state, - s.damage, - s.absorb, - s.resist, - s.blocked, - ) { - center.write(CombatTextEvent { - message_type, - data, - extra, - }); - } - } - let swing = SwingMessage { - attacker: Entity::PLACEHOLDER, // filled per branch below - victim, - hit_info: s.hit_info, - victim_state: s.victim_state, - damage: s.damage, - displayed: s.displayed(), - seq, - }; - if let Some(&e) = index.0.get(&s.attacker) { - // The **observed attacker auto-draws melee** — the ref's SECOND melee draw, independent - // of the attack-start one, and the reason a swing is never delivered in the wrong stance: - // `0x625829 cmp [attacker+0xd40],1; jne` → `SetSheatheState(1, bInstant=1, bFireEvent=1)` - // at `0x62583a`, byte-read here, tabulated in wow-re `sheath-policy.md` §1. It sits - // immediately after the attacker resolve and **before** any hit-info handling, so even a - // swing whose animation is suppressed (`HitInfo & 0x10000`) still draws. Nothing else in - // the policy can do this job: the per-animation reconcile's melee force is gated to - // `CUR != 2` (`0x5fe0f9`/`0x5fe13b`), so a unit swinging with a bow drawn — a ranged - // stance a shot left behind — would otherwise keep swinging with the bow forever. The - // setter's own idempotency is the `cmp`: a request equal to the committed state is - // refused there, so this is free on every swing after the first. - sheaths.write(SheathRequest { - entity: e, - state: 1, - ceremony: false, - }); - swings.write(SwingMessage { - attacker: e, - ..swing - }); - } else if swing.victim.is_some_and(|v| { - // The receive-time arm goes THROUGH the gated dispatcher — `0x625823 je 0x625a3e` takes - // the unresolved-attacker leg, which resolves the victim and calls `0x625a6d call - // 0x624530`, so the LOOTABLE front gate applies here exactly as it does to the tag path - // (`creature_anim::impact::lootable_victim`). It calls no consequence directly. - !stores.get(v).is_ok_and(|s| s.0.unit_lootable()) - }) { - // The client's SMSG-arm fallback: an attacker we can't resolve (out of range) - // can't animate a swing — its victim feedback fires immediately and in FULL - // (`0x625a6d`, the only receive-time victim dispatch). The PLACEHOLDER - // attacker resolves nowhere downstream (blood defaults front). - impacts.write(SwingImpact { - swing, - text_only: false, - natural: None, - // The receive-time arm: no tag fired, so the reference has no event point either — - // the consumer falls back to the victim, the only anchor the packet leaves us. - pos: None, - }); - } -} diff --git a/crates/benilla-app/src/net/apply/mount.rs b/crates/benilla-app/src/net/apply/mount.rs deleted file mode 100644 index 77936eae..00000000 --- a/crates/benilla-app/src/net/apply/mount.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The mount arc's arm bodies for [`super::apply_net_updates`]'s dispatch match (decision 0441) — -//! the (dis)mount attempt's result code (P1) and a nearby rider's flourish (P2). Each `pub(super)` -//! fn here is exactly one arm's body; the match at the call site stays the dispatcher, one call per -//! arm. - -use bevy::prelude::*; - -use crate::creature_anim::MountFlourish; -use crate::ui_action::MountErrors; - -use super::super::{GuidIndex, SelfGuid}; - -/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` — OK is silent in the reference (10 mounting, -/// 3 dismounting); a failure queues the red error line (`ui_action::mount_result_key` — resolved -/// against the VM's GlobalStrings at drain). -pub(super) fn mount_result(mount: bool, code: u32, errors: &mut MountErrors) { - let ok = if mount { code == 10 } else { code == 3 }; - if !ok { - info!( - "net: {}mount refused (code {code})", - if mount { "" } else { "dis" } - ); - errors.0.push((mount, code)); - } -} - -/// `SMSG_MOUNTSPECIAL_ANIM` — a nearby rider's flourish: rear their mount (MountSpecial 94 on the -/// mount child; the hop happens in `creature_anim::flourish_to_anim`). -/// -/// Our OWN guid is dropped: we played it locally at send time, and whether the sender gets the -/// SMSG echoed back is a server-config detail (LIVE-VERIFIED 2026-07-17, double-flourish probe: -/// vmangos's `SendMovementMessageToSet(.., false)` only cheat-logs on the flag — the -/// non-broadcaster delivery hardcodes self=true, so our deployment echoes; the optional per-player -/// broadcaster honors it and would not). Self-suppression on receive is correct under both configs. -pub(super) fn mount_special( - guid: u64, - self_guid: &SelfGuid, - index: &GuidIndex, - out: &mut MessageWriter, -) { - if self_guid.0 != Some(guid) { - if let Some(&e) = index.0.get(&guid) { - out.write(MountFlourish { unit: e }); - } - } -} diff --git a/crates/benilla-app/src/net/apply/names.rs b/crates/benilla-app/src/net/apply/names.rs deleted file mode 100644 index a3f76b56..00000000 --- a/crates/benilla-app/src/net/apply/names.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Name-query answer arm bodies for [`super::apply_net_updates`]'s dispatch match — the three -//! `*_QUERY_RESPONSE` packets that fill the ask-once [`NameCache`] (`NameCache::resolve` is what -//! asked for them). Each `pub(super)` fn here is exactly one arm's body; the match at the call site -//! stays the dispatcher, one call per arm. - -use bevy::prelude::debug; - -use crate::names::{CreatureRecord, NameCache}; - -/// `SMSG_NAME_QUERY_RESPONSE` — a player's name, plus the race/gender/class that ride the same -/// answer. Those three now have a consumer: the `$`-macro expander's subject fallback for a player -/// who isn't streamed ([`crate::npc_text::subject_for_guid`]). -pub(super) fn player_name( - guid: u64, - name: String, - race: u32, - class: u32, - gender: u32, - names: &mut NameCache, -) { - names.insert_player(guid, name, Some((race as u8, class as u8, gender as u8))); -} - -/// `SMSG_PET_NAME_QUERY_RESPONSE` — keyed by pet *number*, not by template entry. -pub(super) fn pet_name(pet_number: u32, name: String, names: &mut NameCache) { - names.insert_pet(pet_number, name); -} - -/// `SMSG_INVALIDATE_PLAYER` — drop this guid so the next resolve re-asks (decision 1689). -/// -/// The name cache has **no TTL** (wow-re `dbcache.md`: eviction is explicit only), so this is the -/// one thing that unsticks a player's name. It matters more to benilla than it did before the -/// cache persisted: in memory a stale name lasted a session, on disk it lasts until something -/// evicts it. -pub(super) fn invalidate_player(guid: u64, names: &mut NameCache) { - debug!("net: invalidating cached name for player {guid:#x}"); - names.invalidate_player(guid); -} - -/// `SMSG_CREATURE_QUERY_RESPONSE` — the template's name plus the hover line's fields. A `None` name -/// is the server's "no such entry", cached as such so the ask never repeats; the remaining fields -/// then carry their miss defaults. -pub(super) fn creature_name( - entry: u32, - name: Option, - subname: Option, - creature_type: Option, - pet_family: u32, - rank: u32, - type_flags: u32, - civilian: bool, - racial_leader: bool, - display_id: u32, - names: &mut NameCache, -) { - names.insert_creature( - entry, - name.map(|n| CreatureRecord { - name: n, - subname, - creature_type: creature_type.unwrap_or(0), - pet_family, - rank, - type_flags, - civilian, - racial_leader, - display_id, - }), - ); -} diff --git a/crates/benilla-app/src/net/apply/params.rs b/crates/benilla-app/src/net/apply/params.rs deleted file mode 100644 index daecefe9..00000000 --- a/crates/benilla-app/src/net/apply/params.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! **The drain's parameters, by name** (decision 2279, 2265 §A1a). -//! -//! [`super::apply_net_updates`] takes every resource the 273 arms write, and Bevy caps a system -//! at sixteen parameters (`bevy_ecs` `system_param.rs`, `all_tuples!(…, 0, 16)`). From day one -//! (0006) the escape was tuples nested up to six deep, addressed by coordinate — `audio.15 .6`, -//! `ui_actions.1 .5` — with fifty comments across the tree explaining that a thing rides where it -//! rides "because this is where the ceiling left room". The `SystemParam` derive emits a named -//! struct with no field cap, so each family below is a struct: the body destructures it by field -//! name, every use site reads as what it is, and a new resource joins the family it belongs to -//! rather than the tuple with a slot free. -//! -//! **Grouping is by what the arms do with a thing**, not by what it is: a session-lifecycle edge, -//! a window's parked state, the action bar's queues, an animation-bearing message, a read-only -//! catalog. Two families the tuples had mis-homed for want of room moved to where they belong -//! (the catalogs, and the queries); one parameter the tuples carried nobody read -//! (`PingShared` — the drain stopped clearing it with B346) is gone. Nothing else changed: the -//! same set of resources, the same access, one system. -//! -//! Splitting the drain itself — each family owning a reader system of its own — is 2265 §A1(b), -//! and starts from these names. - -use bevy::ecs::system::SystemParam; -use bevy::prelude::*; - -use super::super::{ - AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage, - LoggedOutMessage, ObjectStore, PendingTransfer, PetDismissSoundMessage, PetTalkMessage, - RemoteMotion, SelfPlayer, ServerSoundMessage, ServerTime, ServerWallClock, TeleportMessage, - UnitMoveModes, WorldportMessage, -}; -use benilla_world::weather::WeatherMessage; - -/// The clocks: the server's two, and our real one. -#[derive(SystemParam)] -pub(crate) struct Clocks<'w> { - /// The in-game day/night clock the lighting reads (`SMSG_LOGIN_SETTIMESPEED`). - pub server_time: ResMut<'w, ServerTime>, - /// The wall clock the absolute descriptor stamps are dated in (decision 1150). A different - /// quantity from the one above, from the same handler file. - pub wall_clock: ResMut<'w, ServerWallClock>, - /// `Time`, NOT the default virtual one, and every reader here is the reason: a span - /// the SERVER sends us (an aura's remaining duration, the corpse-reclaim delay, a relayed - /// mover's fire-time) counts down in real seconds, so it must be stamped and read on a real - /// clock. `Time` clamps at 250 ms per frame (`max_delta`), which is right for - /// simulation — a 2 s hitch must not teleport animations — and wrong here: every long frame - /// would quietly ADD that much to every buff timer, so an aura would vanish with seconds - /// still showing on its clock. Measured on a hitchy run: the virtual clock lost 20 s against - /// real in 33 s (decision 0846). The relayed-move replay (0601/0615) reads the same clock in - /// `drain_pending_moves`/`extrapolate_remote_units`. - pub real: Res<'w, Time>, -} - -/// Every entity query the drain holds — what it reads or writes on streamed objects. -#[derive(SystemParam)] -pub(crate) struct ObjectQueries<'w, 's> { - pub transforms: Query<'w, 's, &'static mut Transform>, - pub stores: Query<'w, 's, &'static mut ObjectStore>, - /// A relayed player's dead-reckon. - pub remote: Query<'w, 's, &'static mut RemoteMotion>, - /// Any unit's server-granted movement modes (decision 1780). - pub modes: Query<'w, 's, &'static mut UnitMoveModes>, - /// The deck a unit is standing on and its pose there (decision 1936). - pub riders: Query<'w, 's, &'static mut crate::transport::TransportRider>, - /// READ-only: every write to a mover's speed set goes through the drain's - /// `objects::SpeedStage` and lands in one flush at the end (decision 1478). - pub speeds: Query<'w, 's, &'static super::super::UnitSpeeds>, - /// The armed-transport lens for the worldport's spare predicate (decision 0455: a boat whose - /// path touches the destination map survives the purge). - pub transports: Query<'w, 's, &'static crate::transport::Transport>, - /// The cast-state read the spell-id-keyed `Casting` reap needs (decision 0107). - pub casting: Query<'w, 's, &'static crate::creature_anim::Casting>, - /// Are *we* already swinging? The ref's `[player+0xc48]`, mirrored by the server-echoed - /// [`crate::creature_anim::Engaged`] — read by the GO handler's deferred auto-attack start - /// (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with nothing else in the drain. - pub engaged_self: Query<'w, 's, (), (With, With)>, - /// The per-field descriptor edges the merge reports (decision 2297) — the reference's - /// `CMirrorHandler` notify pass, emitted from the one write that holds both sides. - pub field_changes: MessageWriter<'w, super::super::FieldChanged>, -} - -/// The session lifecycle: the glue-screen edges, the player's own teleports and mover -/// handshakes, and the entry/transfer state those edges write (decision 0193). -#[derive(SystemParam)] -pub(crate) struct Session<'w> { - pub teleports: MessageWriter<'w, TeleportMessage>, - pub worldports: MessageWriter<'w, WorldportMessage>, - /// The two park publications: the realm list and the character roster are the same thing - /// one park apart, each published so the app's policy can answer it. - pub char_lists: MessageWriter<'w, CharListMessage>, - pub realm_lists: MessageWriter<'w, crate::net::RealmListMessage>, - /// The pick park's two verdicts: what a create/delete came back with, and the refusal of the - /// pick itself. - pub char_actions: MessageWriter<'w, CharActionResultMessage>, - pub char_login_failures: MessageWriter<'w, super::super::CharacterLoginFailedMessage>, - /// The world-entry pair, one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that - /// the entry's own UI load reads before the first addon's file-scope code asks - /// `GetNumAddOns()` (2175). - pub entered_world: MessageWriter<'w, EnteredWorldMessage>, - pub addon_reply: ResMut<'w, crate::net::AddonInfoReply>, - pub logged_out: MessageWriter<'w, LoggedOutMessage>, - pub speed_changes: MessageWriter<'w, super::super::SpeedChangeMessage>, - /// The two server-authored mover edges the controller both *applies* and *answers*: a - /// granted mode (decisions 0308, 0866) and a knockback launch (decision 1702) are the same - /// handshake, an edge the server may not act on until our own live pose comes back. - pub move_modes: MessageWriter<'w, super::super::MoveModeMessage>, - pub knockbacks: MessageWriter<'w, super::super::KnockBackMessage>, - /// The login screen's dialog + reconnect-policy feed (decision 0539). - pub login_stages: MessageWriter<'w, super::super::LoginStageMessage>, - /// The login queue's position feed (decision 1681). - pub login_queued: MessageWriter<'w, super::super::LoginQueuedMessage>, - pub login_failures: MessageWriter<'w, super::super::LoginFailedMessage>, - pub disconnects: MessageWriter<'w, super::super::DisconnectedMessage>, - /// The server's own answer to a GM dot-command, readable rather than only logged — the probe - /// shield's confirmation channel (decision 0677). - pub server_said: MessageWriter<'w, super::super::ServerSaidMessage>, - /// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with no - /// handshake — the controller applies it in `player::wire_in`. - pub self_moves: MessageWriter<'w, super::super::SelfMoveMessage>, - /// The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or - /// revoked. Forwarded verbatim — only the controller knows the pose it would have to park. - pub client_control: MessageWriter<'w, super::super::ClientControlMessage>, - /// A cinematic to play (`SMSG_TRIGGER_CINEMATIC`) — `crate::cinematic` owns both the playback - /// and the ack that has to answer it (decision 0196). - pub cinematics: MessageWriter<'w, super::super::CinematicTriggeredMessage>, - /// The far-teleport latch (decision 0455): `SMSG_TRANSFER_PENDING`'s transport block routes - /// `NEW_WORLD`'s coordinates; cleared on disconnect. - pub transfer: ResMut<'w, PendingTransfer>, -} - -/// The windows' parked state: the ask-once caches and every `*Open`/`*State`/`*Session` a -/// `feed_*` pushes into the VM later (decision 0081). Where 195 of the 273 arms land. -#[derive(SystemParam)] -pub(crate) struct WindowStores<'w> { - pub names: ResMut<'w, crate::names::NameCache>, - pub items: ResMut<'w, crate::items::Items>, - /// The client-local loot-target latch (the kneel's self trigger, decision 0515). The loot - /// window's handlers own it since 2319 (`ui_loot::net`); it stays here for the one arm that - /// still arms it — `SMSG_SPELL_GO` on a chest (decision 1477) — and leaves with the spells. - pub loot_latch: ResMut<'w, crate::ui_loot::LootLatch>, - pub chat_log: ResMut<'w, crate::ui_chat::ChatLog>, - pub quest: ResMut<'w, crate::ui_quest::QuestGiver>, - pub go_templates: ResMut<'w, crate::go_templates::GameObjectTemplates>, - pub home_bind: ResMut<'w, crate::net::HomeBind>, - pub proficiencies: ResMut<'w, crate::net::Proficiencies>, - pub dropped: ResMut<'w, crate::net::DroppedOpcodes>, - /// The death arc's wire-fed store (decision 0308): reclaim expiry, corpse location, resurrect - /// offer, the spirit-healer confirm. - pub death_net: ResMut<'w, crate::death::DeathNet>, - /// The party/raid roster mirror + its composed system lines (decision 0434). - pub group: ResMut<'w, crate::ui_party::GroupState>, - /// The world-state table the NPC-text `$w` tokens read. - pub world_states: ResMut<'w, crate::world_state::WorldStates>, - /// Read-only here since 2312 — the social handlers own it (`ui_social::net`); the chat arm - /// still reads the ignore list. - pub social: Res<'w, crate::ui_social::SocialState>, - /// The pending logout/quit (decision 0674): the server's response and cancel-ack land here, - /// and `crate::ui_logout` turns them into the countdown dialog. - pub logout: ResMut<'w, crate::ui_logout::LogoutState>, - /// The pet action bar's server-authoritative state + its own cooldown store (decision 0982), - /// replaced wholesale on every `SMSG_PET_SPELLS`. - pub pet_bar: ResMut<'w, crate::ui_pet::PetBar>, - /// The by-key red error queue the pet bar's refused-order feedback rides (the `DisplayError` - /// route, resolved through the VM's own GlobalStrings by `ui_action::feed_actions`). - pub ui_error_keys: ResMut<'w, crate::ui_action::UiErrorKeys>, - pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>, - pub tutorials: ResMut<'w, crate::tutorial::Tutorials>, -} - -/// The action bar's family: the cast/cooldown state and every error queue the red line drains -/// (decision 0137), plus the item-lock bookkeeping the inventory-failure arm also drives -/// (decision 0216 §4 / 0218 §3 — the drain has no `UiScript` to fire `ITEM_LOCK_CHANGED` -/// through, so the transitioned slots queue in `LockTransitions` for the container feed). -#[derive(SystemParam)] -pub(crate) struct ActionStores<'w> { - pub player_actions: ResMut<'w, crate::ui_action::PlayerActions>, - /// The cast + mount error queues, both drained into the red error line by - /// `ui_action::feed_actions`. - pub cast_errors: ResMut<'w, crate::ui_action::CastErrors>, - pub mount_errors: ResMut<'w, crate::ui_action::MountErrors>, - /// The `modalNextSpell` chain's outbox (1597) — `cast_result` fills it, the ui_action drain - /// sends it through the one cast path. - pub chain_casts: ResMut<'w, crate::ui_action::ChainCasts>, - /// The taming-refusal queue (decision 2039) — `SMSG_PET_TAME_FAILURE`'s reason byte. Its own - /// queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings lookups, and - /// the inner one is only reachable at the drain (see the resource's doc). - pub pet_tame_failures: ResMut<'w, crate::ui_action::PetTameFailures>, - /// Spells learned mid-session, awaiting `LEARNED_SPELL_IN_TAB` (2252). A queue for the same - /// reason the reference sorts before it fires: the tab index is only correct against the - /// rebuilt tab list, which is the spellbook feed's, not this apply's. - pub learned_in_tab: ResMut<'w, crate::ui_spellbook::LearnedInTab>, - pub cast_bar: ResMut<'w, crate::ui_cast::CastBarFeed>, - pub pending_cast: ResMut<'w, crate::ui_cast::PendingCast>, - /// The player's cooldown store (decision 0137 phase 4); the pet's is in its bar, and - /// `addressed_store` picks between them. - pub cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>, - /// The live auto-repeat state the bar's flash rides (decision 0137 phase 4). - pub auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>, - /// Our own running channel (the IsCurrentAction channel leg, decision 0137 phase 4). - pub active_channel: ResMut<'w, crate::ui_cast::ActiveChannel>, - /// Pre-formatted UIErrorsFrame lines — text the wire already resolved, so there is no - /// GlobalStrings key to look up: the death durability notice, `SMSG_NOTIFICATION`, and - /// `SMSG_AREA_TRIGGER_MESSAGE`. Drained by `ui_action::feed_actions` beside `UiErrorKeys`. - pub ui_error_texts: ResMut<'w, crate::ui_action::UiErrorTexts>, - /// The queued on-next-swing strike (the melee-slot half of the cast tracking) — the wire - /// resolves it here: GO fires it, a failing result/interrupt kills it. - pub queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>, - /// The aura feed's duration side-table (decisions 0255/0257): the self-only - /// `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped on - /// [`Clocks::real`] for the `ui_aura` slot-join. - pub aura_durations: ResMut<'w, crate::ui_aura::AuraDurations>, - /// The two talent spell-modifier tables (`crate::spell_mods`) — one wire packet is one - /// absolute cell, and this is its only writer. - pub spell_mods: ResMut<'w, crate::spell_mods::SpellModifiers>, -} - -/// Every animation-bearing or audible message the drain emits, in packet order, plus the -/// PlayAnimation call-order counter they stamp (decisions 0107, 0137 phase 2, 0280). -#[derive(SystemParam)] -pub(crate) struct AnimWriters<'w> { - pub server_sounds: MessageWriter<'w, ServerSoundMessage>, - pub weather: MessageWriter<'w, WeatherMessage>, - pub emotes: MessageWriter<'w, EmoteMessage>, - pub swings: MessageWriter<'w, crate::creature_anim::SwingMessage>, - pub cast_events: MessageWriter<'w, crate::creature_anim::CastEvent>, - pub spell_go_targets: MessageWriter<'w, crate::creature_anim::SpellGoTargets>, - /// The floating combat-text feed (decision 0137 phase 2). - pub combat_text: MessageWriter<'w, crate::combat_text::CombatTextSpawn>, - pub swing_impacts: MessageWriter<'w, crate::creature_anim::SwingImpact>, - pub swing_flushes: MessageWriter<'w, crate::creature_anim::SwingFlush>, - pub go_lid_open: MessageWriter<'w, crate::go_anim::GoLidOpen>, - /// The aggro/alert vocal flare + the pushed-kit play (decision 0280). - pub ai_reactions: MessageWriter<'w, AiReactionMessage>, - pub kit_pushes: MessageWriter<'w, crate::creature_anim::KitPush>, - /// The remote landing predictor's report (decision 0415): a relayed FALL_LAND fires the grunt - /// + dust puff for an observed mover, the way the self controller does for us. - pub hard_landings: MessageWriter<'w, crate::creature_anim::HardLanding>, - /// An observed rider's flourish (`SMSG_MOUNTSPECIAL_ANIM`, decision 0441 P2) — the unit → - /// mount-child hop happens in `creature_anim::flourish_to_anim`. - pub mount_flourishes: MessageWriter<'w, crate::creature_anim::MountFlourish>, - /// The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the - /// COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms' - /// self-facing twins of the floating-text spawn. - pub unit_combat: MessageWriter<'w, crate::ui_unit::UnitCombatFeedback>, - pub combat_text_events: MessageWriter<'w, crate::ui_unit::CombatTextEvent>, - /// The sheath setter's queue, which the ATTACKERSTATEUPDATE arm's melee auto-draw writes. - pub sheath_requests: MessageWriter<'w, crate::creature_anim::SheathRequest>, - /// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite splash, - /// decision 1086), the step-8 sibling of `go_lid_open`. - pub go_custom_anim: MessageWriter<'w, crate::go_anim::GoCustomAnim>, - /// The pet's bark (`SMSG_PET_ACTION_SOUND`, decision 2039) — `ai_reactions`' sibling: both - /// are pure audio that resolves a guid and hands the sound layer a state for the SAME - /// `0x623a40` dispatcher. - pub pet_talk: MessageWriter<'w, PetTalkMessage>, - pub pet_dismiss: MessageWriter<'w, PetDismissSoundMessage>, - /// The swing-refusal seam's two edges (`crate::swing_refusal`, decision 2037): the four - /// `SMSG_ATTACKSWING_*` refusals, and our own landed swing, which clears their latch. ONE - /// writer for both so the drain hands them on in packet order. - pub swing_refusals: MessageWriter<'w, crate::swing_refusal::SwingRefusalEdge>, - /// The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every animation-bearing - /// message this drain emits stamps `next()`, in packet order. - pub play_seq: ResMut<'w, crate::creature_anim::PlaySeq>, -} - -/// The read-only reference tables the arms resolve against. Each DBC-backed one is `Option`: -/// absent if its table failed to load, and every reader degrades rather than drops. -#[derive(SystemParam)] -pub(crate) struct Catalogs<'w> { - /// The FactionTemplate catalog the combat log's friend/foe split reads (B297). Absent, an - /// unresolved unit lands on the friendly side of the classifier rather than losing its lines. - pub factions: Option>, - /// The Spell.dbc catalog the cooldown/cast wire laws read. - pub spells: Option>, - /// The combat-feedback CVars — the eight display ranges, `CombatLogPeriodicSpells`, and the - /// three floating-text gates. - pub combat_cvars: crate::ui_chat::combat::CombatFeedbackCvars<'w>, - /// The EnvironmentalDamage.dbc 6-slot table (damage type → SpellVisualKit) the - /// `SMSG_ENVIRONMENTALDAMAGELOG` arm reads — the fall-landing dust puff. - pub env_damage: Option>, - /// The shared AreaTable catalog — the exploration arm's area-id → name resolve (decision - /// 0828) — and the race-keyed discovery-jingle catalog (decision 0829). - pub area_table: Option>, - pub exploration_sounds: Option>, -} diff --git a/crates/benilla-app/src/net/apply/seam_tests.rs b/crates/benilla-app/src/net/apply/seam_tests.rs index b0fde6f1..bb34fbef 100644 --- a/crates/benilla-app/src/net/apply/seam_tests.rs +++ b/crates/benilla-app/src/net/apply/seam_tests.rs @@ -1,9 +1,9 @@ -//! **A run boundary lands exactly what one run lands** (decision 2306). The drain dispatches in -//! wire order across the migration's seam, so a claimed packet arriving between two of the -//! match's own ends the match's run — a command flush — mid-frame. The match keeps three -//! intra-drain accumulators (`pending`, `StagedModes`, `SpeedStage`) precisely because its spawns -//! are deferred; each is *staged, else live*, so what a boundary flushes the next run reads off -//! the component. This drives the real drain on the built client both ways and compares. +//! **Every packet's handler sees what the packets before it did** — the property 2306 measured +//! across the migration's seam (a run boundary was a command flush mid-frame) and the one the +//! whole dispatch rests on now that every packet is its own handler with its commands applied +//! before the next (decision 2327). The first test drives the real drain on the built client +//! over one frame two ways and compares; the rest are 1478's speed-staging laws, which used to +//! be unit tests over a staging map and are now the same laws over consecutive packets. use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH}; use benilla_protocol::messages::{ObjectType, SpeedKind, SplineMode}; @@ -44,7 +44,7 @@ fn create() -> SessionEvent { } /// What the object layer does to a unit born this frame: a values delta, a speed change, a -/// granted mode, a second delta — one of each accumulator, and `pending` twice. +/// granted mode, a second delta. fn updates() -> Vec { vec![ SessionEvent::ObjectValues { @@ -68,7 +68,7 @@ fn updates() -> Vec { ] } -/// A kind the mailbox has claimed — any table kind would do; this one needs no open window. +/// A packet of another subsystem's — any would do; the mailbox's needs no open window. fn claimed() -> SessionEvent { SessionEvent::NextMailTime { seconds: -86400.0 } } @@ -100,14 +100,12 @@ fn drained(events: Vec) -> (Vec<(u16, u32)>, MoveSpeeds, UnitMoveM } #[test] -fn a_claimed_packet_between_a_create_and_its_updates_changes_nothing() { - // One run: the create and everything after it in one batch of the match, the claimed packet - // last — what the two-halves dispatch 2305 landed would have made of either frame. +fn a_foreign_packet_between_a_create_and_its_updates_changes_nothing() { + // The create and everything after it back to back, the mailbox's packet last. let mut one_run = vec![create()]; one_run.extend(updates()); one_run.push(claimed()); - // The seam at its worst: a claimed packet after the create and between every update, so - // each accumulator is flushed and re-seeded from the live component. + // A foreign packet after the create and between every update. let mut split = vec![create()]; for ev in updates() { split.push(claimed()); @@ -130,3 +128,139 @@ fn a_claimed_packet_between_a_create_and_its_updates_changes_nothing() { assert_eq!(split_speeds.walk, speeds.walk); assert_eq!(split_modes, modes); } + +/// A create block with this run speed, or none. +fn create_with_speeds(speeds: Option) -> SessionEvent { + match create() { + SessionEvent::ObjectCreate { + guid, + kind, + display_id, + position, + orientation, + scale, + mover, + transport_progress, + transport, + spline, + fields, + .. + } => SessionEvent::ObjectCreate { + guid, + kind, + display_id, + position, + orientation, + scale, + speeds, + mover, + transport_progress, + transport, + spline, + fields, + }, + _ => unreachable!(), + } +} + +fn force_run(speed: f32) -> SessionEvent { + SessionEvent::ForceSpeedChange { + guid: GUID, + kind: SpeedKind::Run, + counter: 1, + speed, + } +} + +/// One frame of the real drain; the unit's live speed set, if any. +fn speeds_after(events: Vec) -> Option { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetEvents(rx)); + for ev in events { + tx.send(ev).unwrap(); + } + let world = app.world_mut(); + super::apply_net_updates(world); + let e = *world.resource::().0.get(&GUID)?; + world.get::(e).map(|s| s.0) +} + +/// **B213, pinned at the drain.** vmangos's `HandleMoveWorldportAckOpcode` sends the self create +/// block (`Map::Add` → `SendInitSelf`, still carrying the mount's 11.2 yd/s) and then, three +/// statements later, strips the mount because the destination map forbids one — so +/// `SMSG_FORCE_RUN_SPEED_CHANGE` 7.0 rides the same tick. In wire order the change wins; before +/// 1478 the create's *deferred* `UnitSpeeds` insert landed last and `.tele` into BWL left the +/// avatar running at mount speed on foot. Under a handler per packet the create's insert has +/// landed before the change's handler runs, and the change edits the live component. +#[test] +fn a_force_change_beats_a_create_from_the_same_frame() { + let mounted = MoveSpeeds { + run: 11.2, + ..MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + } + }; + let s = speeds_after(vec![create_with_speeds(Some(mounted)), force_run(7.0)]) + .expect("the create landed a speed set"); + assert_eq!(s.run, 7.0, "the dismount is the newer packet"); + assert_eq!( + s.walk, 2.5, + "the other slots still come from the create block" + ); +} + +/// The reverse order is just as much the wire's order: a create block is the server's newest +/// snapshot of the mover, so one arriving *after* a change replaces it whole. +#[test] +fn a_create_after_a_change_replaces_it() { + let on_foot = MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + }; + let mounted = MoveSpeeds { + run: 11.2, + ..on_foot + }; + let s = speeds_after(vec![ + create_with_speeds(Some(on_foot)), + force_run(9.0), + create_with_speeds(Some(mounted)), + ]) + .expect("the create landed a speed set"); + assert_eq!(s.run, 11.2); +} + +/// A one-slot change on a mover that already exists keeps every other slot it isn't addressing. +#[test] +fn a_change_on_a_live_mover_keeps_its_other_slots() { + let on_foot = MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + }; + let s = speeds_after(vec![create_with_speeds(Some(on_foot)), force_run(11.2)]) + .expect("the create landed a speed set"); + assert_eq!(s.run, 11.2); + assert_eq!(s.run_back, on_foot.run_back); + assert_eq!(s.turn_rate, on_foot.turn_rate); +} + +/// A change with nowhere to land — a mover whose create carried no movement block — invents no +/// speed set: nothing is inserted for a set that never existed. +#[test] +fn a_change_with_no_speed_set_invents_none() { + assert!(speeds_after(vec![create_with_speeds(None), force_run(7.0)]).is_none()); +} diff --git a/crates/benilla-app/src/net/apply/world.rs b/crates/benilla-app/src/net/apply/world.rs deleted file mode 100644 index 846e8806..00000000 --- a/crates/benilla-app/src/net/apply/world.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! World-feed arm bodies for [`super::apply_net_updates`]'s dispatch match — what the server -//! pushes about the *world* rather than about an entity's own state: the three ambient audio -//! triggers, the weather change, and the world-state table the UI's `$w` tokens read. Each -//! `pub(super)` fn here is exactly one arm's body; the match at the call site stays the dispatcher, -//! one call per arm. - -use bevy::prelude::*; - -use crate::world_state::WorldStates; - -use super::super::{GuidIndex, ServerSoundKind, ServerSoundMessage}; -use benilla_world::weather::WeatherMessage; - -/// `SMSG_PLAY_SOUND` — a 2D (non-positional) one-shot. -pub(super) fn play_sound(sound_id: u32, out: &mut MessageWriter) { - out.write(ServerSoundMessage { - kind: ServerSoundKind::Sound2d, - sound_id, - source: None, - }); -} - -/// `SMSG_PLAY_MUSIC` — the zone/event music track. -pub(super) fn play_music(music_id: u32, out: &mut MessageWriter) { - out.write(ServerSoundMessage { - kind: ServerSoundKind::Music, - sound_id: music_id, - source: None, - }); -} - -/// `SMSG_PLAY_OBJECT_SOUND` — a one-shot anchored to a streamed object (silent while its guid -/// isn't streamed in: the mixer has nowhere to place it). -pub(super) fn play_object_sound( - sound_id: u32, - guid: u64, - index: &GuidIndex, - out: &mut MessageWriter, -) { - out.write(ServerSoundMessage { - kind: ServerSoundKind::ObjectSound, - sound_id, - source: index.0.get(&guid).copied(), - }); -} - -/// `SMSG_WEATHER` — the zone's weather change (`instant` on zone entry, a ramp otherwise). -pub(super) fn weather( - weather_type: u32, - grade: f32, - sound_id: u32, - instant: bool, - out: &mut MessageWriter, -) { - out.write(WeatherMessage { - weather_type, - grade, - sound_id, - instant, - }); -} - -/// `SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE` — both wires funnel into the one setter, -/// as the reference's own handler does. -/// -/// An **init clears the table first** and records its `(map, zone)` as the world-state UI's display -/// filter — the reference's `0x4c5650`, which runs before the pair loop (wow-re -/// `system/ui/scratch/worldstate-ui-law.md`; rationale on [`crate::world_state`]). The order below -/// is that handler's: clear + scope, then the packet's pairs. -pub(super) fn world_states( - scope: Option<(u32, u32)>, - states: Vec<(u32, u32)>, - world_states: &mut WorldStates, -) { - if let Some((map, zone)) = scope { - debug!( - "world states: map {map} zone {zone}, {} entries", - states.len() - ); - world_states.init_scope(map, zone); - } - world_states.write(&states); -} diff --git a/crates/benilla-app/src/net/handlers.rs b/crates/benilla-app/src/net/handlers.rs index d1a1a132..0c89c6bf 100644 --- a/crates/benilla-app/src/net/handlers.rs +++ b/crates/benilla-app/src/net/handlers.rs @@ -16,17 +16,9 @@ //! that arrived in one drain would swap. The table keeps the one property 2265 said must survive //! any split: one frame, packet order, before anything else runs. //! -//! **The migration.** The dispatch `match` in `apply.rs` still owns every kind no subsystem has -//! claimed, and one drain still runs in **wire order across the seam** (decision 2306): the frame's -//! events are walked once, a run of consecutive unclaimed events goes through the match as one -//! batch, and a claimed event runs its handlers in place — after the run before it has landed, -//! before the run after it starts. A run boundary is a command flush, which is safe because every -//! intra-drain accumulator the match keeps (`pending`, `StagedModes`, `SpeedStage`) is *staged, -//! else live*: what a flush lands, the next run reads off the component. A kind is owned by -//! exactly one of the two, checked on the built app by `every_session_event_kind_has_one_owner`; -//! the one exception is [`BROADCAST`], a session-end the match still handles and peeled windows -//! also listen to, which reaches both — the match first. When the last family leaves the match, -//! the match, the runs and the broadcast list go with it. +//! **The table is the whole dispatch** (since 2327). Every kind has at least one handler, read +//! off the built client by `every_session_event_kind_has_one_owner`; the drain's own match, the +//! runs it took across the seam and the broadcast list (2305, 2306, 2326) are gone. use std::collections::HashMap; @@ -43,17 +35,7 @@ pub(crate) struct NetHandlers { by_kind: HashMap>, } -/// The kinds the dispatch match still owns **and** peeled subsystems listen to — a session end, -/// which every window that dies with the socket answers for itself. It closes the match's run and -/// then runs its listeners, in place. Empty once the session family itself is peeled. -pub(crate) const BROADCAST: &[SessionEventKind] = &[SessionEventKind::Disconnected]; - impl NetHandlers { - /// Is there at least one handler for this kind? - pub(crate) fn handles(&self, kind: SessionEventKind) -> bool { - self.by_kind.contains_key(&kind) - } - /// Every kind with a handler, with the handlers' names in registration order — the owner /// test's view of the table. #[cfg(test)] @@ -136,34 +118,13 @@ impl NetHandlerApp for App { } } -/// One drain's dispatch, **in wire order**: a run of consecutive events the table does not own -/// goes through `unclaimed` — the dispatch match — as one batch; an owned event first lands the -/// run before it, then runs its handlers. A [`BROADCAST`] kind is the last event of the match's -/// run *and* a table event. `unclaimed` is never called with nothing. -pub(crate) fn dispatch( - world: &mut World, - events: Vec, - mut unclaimed: impl FnMut(&mut World, Vec), -) { +/// One drain's dispatch, **in wire order**: every event runs its handlers in place, each +/// handler's commands applied before the next event's. +pub(crate) fn dispatch(world: &mut World, events: Vec) { let handlers = world.remove_resource::().unwrap_or_default(); - let mut run = Vec::new(); for ev in events { - let kind = SessionEventKind::from(&ev); - if !handlers.handles(kind) { - run.push(ev); - continue; - } - if BROADCAST.contains(&kind) { - run.push(ev.clone()); - } - if !run.is_empty() { - unclaimed(world, std::mem::take(&mut run)); - } handlers.run(world, ev); } - if !run.is_empty() { - unclaimed(world, run); - } world.insert_resource(handlers); } @@ -191,13 +152,6 @@ mod tests { log.0.push("logged_out_too".into()); } - fn on_disconnected(In(ev): In, mut log: ResMut) { - let SessionEvent::Disconnected { reason, .. } = ev else { - panic!("the table routes by kind"); - }; - log.0.push(format!("disconnected:{reason}")); - } - fn app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins).init_resource::(); @@ -212,18 +166,12 @@ mod tests { } fn run(app: &mut App, events: Vec) -> Vec { - dispatch(app.world_mut(), events, |world, unclaimed| { - let mut log = world.resource_mut::(); - for ev in unclaimed { - log.0 - .push(format!("match:{:?}", SessionEventKind::from(&ev))); - } - }); + dispatch(app.world_mut(), events); std::mem::take(&mut app.world_mut().resource_mut::().0) } #[test] - fn the_match_and_the_table_interleave_in_wire_order() { + fn events_run_in_wire_order_and_an_unhandled_kind_is_skipped() { let mut app = app(); app.net_handler(SessionEventKind::LoginQueued, on_queued) .net_handler(SessionEventKind::LoggedOut, on_logged_out); @@ -235,51 +183,12 @@ mod tests { vec![ queued(1), stage(), - stage(), SessionEvent::LoggedOut, queued(2), stage(), ], ); - assert_eq!( - log, - vec![ - "queued:1", - "match:LoginStage", - "match:LoginStage", - "logged_out", - "queued:2", - "match:LoginStage" - ] - ); - } - - /// Consecutive unclaimed events are one batch — one run of the match, one command flush — - /// and an empty frame never runs the match at all. - #[test] - fn a_run_of_unclaimed_events_is_one_batch_and_nothing_is_no_batch() { - let mut app = app(); - app.net_handler(SessionEventKind::LoggedOut, on_logged_out); - let stage = || SessionEvent::LoginStage { - stage: benilla_protocol::LoginStage::Connecting, - }; - let mut batches = Vec::new(); - dispatch( - app.world_mut(), - vec![stage(), stage(), SessionEvent::LoggedOut, stage()], - |_, batch| batches.push(batch.len()), - ); - assert_eq!(batches, vec![2, 1]); - batches.clear(); - dispatch(app.world_mut(), vec![], |_, batch| { - batches.push(batch.len()) - }); - dispatch( - app.world_mut(), - vec![SessionEvent::LoggedOut], - |_, batch| batches.push(batch.len()), - ); - assert!(batches.is_empty()); + assert_eq!(log, vec!["queued:1", "logged_out", "queued:2"]); } #[test] @@ -295,86 +204,24 @@ mod tests { ); } - #[test] - fn a_broadcast_kind_reaches_the_match_and_the_table() { - let mut app = app(); - app.net_handler(SessionEventKind::Disconnected, on_disconnected); - let log = run( - &mut app, - vec![SessionEvent::Disconnected { - reason: "socket".into(), - end: benilla_protocol::SessionEnd::Lost, - }], - ); - assert_eq!(log, vec!["match:Disconnected", "disconnected:socket"]); - } - - #[test] - fn with_no_table_at_all_everything_goes_to_the_match() { - let mut app = app(); - let log = run(&mut app, vec![SessionEvent::LoggedOut, queued(0)]); - assert_eq!(log, vec!["match:LoggedOut", "match:LoginQueued"]); - assert!(app.world().resource::().census().is_empty()); - } - - /// **Every kind has exactly one owner** — the dispatch match in `net/apply.rs` or the table, - /// read off the built client — and the [`BROADCAST`] rows are the only kinds in both. A kind - /// neither owns is a packet the client decodes and then drops on the floor. + /// **Every kind has a handler**, read off the built client. A kind with none is a packet the + /// client decodes and then drops on the floor — the reference discards an unregistered + /// opcode in silence; this says so at test time. #[test] fn every_session_event_kind_has_one_owner() { - use std::collections::BTreeSet; - let source = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/net/apply.rs"), - ) - .expect("the drain's source"); - let stripped = regex_lite_strip_comments(&source); - let by_name: HashMap = SessionEventKind::all() - .map(|k| (format!("{k:?}"), k)) - .collect(); - let mut in_match: BTreeSet = BTreeSet::new(); - for token in stripped.split("SessionEvent::").skip(1) { - let name: String = token - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - if let Some(k) = by_name.get(&name) { - in_match.insert(*k); - } - } let mut app = crate::game_plugins::schedule_tests::headless_client(); let table = app.world_mut().resource::().census(); - let in_table: BTreeSet = table.keys().copied().collect(); - let mut problems = Vec::new(); - for kind in SessionEventKind::all() { - let m = in_match.contains(&kind); - let t = in_table.contains(&kind); - let b = BROADCAST.contains(&kind); - match (m, t, b) { - (true, true, true) | (true, false, false) | (false, true, false) => {} - (true, true, false) => problems.push(format!( - "{kind:?}: owned by the match AND handled by {:?} — a peeled kind leaves the match (or is a BROADCAST row)", - table[&kind] - )), - (true, false, true) => problems.push(format!( - "{kind:?}: a BROADCAST row nobody listens to — drop the row" - )), - (false, true, true) => problems.push(format!( - "{kind:?}: a BROADCAST row the match no longer owns — drop the row" - )), - (false, false, _) => problems.push(format!( - "{kind:?}: decoded and dropped — no arm in the match, no handler in the table" - )), - } - } + let unowned: Vec = SessionEventKind::all() + .filter(|k| !table.contains_key(k)) + .map(|k| format!("{k:?}: decoded and dropped — no handler in the table")) + .collect(); eprintln!( - "session event kinds: {} — {} in the dispatch match, {} in the handler table ({} handlers), {} broadcast", + "session event kinds: {} — {} in the handler table ({} handlers)", SessionEventKind::all().count(), - in_match.len(), - in_table.len(), + table.len(), table.values().map(Vec::len).sum::(), - BROADCAST.len() ); - assert!(problems.is_empty(), "{}", problems.join("\n")); + assert!(unowned.is_empty(), "{}", unowned.join("\n")); } /// **Every registered handler can run on the built client.** The dispatch match's parameters diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/objects.rs similarity index 77% rename from crates/benilla-app/src/net/apply/objects.rs rename to crates/benilla-app/src/net/objects.rs index 084a32d4..d93e0793 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -1,15 +1,17 @@ -//! Object-stream arm bodies for [`super::apply_net_updates`]'s dispatch match — the streamed -//! world's lifecycle (create / values-merge / destroy / stream-out), the relayed player movement, -//! and the creature path packet. Each `pub(super)` fn here is exactly one arm's body; the match at -//! the call site stays the dispatcher, one call per arm. - -use std::collections::hash_map::Entry; -use std::collections::HashMap; - +//! The bridge's own object layer (in the net handler table since 2327 — the last family out of +//! the drain's dispatch match, which went with it): the streamed world's creates, deltas, moves +//! and destroys, the movers' speeds and granted modes, the GameObject templates and anims, and +//! the item store's three kinds. Each packet is one handler over [`Scene`]; a handler's commands +//! are applied before the next packet's handler runs (decision 2306), which is why the three +//! intra-drain staging maps the match kept (0061's `pending`, 1478's `SpeedStage`, 1780's +//! `StagedModes`) are gone: a create inserts its store and speeds at spawn, and the next packet +//! reads the live component. Registered from [`super::NetPlugin`]. use benilla_assets::coords::bevy_to_wow; use benilla_protocol::{ guid, EntityKind, MonsterMoveFacing, MoveSpeeds, ObjectFields, SpeedKind, SplineMode, }; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::ecs::system::SystemParam; use bevy::prelude::*; use crate::go_templates::GameObjectTemplates; @@ -18,20 +20,20 @@ use crate::names::NameCache; use benilla_world::model_fade::DespawnFade; use benilla_world::vis_chain::VisChainOnly; -use super::super::motion::{ +use super::motion::{ create_spline, gameobject_rotation, monster_move_spline, pose_transform, resolve_facing, trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, ROOT_APPLY_WIPE, }; -use super::super::{ - merge_store_fields, FieldChanged, Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, - RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds, +use super::{ + merge_store_fields, FieldChanged, Guid, GuidIndex, NetCommands, NetEntity, NetHandlerApp, + ObjectStore, RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds, }; /// A GameObject plays its one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision /// 1086) — bridged to the GO animation machine ([`crate::go_anim`]), which owns the reject /// (`anim_id >= 4`), the id mapping (153..156) and the model-ownership gate. The load-bearing /// sender: the fishing bobber's bite splash (`anim_id 0`). -pub(super) fn gameobject_custom_anim( +fn gameobject_custom_anim( guid: u64, anim_id: u32, plays: &mut MessageWriter, @@ -52,7 +54,7 @@ pub(super) fn gameobject_custom_anim( /// Commands apply in the order they were queued, so an insert queued here is visible to the /// closure the destroy queues below. The arm itself, and the model-ownership gate, are /// [`crate::go_anim`]'s. -pub(super) fn gameobject_despawn_anim(guid: u64, commands: &mut Commands, index: &GuidIndex) { +fn gameobject_despawn_anim(guid: u64, commands: &mut Commands, index: &GuidIndex) { debug!("net: gameobject {guid:#x} despawn anim"); if let Some(&e) = index.0.get(&guid) { commands @@ -61,9 +63,360 @@ pub(super) fn gameobject_despawn_anim(guid: u64, commands: &mut Commands, index: } } +/// Register the object layer's handlers — called from [`super::NetPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::ObjectCreate, on_object_create) + .net_handler(K::ItemCreate, on_item) + .net_handler(K::ItemTemplate, on_item) + .net_handler(K::ItemTime, on_item) + .net_handler(K::ItemEnchantTime, on_item) + .net_handler(K::ObjectMove, on_object_move) + .net_handler(K::MoveTimeSkipped, on_move_time_skipped) + .net_handler(K::UnitMove, on_unit_move) + .net_handler(K::ObjectValues, on_object_values) + .net_handler(K::ObjectDestroyed, on_object_destroyed) + .net_handler(K::ObjectsRemoved, on_objects_removed) + .net_handler(K::MonsterMove, on_monster_move) + .net_handler(K::GameObjectInfo, on_gameobject_info) + .net_handler(K::GameObjectCustomAnim, on_gameobject_anim) + .net_handler(K::GameObjectDespawnAnim, on_gameobject_anim) + .net_handler(K::SplineMoveMode, on_spline_move_mode) + .net_handler(K::ForceSpeedChange, on_speed) + .net_handler(K::SpeedChanged, on_speed); +} + +/// The streamed world as one parameter: the entities and their components the packets write, +/// the caches they warm, the hooks other subsystems keep on the object lifecycle (the reclaim +/// latch's, the roster's), and the edges they publish. +#[derive(SystemParam)] +pub(crate) struct Scene<'w, 's> { + commands: Commands<'w, 's>, + net: Res<'w, NetCommands>, + index: ResMut<'w, GuidIndex>, + self_guid: Res<'w, SelfGuid>, + real: Res<'w, Time>, + transforms: Query<'w, 's, &'static mut Transform>, + stores: Query<'w, 's, &'static mut ObjectStore>, + remote: Query<'w, 's, &'static mut RemoteMotion>, + modes: Query<'w, 's, &'static mut UnitMoveModes>, + riders: Query<'w, 's, &'static mut crate::transport::TransportRider>, + speeds: Query<'w, 's, &'static mut UnitSpeeds>, + field_changes: MessageWriter<'w, FieldChanged>, + speed_changes: MessageWriter<'w, SpeedChangeMessage>, + self_moves: MessageWriter<'w, super::SelfMoveMessage>, + hard_landings: MessageWriter<'w, crate::creature_anim::HardLanding>, + go_custom_anims: MessageWriter<'w, crate::go_anim::GoCustomAnim>, + names: Res<'w, NameCache>, + items: ResMut<'w, Items>, + go_templates: ResMut<'w, GameObjectTemplates>, + death_net: ResMut<'w, crate::death::DeathNet>, + group: ResMut<'w, crate::ui_party::GroupState>, +} + +fn on_object_create(In(ev): In, mut sc: Scene) { + if let SessionEvent::ObjectCreate { + guid, + kind, + display_id, + position, + orientation, + scale, + speeds, + mover, + transport_progress, + transport, + spline, + fields, + } = ev + { + crate::death::net::note_corpse(guid, kind, &fields, &sc.self_guid, &mut sc.death_net); + object_create( + guid, + kind, + display_id, + position, + orientation, + scale, + speeds, + mover, + transport_progress, + transport, + spline, + fields, + &mut sc.commands, + &mut sc.index, + &mut sc.transforms, + &mut sc.stores, + &mut sc.field_changes, + &sc.names, + &sc.go_templates, + &sc.net, + ); + } +} + +/// The item store's kinds: a descriptor-only create, the template's display head +/// (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our `CMSG_ITEM_QUERY_SINGLE` — the ask-once +/// cache, decisions 0068/0072; a server miss records `None` so the entry is never re-asked), the +/// item-lifetime countdown's only feed (decision 1933) and the temporary-enchant countdown's +/// (decision 0920) — both parked on the item store, which every tooltip surface reads back. +fn on_item(In(ev): In, mut sc: Scene) { + match ev { + SessionEvent::ItemCreate { + guid, + container, + fields, + } => item_create(guid, container, fields, &mut sc.items), + SessionEvent::ItemTemplate { entry, info } => { + let info = info.map(|b| *b); + debug!("net: item template {entry} → {info:?}"); + sc.items.insert_template(entry, info); + } + SessionEvent::ItemTime { item_guid, seconds } => { + sc.items.set_item_duration(item_guid, seconds) + } + SessionEvent::ItemEnchantTime { + item_guid, + slot, + seconds, + } => sc.items.set_enchant_deadline(item_guid, slot, seconds), + _ => {} + } +} + +fn on_object_move(In(ev): In, mut sc: Scene) { + if let SessionEvent::ObjectMove { + guid, + position, + orientation, + } = ev + { + object_move( + guid, + position, + orientation, + &mut sc.commands, + &sc.index, + &mut sc.transforms, + ); + } +} + +/// **An observed mover skipped time** (decision 1935). No pose moved — only that unit's clock +/// ran on — so this touches its relay chain and nothing else, which is the whole of what the +/// reference's handler does (`0x603b40` → `0x61ab90`: `[CMovement+0xac] += lag`). A guid we do +/// not hold is dropped, faithfully: the reference resolves under `TYPEMASK_UNIT` and returns on +/// a miss. +fn on_move_time_skipped(In(ev): In, mut sc: Scene) { + if let SessionEvent::MoveTimeSkipped { guid, lag_ms } = ev { + if let Some(mut m) = sc + .index + .0 + .get(&guid) + .and_then(|&e| sc.remote.get_mut(e).ok()) + { + m.relay.skip_time(lag_ms); + } + } +} + +/// The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own replay chain +/// over this packet's wire stamp to get its client fire-time, then applies it now if due, else +/// queues it on the unit for `drain_pending_moves`. +fn on_unit_move(In(ev): In, mut sc: Scene) { + if let SessionEvent::UnitMove { + guid, + position, + orientation, + flags, + pitch, + time, + verb, + fall_time, + jump, + transport, + } = ev + { + let now_ms = sc.real.elapsed_secs_f64() * 1000.0; + unit_move( + guid, + crate::net::motion::RelayMove { + wire_ms: time, + position, + orientation, + flags, + pitch, + fall_time, + jump, + transport, + verb, + }, + now_ms, + &mut sc.commands, + &sc.index, + &sc.self_guid, + &mut sc.remote, + &mut sc.transforms, + &mut sc.hard_landings, + &mut sc.self_moves, + ); + } +} + +/// Our corpse's own `CORPSE_FIELD_FLAGS` can flip to BONES under a live guid; the reclaim latch +/// is re-asked on that edge, as the reference's `FLAGS` mirror handler `0x5d6d60` does (1729). +fn on_object_values(In(ev): In, mut sc: Scene) { + if let SessionEvent::ObjectValues { guid, fields } = ev { + crate::death::net::recheck_corpse(guid, &fields, &sc.self_guid, &mut sc.death_net); + object_values( + guid, + fields, + &mut sc.commands, + &sc.index, + &mut sc.stores, + &mut sc.field_changes, + &mut sc.items, + ); + } +} + +/// The party hook runs FIRST and on the same edge the reference takes it: the deactivate +/// virtual reads the descriptor that is about to go (decision 1640). +fn on_object_destroyed(In(ev): In, mut sc: Scene) { + if let SessionEvent::ObjectDestroyed(guid) = ev { + crate::death::net::forget_corpse(guid, &mut sc.death_net); + let store = sc.index.0.get(&guid).and_then(|e| sc.stores.get(*e).ok()); + crate::ui_party::net::member_deactivated(guid, &mut sc.group, store, &sc.net); + object_destroyed(guid, &mut sc.commands, &mut sc.index, &mut sc.items); + } +} + +/// OUT_OF_RANGE and DESTROY take the same virtual in the reference — so the snapshot + +/// `CMSG_REQUEST_PARTY_MEMBER_STATS` fire here too, which is the edge report B334 is actually +/// about: a member walking over the hill. +fn on_objects_removed(In(ev): In, mut sc: Scene) { + if let SessionEvent::ObjectsRemoved(guids) = ev { + for guid in &guids { + let store = sc.index.0.get(guid).and_then(|e| sc.stores.get(*e).ok()); + crate::ui_party::net::member_deactivated(*guid, &mut sc.group, store, &sc.net); + } + objects_removed(guids, &mut sc.commands, &mut sc.index); + } +} + +fn on_monster_move(In(ev): In, mut sc: Scene) { + if let SessionEvent::MonsterMove { + guid, + transport, + start, + spline_id, + path, + facing, + stop, + duration_ms, + flying, + run_mode, + } = ev + { + let rooted = modes_of(guid, &sc.index, &sc.modes).rooted(); + monster_move( + guid, + transport, + start, + spline_id, + path, + facing, + stop, + duration_ms, + flying, + run_mode, + rooted, + &mut sc.commands, + &sc.index, + &mut sc.transforms, + &mut sc.riders, + ); + } +} + +fn on_gameobject_info(In(ev): In, mut sc: Scene) { + if let SessionEvent::GameObjectInfo { + entry, + type_id, + display_id, + name, + data, + } = ev + { + gameobject_info( + entry, + type_id, + display_id, + name, + &data, + &mut sc.go_templates, + ); + } +} + +fn on_gameobject_anim(In(ev): In, mut sc: Scene) { + match ev { + SessionEvent::GameObjectCustomAnim { guid, anim_id } => { + gameobject_custom_anim(guid, anim_id, &mut sc.go_custom_anims) + } + SessionEvent::GameObjectDespawnAnim { guid } => { + gameobject_despawn_anim(guid, &mut sc.commands, &sc.index) + } + _ => {} + } +} + +/// The observer movement-mode family (decision 1780) — the same modes, on a body somebody else +/// is driving. No ack, so the handler ends the packet. +fn on_spline_move_mode(In(ev): In, mut sc: Scene) { + if let SessionEvent::SplineMoveMode { guid, mode, apply } = ev { + spline_move_mode( + guid, + mode, + apply, + &mut sc.commands, + &sc.index, + &mut sc.modes, + &mut sc.remote, + ); + } +} + +fn on_speed(In(ev): In, mut sc: Scene) { + match ev { + SessionEvent::ForceSpeedChange { + guid, + kind, + counter, + speed, + } => force_speed_change( + guid, + kind, + counter, + speed, + &sc.index, + &mut sc.speeds, + &sc.self_guid, + &mut sc.speed_changes, + ), + SessionEvent::SpeedChanged { guid, kind, speed } => { + speed_changed(guid, kind, speed, &sc.index, &mut sc.speeds) + } + _ => {} + } +} + /// An object entered range / was created (`SMSG_UPDATE_OBJECT` create block): spawn or refresh the -/// entity, warm the ask-once caches, and seed its descriptor store via the per-drain `pending` map. -pub(super) fn object_create( +/// entity, warm the ask-once caches, and seed its descriptor store. A handler's commands are +/// applied before the next packet's handler runs (decision 2306), so the store and the speeds +/// are components at spawn and the next packet reads them live. +fn object_create( guid: u64, kind: EntityKind, display_id: Option, @@ -80,9 +433,7 @@ pub(super) fn object_create( index: &mut GuidIndex, transforms: &mut Query<&mut Transform>, stores: &mut Query<&mut ObjectStore>, - pending: &mut HashMap, edges: &mut MessageWriter, - speed_stage: &mut SpeedStage, names: &NameCache, go_templates: &GameObjectTemplates, net_commands: &NetCommands, @@ -214,7 +565,9 @@ pub(super) fn object_create( // `SMSG_FORCE_*_SPEED_CHANGE` riding the same tick as this create has to be able to land // on top of it (decision 1478, B213). if let Some(s) = speeds { - speed_stage.seed(guid, s); + // A create is the server's newest snapshot of the mover: it replaces the set whole + // (decision 1478), and lands before the next packet's handler reads it. + commands.entity(e).insert(UnitSpeeds(s)); } if let Some(anchor) = transport_anchor { commands.entity(e).insert(anchor); @@ -245,7 +598,7 @@ pub(super) fn object_create( write_pose(commands, transforms, e, position, placement); // Overlay the fresh snapshot's descriptor fields onto the existing store — the reference's // in-place refresh of a live guid, which notifies its field watchers like any delta. - merge_fields(stores, pending, edges, e, guid, fields); + merge_fields(commands, stores, edges, e, guid, fields); } else { // A transport spawns hidden: its create pose is the *stationary* spawn point (or worse, // the origin), not where the boat is in its cycle — the transport tick unhides it at the @@ -266,7 +619,7 @@ pub(super) fn object_create( // root's `Visibility` through `Mut` writes, which don't re-add the sweep row. entity.vis_chain_only(); if let Some(s) = speeds { - speed_stage.seed(guid, s); // staged, like the re-create arm above (1478) + entity.insert(UnitSpeeds(s)); } if let Some(anchor) = transport_anchor { entity.insert(anchor); @@ -280,22 +633,23 @@ pub(super) fn object_create( if let Some(s) = walk { entity.insert(s); } + // The seed itself is never merged, which is the reference's create-time notify-suppress + // (decision 2297). + entity.insert(ObjectStore(fields)); index.0.insert(guid, entity.id()); - // Seed the store via the pending flush — the entity isn't spawned until the sync point. - pending.insert(guid, fields); } } /// An item or container entered our view (`SMSG_UPDATE_OBJECT` descriptor-only create) — no scene /// entity; the item store owns it. -pub(super) fn item_create(guid: u64, container: bool, fields: ObjectFields, items: &mut Items) { +fn item_create(guid: u64, container: bool, fields: ObjectFields, items: &mut Items) { debug!("net: item create {guid:#x} (container: {container})"); items.insert_object(guid, fields); } /// An existing object moved to a new authoritative pose (an `SMSG_UPDATE_OBJECT` movement block) — /// a one-off correction/relocation that supersedes any active path. -pub(super) fn object_move( +fn object_move( guid: u64, position: [f32; 3], orientation: f32, @@ -318,7 +672,7 @@ pub(super) fn object_move( /// applies now, a future one queues on the unit and fires in `drain_pending_moves` — the dead-reckon /// covering the mover's own timeline in between, which is what kills the arrival-jitter snap. /// `WOW_REMOTE_SNAP=1` restores raw apply-at-arrival for an A/B. -pub(super) fn unit_move( +fn unit_move( guid: u64, mv: crate::net::motion::RelayMove, now_ms: f64, @@ -434,31 +788,26 @@ pub(super) fn unit_move( } /// A descriptor delta (`SMSG_UPDATE_OBJECT` values block): merge into the object's store — a scene -/// object's in place (or into the pending seed), an item's into the item store. An unknown guid — +/// object's in place, an item's into the item store. An unknown guid — /// a `Values` with no create seen — is dropped, as before. -pub(super) fn object_values( +fn object_values( guid: u64, fields: ObjectFields, + commands: &mut Commands, index: &GuidIndex, stores: &mut Query<&mut ObjectStore>, - pending: &mut HashMap, edges: &mut MessageWriter, items: &mut Items, ) { if let Some(&e) = index.0.get(&guid) { - merge_fields(stores, pending, edges, e, guid, fields); + merge_fields(commands, stores, edges, e, guid, fields); } else if guid::is_item(guid) { items.merge_object(guid, fields); } } /// The object ceased to exist (`SMSG_DESTROY_OBJECT` — corpse decay ahead of respawn, a despawn). -pub(super) fn object_destroyed( - guid: u64, - commands: &mut Commands, - index: &mut GuidIndex, - items: &mut Items, -) { +fn object_destroyed(guid: u64, commands: &mut Commands, index: &mut GuidIndex, items: &mut Items) { // **The object goes away by the same fade its stream-out takes** — `DespawnFade`, not a raw // despawn (decision 2198). The reference's object-manager destroy hands the object's *model* // to the `SWModelFadeout` scheduler on the way out: the base OnDeactivate `0x6145e0` (vtable @@ -504,7 +853,7 @@ pub(super) fn object_destroyed( /// Stream-out (out-of-range, the update-object `OutOfRange` block): the unit still exists, we just /// left its range. -pub(super) fn objects_removed(guids: Vec, commands: &mut Commands, index: &mut GuidIndex) { +fn objects_removed(guids: Vec, commands: &mut Commands, index: &mut GuidIndex) { // Don't pop the entity, fade it out, then despawn (`apply_despawn_fade` drives the ramp; an // entity with no fadeable geometry pops straight out there). Director-verified look: on the // reference, distant mobs fade out, never blink out (0067's open question, settled by their @@ -529,7 +878,7 @@ pub(super) fn objects_removed(guids: Vec, commands: &mut Commands, index: & /// rider's local pose for `transport::compose_riders` to carry out to the world. When it is `None` /// on a unit we had riding, the unit has *left* the deck — vmangos drops it from the transport on /// exactly this edge (`MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:156-159`). -pub(super) fn monster_move( +fn monster_move( guid: u64, transport: Option, start: [f32; 3], @@ -719,26 +1068,15 @@ pub(super) fn monster_move( } } -/// **Per-drain staging for [`UnitMoveModes`]** — the same shape (and the same reason) as -/// [`merge_fields`]' `pending`: a component this drain inserted through `Commands` is not queryable -/// until the sync point, so a grant and the packet it changes the meaning of can arrive in one drain -/// and the second must see the first. Seeded lazily from the live component; never read after the -/// drain. -pub(super) type StagedModes = HashMap; - -/// This unit's granted modes as of *now within the drain* — the staged word if it was granted this -/// drain, else the live component, else none. The reference's word lives as long as the `CGUnit`, so -/// "no component" and "all bits clear" are the same answer and both are [`UnitMoveModes::default`]. -pub(super) fn modes_of( - guid: u64, - index: &GuidIndex, - modes: &Query<&mut UnitMoveModes>, - staged: &StagedModes, -) -> UnitMoveModes { - staged +/// This unit's granted modes as of now — the live component, else none. A grant lands before +/// the next packet's handler reads it (decision 2306). The reference's word lives as long as the +/// `CGUnit`, so "no component" and "all bits clear" are the same answer and both are +/// [`UnitMoveModes::default`]. +fn modes_of(guid: u64, index: &GuidIndex, modes: &Query<&mut UnitMoveModes>) -> UnitMoveModes { + index + .0 .get(&guid) - .copied() - .or_else(|| index.0.get(&guid).and_then(|&e| modes.get(e).ok().copied())) + .and_then(|&e| modes.get(e).ok().copied()) .unwrap_or_default() } @@ -754,7 +1092,7 @@ pub(super) fn modes_of( /// the reference applies to whatever it finds — so we do too. It is inert on the avatar either way: /// the animation selector's `unify` gives the controller's own `MovementState` precedence, and our /// mover's modes are the handshake family's ([`crate::player::state::MoveModes`]). -pub(super) fn spline_move_mode( +fn spline_move_mode( guid: u64, mode: SplineMode, apply: bool, @@ -762,14 +1100,12 @@ pub(super) fn spline_move_mode( index: &GuidIndex, modes: &mut Query<&mut UnitMoveModes>, remote: &mut Query<&mut RemoteMotion>, - staged: &mut StagedModes, ) { let Some(&e) = index.0.get(&guid) else { return; // no such unit — the reference drops it too }; - let mut word = modes_of(guid, index, modes, staged); + let mut word = modes_of(guid, index, modes); word.set(mode, apply); - staged.insert(guid, word); if let Ok(mut live) = modes.get_mut(e) { *live = word; } else { @@ -801,7 +1137,7 @@ pub(super) fn spline_move_mode( /// The ask-once GameObject template (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, decision 0239): cache it and /// resolve the lockId from the type-specific `data[]` slot — the interact routing reads it to choose /// use-vs-cast; the hover tooltip reads the name (decision 0276's GO law). -pub(super) fn gameobject_info( +fn gameobject_info( entry: u32, type_id: u32, display_id: u32, @@ -813,115 +1149,63 @@ pub(super) fn gameobject_info( go_templates.insert(entry, type_id, name, data); } -/// Merge a descriptor delta into an object's store: into the per-drain `pending` seed when the entity was -/// created earlier this same drain (its spawn `Command` hasn't run, so it isn't queryable yet), else in -/// place on the live component. The final `else` — in the index but neither live nor pending — should not -/// happen (a create always seeds `pending` first), but seeds defensively rather than drop the delta. +/// Merge a descriptor delta into an object's live store. The `else` — in the index but with no +/// store — should not happen (a create inserts the store at spawn, and lands before the next +/// packet's handler), but seeds defensively rather than drop the delta. /// -/// Every merge reports its field edges ([`FieldChanged`], decision 2297) — into the pending seed -/// too: a create and a values delta for the same guid in one drain are two wire blocks, and the -/// reference notifies on the second. The seed itself is never merged, which is the reference's -/// create-time notify-suppress. +/// Every merge reports its field edges ([`FieldChanged`], decision 2297): a create and a values +/// delta for the same guid are two wire blocks, and the reference notifies on the second. fn merge_fields( + commands: &mut Commands, stores: &mut Query<&mut ObjectStore>, - pending: &mut HashMap, edges: &mut MessageWriter, entity: Entity, guid: u64, delta: ObjectFields, ) { - if let Some(f) = pending.get_mut(&guid) { - merge_store_fields(f, delta, entity, guid, |e| { - edges.write(e); - }); - } else if let Ok(mut s) = stores.get_mut(entity) { + if let Ok(mut s) = stores.get_mut(entity) { merge_store_fields(&mut s.0, delta, entity, guid, |e| { edges.write(e); }); } else { - pending.insert(guid, delta); + warn!( + "net: values delta for {guid:#x} found no store on its entity — seeding from the delta" + ); + commands.entity(entity).insert(ObjectStore(delta)); } } -/// Per-drain staging for every mover's [`UnitSpeeds`] — the drain's single speed writer -/// (decision 1478). +/// Write one speed-kind slot of a mover's live [`UnitSpeeds`]. `false` means there is nowhere to +/// write — an untracked guid, or a mover whose create carried no movement block — and the change +/// is reported unapplied rather than silently dropped. /// -/// The two speed sources used to disagree about *when* they land. [`object_create`] inserts a whole -/// speed set through `Commands`, which is deferred to the drain's sync point; a -/// `SMSG_FORCE_*_SPEED_CHANGE` edited one slot of the live component immediately. Mixed in one -/// drain, the **later packet loses** either way: on an entity born this drain the component isn't -/// there yet so the query miss was silent, and on one that already existed the create's deferred -/// insert landed afterwards and overwrote it. -/// -/// vmangos puts exactly those two packets back to back on purpose. `HandleMoveWorldportAckOpcode` -/// sends the self create block inside `Map::Add` → `SendInitSelf` (carrying the speeds the player -/// still has) and then, three statements later, strips the mount on a map that forbids one -/// (`if (!mEntry->IsMountAllowed()) RemoveSpellsCausingAura(SPELL_AURA_MOUNTED)`) — whose -/// `SMSG_FORCE_RUN_SPEED_CHANGE` therefore rides the same tick, and so the same drain. That is -/// B213: `.tele` into BWL/LBRS on a mount auto-dismounted you and left the mount's run speed on -/// foot, because the create's 11.2 yd/s landed last. -/// -/// So both sources stage here instead, **in packet order**, and the drain flushes once at the end — -/// the `pending: HashMap` pattern (decision 0061) applied to speeds. Nothing -/// writes the component directly any more, which is what makes the order the wire's order. -#[derive(Default)] -pub(super) struct SpeedStage(HashMap); - -impl SpeedStage { - /// A create block's whole speed set. A create is the server's newest snapshot of the mover, so - /// it **replaces** anything staged for that guid rather than merging into it. - fn seed(&mut self, guid: u64, speeds: MoveSpeeds) { - self.0.insert(guid, speeds); +/// The two speed sources used to disagree about *when* they land (decision 1478, B213): a +/// create's whole set was inserted through `Commands`, deferred to the drain's sync point, while +/// a `SMSG_FORCE_*_SPEED_CHANGE` edited the live component at once — so the later packet lost +/// either way when vmangos put the two back to back (`HandleMoveWorldportAckOpcode`'s self create +/// and the mount strip three statements later). Under a handler per packet the create's insert +/// lands before the change's handler runs (decision 2306), so both write the component and the +/// wire's order is the order. +fn set_speed( + guid: u64, + kind: SpeedKind, + speed: f32, + index: &GuidIndex, + speeds: &mut Query<&mut UnitSpeeds>, +) -> bool { + let Some(mut s) = index.0.get(&guid).and_then(|&e| speeds.get_mut(e).ok()) else { + return false; + }; + let s = &mut s.0; + match kind { + SpeedKind::Walk => s.walk = speed, + SpeedKind::Run => s.run = speed, + SpeedKind::RunBack => s.run_back = speed, + SpeedKind::Swim => s.swim = speed, + SpeedKind::SwimBack => s.swim_back = speed, + SpeedKind::TurnRate => s.turn_rate = speed, } - - /// Write one speed-kind slot, seeding the staged set from the live component on first touch. - /// `live` is only called when nothing is staged yet; `None` from it means there is nowhere to - /// write — an untracked guid, or a mover whose create carried no movement block — and the - /// change is reported unapplied rather than silently dropped. - fn set( - &mut self, - guid: u64, - kind: SpeedKind, - speed: f32, - live: impl FnOnce() -> Option, - ) -> bool { - let s = match self.0.entry(guid) { - Entry::Occupied(o) => o.into_mut(), - Entry::Vacant(v) => match live() { - Some(s) => v.insert(s), - None => return false, - }, - }; - match kind { - SpeedKind::Walk => s.walk = speed, - SpeedKind::Run => s.run = speed, - SpeedKind::RunBack => s.run_back = speed, - SpeedKind::Swim => s.swim = speed, - SpeedKind::SwimBack => s.swim_back = speed, - SpeedKind::TurnRate => s.turn_rate = speed, - } - true - } - - /// Land every staged set on its entity, now that this drain's spawn `Command`s have run. - /// `try_insert`: the same drain may have queued a despawn for it (a stream-out, the worldport - /// purge), and a guid the purge dropped from the index resolves to nothing at all. - pub(super) fn flush(self, commands: &mut Commands, index: &GuidIndex) { - for (guid, speeds) in self.0 { - if let Some(&e) = index.0.get(&guid) { - commands.entity(e).try_insert(UnitSpeeds(speeds)); - } - } - } -} - -/// Read a mover's live speed set — [`SpeedStage::set`]'s seed for a mover that already exists. -fn live_speeds(index: &GuidIndex, speeds: &Query<&UnitSpeeds>, guid: u64) -> Option { - index - .0 - .get(&guid) - .and_then(|&e| speeds.get(e).ok()) - .map(|s| s.0) + true } /// A forced speed change on a mover (aura/mount/GM `.modify speed`): stage the new value onto the @@ -929,18 +1213,17 @@ fn live_speeds(index: &GuidIndex, speeds: &Query<&UnitSpeeds>, guid: u64) -> Opt /// answers the mandatory ack with its live pose (the TeleportMessage pattern). An unknown guid /// still acks if it's ours-by-guid; a foreign mover (we never control others) is only applied, /// never acked — acking a unit we don't control is the server's error path. -pub(super) fn force_speed_change( +fn force_speed_change( guid: u64, kind: SpeedKind, counter: u32, speed: f32, index: &GuidIndex, - speeds: &Query<&UnitSpeeds>, - stage: &mut SpeedStage, + speeds: &mut Query<&mut UnitSpeeds>, self_guid: &SelfGuid, speed_changes: &mut MessageWriter, ) { - let applied = stage.set(guid, kind, speed, || live_speeds(index, speeds, guid)); + let applied = set_speed(guid, kind, speed, index, speeds); if self_guid.0 == Some(guid) { info!("net: force {kind:?} speed change -> {speed} yd/s (counter {counter})"); // Our own mover having no speed set to write is B213's failure shape, and it was silent @@ -949,7 +1232,7 @@ pub(super) fn force_speed_change( if !applied { warn!( "net: force {kind:?} speed change for OUR mover {guid:#x} landed nowhere \ - (no speed set staged or live) — we still ack, so the server and we now disagree" + (no live speed set) — we still ack, so the server and we now disagree" ); } speed_changes.write(SpeedChangeMessage { @@ -966,15 +1249,14 @@ pub(super) fn force_speed_change( /// An observed unit's speed changed (the SPLINE_SET / MOVE_SET families — another player /// mounting up, a hastened creature): stage it onto that unit's speed set, nothing to ack /// (decision 0441). The MOVE_SET flavour's pose already arrived as its own UnitMove. -pub(super) fn speed_changed( +fn speed_changed( guid: u64, kind: SpeedKind, speed: f32, index: &GuidIndex, - speeds: &Query<&UnitSpeeds>, - stage: &mut SpeedStage, + speeds: &mut Query<&mut UnitSpeeds>, ) { - if !stage.set(guid, kind, speed, || live_speeds(index, speeds, guid)) { + if !set_speed(guid, kind, speed, index, speeds) { // Ordinary: a unit's speed broadcast can outrun its create block. Nothing to ack and the // create that follows carries the same set, so this self-heals. debug!("net: {kind:?} speed change for untracked mover {guid:#x} — nothing to write"); @@ -985,8 +1267,6 @@ pub(super) fn speed_changed( mod tests { use super::*; - const ME: u64 = 0x0000_0000_0000_002A; - /// The observer movement-mode family's own harness (decision 1780): one indexed unit, and a /// `World` small enough that the only thing that can move it is the code under test. mod spline_modes { @@ -1034,7 +1314,6 @@ mod tests { index: Res, mut modes: Query<&mut UnitMoveModes>, mut remote: Query<&mut RemoteMotion>| { - let mut staged = StagedModes::new(); spline_move_mode( MOB, mode, @@ -1043,7 +1322,6 @@ mod tests { &index, &mut modes, &mut remote, - &mut staged, ); }, ) @@ -1147,7 +1425,6 @@ mod tests { index: Res, mut modes: Query<&mut UnitMoveModes>, mut remote: Query<&mut RemoteMotion>| { - let mut staged = StagedModes::new(); spline_move_mode( 0xDEAD_BEEF, SplineMode::Root, @@ -1156,11 +1433,6 @@ mod tests { &index, &mut modes, &mut remote, - &mut staged, - ); - assert!( - staged.is_empty(), - "nothing staged for a unit we do not have" ); }, ) @@ -1338,115 +1610,4 @@ mod tests { assert_eq!(w.entity(e).get::().expect("a path").deck, None); } } - - /// A dismounted human's set — 1.12.1's base run 7.0 (the `--speed` probe's own golden). - fn on_foot() -> MoveSpeeds { - MoveSpeeds { - walk: 2.5, - run: 7.0, - run_back: 4.5, - swim: 4.722_222, - swim_back: 2.5, - turn_rate: std::f32::consts::PI, - } - } - - /// The same set with a 60% mount's run speed on it — what `SendInitSelf` puts in the create - /// block of a worldport that is *about* to strip the mount. - fn mounted() -> MoveSpeeds { - MoveSpeeds { - run: 11.2, - ..on_foot() - } - } - - /// **B213, pinned.** vmangos's `HandleMoveWorldportAckOpcode` sends the self create block - /// (`Map::Add` → `SendInitSelf`, still carrying the mount's 11.2 yd/s) and then, three - /// statements later, strips the mount because the destination map forbids one — so - /// `SMSG_FORCE_RUN_SPEED_CHANGE` 7.0 rides the same tick and lands in the same drain. - /// Staged in packet order the change wins; before 1478 the create's *deferred* `UnitSpeeds` - /// insert landed last and `.tele` into BWL left the avatar running at mount speed on foot. - #[test] - fn a_force_change_beats_a_create_from_the_same_drain() { - let mut stage = SpeedStage::default(); - stage.seed(ME, mounted()); - assert!(stage.set(ME, SpeedKind::Run, 7.0, || panic!( - "the create staged a set this drain — the live component must not be consulted" - ))); - stage.flush_into(|guid, s| { - assert_eq!(guid, ME); - assert_eq!(s.run, 7.0, "the dismount is the newer packet"); - assert_eq!( - s.walk, 2.5, - "the other slots still come from the create block" - ); - }); - } - - /// The reverse order is just as much the wire's order: a create block is the server's newest - /// snapshot of the mover, so one arriving *after* a change replaces it whole rather than - /// merging under it. - #[test] - fn a_create_after_a_change_replaces_it() { - let mut stage = SpeedStage::default(); - assert!(stage.set(ME, SpeedKind::Run, 7.0, || Some(mounted()))); - stage.seed(ME, mounted()); - stage.flush_into(|_, s| assert_eq!(s.run, 11.2)); - } - - /// Nothing staged yet (an entity that has existed since an earlier drain): the live component - /// seeds the cell, so a one-slot change keeps every other slot it isn't addressing. - #[test] - fn a_change_on_a_live_mover_seeds_from_the_component() { - let mut stage = SpeedStage::default(); - assert!(stage.set(ME, SpeedKind::Run, 11.2, || Some(on_foot()))); - stage.flush_into(|_, s| { - assert_eq!(s.run, 11.2); - assert_eq!(s.run_back, on_foot().run_back); - assert_eq!(s.turn_rate, on_foot().turn_rate); - }); - } - - /// A change with nowhere to land — an untracked guid, or a mover whose create carried no - /// movement block — reports itself unapplied rather than vanishing. It stages nothing, so a - /// later create can't inherit a slot invented for a set that never existed. - #[test] - fn a_change_with_no_speed_set_is_reported_not_swallowed() { - let mut stage = SpeedStage::default(); - assert!(!stage.set(ME, SpeedKind::Run, 7.0, || None)); - let mut flushed = 0; - stage.flush_into(|_, _| flushed += 1); - assert_eq!(flushed, 0); - } - - /// The trap itself, machine-checked rather than asserted in prose: a component inserted - /// through this drain's `Commands` is **not** visible to a query in the same drain — the spawn - /// only runs at the sync point. That is why staging is the fix and why a live-component write - /// could never have been the fix: the force-change arm had nothing to write onto. - #[test] - fn a_component_this_drain_queued_is_invisible_until_the_sync_point() { - use bevy::ecs::system::RunSystemOnce; - let mut world = World::new(); - let missed = world - .run_system_once(|mut commands: Commands, live: Query<&UnitSpeeds>| { - let e = commands.spawn(UnitSpeeds(mounted())).id(); - live.get(e).is_err() - }) - .expect("the probe system runs"); - assert!( - missed, - "a create's UnitSpeeds must be unreadable later in the same drain — if this ever \ - passes, `Commands` stopped being deferred and 1478's staging can be reconsidered" - ); - } - - impl SpeedStage { - /// [`SpeedStage::flush`] without the ECS half — the staged sets in the shape the flush - /// would insert them, so the ordering rules above are testable as the pure thing they are. - fn flush_into(self, mut f: impl FnMut(u64, MoveSpeeds)) { - for (guid, speeds) in self.0 { - f(guid, speeds); - } - } - } } diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/session.rs similarity index 58% rename from crates/benilla-app/src/net/apply/session.rs rename to crates/benilla-app/src/net/session.rs index 2f71bb4d..f9b55b19 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -1,8 +1,10 @@ -//! Session-lifecycle arm bodies for [`super::apply_net_updates`]'s dispatch match — the connection -//! edges (the login stages, character select, entering the world, logout, the disconnect -//! teardown), our own teleport/worldport snaps, the server clock, and the login reputation store. -//! Each `pub(super)` fn here is exactly one arm's body; the match at the call site stays the -//! dispatcher, one call per arm. +//! The bridge's own session handlers (in the net handler table since 2326, moved out of the +//! drain's session arm file) — the connection edges (the login stages, character select, +//! entering the world, logout, the disconnect teardown), our own teleport/worldport snaps and +//! control edges, the server clock, the login reputation store, and the player's login-scoped +//! stores the bridge defines (the home bind, the proficiencies). Registered from +//! [`super::NetPlugin`], ahead of every window plugin, so the teardown runs before the windows' +//! session-end listeners — each a second handler on `Disconnected`. use benilla_protocol::messages::Character; use benilla_protocol::JumpInfo; @@ -10,28 +12,345 @@ use bevy::prelude::*; use crate::items::Items; use crate::names::NameCache; -use crate::ui_chat::ChatLog; use crate::ui_quest::QuestGiver; -use super::super::{ +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::ecs::system::SystemParam; + +use super::{ CharActionResultMessage, CharListMessage, CharacterLoginFailedMessage, - CinematicTriggeredMessage, DisconnectedMessage, DroppedOpcodes, EnteredWorldMessage, GameTime, - GuidIndex, KnockBackMessage, LoggedOutMessage, LoginFailedMessage, LoginStageMessage, - NetStatus, PendingTransfer, Reputations, SelfGuid, ServerTime, ServerWallClock, - TeleportMessage, WorldportMessage, + CinematicTriggeredMessage, ClientControlMessage, DisconnectedMessage, DroppedOpcodes, + EnteredWorldMessage, GameTime, GuidIndex, HomeBind, KnockBackMessage, LoggedOutMessage, + LoginFailedMessage, LoginQueuedMessage, LoginStageMessage, NetCommands, NetHandlerApp, + NetStatus, ObjectStore, PendingTransfer, Proficiencies, RealmListMessage, Reputations, + SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage, }; -/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it. -pub(super) fn login_stage( - stage: benilla_protocol::LoginStage, - out: &mut MessageWriter, +/// Register the session handlers — called from [`super::NetPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::LoginStage, on_login_edge) + .net_handler(K::LoginQueued, on_login_edge) + .net_handler(K::LoginFailed, on_login_edge) + .net_handler(K::RealmList, on_login_edge) + .net_handler(K::CharacterList, on_character_list) + .net_handler(K::CharActionResult, on_login_edge) + .net_handler(K::CharacterLoginFailed, on_login_edge) + .net_handler(K::CinematicTriggered, on_login_edge) + .net_handler(K::Connected, on_connected) + .net_handler(K::LoggedOut, on_logged_out) + .net_handler(K::Disconnected, on_disconnected) + .net_handler(K::Teleport, on_mover_edge) + .net_handler(K::KnockBack, on_mover_edge) + .net_handler(K::ClientControl, on_mover_edge) + .net_handler(K::Worldport, on_worldport) + .net_handler(K::TransferPending, on_transfer) + .net_handler(K::TransferAborted, on_transfer) + .net_handler(K::TimeSpeed, on_clock) + .net_handler(K::ServerUnixTime, on_clock) + .net_handler(K::Reputations, on_reputations) + .net_handler(K::ReputationDelta, on_reputation_delta) + .net_handler(K::ReputationVisible, on_reputations) + .net_handler(K::BindPoint, on_player_store) + .net_handler(K::Proficiency, on_player_store) + .net_handler(K::PacketDropped, on_packet_dropped) + .net_handler(K::Pong, on_pong); +} + +/// The edges the bridge publishes as messages — the login screen's, the character screen's, +/// the controller's — and the two resources they park in. +#[derive(SystemParam)] +pub(crate) struct Edges<'w> { + teleports: MessageWriter<'w, TeleportMessage>, + worldports: MessageWriter<'w, WorldportMessage>, + char_lists: MessageWriter<'w, CharListMessage>, + realm_lists: MessageWriter<'w, RealmListMessage>, + char_actions: MessageWriter<'w, CharActionResultMessage>, + char_login_failures: MessageWriter<'w, CharacterLoginFailedMessage>, + entered_world: MessageWriter<'w, EnteredWorldMessage>, + addon_reply: ResMut<'w, super::AddonInfoReply>, + logged_out: MessageWriter<'w, LoggedOutMessage>, + knockbacks: MessageWriter<'w, KnockBackMessage>, + login_stages: MessageWriter<'w, LoginStageMessage>, + login_queued: MessageWriter<'w, LoginQueuedMessage>, + login_failures: MessageWriter<'w, LoginFailedMessage>, + disconnects: MessageWriter<'w, DisconnectedMessage>, + client_control: MessageWriter<'w, ClientControlMessage>, + cinematics: MessageWriter<'w, CinematicTriggeredMessage>, + transfer: ResMut<'w, PendingTransfer>, +} + +/// The bridge's own state the session edges write: the guid index and our guid, the status, +/// the ask-once caches the teardown clears, the clocks, the reputations and the player's stores. +#[derive(SystemParam)] +pub(crate) struct Bridge<'w, 's> { + commands: Commands<'w, 's>, + index: ResMut<'w, GuidIndex>, + self_guid: ResMut<'w, SelfGuid>, + status: ResMut<'w, NetStatus>, + names: ResMut<'w, NameCache>, + items: ResMut<'w, Items>, + cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>, + reputations: ResMut<'w, Reputations>, + server_time: ResMut<'w, ServerTime>, + wall_clock: ResMut<'w, ServerWallClock>, + home_bind: ResMut<'w, HomeBind>, + proficiencies: ResMut<'w, Proficiencies>, + dropped: ResMut<'w, DroppedOpcodes>, + net: Res<'w, NetCommands>, + stores: Query<'w, 's, &'static mut ObjectStore>, + transports: Query<'w, 's, &'static crate::transport::Transport>, +} + +fn on_login_edge(In(ev): In, mut e: Edges) { + match ev { + SessionEvent::LoginStage { stage } => login_stage(stage, &mut e.login_stages), + SessionEvent::LoginQueued { position, realm } => { + e.login_queued.write(LoginQueuedMessage { position, realm }); + } + SessionEvent::LoginFailed { + refusal, + reason, + terminal, + dial, + } => login_failed(refusal, reason, terminal, dial, &mut e.login_failures), + SessionEvent::RealmList { realms } => { + e.realm_lists.write(RealmListMessage { realms }); + } + SessionEvent::CharActionResult { action, code } => { + char_action_result(action, code, &mut e.char_actions) + } + SessionEvent::CharacterLoginFailed { result } => { + character_login_failed(result, &mut e.char_login_failures) + } + SessionEvent::CinematicTriggered { cinematic_id } => { + cinematic_triggered(cinematic_id, &mut e.cinematics) + } + _ => {} + } +} + +fn on_character_list(In(ev): In, mut e: Edges, mut b: Bridge) { + if let SessionEvent::CharacterList { characters, realm } = ev { + character_list(characters, realm, &mut b.status, &mut e.char_lists); + } +} + +fn on_connected(In(ev): In, mut e: Edges, mut b: Bridge) { + if let SessionEvent::Connected { + self_guid: guid, + name, + billing_time_rested, + tutorial_flags, + addon_info, + } = ev + { + connected( + guid, + name, + billing_time_rested, + tutorial_flags, + addon_info, + &mut e.addon_reply, + &mut b.self_guid, + &mut b.status, + &mut b.names, + &mut e.entered_world, + ); + } +} + +fn on_logged_out(In(ev): In, mut e: Edges, mut b: Bridge) { + if let SessionEvent::LoggedOut = ev { + logged_out( + &mut b.commands, + &mut b.index, + &mut b.self_guid, + &mut e.logged_out, + ); + } +} + +/// The teardown — the bridge's own half of the session end. Every window that dies with the +/// socket has its own listener on this kind, registered after this one and so run after it. +fn on_disconnected(In(ev): In, mut e: Edges, mut b: Bridge) { + if let SessionEvent::Disconnected { reason, end } = ev { + disconnected( + reason, + end, + &mut b.commands, + &mut b.index, + &mut b.self_guid, + &mut b.status, + &mut b.names, + &mut b.items, + &mut b.cooldowns, + &mut e.transfer, + &mut e.disconnects, + ); + } +} + +/// The three server-authored mover edges the controller both *applies* and *answers*: a +/// teleport snap, a knockback launch (decision 1702) and possession's control half (B211), +/// forwarded whole and unjudged — only the controller can act on them. +fn on_mover_edge(In(ev): In, mut e: Edges, b: Bridge) { + match ev { + SessionEvent::Teleport { + guid, + counter, + position, + orientation, + } => teleport( + guid, + counter, + position, + orientation, + &b.self_guid, + &mut e.teleports, + ), + SessionEvent::KnockBack { + guid, + counter, + launch, + } => knock_back(guid, counter, launch, &b.self_guid, &mut e.knockbacks), + SessionEvent::ClientControl { mover, allow_move } => { + e.client_control + .write(ClientControlMessage { mover, allow_move }); + } + _ => {} + } +} + +fn on_worldport( + In(ev): In, + mut e: Edges, + mut b: Bridge, + mut group: ResMut, ) { + if let SessionEvent::Worldport { + map_id, + position, + orientation, + needs_ack, + } = ev + { + // Every streamed roster member's object is about to be purged — the same deactivation + // the reference runs one object at a time (decision 1640). + crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.net); + worldport( + map_id, + position, + orientation, + needs_ack, + &mut b.commands, + &mut b.index, + &mut e.transfer, + &b.transports, + &mut e.worldports, + ); + } +} + +fn on_transfer(In(ev): In, mut e: Edges) { + match ev { + SessionEvent::TransferPending { + map_id, + transport_entry, + } => transfer_pending(map_id, transport_entry, &mut e.transfer), + SessionEvent::TransferAborted { reason } => transfer_aborted(reason, &mut e.transfer), + _ => {} + } +} + +fn on_clock(In(ev): In, mut b: Bridge) { + match ev { + SessionEvent::TimeSpeed { + hours, + minutes, + day_serial, + timescale, + } => time_speed(hours, minutes, day_serial, timescale, &mut b.server_time), + SessionEvent::ServerUnixTime { unix_time } => { + server_unix_time(unix_time, &mut b.wall_clock) + } + _ => {} + } +} + +fn on_reputations(In(ev): In, mut b: Bridge) { + match ev { + SessionEvent::Reputations { standings } => reputations(standings, &mut b.reputations), + SessionEvent::ReputationVisible { list_id } => { + reputation_visible(list_id, &mut b.reputations) + } + _ => {} + } +} + +/// The chat line reads the deltas against the store, so it runs BEFORE the overwrite — after +/// it, every delta is zero. One handler for both legs, for that order. +fn on_reputation_delta( + In(ev): In, + mut b: Bridge, + factions: Option>, + mut chat_log: ResMut, + mut quest: ResMut, +) { + if let SessionEvent::ReputationDelta { standings } = ev { + crate::combat_log::chat::faction_standing( + &standings, + &b.reputations, + factions.as_deref(), + &mut chat_log, + ); + reputation_delta(standings, &mut b.reputations, &mut quest); + } +} + +/// The player's login-scoped stores the bridge defines. +fn on_player_store(In(ev): In, mut b: Bridge) { + match ev { + SessionEvent::BindPoint { area } => b.home_bind.0 = Some(area), + SessionEvent::Proficiency { + item_class, + subclass_mask, + } => { + b.proficiencies.0.insert(item_class, subclass_mask); + } + _ => {} + } +} + +fn on_packet_dropped(In(ev): In, mut b: Bridge) { + if let SessionEvent::PacketDropped { + opcode, + unparseable, + } = ev + { + packet_dropped(opcode, unparseable, &mut b.dropped); + } +} + +/// **The pong never gets here** — the read thread measures it against the ping clock the +/// instant it lands and stops it, the way the reference's `OnData 0x537b10` hands `SMSG_PONG` +/// to `HandlePong 0x537d60` inline instead of queueing it (`net::io`). Reaching this handler +/// means that bypass was undone and every latency reading is a client frame too slow again, +/// which is B346 exactly — so it says so out loud rather than measuring here and hiding it. +fn on_pong(In(ev): In) { + if let SessionEvent::Pong { sequence } = ev { + warn!("net: pong seq={sequence} reached the drain — the read thread's RTT bypass is gone (B346)"); + } +} + +/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it. +fn login_stage(stage: benilla_protocol::LoginStage, out: &mut MessageWriter) { out.write(LoginStageMessage { stage }); } /// A login attempt failed before the roster (decision 0539): the IO thread is back at its pre-logon /// park, and [`crate::login`]'s policy decides what happens next. -pub(super) fn login_failed( +fn login_failed( refusal: Option, reason: String, terminal: bool, @@ -48,7 +367,7 @@ pub(super) fn login_failed( /// The verdict on a character create/delete (`SMSG_CHAR_CREATE`/`SMSG_CHAR_DELETE`) — the glue /// screen turns the code into its own refusal string. -pub(super) fn char_action_result( +fn char_action_result( action: benilla_protocol::CharAction, code: u8, out: &mut MessageWriter, @@ -58,7 +377,7 @@ pub(super) fn char_action_result( /// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and parked /// at character select — surface the list (+ the connected realm's identity) to the glue screen. -pub(super) fn character_list( +fn character_list( characters: Vec, realm: Option, status: &mut NetStatus, @@ -78,10 +397,7 @@ pub(super) fn character_list( /// The server refused the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — the entry /// announced a moment ago is void. `crate::char_select` takes the screen back and says why. -pub(super) fn character_login_failed( - result: u8, - out: &mut MessageWriter, -) { +fn character_login_failed(result: u8, out: &mut MessageWriter) { warn!("net: character login refused (result {result:#04x})"); out.write(CharacterLoginFailedMessage { result }); } @@ -95,7 +411,7 @@ pub(super) fn character_login_failed( /// — so the ack must still happen, at the *end* of playback rather than instantly. The cinematic /// plugin sends it on a natural end, on an ESC skip, and immediately for a trigger it cannot /// resolve to a shot, so no path drops it. -pub(super) fn cinematic_triggered( +fn cinematic_triggered( cinematic_id: u32, triggered: &mut MessageWriter, ) { @@ -105,7 +421,7 @@ pub(super) fn cinematic_triggered( /// We are in the world (the IO thread's first in-world event): record our guid, flip the status, /// and seed the name cache with our own name. -pub(super) fn connected( +fn connected( guid: u64, name: String, billing_time_rested: u32, @@ -140,7 +456,7 @@ pub(super) fn connected( } /// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — back to character select. -pub(super) fn logged_out( +fn logged_out( commands: &mut Commands, index: &mut GuidIndex, self_guid: &mut SelfGuid, @@ -162,7 +478,7 @@ pub(super) fn logged_out( /// The session ended (socket closed / handshake failure): tear down the streamed world and clear /// every session-scoped cache. -pub(super) fn disconnected( +fn disconnected( reason: String, end: benilla_protocol::SessionEnd, commands: &mut Commands, @@ -171,9 +487,6 @@ pub(super) fn disconnected( status: &mut NetStatus, names: &mut NameCache, items: &mut Items, - chat_log: &mut ChatLog, - death_net: &mut crate::death::DeathNet, - group: &mut crate::ui_party::GroupState, cooldowns: &mut crate::cooldowns::Cooldowns, pending_transfer: &mut PendingTransfer, disconnects: &mut MessageWriter, @@ -219,8 +532,6 @@ pub(super) fn disconnected( // In-flight name queries died with the socket; let the next resolve re-ask. names.clear_pending(); items.clear_session(); - chat_log.clear_session(); - group.clear_session(); // The cooldown list is session-scoped — the next login may be a different character — and // had been missing from this sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown // still running at every world entry and `seed_initial` APPENDS, so a list that outlives the @@ -228,14 +539,11 @@ pub(super) fn disconnected( // own stale copy over the wire's fresh remainder, and a login on a different character // inherits cooldowns that were never theirs. cooldowns.clear_session(); - // The death stores are session-scoped too: a reclaim expiry, resurrect offer, or corpse - // marker must not survive the socket (the reconnect re-sends the reclaim delay when dead). - *death_net = crate::death::DeathNet::default(); } /// A teleport ack request (`MSG_MOVE_TELEPORT_ACK`) — only our own matters (the ack resumes our /// movement); the controller consumes the message. -pub(super) fn teleport( +fn teleport( guid: u64, counter: u32, position: [f32; 3], @@ -263,7 +571,7 @@ pub(super) fn teleport( /// different opcode with a different handler (`MSG_MOVE_KNOCK_BACK`, wow-re /// `system/collision/scratch/knockback-law.md`), so a knockback naming somebody else is not ours to /// fly. -pub(super) fn knock_back( +fn knock_back( guid: u64, counter: u32, launch: JumpInfo, @@ -281,11 +589,7 @@ pub(super) fn knock_back( /// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): latch it for the coming worldport — /// its transport block decides whether NEW_WORLD's coordinates are boat-local (decision 0455). -pub(super) fn transfer_pending( - map_id: u32, - transport_entry: Option, - pending: &mut PendingTransfer, -) { +fn transfer_pending(map_id: u32, transport_entry: Option, pending: &mut PendingTransfer) { match transport_entry { Some(entry) => info!("net: transfer pending → map {map_id} riding transport {entry}"), None => info!("net: transfer pending → map {map_id}"), @@ -297,7 +601,7 @@ pub(super) fn transfer_pending( } /// `SMSG_TRANSFER_ABORTED`: the announced transfer won't happen — clear the latch. -pub(super) fn transfer_aborted(reason: u8, pending: &mut PendingTransfer) { +fn transfer_aborted(reason: u8, pending: &mut PendingTransfer) { warn!("net: transfer aborted (reason {reason})"); pending.0 = None; } @@ -311,7 +615,7 @@ pub(super) fn transfer_aborted(reason: u8, pending: &mut PendingTransfer) { /// the seam (the `CurrentMap` flip itself flips which legs render), keeping the ride attachment /// and the deck collider valid the whole way; the server's post-ack re-create then refreshes its /// anchor in place. Boats whose paths never reach the new map despawn like everything else. -pub(super) fn worldport( +fn worldport( map_id: u32, position: [f32; 3], orientation: f32, @@ -354,7 +658,7 @@ pub(super) fn worldport( } /// The server game clock (`SMSG_LOGIN_SETTIMESPEED`) — drives the day/night lighting. -pub(super) fn time_speed( +fn time_speed( hours: u8, minutes: u8, day_serial: u32, @@ -374,7 +678,7 @@ pub(super) fn time_speed( /// /// Logged once per session at info, with the skew against our own clock: that number is exactly /// what a "the countdown is wrong by a constant" report would be about, and it costs one line. -pub(super) fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) { +fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) { if clock.0.is_none() { let local = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -389,7 +693,7 @@ pub(super) fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) { } /// The login reputation store (`SMSG_INITIALIZE_FACTIONS`). -pub(super) fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputations) { +fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputations) { info!("net: reputation store ({} slots)", standings.len()); reputations.0 = standings; } @@ -404,7 +708,7 @@ pub(super) fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputatio /// `SMSG_SET_FACTION_VISIBLE` for the same slot in most cases (vmangos `SetOneFactionReputation` /// calls `SetVisible`), so this is usually belt to that braces; it matters when the reveal and the /// standing arrive in the other order, and it is what the client does regardless. -pub(super) fn reputation_delta( +fn reputation_delta( standings: Vec<(u32, i32)>, reputations: &mut Reputations, quest: &mut QuestGiver, @@ -434,7 +738,7 @@ pub(super) fn reputation_delta( /// standing** — the slot's standing was already correct and stays untouched. Dropping it is the /// silent failure it exists to prevent: the pane keys row membership off this bit, so a faction met /// mid-session would keep accruing reputation the player could never see. -pub(super) fn reputation_visible(list_id: u32, reputations: &mut Reputations) { +fn reputation_visible(list_id: u32, reputations: &mut Reputations) { let Some(i) = reputation_slot(list_id, "SMSG_SET_FACTION_VISIBLE") else { return; }; @@ -464,7 +768,7 @@ fn reputation_slot(list_id: u32, opcode: &str) -> Option { /// The dropped-packet tally (the wire-coverage instrument): count it, and announce each opcode's /// FIRST drop at info — visible in any log without the panel open, and one line per opcode per /// run, so it can never flood. -pub(super) fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes) { +fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes) { let tally = dropped.0.entry(opcode).or_default(); if tally.unknown + tally.unparseable == 0 { info!( diff --git a/crates/benilla-app/src/net/world.rs b/crates/benilla-app/src/net/world.rs new file mode 100644 index 00000000..3c144363 --- /dev/null +++ b/crates/benilla-app/src/net/world.rs @@ -0,0 +1,80 @@ +//! The bridge's own world feed (in the net handler table since 2325, moved out of the drain's +//! world arm file) — what the server pushes about the *world* rather than about an entity or a +//! window, forwarded by the bridge because what receives it is not an app subsystem: the weather +//! change goes to the world crate's weather as a [`WeatherMessage`], and the world-state table the +//! UI's `$w` tokens read is a cache the bridge initialises ([`WorldStates`]). Registered from +//! [`super::NetPlugin`]. + +use benilla_protocol::{SessionEvent, SessionEventKind}; +use benilla_world::weather::WeatherMessage; +use bevy::prelude::*; + +use super::NetHandlerApp; +use crate::world_state::WorldStates; + +/// Register the two handlers — called from [`super::NetPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::Weather, on_weather) + .net_handler(K::WorldStates, on_world_states); +} + +fn on_weather(In(ev): In, mut out: MessageWriter) { + if let SessionEvent::Weather { + weather_type, + grade, + sound_id, + instant, + } = ev + { + weather(weather_type, grade, sound_id, instant, &mut out); + } +} + +fn on_world_states(In(ev): In, mut states: ResMut) { + if let SessionEvent::WorldStates { + scope, + states: pairs, + } = ev + { + world_states(scope, pairs, &mut states); + } +} + +/// `SMSG_WEATHER` — the zone's weather change (`instant` on zone entry, a ramp otherwise). +fn weather( + weather_type: u32, + grade: f32, + sound_id: u32, + instant: bool, + out: &mut MessageWriter, +) { + out.write(WeatherMessage { + weather_type, + grade, + sound_id, + instant, + }); +} + +/// `SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE` — both wires funnel into the one setter, +/// as the reference's own handler does. +/// +/// An **init clears the table first** and records its `(map, zone)` as the world-state UI's display +/// filter — the reference's `0x4c5650`, which runs before the pair loop (wow-re +/// `system/ui/scratch/worldstate-ui-law.md`; rationale on [`crate::world_state`]). The order below +/// is that handler's: clear + scope, then the packet's pairs. +fn world_states( + scope: Option<(u32, u32)>, + states: Vec<(u32, u32)>, + world_states: &mut WorldStates, +) { + if let Some((map, zone)) = scope { + debug!( + "world states: map {map} zone {zone}, {} entries", + states.len() + ); + world_states.init_scope(map, zone); + } + world_states.write(&states); +} diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index 13012951..f36145f4 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -37,6 +37,7 @@ mod mix_tap; mod mixer; mod money; mod mount; +mod net; // Crate-visible for one reader: the dev-only stall watchdog asks `output::device_open` before // it suspends the process (decision 1857). Dev may see anything; nothing here knows dev exists. pub(crate) mod output; @@ -551,6 +552,7 @@ pub(crate) fn on_cvar(ev: On, mut sound: ResMut, + index: Res, + mut out: MessageWriter, +) { + match ev { + SessionEvent::PlaySound { sound_id } => play_sound(sound_id, &mut out), + SessionEvent::PlayMusic { music_id } => play_music(music_id, &mut out), + SessionEvent::PlayObjectSound { sound_id, guid } => { + play_object_sound(sound_id, guid, &index, &mut out) + } + _ => {} + } +} + +/// `SMSG_PLAY_SOUND` — a 2D (non-positional) one-shot. +fn play_sound(sound_id: u32, out: &mut MessageWriter) { + out.write(ServerSoundMessage { + kind: ServerSoundKind::Sound2d, + sound_id, + source: None, + }); +} + +/// `SMSG_PLAY_MUSIC` — the zone/event music track. +fn play_music(music_id: u32, out: &mut MessageWriter) { + out.write(ServerSoundMessage { + kind: ServerSoundKind::Music, + sound_id: music_id, + source: None, + }); +} + +/// `SMSG_PLAY_OBJECT_SOUND` — a one-shot anchored to a streamed object (silent while its guid +/// isn't streamed in: the mixer has nowhere to place it). +fn play_object_sound( + sound_id: u32, + guid: u64, + index: &GuidIndex, + out: &mut MessageWriter, +) { + out.write(ServerSoundMessage { + kind: ServerSoundKind::ObjectSound, + sound_id, + source: index.0.get(&guid).copied(), + }); +} diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs new file mode 100644 index 00000000..5471c88b --- /dev/null +++ b/crates/benilla-app/src/spell/mod.rs @@ -0,0 +1,18 @@ +//! **The spell** — the reference's `Spell_C` owns the cast lifecycle, the cooldowns, the +//! channels, the spell table and its 22 packet registrations in one translation unit; benilla +//! had no such owner (2265 §A7). This module is where that owner lives. Since decision 2324 it +//! holds the packet handlers ([`net`]); the lifecycle state they fold into is still spread over +//! `ui_action`, `ui_cast` and `cooldowns`, and moving it here is A7's remaining half. + +use bevy::prelude::*; + +pub(crate) mod net; + +/// Registration only — the state is still the feeds' (see the module doc). +pub(crate) struct SpellPlugin; + +impl Plugin for SpellPlugin { + fn build(&self, app: &mut App) { + net::register(app); + } +} diff --git a/crates/benilla-app/src/net/apply/spells.rs b/crates/benilla-app/src/spell/net.rs similarity index 89% rename from crates/benilla-app/src/net/apply/spells.rs rename to crates/benilla-app/src/spell/net.rs index a2a6a15c..04b94f4f 100644 --- a/crates/benilla-app/src/net/apply/spells.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -1,7 +1,9 @@ -//! Spell-book/action-bar + cast-lifecycle arm bodies for [`super::apply_net_updates`]'s dispatch -//! match — one of the largest arm families, split out on its own (the decision 0099/0107 precast → -//! resolve pipeline). Each `pub(super)` fn here is exactly one arm's body; the match at the call -//! site stays the dispatcher, one call per arm. +//! The spell's packet handlers (in the net handler table since 2324, moved out of the drain's +//! spells arm file) — the spell book and the action bar, the cast lifecycle (the decision +//! 0099/0107 precast → resolve pipeline), the cooldowns, the channels, the aura durations and the +//! spell modifiers. The lifecycle *state* these fold into is still spread over `ui_action`, +//! `ui_cast` and `cooldowns` (2265 §A7's remaining half); the handlers reach it through +//! [`Lifecycle`] and [`Scene`]. use std::time::{Duration, Instant}; @@ -16,12 +18,421 @@ use crate::ui_aura::AuraDurations; use crate::ui_cast::{ActiveChannel, CastBarEdge, CastBarFeed, PendingCast, QueuedMeleeSpell}; use crate::ui_spellbook::LearnedInTab; -use super::super::{GuidIndex, ObjectStore, SelfGuid}; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::ecs::system::SystemParam; + +use crate::net::{GuidIndex, NetCommands, NetHandlerApp, ObjectStore, SelfGuid, SelfPlayer}; + +/// Register the spell's handlers — called from [`super::SpellPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::SpellBook, on_spell_book) + .net_handler(K::ActionButtons, on_action_buttons) + .net_handler(K::SpellLearned, on_spell_learned) + .net_handler(K::SpellRemoved, on_spell_removed) + .net_handler(K::SpellSuperceded, on_spell_superceded) + .net_handler(K::CastResult, on_cast_result) + .net_handler(K::SpellStart, on_spell_start) + .net_handler(K::SpellGo, on_spell_go) + .net_handler(K::SpellChainTargets, on_spell_chain_targets) + .net_handler(K::SpellFailedOther, on_spell_failed_other) + .net_handler(K::SpellDelayed, on_spell_delayed) + .net_handler(K::CancelAutoRepeat, on_cancel_auto_repeat) + .net_handler(K::SpellCooldowns, on_cooldown_packet) + .net_handler(K::CooldownEvent, on_cooldown_packet) + .net_handler(K::ClearCooldown, on_cooldown_packet) + .net_handler(K::CooldownCheat, on_cooldown_packet) + .net_handler(K::ItemCooldown, on_item_cooldown) + .net_handler(K::ChannelStart, on_channel) + .net_handler(K::ChannelUpdate, on_channel) + .net_handler(K::AuraDuration, on_aura_duration) + .net_handler(K::SpellModifier, on_spell_modifier); +} + +/// The cast lifecycle's state and catalogs, as one parameter — what every handler here folds +/// its packet into. Still owned by `ui_action`, `ui_cast`, `cooldowns`, `ui_aura`, +/// `spell_mods` and the pet bar (2265 §A7). +#[derive(SystemParam)] +pub(crate) struct Lifecycle<'w> { + self_guid: Res<'w, SelfGuid>, + index: Res<'w, GuidIndex>, + spells: Option>, + net: Res<'w, NetCommands>, + actions: ResMut<'w, PlayerActions>, + cast_errors: ResMut<'w, CastErrors>, + chain_casts: ResMut<'w, crate::ui_action::ChainCasts>, + learned_in_tab: ResMut<'w, LearnedInTab>, + cast_bar: ResMut<'w, CastBarFeed>, + pending: ResMut<'w, PendingCast>, + queued_melee: ResMut<'w, QueuedMeleeSpell>, + cooldowns: ResMut<'w, Cooldowns>, + auto_repeat: ResMut<'w, AutoRepeatActive>, + channel: ResMut<'w, ActiveChannel>, + aura_durations: ResMut<'w, AuraDurations>, + spell_mods: ResMut<'w, crate::spell_mods::SpellModifiers>, + pet_bar: ResMut<'w, crate::ui_pet::PetBar>, + /// The PlayAnimation call-order counter: every animation-bearing message stamps `next()`, + /// in packet order. + play_seq: ResMut<'w, crate::creature_anim::PlaySeq>, +} + +/// The scene a cast lands in — the streamed units, the item store, and the animation, text and +/// loot sinks a resolve writes. +#[derive(SystemParam)] +pub(crate) struct Scene<'w, 's> { + commands: Commands<'w, 's>, + casting: Query<'w, 's, &'static Casting>, + engaged_self: Query<'w, 's, (), (With, With)>, + stores: Query<'w, 's, &'static mut ObjectStore>, + items: ResMut<'w, crate::items::Items>, + loot_latch: ResMut<'w, crate::ui_loot::LootLatch>, + damage_text: Res<'w, crate::combat_text::DamageTextGates>, + cast_events: MessageWriter<'w, CastEvent>, + go_targets: MessageWriter<'w, SpellGoTargets>, + text: MessageWriter<'w, crate::combat_text::CombatTextSpawn>, + go_lid: MessageWriter<'w, crate::go_anim::GoLidOpen>, + sheaths: MessageWriter<'w, crate::creature_anim::SheathRequest>, +} + +/// The spell-book/action-bar pair → the action store the UI feed reads (`crate::ui_action`), +/// sent once at login (and the bar again on server-side edits). +fn on_spell_book(In(ev): In, mut l: Lifecycle) { + if let SessionEvent::SpellBook { + spell_ids, + cooldowns, + } = ev + { + spell_book(spell_ids, cooldowns, &mut l.actions, &mut l.cooldowns); + } +} + +fn on_action_buttons(In(ev): In, mut l: Lifecycle) { + if let SessionEvent::ActionButtons { buttons } = ev { + action_buttons(buttons, &mut l.actions); + } +} + +fn on_spell_learned(In(ev): In, mut l: Lifecycle, mut errors: ResMut) { + if let SessionEvent::SpellLearned { spell_id } = ev { + learned_spell( + spell_id, + &mut l.actions, + l.spells.as_deref(), + &mut errors, + &mut l.learned_in_tab, + ); + } +} + +fn on_spell_removed(In(ev): In, mut l: Lifecycle, mut errors: ResMut) { + if let SessionEvent::SpellRemoved { spell_id } = ev { + removed_spell(spell_id, &mut l.actions, l.spells.as_deref(), &mut errors); + } +} + +fn on_spell_superceded( + In(ev): In, + mut l: Lifecycle, + mut errors: ResMut, +) { + if let SessionEvent::SpellSuperceded { + old_spell_id, + new_spell_id, + } = ev + { + superceded_spell( + old_spell_id, + new_spell_id, + &mut l.actions, + l.spells.as_deref(), + &mut errors, + &mut l.learned_in_tab, + ); + } +} + +fn on_cast_result(In(ev): In, mut l: Lifecycle, mut sc: Scene) { + if let SessionEvent::CastResult { + spell_id, + success, + reason, + arg, + } = ev + { + cast_result( + spell_id, + success, + reason, + arg, + &mut sc.commands, + &l.self_guid, + &l.index, + &mut l.cast_errors, + &sc.casting, + &mut sc.cast_events, + &mut l.cast_bar, + &mut l.pending, + &mut l.queued_melee, + &mut l.cooldowns, + &mut l.auto_repeat, + l.spells.as_deref(), + &l.net, + &mut l.chain_casts, + l.play_seq.next(), + ); + } +} + +fn on_spell_start(In(ev): In, mut l: Lifecycle, mut sc: Scene) { + if let SessionEvent::SpellStart { + caster, + spell_id, + cast_flags, + cast_time_ms, + target, + ammo_display_id, + } = ev + { + spell_start( + caster, + spell_id, + cast_flags, + cast_time_ms, + target, + ammo_display_id, + &mut sc.commands, + &l.index, + &mut sc.cast_events, + &l.self_guid, + &mut l.cast_bar, + &mut l.pending, + l.spells.as_deref(), + l.play_seq.next(), + ); + } +} + +fn on_spell_go(In(ev): In, mut l: Lifecycle, mut sc: Scene) { + if let SessionEvent::SpellGo { + caster, + spell_id, + cast_flags, + hits, + misses, + target, + go_target, + dest, + ammo_display_id, + item_caster, + } = ev + { + let engaged = !sc.engaged_self.is_empty(); + spell_go( + caster, + spell_id, + cast_flags, + hits, + misses, + target, + go_target, + dest, + ammo_display_id, + item_caster, + &mut sc.commands, + &l.index, + &sc.casting, + &mut sc.cast_events, + &mut sc.go_targets, + &l.self_guid, + &sc.stores, + &mut l.cast_bar, + &mut l.pending, + &mut l.queued_melee, + &mut sc.text, + *sc.damage_text, + &mut sc.go_lid, + &mut sc.loot_latch, + ( + &mut l.cooldowns, + l.spells.as_deref(), + &mut sc.items, + &l.net, + &mut l.pet_bar, + ), + (&mut l.auto_repeat, &mut sc.sheaths, engaged), + l.play_seq.next(), + ); + } +} + +fn on_spell_chain_targets(In(ev): In, l: Lifecycle, mut sc: Scene) { + if let SessionEvent::SpellChainTargets { + caster, + spell_id, + targets, + } = ev + { + spell_chain_targets(caster, spell_id, targets, &mut sc.commands, &l.index); + } +} + +fn on_spell_failed_other(In(ev): In, mut l: Lifecycle, mut sc: Scene) { + if let SessionEvent::SpellFailedOther { caster, spell_id } = ev { + spell_failed_other( + caster, + spell_id, + &mut sc.commands, + &l.index, + &sc.casting, + &mut sc.cast_events, + &l.self_guid, + &mut l.cast_bar, + &mut l.pending, + &mut l.queued_melee, + l.play_seq.next(), + ); + } +} + +fn on_spell_delayed(In(ev): In, mut l: Lifecycle) { + if let SessionEvent::SpellDelayed { caster, delay_ms } = ev { + spell_delayed( + caster, + delay_ms, + &l.self_guid, + &mut l.cast_bar, + &mut l.pending, + ); + } +} + +fn on_cancel_auto_repeat(In(ev): In, mut l: Lifecycle, mut sc: Scene) { + if let SessionEvent::CancelAutoRepeat = ev { + cancel_auto_repeat( + &mut l.auto_repeat, + &l.self_guid, + &l.index, + &mut sc.commands, + &l.net, + ); + } +} + +/// The four cooldown packets, each resolved once through [`addressed_store`] — ours or the +/// pet's, by the caster guid. +fn on_cooldown_packet(In(ev): In, mut l: Lifecycle) { + let Lifecycle { + self_guid, + spells, + cooldowns, + pet_bar, + .. + } = &mut l; + match ev { + SessionEvent::SpellCooldowns { + caster, + cooldowns: pairs, + } => { + if let Some(store) = addressed_store(caster, self_guid, cooldowns, pet_bar) { + spell_cooldowns(caster, pairs, spells.as_deref(), store); + } + } + SessionEvent::CooldownEvent { spell_id, caster } => { + if let Some(store) = addressed_store(caster, self_guid, cooldowns, pet_bar) { + cooldown_event(spell_id, caster, store); + } + } + SessionEvent::ClearCooldown { spell_id, caster } => { + if let Some(store) = addressed_store(caster, self_guid, cooldowns, pet_bar) { + clear_cooldown(spell_id, caster, store); + } + } + SessionEvent::CooldownCheat { caster } => { + if let Some(store) = addressed_store(caster, self_guid, cooldowns, pet_bar) { + cooldown_cheat(caster, store); + } + } + _ => {} + } +} + +fn on_item_cooldown(In(ev): In, mut l: Lifecycle, sc: Scene) { + if let SessionEvent::ItemCooldown { + item_guid, + spell_id, + } = ev + { + item_cooldown(item_guid, spell_id, &sc.items, &mut l.cooldowns); + } +} + +fn on_channel(In(ev): In, mut l: Lifecycle) { + match ev { + SessionEvent::ChannelStart { + spell_id, + duration_ms, + } => channel_start(spell_id, duration_ms, &mut l.channel, &mut l.cast_bar), + SessionEvent::ChannelUpdate { remaining_ms } => { + channel_update(remaining_ms, &mut l.channel, &mut l.cast_bar) + } + _ => {} + } +} + +fn on_aura_duration(In(ev): In, mut l: Lifecycle, real_clock: Res>) { + if let SessionEvent::AuraDuration { slot, remaining_ms } = ev { + aura_duration( + slot, + remaining_ms, + &mut l.aura_durations, + real_clock.elapsed_secs_f64(), + ); + } +} + +fn on_spell_modifier(In(ev): In, mut l: Lifecycle) { + if let SessionEvent::SpellModifier { + flat, + mask_bit, + op, + value, + } = ev + { + set_spell_modifier(flat, mask_bit, op, value, &mut l.spell_mods); + } +} + +/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982). +/// +/// All four of them (`SMSG_SPELL_COOLDOWN`, `_COOLDOWN_EVENT`, `_CLEAR_COOLDOWN`, +/// `_COOLDOWN_CHEAT`) carry a caster guid, and until the pet bar existed all four answered it the +/// same way: "is it us? then apply, else drop" — four copies of a self-only assumption, each +/// inside its own arm. Since the server sends a pet's cooldowns on the pet's guid, that +/// assumption silently discarded every one of them. Resolving the guid ONCE, here, is what let the +/// pet bar sweep for real without a second copy of any arm; it also matches the reference, whose +/// `SMSG_COOLDOWN_CHEAT` handler wipes "the self/pet cooldown list" off exactly this test. +/// +/// `None` = a guid we hold no store for (another player's pet, a stale packet): dropped, as the +/// client drops an unknown guid. +fn addressed_store<'a>( + caster: u64, + self_guid: &SelfGuid, + player: &'a mut crate::cooldowns::Cooldowns, + pet: &'a mut crate::ui_pet::PetBar, +) -> Option<&'a mut crate::cooldowns::Cooldowns> { + if self_guid.0 == Some(caster) { + Some(player) + } else if pet.has_bar() && pet.spells.pet_guid == caster { + Some(&mut pet.cooldowns) + } else { + None + } +} /// The player's spell book (`SMSG_INITIAL_SPELLS`, once at login) → the action store the UI feed /// reads (`crate::ui_action`), plus the active-cooldown list → the cooldown store (the wire /// carries *remaining* ms — [`Cooldowns::seed_initial`]'s law). -pub(super) fn spell_book( +fn spell_book( spell_ids: Vec, initial_cooldowns: Vec, actions: &mut PlayerActions, @@ -42,7 +453,7 @@ pub(super) fn spell_book( /// The player's saved action bar (`SMSG_ACTION_BUTTONS`, once at login, and again on server-side /// edits) → the action store the UI feed reads. -pub(super) fn action_buttons(buttons: Vec, actions: &mut PlayerActions) { +fn action_buttons(buttons: Vec, actions: &mut PlayerActions) { debug!("net: action bar — {} occupied slots", buttons.len()); actions.buttons = buttons.into_iter().map(|b| (b.slot, b)).collect(); actions.dirty = true; @@ -56,7 +467,7 @@ pub(super) fn action_buttons(buttons: Vec, actions: &mut PlayerAct /// It also **announces the learn in chat**, which is the reference's own tail on this packet and /// not a nicety: `0x5e61c0` -> `AddSpell(id, slot, 1, 1)` -> the registrar `0x4b25b0` with its /// announce flag set (decision 2243, [`announce_learn`]). -pub(super) fn learned_spell( +fn learned_spell( spell_id: u32, actions: &mut PlayerActions, spells: Option<&Spells>, @@ -120,7 +531,7 @@ fn announce_learn(spell_id: u32, spells: Option<&Spells>, errors: &mut UiErrorKe /// [`crate::ui_action::LearnedAbilities`] re-derives off the same change (the reference's own /// unlearn write site, `0x4b2c50`). What happens to a **bar button** still pointing at the removed /// spell is a separate law this arm deliberately does not invent — see 1584's scope note. -pub(super) fn removed_spell( +fn removed_spell( spell_id: u32, actions: &mut PlayerActions, spells: Option<&Spells>, @@ -159,7 +570,7 @@ fn announce_unlearn(spell_id: u32, spells: Option<&Spells>, errors: &mut UiError /// *here* as well would be a second, weaker copy of that law — weaker because this packet doesn't /// arrive at all when the rank was gained while the character was loading (vmangos suppresses it /// with `IsInWorld()`), which is exactly the case that shipped a dead rank-1 button. -pub(super) fn superceded_spell( +fn superceded_spell( old_spell_id: u32, new_spell_id: u32, actions: &mut PlayerActions, @@ -181,7 +592,7 @@ pub(super) fn superceded_spell( } /// The server's verdict on our cast (`SMSG_CAST_RESULT`). -pub(super) fn cast_result( +fn cast_result( spell_id: u32, success: bool, reason: Option, @@ -335,7 +746,7 @@ pub(super) fn cast_result( /// A unit began a non-triggered cast (`SMSG_SPELL_START`), instants included (`cast_time_ms == 0`) /// — the precast trigger the phase-2 casting animation loop builds on (decision 0099 phase 1). -pub(super) fn spell_start( +fn spell_start( caster: u64, spell_id: u32, cast_flags: u16, @@ -445,7 +856,7 @@ const GO_TYPE_CHEST: i32 = 3; /// nothing about missile travel rides this packet; the client (and we) rebuild the flight /// visually from the same Speed column (decision 0099 phase 4: the target lists go out as /// [`SpellGoTargets`] for the router's instant-impact/missile branch). -pub(super) fn spell_go( +fn spell_go( caster: u64, spell_id: u32, cast_flags: u16, @@ -718,15 +1129,16 @@ pub(super) fn spell_go( // is spell-GOLD. The three CVar gates come with it — they are read inside `0x607140`, which // every word path calls, so `CombatDamage 0` silences these words too. if !misses.is_empty() && display.is_none_or(|d| d.speed == 0.0) { - if let Some(color) = super::combat_log::classify_source(caster, index, self_guid, stores) - .and_then(|source| { - crate::combat_text::damage_color( - text_gates, - source, - crate::combat_text::melee_styled(display), - ) - }) - { + if let Some(color) = crate::combat_log::text::classify_source( + caster, index, self_guid, stores, + ) + .and_then(|source| { + crate::combat_text::damage_color( + text_gates, + source, + crate::combat_text::melee_styled(display), + ) + }) { for &(guid, code) in &misses { let anchor_guid = if code == 11 { caster } else { guid }; if self_guid.0 == Some(anchor_guid) { @@ -799,7 +1211,7 @@ pub(super) fn spell_go( /// An observed cast was interrupted/cancelled (`SMSG_SPELL_FAILED_OTHER`) — ends the caster's /// `Casting` state seam the same as [`spell_go`]. -pub(super) fn spell_failed_other( +fn spell_failed_other( caster: u64, spell_id: u32, commands: &mut Commands, @@ -851,7 +1263,7 @@ pub(super) fn spell_failed_other( /// `startTime`/`maxValue` shift — the spark jumps back and the bar keeps running), so a hit no /// longer lets the bar finish early while the real cast runs on (decision 0256). Self-only on the /// wire, but the caster guid is on the packet — gate on it like every other own-cast edge. -pub(super) fn spell_delayed( +fn spell_delayed( caster: u64, delay_ms: u32, self_guid: &SelfGuid, @@ -891,7 +1303,7 @@ pub(super) fn spell_delayed( /// `InterruptSpell(CURRENT_AUTOREPEAT_SPELL)` → `SendAutoRepeatCancel`). Movement does NOT /// (`_UpdateAutoRepeatSpell` interrupts only Category 351, the wand, when moving) — Auto Shot /// stays armed across a run, exactly like vanilla. -pub(super) fn cancel_auto_repeat( +fn cancel_auto_repeat( auto_repeat: &mut AutoRepeatActive, self_guid: &SelfGuid, index: &GuidIndex, @@ -957,7 +1369,7 @@ fn pet_go_cooldown( /// own list). **Which unit's store** it lands in is the caller's decision /// ([`super::addressed_store`]): the four cooldown packets all carry a caster guid, and resolving /// it in one place is what let the pet bar have real cooldowns without a second copy of this arm. -pub(super) fn spell_cooldowns( +fn spell_cooldowns( caster: u64, pairs: Vec<(u32, u32)>, spells: Option<&Spells>, @@ -976,7 +1388,7 @@ pub(super) fn spell_cooldowns( /// `SMSG_ITEM_COOLDOWN` (`0x6e95d0`) — the fixed 30 s use cooldown, keyed on the item instance's /// template entry (the client resolves the guid to its item record the same way). -pub(super) fn item_cooldown( +fn item_cooldown( item_guid: u64, spell_id: u32, items: &crate::items::Items, @@ -990,13 +1402,13 @@ pub(super) fn item_cooldown( /// `SMSG_COOLDOWN_EVENT` (`0x6e9670` → `0x6e3050(force=0)`) — start an on-hold cooldown's parked /// timers now (Stealth ends, Feign Death drops). Store chosen by [`super::addressed_store`]. -pub(super) fn cooldown_event(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) { +fn cooldown_event(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) { debug!("net: cooldown event — spell {spell_id} on {caster:#x}"); cooldowns.cooldown_event(spell_id, Instant::now()); } /// `SMSG_CLEAR_COOLDOWN` (`0x6e9670` → `0x6e3050(force=1)`) — remove the spell's record outright. -pub(super) fn clear_cooldown(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) { +fn clear_cooldown(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) { debug!("net: clear cooldown — spell {spell_id} on {caster:#x}"); cooldowns.clear_spell(spell_id); } @@ -1004,14 +1416,14 @@ pub(super) fn clear_cooldown(spell_id: u32, caster: u64, cooldowns: &mut Cooldow /// `SMSG_COOLDOWN_CHEAT` (`0x6e9730` → `0x6e9700`) — the GM reset wipes the whole list. The /// reference's own handler wipes "the self/pet cooldown list" on a guid match, which is exactly /// what routing through [`super::addressed_store`] now reproduces. -pub(super) fn cooldown_cheat(caster: u64, cooldowns: &mut Cooldowns) { +fn cooldown_cheat(caster: u64, cooldowns: &mut Cooldowns) { debug!("net: cooldown cheat (wipe) for {caster:#x}"); cooldowns.wipe(); } /// `MSG_CHANNEL_START` — self-only on the wire (no guid), so it goes straight to the cast bar; the /// channel *animation* state rides the unit-field pair instead (decision 0137). -pub(super) fn channel_start( +fn channel_start( spell_id: u32, duration_ms: u32, channel: &mut ActiveChannel, @@ -1025,11 +1437,7 @@ pub(super) fn channel_start( } /// `MSG_CHANNEL_UPDATE` — the running channel's remaining time (`0` is its stop edge). -pub(super) fn channel_update( - remaining_ms: u32, - channel: &mut ActiveChannel, - feed: &mut CastBarFeed, -) { +fn channel_update(remaining_ms: u32, channel: &mut ActiveChannel, feed: &mut CastBarFeed) { channel.update(remaining_ms, Instant::now()); feed.0.push(CastBarEdge::ChannelUpdate { remaining_ms }); } @@ -1037,12 +1445,7 @@ pub(super) fn channel_update( /// `SMSG_UPDATE_AURA_DURATION` — one of our own auras' remaining time (decisions 0255/0257), keyed /// by raw slot and stamped with the receive time. The `ui_aura` feed joins it to the aura in that /// slot by arrival order; it arrives *before* the descriptor delta that names the slot. -pub(super) fn aura_duration( - slot: u8, - remaining_ms: u32, - durations: &mut AuraDurations, - now_secs: f64, -) { +fn aura_duration(slot: u8, remaining_ms: u32, durations: &mut AuraDurations, now_secs: f64) { durations.set(slot, remaining_ms, now_secs); } @@ -1053,7 +1456,7 @@ pub(super) fn aura_duration( /// /// The out-of-range refusal lives on the store ([`crate::spell_mods::SpellModifiers::set`], which /// documents why it is ours and not the reference's). -pub(super) fn set_spell_modifier( +fn set_spell_modifier( flat: bool, mask_bit: u8, op: u8, @@ -1097,7 +1500,7 @@ fn fill_chain_hops( /// its GO handler), which is how a non-channeled chain spell draws at all. vmangos sends this one /// only for channelled spells (`Spell::SendChannelStart`), so on this server it is the drains' /// and Mind Flay's producer, and the GO leg is every other chain spell's. -pub(super) fn spell_chain_targets( +fn spell_chain_targets( caster: u64, spell_id: u32, targets: Vec, diff --git a/crates/benilla-app/src/tutorial.rs b/crates/benilla-app/src/tutorial.rs index 9f54ab19..9bc33c60 100644 --- a/crates/benilla-app/src/tutorial.rs +++ b/crates/benilla-app/src/tutorial.rs @@ -570,6 +570,7 @@ pub(crate) struct TutorialPlugin; impl Plugin for TutorialPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_message::() @@ -608,6 +609,26 @@ impl Plugin for TutorialPlugin { } } +/// The tutorial flags' packet handler (in the net handler table since 2326). +mod net { + use benilla_protocol::{SessionEvent, SessionEventKind}; + use bevy::prelude::*; + + use super::Tutorials; + use crate::net::NetHandlerApp; + + /// Register the handler — called from [`super::TutorialPlugin`]. + pub(super) fn register(app: &mut App) { + app.net_handler(SessionEventKind::TutorialFlags, on_flags); + } + + fn on_flags(In(ev): In, mut tutorials: ResMut) { + if let SessionEvent::TutorialFlags(bytes) = ev { + tutorials.apply_flags(&bytes); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 256dd71e..1d82cd46 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -44,6 +44,7 @@ mod errors; mod feed; #[cfg(test)] mod feed_tests; +mod net; mod ranks; mod state; pub(crate) mod targeting; @@ -364,6 +365,7 @@ pub(crate) struct UiActionPlugin; impl Plugin for UiActionPlugin { fn build(&self, app: &mut App) { + net::register(app); app.add_observer(on_cvar); app.init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_action/net.rs b/crates/benilla-app/src/ui_action/net.rs new file mode 100644 index 00000000..24b1e6c1 --- /dev/null +++ b/crates/benilla-app/src/ui_action/net.rs @@ -0,0 +1,34 @@ +//! The action bar's packet handler (in the net handler table since 2322, moved out of the drain's +//! mount arm file) — the (dis)mount attempt's result code (decision 0441 P1) onto the mount error +//! line. The flourish half of that arc is [`crate::creature_anim::net`]'s. + +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::prelude::*; + +use super::MountErrors; +use crate::net::NetHandlerApp; + +/// Register the handler — called from [`super::UiActionPlugin`]. +pub(super) fn register(app: &mut App) { + app.net_handler(SessionEventKind::MountResult, on_mount_result); +} + +fn on_mount_result(In(ev): In, mut errors: ResMut) { + if let SessionEvent::MountResult { mount, code } = ev { + mount_result(mount, code, &mut errors); + } +} + +/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` — OK is silent in the reference (10 mounting, +/// 3 dismounting); a failure queues the red error line (`ui_action::mount_result_key` — resolved +/// against the VM's GlobalStrings at drain). +fn mount_result(mount: bool, code: u32, errors: &mut MountErrors) { + let ok = if mount { code == 10 } else { code == 3 }; + if !ok { + info!( + "net: {}mount refused (code {code})", + if mount { "" } else { "dis" } + ); + errors.0.push((mount, code)); + } +} diff --git a/crates/benilla-app/src/ui_auction/net.rs b/crates/benilla-app/src/ui_auction/net.rs index 93481794..d286d40e 100644 --- a/crates/benilla-app/src/ui_auction/net.rs +++ b/crates/benilla-app/src/ui_auction/net.rs @@ -102,7 +102,7 @@ fn on_removed_notification(In(ev): In, mut auction: ResMut, mut auction: ResMut) { auction.clear_session(); } @@ -330,22 +330,12 @@ mod tests { app.add_plugins(MinimalPlugins) .init_resource::(); register(&mut app); - let through_the_match = |_: &mut World, unclaimed: Vec| { - let kinds: Vec = - unclaimed.iter().map(SessionEventKind::from).collect(); - assert_eq!( - kinds, - vec![SessionEventKind::Disconnected], - "the broadcast reaches the match too" - ); - }; crate::net::handlers::dispatch( app.world_mut(), vec![SessionEvent::AuctionHello { auctioneer: 0x10, house_id: 1, }], - |_, unclaimed| assert!(unclaimed.is_empty()), ); assert_eq!(app.world().resource::().auctioneer, Some(0x10)); crate::net::handlers::dispatch( @@ -354,7 +344,6 @@ mod tests { reason: "socket".into(), end: benilla_protocol::SessionEnd::Lost, }], - through_the_match, ); assert_eq!(app.world().resource::().auctioneer, None); } diff --git a/crates/benilla-app/src/ui_bank/net.rs b/crates/benilla-app/src/ui_bank/net.rs index e2157b8f..d73cb6e2 100644 --- a/crates/benilla-app/src/ui_bank/net.rs +++ b/crates/benilla-app/src/ui_bank/net.rs @@ -37,7 +37,7 @@ fn on_buy_slot_result(In(ev): In, mut errors: ResMut) } /// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker. -/// A listener on the session end ([`crate::net::handlers::BROADCAST`]). +/// A listener on the session end (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut bank: ResMut) { bank.clear_session(); } diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 141fd711..44e1edac 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -40,6 +40,7 @@ mod input; mod language; /// `LoggingChat`/`LoggingCombat` — the two log files `/chatlog` and `/combatlog` toggle. mod logging; +mod net; /// The `AUTO_JOIN_GUILD_CHANNEL` cascade (decision 2144) — the one place the client joins or /// leaves `GuildRecruitment - City` on its own. mod recruitment; @@ -75,6 +76,7 @@ pub(crate) struct UiChatPlugin; impl Plugin for UiChatPlugin { fn build(&self, app: &mut App) { + net::register(app); app.add_observer(combat::on_cvar); app.init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs index 3342719a..664e5ff8 100644 --- a/crates/benilla-app/src/ui_chat/combat.rs +++ b/crates/benilla-app/src/ui_chat/combat.rs @@ -251,22 +251,6 @@ impl Default for LogPeriodicSpells { /// `CombatLogPeriodicSpells`' registered name. pub(crate) const LOG_PERIODIC_CVAR: &str = "CombatLogPeriodicSpells"; -/// **The combat-feedback CVars, as one system parameter** — what a packet handler needs to know -/// about the player's settings before it emits a line or a floating number. -/// -/// Bundled because they are one concern — the reference reads all three inside the same -/// combat-log/world-text translation unit — and read by the net drain as the combat-feedback -/// member of its catalogs (`net::apply::params::Catalogs`). -#[derive(bevy::ecs::system::SystemParam)] -pub(crate) struct CombatFeedbackCvars<'w> { - /// The eight display ranges. - pub ranges: Res<'w, CombatLogRanges>, - /// `CombatLogPeriodicSpells`. - pub periodic: Res<'w, LogPeriodicSpells>, - /// `CombatDamage` + the two `Pet*` sub-gates. - pub damage_text: Res<'w, crate::combat_text::DamageTextGates>, -} - /// `CombatDeathLogRange`'s registered name and default — `0x626d5f`, default string `"60"` /// (`0x862e14`). pub(crate) const DEATH_LOG_RANGE_CVAR: &str = "CombatDeathLogRange"; diff --git a/crates/benilla-app/src/net/apply/chat.rs b/crates/benilla-app/src/ui_chat/net.rs similarity index 79% rename from crates/benilla-app/src/net/apply/chat.rs rename to crates/benilla-app/src/ui_chat/net.rs index 9c9d20db..dbc7e4ba 100644 --- a/crates/benilla-app/src/net/apply/chat.rs +++ b/crates/benilla-app/src/ui_chat/net.rs @@ -1,23 +1,137 @@ -//! Chat-window arm bodies for [`super::apply_net_updates`]'s dispatch match — the spoken line -//! itself, plus the server notices and query answers that render as chat lines (the channel -//! roster, whisper refusals, `/played`). Each `pub(super)` fn here is exactly one arm's body; the -//! match at the call site stays the dispatcher, one call per arm. +//! The chat window's packet handlers (in the net handler table since 2323, moved out of the +//! drain's chat arm file) — the spoken line itself, plus the server notices and query answers +//! that render as chat lines (the channel roster, whisper refusals, `/played`). //! -//! Two arms here do NOT render as chat: `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` are -//! the reference's UIErrorsFrame toasts, and queue onto [`UiErrorTexts`]. They live in this file -//! because they are text-carrying server notices, not because they share a sink. +//! Two handlers here do NOT render as chat: `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` +//! are the reference's UIErrorsFrame toasts, and queue onto [`UiErrorTexts`]. They live in this +//! file because they are text-carrying server notices, not because they share a sink. use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER}; +use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; -use crate::ui_action::UiErrorTexts; -use crate::ui_chat::{Broadcast, ChatEvent, ChatEventKind, ChatLog}; +use super::{Broadcast, ChatEvent, ChatEventKind, ChatLog}; +use crate::net::{ClientCommand, NetCommands, NetHandlerApp, PlayedTimeAnswer, ServerSaidMessage}; +use crate::ui_action::{UiErrorKeys, UiErrorTexts}; use crate::ui_social::SocialState; -use super::super::{ClientCommand, NetCommands, ServerSaidMessage}; +/// Register the chat window's handlers — called from [`super::UiChatPlugin`]. One per kind +/// (the four world broadcasts share one), plus the session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::Chat, on_chat) + .net_handler(K::ChannelList, on_channel_list) + .net_handler(K::ChatPlayerNotFound, on_whisper_refusal) + .net_handler(K::ChatWrongFaction, on_whisper_refusal) + .net_handler(K::ZoneUnderAttack, on_broadcast) + .net_handler(K::DefenseMessage, on_broadcast) + .net_handler(K::ServerMessage, on_broadcast) + .net_handler(K::ChatRestricted, on_broadcast) + .net_handler(K::Notification, on_toast) + .net_handler(K::AreaTriggerMessage, on_toast) + .net_handler(K::PlayedTime, on_played_time) + .net_handler(K::ChannelNotify, on_channel_notify) + .net_handler(K::RandomRoll, on_random_roll) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_chat( + In(ev): In, + mut chat_log: ResMut, + social: Res, + commands: Res, + mut server_said: MessageWriter, +) { + if let SessionEvent::Chat(m) = ev { + chat(m, &mut chat_log, &social, &commands, &mut server_said); + } +} + +fn on_channel_list(In(ev): In, mut chat_log: ResMut) { + if let SessionEvent::ChannelList { + channel, members, .. + } = ev + { + channel_list(channel, &members, &mut chat_log); + } +} + +fn on_whisper_refusal(In(ev): In, mut errors: ResMut) { + match ev { + SessionEvent::ChatPlayerNotFound { name } => chat_player_not_found(&name, &mut errors), + SessionEvent::ChatWrongFaction => chat_wrong_faction(&mut errors), + _ => {} + } +} + +/// The four world broadcasts — parked for [`super::broadcast`]'s resolve pass, which owns the +/// AreaTable/ServerMessages lookups and the joined-defense-channel walk. +fn on_broadcast(In(ev): In, mut chat_log: ResMut) { + let b = match ev { + SessionEvent::ZoneUnderAttack { area_id } => Broadcast::ZoneUnderAttack { area_id }, + SessionEvent::DefenseMessage { zone_id, text } => Broadcast::Defense { zone_id, text }, + SessionEvent::ServerMessage { message_type, text } => { + Broadcast::Server { message_type, text } + } + SessionEvent::ChatRestricted => Broadcast::ChatRestricted, + _ => return, + }; + broadcast(b, &mut chat_log); +} + +fn on_toast(In(ev): In, mut errors: ResMut) { + match ev { + SessionEvent::Notification { text } => notification(text, &mut errors), + SessionEvent::AreaTriggerMessage { text } => area_trigger_message(text, &mut errors), + _ => {} + } +} + +/// BOTH halves, and they are not redundant. The chat breakdown is our stand-in for the +/// reference's `ChatFrame_DisplayTimePlayed`, which we do not ship; the mailbox is what becomes +/// `TIME_PLAYED_MSG(total, level)` for an addon that asked. +fn on_played_time( + In(ev): In, + mut answer: ResMut, + mut chat_log: ResMut, +) { + if let SessionEvent::PlayedTime { total, level } = ev { + answer.0 = Some((total, level)); + played_time(total, level, &mut chat_log); + } +} + +fn on_channel_notify(In(ev): In, mut chat_log: ResMut) { + if let SessionEvent::ChannelNotify { + notice, + channel, + tail, + } = ev + { + chat_log.push_channel_notice(notice, channel, &tail); + } +} + +fn on_random_roll(In(ev): In, mut chat_log: ResMut) { + if let SessionEvent::RandomRoll { + min, + max, + roll, + guid, + } = ev + { + chat_log.push_roll(min, max, roll, guid); + } +} + +/// The chat log's session state dies with the socket. A listener on the session end +/// (a second handler on the kind, after the bridge's own teardown). +fn on_session_end(In(_): In, mut chat_log: ResMut) { + chat_log.clear_session(); +} /// A spoken line (`SMSG_MESSAGECHAT`) — the chat window's own feed (decision 0084): -/// [`crate::ui_chat`] formats + colors per type, resolves the sender name ask-once, and +/// [`super`] formats + colors per type, resolves the sender name ask-once, and /// AddMessages it into ChatFrame1. /// /// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S ANSWER to a GM @@ -27,7 +141,7 @@ use super::super::{ClientCommand, NetCommands, ServerSaidMessage}; /// invisible at the default level, and a refused command read exactly like an applied one /// (decision 0651 — the rig's whole batch silently no-op'd on a too-low GM level and nothing said /// so). Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume. -pub(super) fn chat( +fn chat( m: ChatMessage, chat_log: &mut ChatLog, social: &SocialState, @@ -152,7 +266,7 @@ pub(super) fn chat( /// The `/chatlist` roster (`SMSG_CHANNEL_LIST`) — CHAT_CHANNEL_LIST_GET "[%s] " + the roster. /// Names arrive as guids; v1 renders the count (the per-member resolve fan-out lands with the /// P6 channel wiring — /chatlist is rare enough that a count is honest, never wrong). -pub(super) fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mut ChatLog) { +fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mut ChatLog) { let mut ev = ChatEvent::text_only( ChatEventKind::ChannelList, format!("{} member(s)", members.len()), @@ -168,7 +282,7 @@ pub(super) fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mu /// there is no VM here, so `ui_action`'s drain is what resolves it against the player's own /// `GlobalStrings.lua` — and the row, not this call site, is what says the line goes to chat /// (`kind 0`) and makes no sound. -pub(super) fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::UiErrorKeys) { +fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::UiErrorKeys) { errors.0.push(crate::ui_action::UiError::s( "ERR_CHAT_PLAYER_NOT_FOUND_S", name, @@ -177,7 +291,7 @@ pub(super) fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::U /// A cross-faction whisper was refused — `ERR_CHAT_WRONG_FACTION`, catalog row 240. Same route and /// the same reason as [`chat_player_not_found`], with no argument to fill. -pub(super) fn chat_wrong_faction(errors: &mut crate::ui_action::UiErrorKeys) { +fn chat_wrong_faction(errors: &mut crate::ui_action::UiErrorKeys) { errors .0 .push(crate::ui_action::UiError::key("ERR_CHAT_WRONG_FACTION")); @@ -198,7 +312,7 @@ pub(super) fn chat_wrong_faction(errors: &mut crate::ui_action::UiErrorKeys) { /// `Player::SetGameMaster` answers `.gm on|off` with **both** `SendSysMessage` and /// `SendNotification` (`Objects/Player.cpp:2676-2677`/`2701-2702`), so a client that sinks the /// notification into chat prints "GM mode is ON" **twice** where the reference prints it once. -pub(super) fn notification(text: String, errors: &mut UiErrorTexts) { +fn notification(text: String, errors: &mut UiErrorTexts) { // The handler's own console leg, and the same reason 0651 logs the dot-command answers: a // toast that flashed for five seconds and one that never arrived look identical afterwards. info!("net: notification — {text}"); @@ -212,7 +326,7 @@ pub(super) fn notification(text: String, errors: &mut UiErrorTexts) { /// comment here called it the same sink as the notification and left it at that; the sink is the /// same, the arm is not.) Without it, a refused portal is silent, which reads exactly like a /// portal that is still broken. -pub(super) fn area_trigger_message(text: String, errors: &mut UiErrorTexts) { +fn area_trigger_message(text: String, errors: &mut UiErrorTexts) { // Logged for the same reason 0651 logs the server's dot-command answers: a trigger that // refused and a trigger the client never noticed look identical from outside. info!("net: area-trigger message — {text}"); @@ -220,7 +334,7 @@ pub(super) fn area_trigger_message(text: String, errors: &mut UiErrorTexts) { } /// The four **world broadcasts** — parked on [`ChatLog`]'s broadcast queue for -/// [`crate::ui_chat`]'s resolve pass, which holds the AreaTable/ServerMessages catalogs and the +/// [`super`]'s resolve pass, which holds the AreaTable/ServerMessages catalogs and the /// joined-channel walk this site does not (`ui_chat::broadcast` carries the whole mechanism). /// /// Only the parking happens here, deliberately: half-resolving at the packet — naming the area @@ -231,7 +345,7 @@ pub(super) fn area_trigger_message(text: String, errors: &mut UiErrorTexts) { /// arrived" have to be distinguishable in a log afterwards. A defense broadcast that reaches a /// character in neither defense channel prints nothing on screen and is faithful in doing so — the /// log line is the only trace it happened at all. -pub(super) fn broadcast(b: Broadcast, chat_log: &mut ChatLog) { +fn broadcast(b: Broadcast, chat_log: &mut ChatLog) { info!("net: world broadcast — {b:?}"); chat_log.push_broadcast(b); } @@ -239,7 +353,7 @@ pub(super) fn broadcast(b: Broadcast, chat_log: &mut ChatLog) { /// The `/played` answer (`SMSG_PLAYED_TIME`) — TIME_PLAYED_TOTAL/LEVEL over /// TIME_DAYHOURMINUTESECOND (GlobalStrings:4243-4247; the ref's /// ChatFrame_DisplayTimePlayed breakdown). -pub(super) fn played_time(total: u32, level: u32, chat_log: &mut ChatLog) { +fn played_time(total: u32, level: u32, chat_log: &mut ChatLog) { for (label, secs) in [ ("Total time played", total), ("Time played this level", level), diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 840bf295..0f23e212 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -270,7 +270,7 @@ pub(crate) mod net { /// A pending challenge, a running duel, and its countdown all die with the socket (decision /// 0633) — the server drops the duel too (`Player::DuelComplete(DUEL_FLED)` on logout), and a /// stale arbiter guid would make the next AcceptDuel echo a dead object. A listener on the - /// session end ([`crate::net::handlers::BROADCAST`]). + /// session end (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut duel: ResMut) { *duel = DuelState::default(); } diff --git a/crates/benilla-app/src/ui_gm_ticket.rs b/crates/benilla-app/src/ui_gm_ticket.rs index f7aba93b..74eadd1c 100644 --- a/crates/benilla-app/src/ui_gm_ticket.rs +++ b/crates/benilla-app/src/ui_gm_ticket.rs @@ -454,7 +454,7 @@ pub(crate) mod net { /// ticket belongs to the CHARACTER, and the next login may be a different one. Its answer /// counters go with it, so the first `SMSG_GMTICKET_GETTICKET` of the new session re-fires /// `UPDATE_TICKET` rather than being diffed away against the old character's answer count. - /// A listener on the session end ([`crate::net::handlers::BROADCAST`]). + /// A listener on the session end (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut ticket: ResMut) { ticket.clear_session(); } diff --git a/crates/benilla-app/src/ui_gossip/net.rs b/crates/benilla-app/src/ui_gossip/net.rs index d4dd41e3..a7c0c83e 100644 --- a/crates/benilla-app/src/ui_gossip/net.rs +++ b/crates/benilla-app/src/ui_gossip/net.rs @@ -69,7 +69,7 @@ fn on_gossip_complete( } /// An open gossip menu dies with the socket. A listener on the session end -/// ([`crate::net::handlers::BROADCAST`]). +/// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut gossip: ResMut) { gossip.clear_session(); } diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs index 79fc3898..7eefa2a9 100644 --- a/crates/benilla-app/src/ui_guild/mod.rs +++ b/crates/benilla-app/src/ui_guild/mod.rs @@ -750,7 +750,7 @@ pub(crate) mod net { /// survive correctly, but the reference's own is backed by `guildcache.wdb` and re-primed /// lazily, and keeping a cache alive across a socket only to save one query is not worth the /// one wrong name a renamed guild would show. A listener on the session end - /// ([`crate::net::handlers::BROADCAST`]). + /// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut guild: ResMut) { *guild = GuildState::default(); } diff --git a/crates/benilla-app/src/ui_logout.rs b/crates/benilla-app/src/ui_logout.rs index 3e863b9f..5ebd8b84 100644 --- a/crates/benilla-app/src/ui_logout.rs +++ b/crates/benilla-app/src/ui_logout.rs @@ -261,6 +261,7 @@ pub struct UiLogoutPlugin; impl Plugin for UiLogoutPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( @@ -272,6 +273,33 @@ impl Plugin for UiLogoutPlugin { } } +/// The logout arc's two narration packets (decision 0674; in the net handler table since 2326) +/// — this module owns the decision table; the handler is only the hand-off. +mod net { + use benilla_protocol::{SessionEvent, SessionEventKind}; + use bevy::prelude::*; + + use super::LogoutState; + use crate::net::NetHandlerApp; + + /// Register the pair — called from [`super::UiLogoutPlugin`]. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::LogoutResponse, on_narration) + .net_handler(K::LogoutCancelled, on_narration); + } + + fn on_narration(In(ev): In, mut logout: ResMut) { + match ev { + SessionEvent::LogoutResponse { reason, instant } => { + logout.apply_response(reason, instant) + } + SessionEvent::LogoutCancelled => logout.apply_cancelled(), + _ => {} + } + } +} + #[cfg(test)] mod tests { use bevy::ecs::system::RunSystemOnce; diff --git a/crates/benilla-app/src/ui_loot/net.rs b/crates/benilla-app/src/ui_loot/net.rs index 730d9874..09a99da7 100644 --- a/crates/benilla-app/src/ui_loot/net.rs +++ b/crates/benilla-app/src/ui_loot/net.rs @@ -112,7 +112,7 @@ fn on_item_push_result( } /// An open loot window and the kneel latch die with the socket (unconditionally). A listener on -/// the session end ([`crate::net::handlers::BROADCAST`]). +/// the session end (a second handler on the kind, after the bridge's own teardown). fn on_session_end( In(_): In, mut loot: ResMut, diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index 3cfc5e01..81cfeef9 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -321,7 +321,7 @@ mod net { } /// Open group rolls die with the socket (decision 0591). A listener on the session end - /// ([`crate::net::handlers::BROADCAST`]). + /// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut rolls: ResMut) { rolls.clear(); } diff --git a/crates/benilla-app/src/ui_mail/net.rs b/crates/benilla-app/src/ui_mail/net.rs index a0f53af5..ce43e108 100644 --- a/crates/benilla-app/src/ui_mail/net.rs +++ b/crates/benilla-app/src/ui_mail/net.rs @@ -83,7 +83,7 @@ fn on_next_mail_time(In(ev): In, mut pending: ResMut) /// An open mailbox dies with the socket, and the arrival countdown is login-scoped (decision /// 0544 P3): a fresh login re-queries `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing /// carries over across a reconnect. A listener on the session end, which the drain's dispatch -/// match still owns ([`crate::net::handlers::BROADCAST`]). +/// match still owns (a second handler on the kind, after the bridge's own teardown). fn on_session_end( In(_): In, mut mail: ResMut, @@ -262,8 +262,8 @@ mod tests { use super::*; /// End to end through the real registration: the wire's "mail is waiting" reaches the - /// countdown, a mailbox click survives until the session ends, and the session end — a - /// broadcast the match still owns — resets both. + /// countdown, a mailbox click survives until the session ends, and the session end — the + /// bridge's own teardown beside this listener — resets both. #[test] fn the_table_routes_the_arrival_and_the_session_end_to_the_mailbox() { let (tx, _rx) = crossbeam_channel::unbounded(); @@ -279,20 +279,16 @@ mod tests { crate::net::handlers::dispatch( app.world_mut(), vec![SessionEvent::NextMailTime { seconds: 0.0 }], - |_, _| panic!("a claimed kind never reaches the match"), ); assert!(app.world().resource::().has_new_mail()); - let mut through_the_match = Vec::new(); crate::net::handlers::dispatch( app.world_mut(), vec![SessionEvent::Disconnected { reason: "socket".into(), end: benilla_protocol::SessionEnd::Lost, }], - |_, unclaimed| through_the_match.extend(unclaimed.iter().map(SessionEventKind::from)), ); - assert_eq!(through_the_match, vec![SessionEventKind::Disconnected]); assert!(!app.world().resource::().has_new_mail()); assert_eq!(app.world().resource::().mailbox, None); } diff --git a/crates/benilla-app/src/ui_merchant/net.rs b/crates/benilla-app/src/ui_merchant/net.rs index d8440fa9..ce5146d4 100644 --- a/crates/benilla-app/src/ui_merchant/net.rs +++ b/crates/benilla-app/src/ui_merchant/net.rs @@ -60,7 +60,7 @@ fn on_sell_failed(In(ev): In, mut errors: ResMut) } /// An open vendor window dies with the socket. A listener on the session end -/// ([`crate::net::handlers::BROADCAST`]). +/// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut merchant: ResMut) { merchant.clear_session(); } diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index bea9d671..e6926d42 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -35,6 +35,7 @@ use bevy::prelude::*; use crate::ui_script::{UiFeed, UiInput}; mod feed; +pub(crate) mod net; pub(crate) use feed::{ raid_row_guid, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP, PARTY_TOKENS, RAID_TOKENS, @@ -44,6 +45,7 @@ pub(crate) struct UiPartyPlugin; impl Plugin for UiPartyPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( diff --git a/crates/benilla-app/src/net/apply/group.rs b/crates/benilla-app/src/ui_party/net.rs similarity index 76% rename from crates/benilla-app/src/net/apply/group.rs rename to crates/benilla-app/src/ui_party/net.rs index 80ce811c..c80b1e24 100644 --- a/crates/benilla-app/src/net/apply/group.rs +++ b/crates/benilla-app/src/ui_party/net.rs @@ -1,8 +1,10 @@ -//! The group/party arm bodies (decision 0434 §D2, superseded by 0440) for -//! [`super::apply_net_updates`]'s dispatch match. [`GroupState`] mirrors the wire and names the -//! **messages** its roster diff implies; these are the drain-side shims that put them on the -//! shared by-key queue. Each `pub(super)` fn here is exactly one arm's body; the match at the call -//! site stays the dispatcher, one call per arm. +//! The group/party packet handlers (decision 0434 §D2, superseded by 0440; in the net handler +//! table since 2321, moved out of the drain's group arm file). [`GroupState`] mirrors the wire and +//! names the **messages** its roster diff implies; these are the shims that put them on the +//! shared by-key queue. Two bodies here are not packet handlers but the object layer's hook on a +//! roster member's stream-out ([`member_deactivated`], [`roster_deactivated`]); the drain's +//! object arms still call them, and they become listeners on the object kinds once those are +//! peeled. //! //! **They are message ids, not sentences** (decisions 2045/2054). The reference reaches all twelve //! through one `CGGameUI::DisplayError(msgId)`, and the catalog row that id names answers three @@ -15,12 +17,184 @@ use benilla_protocol::messages::{ }; use bevy::prelude::*; +use benilla_protocol::{SessionEvent, SessionEventKind}; + +use super::GroupState; use crate::names::NameCache; +use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectStore, SelfGuid}; use crate::ui_action::{UiError, UiErrorKeys}; -use crate::ui_party::GroupState; use crate::ui_quest::QuestGiver; -use super::super::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid}; +/// Register the group handlers — called from [`super::UiPartyPlugin`]. One per kind, plus the +/// session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::GroupInvite, on_invite) + .net_handler(K::GroupDecline, on_decline) + .net_handler(K::GroupUninvited, on_uninvited) + .net_handler(K::GroupLeaderChanged, on_leader_changed) + .net_handler(K::GroupDestroyed, on_destroyed) + .net_handler(K::GroupList, on_list) + .net_handler(K::PartyCommandResult, on_command_result) + .net_handler(K::PartyMemberStats, on_member_stats) + .net_handler(K::RaidTargetSet, on_raid_target) + .net_handler(K::RaidTargetList, on_raid_target) + .net_handler(K::ReadyCheckRequest, on_ready_check_request) + .net_handler(K::ReadyCheckAnswer, on_ready_check_answer) + .net_handler(K::RaidInstanceInfo, on_raid_instance_info) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_invite( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, +) { + if let SessionEvent::GroupInvite { inviter } = ev { + invited(&mut group, &mut errors, &inviter); + } +} + +fn on_decline( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, +) { + if let SessionEvent::GroupDecline { name } = ev { + declined(&mut group, &mut errors, &name); + } +} + +fn on_uninvited( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, +) { + if let SessionEvent::GroupUninvited = ev { + uninvited(&mut group, &mut errors); + } +} + +fn on_leader_changed( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, + self_guid: Res, + names: Res, + commands: Res, +) { + if let SessionEvent::GroupLeaderChanged { name } = ev { + leader_changed( + &mut group, + &mut errors, + &name, + &self_guid, + &names, + &commands, + ); + } +} + +fn on_destroyed( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, +) { + if let SessionEvent::GroupDestroyed = ev { + destroyed(&mut group, &mut errors); + } +} + +fn on_list( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, + mut quest: ResMut, + names: Res, + index: Res, + commands: Res, +) { + if let SessionEvent::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } = ev + { + list( + &mut group, + &mut errors, + &mut quest, + group_type, + own_flags, + members, + leader, + loot, + &names, + &index, + &commands, + ); + } +} + +fn on_command_result( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, +) { + if let SessionEvent::PartyCommandResult { + operation, + member, + result, + } = ev + { + command_result(&mut group, &mut errors, operation, &member, result); + } +} + +fn on_member_stats(In(ev): In, mut group: ResMut) { + if let SessionEvent::PartyMemberStats { guid, full, info } = ev { + group.apply_stats(guid, full, *info); + } +} + +fn on_raid_target(In(ev): In, mut group: ResMut) { + match ev { + SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid), + SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries), + _ => {} + } +} + +fn on_ready_check_request( + In(ev): In, + mut group: ResMut, + mut errors: ResMut, + self_guid: Res, +) { + if let SessionEvent::ReadyCheckRequest = ev { + ready_check_request(&mut group, &mut errors, &self_guid); + } +} + +fn on_ready_check_answer(In(ev): In, mut group: ResMut) { + if let SessionEvent::ReadyCheckAnswer { guid, ready } = ev { + group.apply_ready_check_answer(guid, ready != 0); + } +} + +fn on_raid_instance_info(In(ev): In, mut group: ResMut) { + if let SessionEvent::RaidInstanceInfo { entries } = ev { + group.apply_raid_instance_info(entries); + } +} + +/// The group dies with the socket. A listener on the session end +/// (a second handler on the kind, after the bridge's own teardown). +fn on_session_end(In(_): In, mut group: ResMut) { + group.clear_session(); +} /// Queue the messages a `GroupState::apply_*` named. `ui_action::feed_actions` resolves each key /// against the VM's own `GlobalStrings.lua` and puts the line on the surface its catalog row @@ -32,38 +206,34 @@ fn push_group_lines(errors: &mut UiErrorKeys, lines: Vec) { /// `MSG_RAID_READY_CHECK`, the open form (decision 1989): our own echo as leader takes the /// response-collection arm and prints nothing; as anyone else we print the leader's line and take /// the popup ticket. The leader test is the reference's guid compare (`0x4ba3a0`). -pub(super) fn ready_check_request( - group: &mut GroupState, - errors: &mut UiErrorKeys, - self_guid: &SelfGuid, -) { +fn ready_check_request(group: &mut GroupState, errors: &mut UiErrorKeys, self_guid: &SelfGuid) { let we_lead = self_guid.0 == Some(group.leader); push_group_lines(errors, group.apply_ready_check_request(we_lead)); } /// `SMSG_GROUP_INVITE` — someone asked us into their group. -pub(super) fn invited(group: &mut GroupState, errors: &mut UiErrorKeys, inviter: &str) { +fn invited(group: &mut GroupState, errors: &mut UiErrorKeys, inviter: &str) { push_group_lines(errors, group.apply_invited(inviter)); } /// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only). -pub(super) fn declined(group: &mut GroupState, errors: &mut UiErrorKeys, name: &str) { +fn declined(group: &mut GroupState, errors: &mut UiErrorKeys, name: &str) { push_group_lines(errors, group.apply_declined(name)); } /// `SMSG_GROUP_UNINVITE` — we were kicked. -pub(super) fn uninvited(group: &mut GroupState, errors: &mut UiErrorKeys) { +fn uninvited(group: &mut GroupState, errors: &mut UiErrorKeys) { push_group_lines(errors, group.apply_uninvited()); } /// `SMSG_GROUP_DESTROYED` — the group is gone outright. -pub(super) fn destroyed(group: &mut GroupState, errors: &mut UiErrorKeys) { +fn destroyed(group: &mut GroupState, errors: &mut UiErrorKeys) { push_group_lines(errors, group.apply_destroyed()); } /// `SMSG_GROUP_SET_LEADER` — the line reads differently when the new leader is us, so the composer /// needs our own name. It is cache-seeded at login (`session::connected`), so this never asks. -pub(super) fn leader_changed( +fn leader_changed( group: &mut GroupState, errors: &mut UiErrorKeys, name: &str, @@ -88,7 +258,7 @@ pub(super) fn leader_changed( /// read them and both were empty for an out-of-area member before this — the raid grid's class /// column (`ui_party::feed::raid_roster`, whose own-row twin of this hole 1549 §7 found live), and /// the party frame's 2D portrait stand-in (`portrait::temporary_portrait`, report B315). -pub(super) fn list( +fn list( group: &mut GroupState, errors: &mut UiErrorKeys, quest: &mut QuestGiver, @@ -170,7 +340,7 @@ fn seat_new_records( /// on the roster falls straight through; and a guid we hold **no object for** falls through too — /// the hook is a *virtual on the object*, so no object means it never ran, and a server that /// re-announces a stream-out we have already applied must not cost a packet. -pub(super) fn member_deactivated( +pub(crate) fn member_deactivated( guid: u64, group: &mut GroupState, store: Option<&ObjectStore>, @@ -195,7 +365,7 @@ pub(super) fn member_deactivated( /// [`member_deactivated`] for **every** streamed roster member at once — the bulk teardown a /// cross-map transfer performs, where the reference destroys the same objects one at a time and /// runs the same hook on each. -pub(super) fn roster_deactivated( +pub(crate) fn roster_deactivated( group: &mut GroupState, index: &GuidIndex, stores: &Query<&mut ObjectStore>, @@ -214,7 +384,7 @@ pub(super) fn roster_deactivated( } /// `SMSG_PARTY_COMMAND_RESULT` — the verdict on an invite/kick/leave we asked for. -pub(super) fn command_result( +fn command_result( group: &mut GroupState, errors: &mut crate::ui_action::UiErrorKeys, operation: u32, diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index 8be067d2..38dcea7c 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -49,6 +49,7 @@ use crate::ui_unit::UnitFeed; mod bar; mod drain; mod menu; +mod net; mod unit; use bar::feed_pet_bar; @@ -139,6 +140,7 @@ pub(crate) struct UiPetPlugin; impl Plugin for UiPetPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( diff --git a/crates/benilla-app/src/net/apply/pet.rs b/crates/benilla-app/src/ui_pet/net.rs similarity index 84% rename from crates/benilla-app/src/net/apply/pet.rs rename to crates/benilla-app/src/ui_pet/net.rs index 2de8dcb1..3883a47f 100644 --- a/crates/benilla-app/src/net/apply/pet.rs +++ b/crates/benilla-app/src/ui_pet/net.rs @@ -1,9 +1,11 @@ -//! The pet bar's wire arms (decisions 0982, 0988) — `SMSG_PET_SPELLS`, `SMSG_PET_MODE`, -//! `SMSG_PET_ACTION_FEEDBACK`, `SMSG_PET_CAST_FAILED` folded into [`crate::ui_pet::PetBar`]. +//! The pet's packet handlers (decisions 0982, 0988, 2039; in the net handler table since 2321, +//! moved out of the drain's pet arm file) — `SMSG_PET_SPELLS`, `SMSG_PET_MODE`, +//! `SMSG_PET_ACTION_FEEDBACK`, `SMSG_PET_CAST_FAILED` folded into [`PetBar`], the three +//! pet-feedback packets and the pet's voice. //! //! The whole file is short because `SMSG_PET_SPELLS` is not a delta — it **is** the bar, so //! applying it is a replace. These four arms own the bar's CONTENTS; its lit state is -//! [`crate::ui_pet`]'s, latched on the press because the server never answers one (that file's +//! [`super`]'s, latched on the press because the server never answers one (that file's //! module doc has the why). The only state this file writes is what actually arrives on the wire. //! //! The two refusal arms share the player's red-line **queues** — one drain, one sink, one @@ -21,12 +23,83 @@ use std::time::Instant; use bevy::prelude::*; use benilla_protocol::messages::{PetMode, PetSpells}; +use benilla_protocol::{SessionEvent, SessionEventKind}; use benilla_assets::coords::wow_to_bevy; -use super::super::{GuidIndex, PetDismissSoundMessage, PetTalkMessage}; +use super::PetBar; +use crate::net::{GuidIndex, NetHandlerApp, PetDismissSoundMessage, PetTalkMessage}; use crate::ui_action::{CastErrors, PetTameFailures, Spells, UiError, UiErrorKeys}; -use crate::ui_pet::PetBar; + +/// Register the pet's handlers — called from [`super::UiPetPlugin`]. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::PetSpells, on_spells) + .net_handler(K::PetMode, on_mode) + .net_handler(K::PetActionFeedback, on_action_feedback) + .net_handler(K::PetCastFailed, on_cast_failed) + .net_handler(K::PetTameFailure, on_tame_failure) + .net_handler(K::PetNameInvalid, on_refusal_line) + .net_handler(K::PetBroken, on_refusal_line) + .net_handler(K::PetActionSound, on_action_sound) + .net_handler(K::PetDismissSound, on_dismiss_sound); +} + +/// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a wholesale +/// replace and its zero-guid form is the teardown. +fn on_spells(In(ev): In, catalog: Option>, mut bar: ResMut) { + if let SessionEvent::PetSpells(spells) = ev { + pet_spells(*spells, catalog.as_deref(), &mut bar); + } +} + +fn on_mode(In(ev): In, mut bar: ResMut) { + if let SessionEvent::PetMode(mode) = ev { + pet_mode(mode, &mut bar); + } +} + +fn on_action_feedback(In(ev): In, mut errors: ResMut) { + if let SessionEvent::PetActionFeedback { reason } = ev { + pet_action_feedback(reason, &mut errors); + } +} + +fn on_cast_failed(In(ev): In, mut errors: ResMut) { + if let SessionEvent::PetCastFailed { spell_id, reason } = ev { + pet_cast_failed(spell_id, reason, &mut errors); + } +} + +fn on_tame_failure(In(ev): In, mut failures: ResMut) { + if let SessionEvent::PetTameFailure { reason } = ev { + pet_tame_failure(reason, &mut failures); + } +} + +fn on_refusal_line(In(ev): In, mut errors: ResMut) { + match ev { + SessionEvent::PetNameInvalid => pet_name_invalid(&mut errors), + SessionEvent::PetBroken => pet_broken(&mut errors), + _ => {} + } +} + +fn on_action_sound( + In(ev): In, + index: Res, + mut talks: MessageWriter, +) { + if let SessionEvent::PetActionSound { pet_guid, talk } = ev { + pet_action_sound(pet_guid, talk, &index, &mut talks); + } +} + +fn on_dismiss_sound(In(ev): In, mut sounds: MessageWriter) { + if let SessionEvent::PetDismissSound { model_id, position } = ev { + pet_dismiss_sound(model_id, position, &mut sounds); + } +} /// `SMSG_PET_SPELLS` — replace the whole bar, and reseed the pet's own cooldown store from the /// packet's tail. @@ -35,7 +108,7 @@ use crate::ui_pet::PetBar; /// cooldown store goes with it, because the next pet is a different unit with different timers. /// Everything else is a wholesale replace — including a re-send from the same pet, which is how a /// learned spell, a mode change or an autocast toggle actually reaches the bar. -pub(super) fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) { +fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) { if spells.pet_guid == 0 { if bar.spells.pet_guid != 0 { debug!("net: pet bar torn down"); @@ -121,7 +194,7 @@ pub(super) fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut /// `SMSG_PET_MODE` — the react/command state alone. Applied only when it names the pet whose bar /// we actually hold: a mode packet for a unit we have no bar for has nothing to write into, and /// taking its state anyway would light a reaction button on the wrong pet's bar. -pub(super) fn pet_mode(mode: PetMode, bar: &mut PetBar) { +fn pet_mode(mode: PetMode, bar: &mut PetBar) { if bar.spells.pet_guid == 0 || bar.spells.pet_guid != mode.pet_guid { return; } @@ -134,7 +207,7 @@ pub(super) fn pet_mode(mode: PetMode, bar: &mut PetBar) { /// `SMSG_PET_ACTION_FEEDBACK` — one reason byte for a refused order, queued onto the red line by /// GlobalStrings key ([`UiErrorKeys`], the `DisplayError` route). An unrecognised code queues /// nothing, exactly as an absent key shows nothing. -pub(super) fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) { +fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) { debug!("net: pet action feedback {reason}"); if let Some(key) = pet_feedback_key(reason) { errors.0.push(UiError::key(key)); @@ -188,7 +261,7 @@ fn pet_feedback_key(reason: u8) -> Option<&'static str> { /// No local state moves: the refusal is the server's last word on a spell that never took, and /// the client's own cast bookkeeping was already unwound by the `SMSG_CAST_RESULT` that came with /// it. -pub(super) fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) { +fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) { debug!( "net: pet tame failure {reason} ({})", benilla_protocol::messages::pet_tame_failure_key(reason) @@ -205,7 +278,7 @@ pub(super) fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) { /// /// Nothing else moves: the rename was optimistic-free by design (1066), so there is no local name /// to roll back, and the popup has already closed. -pub(super) fn pet_name_invalid(errors: &mut UiErrorKeys) { +fn pet_name_invalid(errors: &mut UiErrorKeys) { debug!("net: pet name refused"); errors.0.push(UiError::key("ERR_INVALID_PETNAME")); } @@ -218,7 +291,7 @@ pub(super) fn pet_name_invalid(errors: &mut UiErrorKeys) { /// `Unsummon(PET_SAVE_AS_DELETED)` (`Pet.cpp:822`), so the zero-guid `SMSG_PET_SPELLS` that /// actually clears the bar arrives on its own heels — which is why touching the bar here would be /// a second, racing teardown rather than a fix. -pub(super) fn pet_broken(errors: &mut UiErrorKeys) { +fn pet_broken(errors: &mut UiErrorKeys) { debug!("net: pet ran away"); errors.0.push(UiError::key("ERR_PET_BROKEN")); } @@ -232,7 +305,7 @@ pub(super) fn pet_broken(errors: &mut UiErrorKeys) { /// /// The selector is not validated here — [`crate::sound::creature`]'s reader is where the /// two-armed `cmp` lives, because that is where the reference has it too (`0x604106`/`0x60411c`). -pub(super) fn pet_action_sound( +fn pet_action_sound( pet_guid: u64, talk: u32, index: &GuidIndex, @@ -250,7 +323,7 @@ pub(super) fn pet_action_sound( /// The `+1.0` on `z` is the reference's own (`0x6041d0 fadd [0x7ff9d8]`), applied **before** the /// basis change because it is a WoW-space offset: the point on the wire is where the pet stood, /// and the kit sounds a yard above it. -pub(super) fn pet_dismiss_sound( +fn pet_dismiss_sound( model_id: u32, position: [f32; 3], sounds: &mut MessageWriter, @@ -282,7 +355,7 @@ pub(super) fn pet_dismiss_sound( /// set is the two packet readers, `0x496720`, and the string plumbing behind it. So a pet's /// refusal never prints "You fail to cast Growl: ..." in the log, and the drain's combat twin /// skips it. -pub(super) fn pet_cast_failed(spell_id: u32, reason: Option, errors: &mut CastErrors) { +fn pet_cast_failed(spell_id: u32, reason: Option, errors: &mut CastErrors) { debug!("net: pet cast failed — spell {spell_id} reason {reason:?}"); if let Some(reason) = reason { errors.push_pet(spell_id, reason); diff --git a/crates/benilla-app/src/ui_quest/net.rs b/crates/benilla-app/src/ui_quest/net.rs index a174c717..e45383fe 100644 --- a/crates/benilla-app/src/ui_quest/net.rs +++ b/crates/benilla-app/src/ui_quest/net.rs @@ -131,7 +131,7 @@ fn on_giver_failed( } /// An open questgiver panel dies with the socket. A listener on the session end -/// ([`crate::net::handlers::BROADCAST`]). +/// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut quest: ResMut) { quest.clear_session(); } diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index a2c1cdec..fe17dcab 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -154,7 +154,7 @@ mod net { } /// The log's session state dies with the socket. A listener on the session end - /// ([`crate::net::handlers::BROADCAST`]). + /// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut quest_log: ResMut) { quest_log.clear_session(); } diff --git a/crates/benilla-app/src/ui_quest_share.rs b/crates/benilla-app/src/ui_quest_share.rs index 4631ae3f..f8f960fa 100644 --- a/crates/benilla-app/src/ui_quest_share.rs +++ b/crates/benilla-app/src/ui_quest_share.rs @@ -166,7 +166,7 @@ mod net { /// A verdict on a share nobody is listening for any more, and a confirm whose server-side /// latch died with the socket (decision 1733). A listener on the session end - /// ([`crate::net::handlers::BROADCAST`]). + /// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut share: ResMut) { share.clear_session(); } diff --git a/crates/benilla-app/src/ui_social/mod.rs b/crates/benilla-app/src/ui_social/mod.rs index 3d0828bd..ae0fe5e5 100644 --- a/crates/benilla-app/src/ui_social/mod.rs +++ b/crates/benilla-app/src/ui_social/mod.rs @@ -339,7 +339,7 @@ pub(crate) mod net { /// wrong guids after a reconnect renumbers nothing but re-streams everything. The `/who` /// sort chain is the one thing that survives — it is per-PROCESS in the reference, not /// per-login (decision 2030), which is why this is a `clear_session` and not a `default()`. - /// A listener on the session end ([`crate::net::handlers::BROADCAST`]). + /// A listener on the session end (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut social: ResMut) { social.clear_session(); } diff --git a/crates/benilla-app/src/ui_taxi/net.rs b/crates/benilla-app/src/ui_taxi/net.rs index e323dd96..ba1da831 100644 --- a/crates/benilla-app/src/ui_taxi/net.rs +++ b/crates/benilla-app/src/ui_taxi/net.rs @@ -50,7 +50,7 @@ fn on_new_path(In(ev): In, mut taxi: ResMut) { } /// An open taxi map dies with the socket. A listener on the session end -/// ([`crate::net::handlers::BROADCAST`]). +/// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut taxi: ResMut) { taxi.clear_session(); } diff --git a/crates/benilla-app/src/ui_trade/net.rs b/crates/benilla-app/src/ui_trade/net.rs index d7d387ac..8e86cd81 100644 --- a/crates/benilla-app/src/ui_trade/net.rs +++ b/crates/benilla-app/src/ui_trade/net.rs @@ -40,7 +40,7 @@ fn on_trade_status_extended(In(ev): In, mut trade: ResMut, mut trade: ResMut) { trade.clear_session(); } @@ -339,7 +339,7 @@ mod tests { } /// End to end through the real registration: a status opens the window, and the session - /// end — a broadcast the match still owns — takes the trade with it. + /// end — the bridge's own teardown beside this listener — takes the trade with it. #[test] fn the_table_routes_the_status_and_the_session_end_to_the_trade() { let (_, commands, _rx) = sink(); @@ -355,20 +355,16 @@ mod tests { vec![SessionEvent::TradeStatus { status: TradeStatus::OpenWindow, }], - |_, _| panic!("a claimed kind never reaches the match"), ); assert!(app.world().resource::().is_open()); - let mut through_the_match = Vec::new(); crate::net::handlers::dispatch( app.world_mut(), vec![SessionEvent::Disconnected { reason: "socket".into(), end: benilla_protocol::SessionEnd::Lost, }], - |_, unclaimed| through_the_match.extend(unclaimed.iter().map(SessionEventKind::from)), ); - assert_eq!(through_the_match, vec![SessionEventKind::Disconnected]); assert!(!app.world().resource::().is_open()); } diff --git a/crates/benilla-app/src/ui_trainer/net.rs b/crates/benilla-app/src/ui_trainer/net.rs index a76231fd..68e45909 100644 --- a/crates/benilla-app/src/ui_trainer/net.rs +++ b/crates/benilla-app/src/ui_trainer/net.rs @@ -48,7 +48,7 @@ fn on_buy_failed(In(ev): In, mut errors: ResMut) { } /// An open trainer window dies with the socket. A listener on the session end -/// ([`crate::net::handlers::BROADCAST`]). +/// (a second handler on the kind, after the bridge's own teardown). fn on_session_end(In(_): In, mut trainer_open: ResMut) { trainer_open.clear_session(); }