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) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:04:14 +07:00
co-authored by Claude Opus 5.5
parent d0e29635d0
commit f20f77d695
56 changed files with 3203 additions and 2855 deletions
@@ -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>,
+335
View File
@@ -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<Res<'w, crate::target::ring::Factions>>,
reputations: Res<'w, Reputations>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
/// 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>,
c: Ctx,
mut l: Sinks,
area_table: Option<Res<crate::area::AreaTableRes>>,
exploration_sounds: Option<Res<crate::sound::ExplorationSounds>>,
mut server_sounds: MessageWriter<ServerSoundMessage>,
) {
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<SessionEvent>, 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<SessionEvent>, 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,
});
}
}
@@ -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<En
/// against the active player — `Player` (me), `Pet` (a unit whose Summoned/CreatedBy guid is me:
/// pet, guardian, totem), or `None` = every other class, which SUPPRESSES the emit entirely
/// (another unit's damage is never drawn; the real emitter returns before submitting).
pub(super) fn classify_source(
pub(crate) fn classify_source(
guid: u64,
index: &GuidIndex,
self_guid: &SelfGuid,
@@ -120,7 +120,7 @@ fn spell_center_text(
/// event). Helper-B partials CONFIRMED: a landed hit with a partial block/absorb/resist fires
/// the word type with `(damage, partial)` — the addon renders `"25 (10 blocked)"`. The
/// partial-vs-crit precedence when both apply is unpinned — partials win here, flagged in 0580.
pub(super) fn melee_center_text(
pub(crate) fn melee_center_text(
hit_info: u32,
victim_state: u32,
damage: u32,
@@ -623,7 +623,7 @@ pub(super) fn exploration_xp(
index: &GuidIndex,
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
sound_out: &mut MessageWriter<super::super::ServerSoundMessage>,
sound_out: &mut MessageWriter<crate::net::ServerSoundMessage>,
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,
});
+2
View File
@@ -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);
+471
View File
@@ -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<SessionEvent>, mut commands: Commands, index: Res<GuidIndex>) {
if let SessionEvent::AttackStart { attacker, victim } = ev {
attack_start(attacker, victim, &mut commands, &index);
}
}
fn on_attack_stop(
In(ev): In<SessionEvent>,
mut commands: Commands,
index: Res<GuidIndex>,
mut flushes: MessageWriter<SwingFlush>,
) {
if let SessionEvent::AttackStop { attacker, victim } = ev {
attack_stop(attacker, victim, &mut commands, &index, &mut flushes);
}
}
fn on_ai_reaction(
In(ev): In<SessionEvent>,
index: Res<GuidIndex>,
mut reactions: MessageWriter<AiReactionMessage>,
) {
if let SessionEvent::AiReaction { unit, reaction } = ev {
ai_reaction(unit, reaction, &index, &mut reactions);
}
}
fn on_swing_refusal(In(ev): In<SessionEvent>, mut edges: MessageWriter<SwingRefusalEdge>) {
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<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
if let SessionEvent::FeignDeathResisted = ev {
feign_death_resisted(&mut errors);
}
}
fn on_mount_special(
In(ev): In<SessionEvent>,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
mut out: MessageWriter<MountFlourish>,
) {
if let SessionEvent::MountSpecial { guid } = ev {
mount_special(guid, &self_guid, &index, &mut out);
}
}
fn on_text_emote(
In(ev): In<SessionEvent>,
index: Res<GuidIndex>,
mut out: MessageWriter<EmoteMessage>,
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
) {
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<SessionEvent>, index: Res<GuidIndex>, mut out: MessageWriter<EmoteMessage>) {
if let SessionEvent::Emote { guid, emote_id } = ev {
emote(guid, emote_id, &index, &mut out);
}
}
fn on_attacker_state(
In(ev): In<SessionEvent>,
index: Res<GuidIndex>,
self_guid: Res<SelfGuid>,
mut swings: MessageWriter<SwingMessage>,
mut impacts: MessageWriter<SwingImpact>,
mut center: MessageWriter<CombatTextEvent>,
mut sheaths: MessageWriter<SheathRequest>,
mut edges: MessageWriter<SwingRefusalEdge>,
stores: Query<&mut crate::net::ObjectStore>,
mut play_seq: ResMut<PlaySeq>,
) {
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<SessionEvent>,
index: Res<GuidIndex>,
table: Option<Res<EnvDamageTable>>,
mut play_seq: ResMut<PlaySeq>,
mut out: MessageWriter<KitPush>,
) {
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<SessionEvent>,
index: Res<GuidIndex>,
mut play_seq: ResMut<PlaySeq>,
mut out: MessageWriter<KitPush>,
) {
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::<RangedHold>();
}
}
/// A unit stopped melee auto-attack (`SMSG_ATTACKSTOP`).
fn attack_stop(
attacker: u64,
victim: u64,
commands: &mut Commands,
index: &GuidIndex,
flushes: &mut MessageWriter<SwingFlush>,
) {
debug!("net: attack stop {attacker:#x} → {victim:#x}");
if let Some(&e) = index.0.get(&attacker) {
commands.entity(e).remove::<Engaged>();
// 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<SwingRefusalEdge>) {
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<SwingRefusalEdge>) {
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<AiReactionMessage>,
) {
// 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<SwingMessage>,
impacts: &mut MessageWriter<SwingImpact>,
center: &mut MessageWriter<CombatTextEvent>,
sheaths: &mut MessageWriter<SheathRequest>,
edges: &mut MessageWriter<SwingRefusalEdge>,
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<EmoteMessage>,
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<EmoteMessage>) {
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<KitPush>,
) {
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<KitPush>,
) {
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<MountFlourish>,
) {
if self_guid.0 != Some(guid) {
if let Some(&e) = index.0.get(&guid) {
out.write(MountFlourish { unit: e });
}
}
}
@@ -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::<DeathNet>()
.init_resource::<DeathFeedState>()
.add_systems(
@@ -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<SessionEvent>, mut death_net: ResMut<DeathNet>) {
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<SessionEvent>,
mut death_net: ResMut<DeathNet>,
real_clock: Res<Time<Real>>,
) {
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<SessionEvent>, mut death_net: ResMut<DeathNet>) {
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<SessionEvent>, mut death_net: ResMut<DeathNet>) {
if let SessionEvent::SpiritHealerConfirm { npc } = ev {
spirit_healer_confirm(npc, &mut death_net);
}
}
fn on_durability_damage_death(In(ev): In<SessionEvent>, mut log: ResMut<crate::ui_chat::ChatLog>) {
if let SessionEvent::DurabilityDamageDeath = ev {
durability_damage_death(&mut log);
}
}
fn on_move_mode(
In(ev): In<SessionEvent>,
self_guid: Res<SelfGuid>,
mut death_net: ResMut<DeathNet>,
mut out: MessageWriter<MoveModeMessage>,
) {
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<SessionEvent>, mut death_net: ResMut<DeathNet>) {
*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,
+1 -1
View File
@@ -436,7 +436,7 @@ fn miss_defense_state(code: u8) -> Option<u32> {
/// 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> {
+6 -1
View File
@@ -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 `<tap-gone>` 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<DeathAnnounced>`: 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"),
+2
View File
@@ -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;
+138
View File
@@ -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<SessionEvent>, mut names: ResMut<NameCache>) {
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<SessionEvent>, mut names: ResMut<NameCache>) {
if let SessionEvent::PetName { pet_number, name } = ev {
pet_name(pet_number, name, &mut names);
}
}
fn on_creature_name(In(ev): In<SessionEvent>, mut names: ResMut<NameCache>) {
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<SessionEvent>, mut names: ResMut<NameCache>) {
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<String>,
subname: Option<String>,
creature_type: Option<u32>,
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::*;
+9
View File
@@ -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))
File diff suppressed because it is too large Load Diff
-98
View File
@@ -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<EmoteMessage>,
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<EmoteMessage>,
) {
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<KitPush>,
) {
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<KitPush>,
) {
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(),
});
}
}
}
-218
View File
@@ -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::<RangedHold>();
}
}
/// 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<SwingFlush>,
) {
debug!("net: attack stop {attacker:#x} → {victim:#x}");
if let Some(&e) = index.0.get(&attacker) {
commands.entity(e).remove::<Engaged>();
// 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<SwingRefusalEdge>,
) {
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<SwingRefusalEdge>) {
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<AiReactionMessage>,
) {
// 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<SwingMessage>,
impacts: &mut MessageWriter<SwingImpact>,
center: &mut MessageWriter<CombatTextEvent>,
sheaths: &mut MessageWriter<SheathRequest>,
edges: &mut MessageWriter<SwingRefusalEdge>,
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,
});
}
}
-46
View File
@@ -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<MountFlourish>,
) {
if self_guid.0 != Some(guid) {
if let Some(&e) = index.0.get(&guid) {
out.write(MountFlourish { unit: e });
}
}
}
-70
View File
@@ -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<String>,
subname: Option<String>,
creature_type: Option<u32>,
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,
}),
);
}
-283
View File
@@ -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<Real>`, 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<Virtual>` 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<Real>>,
}
/// 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<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
/// 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 `$<n>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<Res<'w, crate::target::ring::Factions>>,
/// The Spell.dbc catalog the cooldown/cast wire laws read.
pub spells: Option<Res<'w, crate::ui_action::Spells>>,
/// 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<Res<'w, crate::creature_anim::EnvDamageTable>>,
/// 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<Res<'w, crate::area::AreaTableRes>>,
pub exploration_sounds: Option<Res<'w, crate::sound::ExplorationSounds>>,
}
+147 -13
View File
@@ -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<SessionEvent> {
vec![
SessionEvent::ObjectValues {
@@ -68,7 +68,7 @@ fn updates() -> Vec<SessionEvent> {
]
}
/// 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<SessionEvent>) -> (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<MoveSpeeds>) -> 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<SessionEvent>) -> Option<MoveSpeeds> {
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::<GuidIndex>().0.get(&GUID)?;
world.get::<UnitSpeeds>(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());
}
-83
View File
@@ -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 `$<n>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<ServerSoundMessage>) {
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<ServerSoundMessage>) {
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<ServerSoundMessage>,
) {
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<WeatherMessage>,
) {
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);
}
+19 -172
View File
@@ -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<SessionEventKind, Vec<Handler>>,
}
/// 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<SessionEvent>,
mut unclaimed: impl FnMut(&mut World, Vec<SessionEvent>),
) {
/// 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<SessionEvent>) {
let handlers = world.remove_resource::<NetHandlers>().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<SessionEvent>, mut log: ResMut<Log>) {
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::<Log>();
@@ -212,18 +166,12 @@ mod tests {
}
fn run(app: &mut App, events: Vec<SessionEvent>) -> Vec<String> {
dispatch(app.world_mut(), events, |world, unclaimed| {
let mut log = world.resource_mut::<Log>();
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::<Log>().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::<NetHandlers>().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<String, SessionEventKind> = SessionEventKind::all()
.map(|k| (format!("{k:?}"), k))
.collect();
let mut in_match: BTreeSet<SessionEventKind> = 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::<NetHandlers>().census();
let in_table: BTreeSet<SessionEventKind> = 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<String> = 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::<usize>(),
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
@@ -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<crate::go_anim::GoCustomAnim>,
@@ -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<Real>>,
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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<u32>,
@@ -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<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
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<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
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<u64>, commands: &mut Commands, index: &mut GuidIndex) {
fn objects_removed(guids: Vec<u64>, 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<u64>, 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<u64>,
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<u64, UnitMoveModes>;
/// 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<u64, ObjectFields>,
edges: &mut MessageWriter<FieldChanged>,
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<u64, ObjectFields>` 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<u64, MoveSpeeds>);
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<MoveSpeeds>,
) -> 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<MoveSpeeds> {
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<SpeedChangeMessage>,
) {
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<GuidIndex>,
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<GuidIndex>,
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::<Spline>().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);
}
}
}
}
@@ -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<LoginStageMessage>,
/// 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>,
mut e: Edges,
mut b: Bridge,
mut group: ResMut<crate::ui_party::GroupState>,
) {
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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>,
mut b: Bridge,
factions: Option<Res<crate::target::ring::Factions>>,
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
mut quest: ResMut<QuestGiver>,
) {
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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>) {
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<LoginStageMessage>) {
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<benilla_protocol::LoginRefusal>,
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<CharActionResultMessage>,
@@ -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<Character>,
realm: Option<benilla_protocol::RealmInfo>,
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<CharacterLoginFailedMessage>,
) {
fn character_login_failed(result: u8, out: &mut MessageWriter<CharacterLoginFailedMessage>) {
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<CinematicTriggeredMessage>,
) {
@@ -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<DisconnectedMessage>,
@@ -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<u32>,
pending: &mut PendingTransfer,
) {
fn transfer_pending(map_id: u32, transport_entry: Option<u32>, 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<usize> {
/// 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!(
+80
View File
@@ -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 `$<n>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<SessionEvent>, mut out: MessageWriter<WeatherMessage>) {
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<SessionEvent>, mut states: ResMut<WorldStates>) {
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<WeatherMessage>,
) {
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);
}
+2
View File
@@ -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<crate::cvars::CvarChanged>, mut sound: ResMut<Sound
impl Plugin for SoundPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.add_observer(on_cvar);
// Who gets sound: a run a human launched, and only that. The default posture is audible
// (decision 1026 — `SoundConfig::muted` starts false), so the silence has to be opt-in by
+64
View File
@@ -0,0 +1,64 @@
//! The sound layer's packet handlers (in the net handler table since 2325, moved out of the
//! drain's world arm file) — the three ambient audio triggers, each a [`ServerSoundMessage`] the
//! zone mixer ([`super::zone`]) plays.
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use crate::net::{GuidIndex, NetHandlerApp, ServerSoundKind, ServerSoundMessage};
/// Register the handlers — called from [`super::SoundPlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::PlaySound, on_play)
.net_handler(K::PlayMusic, on_play)
.net_handler(K::PlayObjectSound, on_play);
}
fn on_play(
In(ev): In<SessionEvent>,
index: Res<GuidIndex>,
mut out: MessageWriter<ServerSoundMessage>,
) {
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<ServerSoundMessage>) {
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<ServerSoundMessage>) {
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<ServerSoundMessage>,
) {
out.write(ServerSoundMessage {
kind: ServerSoundKind::ObjectSound,
sound_id,
source: index.0.get(&guid).copied(),
});
}
+18
View File
@@ -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);
}
}
@@ -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<Res<'w, Spells>>,
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<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
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<SessionEvent>, 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<SessionEvent>, mut l: Lifecycle) {
if let SessionEvent::ActionButtons { buttons } = ev {
action_buttons(buttons, &mut l.actions);
}
}
fn on_spell_learned(In(ev): In<SessionEvent>, mut l: Lifecycle, mut errors: ResMut<UiErrorKeys>) {
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<SessionEvent>, mut l: Lifecycle, mut errors: ResMut<UiErrorKeys>) {
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<SessionEvent>,
mut l: Lifecycle,
mut errors: ResMut<UiErrorKeys>,
) {
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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, mut l: Lifecycle, real_clock: Res<Time<Real>>) {
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<SessionEvent>, 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<u32>,
initial_cooldowns: Vec<SpellCooldown>,
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<ActionButton>, actions: &mut PlayerActions) {
fn action_buttons(buttons: Vec<ActionButton>, 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<ActionButton>, 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<u8>,
@@ -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<u64>,
+21
View File
@@ -570,6 +570,7 @@ pub(crate) struct TutorialPlugin;
impl Plugin for TutorialPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<Tutorials>()
.init_resource::<WorldEnterCascade>()
.add_message::<TutorialEvent>()
@@ -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<SessionEvent>, mut tutorials: ResMut<Tutorials>) {
if let SessionEvent::TutorialFlags(bytes) = ev {
tutorials.apply_flags(&bytes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -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::<PlayerActions>()
.init_resource::<LearnedAbilities>()
+34
View File
@@ -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<SessionEvent>, mut errors: ResMut<MountErrors>) {
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));
}
}
+1 -12
View File
@@ -102,7 +102,7 @@ fn on_removed_notification(In(ev): In<SessionEvent>, mut auction: ResMut<Auction
/// An open auction house dies with the socket (decision 1511): every auction command
/// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
/// window whose every button silently failed. A listener on the session end, which the drain's
/// dispatch match still owns ([`crate::net::handlers::BROADCAST`]).
/// dispatch match still owns (a second handler on the kind, after the bridge's own teardown).
fn on_session_end(In(_): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
auction.clear_session();
}
@@ -330,22 +330,12 @@ mod tests {
app.add_plugins(MinimalPlugins)
.init_resource::<AuctionOpen>();
register(&mut app);
let through_the_match = |_: &mut World, unclaimed: Vec<SessionEvent>| {
let kinds: Vec<SessionEventKind> =
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::<AuctionOpen>().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::<AuctionOpen>().auctioneer, None);
}
+1 -1
View File
@@ -37,7 +37,7 @@ fn on_buy_slot_result(In(ev): In<SessionEvent>, mut errors: ResMut<BankErrors>)
}
/// 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<SessionEvent>, mut bank: ResMut<BankOpen>) {
bank.clear_session();
}
+2
View File
@@ -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::<ChatLog>()
.init_resource::<away::AfkMirror>()
-16
View File
@@ -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";
@@ -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<SessionEvent>,
mut chat_log: ResMut<ChatLog>,
social: Res<SocialState>,
commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>,
) {
if let SessionEvent::Chat(m) = ev {
chat(m, &mut chat_log, &social, &commands, &mut server_said);
}
}
fn on_channel_list(In(ev): In<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
if let SessionEvent::ChannelList {
channel, members, ..
} = ev
{
channel_list(channel, &members, &mut chat_log);
}
}
fn on_whisper_refusal(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
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<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
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<SessionEvent>, mut errors: ResMut<UiErrorTexts>) {
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<SessionEvent>,
mut answer: ResMut<PlayedTimeAnswer>,
mut chat_log: ResMut<ChatLog>,
) {
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<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
if let SessionEvent::ChannelNotify {
notice,
channel,
tail,
} = ev
{
chat_log.push_channel_notice(notice, channel, &tail);
}
}
fn on_random_roll(In(ev): In<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
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<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
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),
+1 -1
View File
@@ -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<SessionEvent>, mut duel: ResMut<DuelState>) {
*duel = DuelState::default();
}
+1 -1
View File
@@ -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<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
ticket.clear_session();
}
+1 -1
View File
@@ -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<SessionEvent>, mut gossip: ResMut<GossipState>) {
gossip.clear_session();
}
+1 -1
View File
@@ -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<SessionEvent>, mut guild: ResMut<GuildState>) {
*guild = GuildState::default();
}
+28
View File
@@ -261,6 +261,7 @@ pub struct UiLogoutPlugin;
impl Plugin for UiLogoutPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<LogoutState>().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<SessionEvent>, mut logout: ResMut<LogoutState>) {
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;
+1 -1
View File
@@ -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<SessionEvent>,
mut loot: ResMut<LootState>,
+1 -1
View File
@@ -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<SessionEvent>, mut rolls: ResMut<LootRolls>) {
rolls.clear();
}
+3 -7
View File
@@ -83,7 +83,7 @@ fn on_next_mail_time(In(ev): In<SessionEvent>, mut pending: ResMut<MailPending>)
/// 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<SessionEvent>,
mut mail: ResMut<MailOpen>,
@@ -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::<MailPending>().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::<MailPending>().has_new_mail());
assert_eq!(app.world().resource::<MailOpen>().mailbox, None);
}
+1 -1
View File
@@ -60,7 +60,7 @@ fn on_sell_failed(In(ev): In<SessionEvent>, mut errors: ResMut<MerchantErrors>)
}
/// 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<SessionEvent>, mut merchant: ResMut<MerchantOpen>) {
merchant.clear_session();
}
+2
View File
@@ -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::<GroupState>().add_systems(
Update,
(
@@ -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<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GroupInvite { inviter } = ev {
invited(&mut group, &mut errors, &inviter);
}
}
fn on_decline(
In(ev): In<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GroupDecline { name } = ev {
declined(&mut group, &mut errors, &name);
}
}
fn on_uninvited(
In(ev): In<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GroupUninvited = ev {
uninvited(&mut group, &mut errors);
}
}
fn on_leader_changed(
In(ev): In<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
self_guid: Res<SelfGuid>,
names: Res<NameCache>,
commands: Res<NetCommands>,
) {
if let SessionEvent::GroupLeaderChanged { name } = ev {
leader_changed(
&mut group,
&mut errors,
&name,
&self_guid,
&names,
&commands,
);
}
}
fn on_destroyed(
In(ev): In<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GroupDestroyed = ev {
destroyed(&mut group, &mut errors);
}
}
fn on_list(
In(ev): In<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
mut quest: ResMut<QuestGiver>,
names: Res<NameCache>,
index: Res<GuidIndex>,
commands: Res<NetCommands>,
) {
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<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::PartyCommandResult {
operation,
member,
result,
} = ev
{
command_result(&mut group, &mut errors, operation, &member, result);
}
}
fn on_member_stats(In(ev): In<SessionEvent>, mut group: ResMut<GroupState>) {
if let SessionEvent::PartyMemberStats { guid, full, info } = ev {
group.apply_stats(guid, full, *info);
}
}
fn on_raid_target(In(ev): In<SessionEvent>, mut group: ResMut<GroupState>) {
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<SessionEvent>,
mut group: ResMut<GroupState>,
mut errors: ResMut<UiErrorKeys>,
self_guid: Res<SelfGuid>,
) {
if let SessionEvent::ReadyCheckRequest = ev {
ready_check_request(&mut group, &mut errors, &self_guid);
}
}
fn on_ready_check_answer(In(ev): In<SessionEvent>, mut group: ResMut<GroupState>) {
if let SessionEvent::ReadyCheckAnswer { guid, ready } = ev {
group.apply_ready_check_answer(guid, ready != 0);
}
}
fn on_raid_instance_info(In(ev): In<SessionEvent>, mut group: ResMut<GroupState>) {
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<SessionEvent>, mut group: ResMut<GroupState>) {
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<UiError>) {
/// `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,
+2
View File
@@ -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::<PetBar>().add_systems(
Update,
(
@@ -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<SessionEvent>, catalog: Option<Res<Spells>>, mut bar: ResMut<PetBar>) {
if let SessionEvent::PetSpells(spells) = ev {
pet_spells(*spells, catalog.as_deref(), &mut bar);
}
}
fn on_mode(In(ev): In<SessionEvent>, mut bar: ResMut<PetBar>) {
if let SessionEvent::PetMode(mode) = ev {
pet_mode(mode, &mut bar);
}
}
fn on_action_feedback(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
if let SessionEvent::PetActionFeedback { reason } = ev {
pet_action_feedback(reason, &mut errors);
}
}
fn on_cast_failed(In(ev): In<SessionEvent>, mut errors: ResMut<CastErrors>) {
if let SessionEvent::PetCastFailed { spell_id, reason } = ev {
pet_cast_failed(spell_id, reason, &mut errors);
}
}
fn on_tame_failure(In(ev): In<SessionEvent>, mut failures: ResMut<PetTameFailures>) {
if let SessionEvent::PetTameFailure { reason } = ev {
pet_tame_failure(reason, &mut failures);
}
}
fn on_refusal_line(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
match ev {
SessionEvent::PetNameInvalid => pet_name_invalid(&mut errors),
SessionEvent::PetBroken => pet_broken(&mut errors),
_ => {}
}
}
fn on_action_sound(
In(ev): In<SessionEvent>,
index: Res<GuidIndex>,
mut talks: MessageWriter<PetTalkMessage>,
) {
if let SessionEvent::PetActionSound { pet_guid, talk } = ev {
pet_action_sound(pet_guid, talk, &index, &mut talks);
}
}
fn on_dismiss_sound(In(ev): In<SessionEvent>, mut sounds: MessageWriter<PetDismissSoundMessage>) {
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<PetDismissSoundMessage>,
@@ -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<u8>, errors: &mut CastErrors) {
fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
if let Some(reason) = reason {
errors.push_pet(spell_id, reason);
+1 -1
View File
@@ -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<SessionEvent>, mut quest: ResMut<QuestGiver>) {
quest.clear_session();
}
+1 -1
View File
@@ -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<SessionEvent>, mut quest_log: ResMut<QuestLog>) {
quest_log.clear_session();
}
+1 -1
View File
@@ -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<SessionEvent>, mut share: ResMut<QuestShare>) {
share.clear_session();
}
+1 -1
View File
@@ -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<SessionEvent>, mut social: ResMut<SocialState>) {
social.clear_session();
}
+1 -1
View File
@@ -50,7 +50,7 @@ fn on_new_path(In(ev): In<SessionEvent>, mut taxi: ResMut<TaxiState>) {
}
/// 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<SessionEvent>, mut taxi: ResMut<TaxiState>) {
taxi.clear_session();
}
+2 -6
View File
@@ -40,7 +40,7 @@ fn on_trade_status_extended(In(ev): In<SessionEvent>, mut trade: ResMut<TradeSes
/// An open trade dies with the socket (decision 0592) — the reconnect starts with no trade. A
/// listener on the session end, which the drain's dispatch match still owns
/// ([`crate::net::handlers::BROADCAST`]).
/// (a second handler on the kind, after the bridge's own teardown).
fn on_session_end(In(_): In<SessionEvent>, mut trade: ResMut<TradeSession>) {
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::<TradeSession>().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::<TradeSession>().is_open());
}
+1 -1
View File
@@ -48,7 +48,7 @@ fn on_buy_failed(In(ev): In<SessionEvent>, mut errors: ResMut<TrainerErrors>) {
}
/// 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<SessionEvent>, mut trainer_open: ResMut<TrainerOpen>) {
trainer_open.clear_session();
}