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:
+7
-7
@@ -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>,
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
|
||||
+13
-1348
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+445
-284
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+353
-49
@@ -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!(
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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>,
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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),
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
(
|
||||
|
||||
+191
-21
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user