wire-order dispatch, and eleven windows register their own handlers

- the net drain dispatches a frame's packets in wire order across the migration seam: a claimed packet lands the run before it first, so two red lines from different families print in the order the server sent them (2306)
- the mailbox and the player trade register their own packet handlers and leave the dispatch match, each with a session-end listener that clears what dies with the socket (2306)
- nine windows — binder, duel, GM ticket, guild, instance lockouts, petition, social, summon, tabard vendor — register theirs: 41 kinds leave the match, and the table gains two checks the match gave for free (2312)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-19 17:09:09 +07:00
co-authored by Claude Fable 5.1
parent edec5b2207
commit c79e2e7ca3
19 changed files with 1238 additions and 497 deletions
+1 -1
View File
@@ -845,7 +845,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
// silent when a guildmate logs in, and a whole-image census of the record global `0xc4d3c4`
// finds exactly two readers, both inside `SMSG_GUILD_EVENT`'s handler. The knob is
// [`crate::ui_guild::GuildMemberNotify`]; the other three conjuncts of the line's display
// condition live on `ui_guild::apply::event`.
// condition live on `ui_guild::net::event`.
same("guildMemberNotify", "0"),
// The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"`
// (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the
+9 -284
View File
@@ -21,7 +21,6 @@ mod combat_log;
mod death;
mod group;
mod loot;
mod mail;
mod mount;
mod names;
mod npc;
@@ -29,9 +28,10 @@ mod objects;
mod params;
mod pet;
mod quests;
#[cfg(test)]
mod seam_tests;
mod session;
mod spells;
mod trade;
mod world;
use params::{ActionStores, AnimWriters, Catalogs, Clocks, ObjectQueries, Session, WindowStores};
@@ -85,12 +85,12 @@ fn addressed_store<'a>(
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
/// The drain: take this frame's events off the channel and run them, **in packet order**, through
/// the two halves of the dispatch (decision 2305) — first the kinds the `match` below still owns,
/// through [`apply_unpeeled`], then the kinds a subsystem has claimed in the handler table
/// ([`super::handlers`]), each handler a one-shot system over this exclusive world. One frame,
/// packet order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the
/// property 0006 built and 2265 said every split must keep.
/// The drain: take this frame's events off the channel and run them **in wire order** — a run of
/// kinds the `match` below still owns goes through [`apply_unpeeled`] as one batch, a kind a
/// subsystem has claimed in the handler table ([`super::handlers`]) runs its handlers in place,
/// each a one-shot system over this exclusive world (decisions 2305, 2306). One frame, packet
/// order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the property
/// 0006 built and 2265 said every split must keep.
pub(crate) fn apply_net_updates(world: &mut World) {
let events: Vec<SessionEvent> = world.resource::<NetEvents>().0.try_iter().collect();
super::handlers::dispatch(world, events, |world, unclaimed| {
@@ -194,7 +194,6 @@ fn apply_unpeeled(
area_table,
exploration_sounds,
current_map,
guild_notify,
} = catalogs;
// A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp
// it *conditionally* can take it by reference and only advance it when they emit.
@@ -220,22 +219,16 @@ fn apply_unpeeled(
mut death_net,
mut group,
mut taxi,
mut mail_open,
mut mail_pending,
mut trade_session,
mut bank_open,
mut bank_errors,
mut world_states,
mut duel,
mut social,
social,
mut logout,
mut mirror_timers,
mut pet_bar,
mut ui_error_keys,
mut page_texts,
mut played_time_answer,
mut guild,
mut binder,
mut talent_wipe,
mut pet_unlearn,
mut instance_boot,
@@ -246,15 +239,9 @@ fn apply_unpeeled(
mut battlefield,
mut tutorials,
mut battlefield_positions,
mut tabard,
mut poi_marker,
mut inspect_honor,
mut ping,
mut gm_ticket,
mut registrar,
mut petition,
mut summon,
mut instances,
} = windows;
let Session {
mut teleports,
@@ -387,14 +374,7 @@ fn apply_unpeeled(
&mut death_net,
&mut group,
&mut taxi,
&mut mail_open,
&mut mail_pending,
&mut trade_session,
&mut bank_open,
&mut duel,
&mut social,
&mut guild,
&mut gm_ticket,
&mut cooldown_store,
&mut pending_transfer,
&mut disconnects,
@@ -662,62 +642,6 @@ fn apply_unpeeled(
extra: None,
});
}
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
// Someone is asking to pull us to them (decision 1747). The reference gates this in
// the HANDLER, not the dialog: a dead or ghost player's request is dropped before the
// latch, so it cannot disturb a live question either (`0x5e6194`). The predicate is
// `0x605f30` — **health ≤ 0 OR (is-player AND `PLAYER_FLAGS` ghost bit)**, which is
// both of these accessors and not the one `unit_is_dead` alone would give (a ghost's
// wire health is 1). A self object we have not streamed yet reads as alive: the
// reference's own default (`0x5e6189` sends a NULL object through to the latch).
SessionEvent::SummonRequest {
summoner,
zone,
delay_ms,
} => {
let dead_or_ghost = self_guid
.0
.and_then(|g| index.0.get(&g))
.and_then(|e| stores.get(*e).ok())
.is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost());
crate::ui_summon::apply::request(
summoner,
zone,
delay_ms,
dead_or_ghost,
real_clock.elapsed_secs_f64(),
&mut summon,
);
}
// The GM ticket answers (decision 1673). The GETTICKET arm takes EVERY answer,
// including `None` ("you have no ticket") and including an unsolicited one pushed by a
// GM's `.ticket view`/`escalate`/`complete` — they are indistinguishable on the wire
// and want identical handling.
SessionEvent::GmTicket { ticket } => gm_ticket.answer(ticket),
SessionEvent::GmTicketSystemStatus { status } => gm_ticket.answer_queue(status),
// A GM touched the ticket. Value 1 makes the reference re-ask (`0x5e7932`), the same
// leg the create/update success codes take; 2 (closed) and 3 (survey offered) are
// recorded and not acted on — 3 is the survey trigger and that window is deferred.
// vmangos never sends this packet at all, so on our server the arm is dead; cmangos
// makes it the whole notification model, which is why it is parsed rather than dropped.
SessionEvent::GmTicketStatusUpdate { status } => {
crate::ui_gm_ticket::apply::status_update(status, &mut gm_ticket)
}
// The three response codes have no consumer in the shipped 1.12 UI — no event, no
// handler. Logged so a refusal is visible in a session log rather than silent; the
// `ERR_TICKET_*` display path is still unpinned (see `ui_gm_ticket::apply`).
// Create-ok (2) and update-ok (4) make the ENGINE re-ask for the ticket — the
// reference's own `0x5e4479` arm, and the reason the shipped UI needs no handler for
// either opcode. Without it a filed ticket goes unseen until the 10-minute poll.
SessionEvent::GmTicketCreated { response } => {
crate::ui_gm_ticket::apply::write_response("create", response, 2, &mut gm_ticket)
}
SessionEvent::GmTicketUpdated { response } => {
crate::ui_gm_ticket::apply::write_response("update", response, 4, &mut gm_ticket)
}
SessionEvent::GmTicketDeleted { response } => {
crate::ui_gm_ticket::apply::response("delete", response)
}
// A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a
// question — there is nothing to ask about, so nothing goes on screen (decision 1580;
// `crate::ui_talent_wipe`'s header carries why the reference instead re-sends here).
@@ -761,24 +685,6 @@ fn apply_unpeeled(
SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice),
SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes),
SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet),
SessionEvent::TabardVendorActivate(vendor) => tabard.open(vendor),
// A saved emblem evicts our guild's cached record (`0x5e715f`): the next query
// anywhere re-fetches it — no event, no packet.
SessionEvent::SaveGuildEmblemResult(result) => {
if tabard.apply_result(result) {
guild.evict_own_identity();
}
}
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
crate::ui_binder::apply::bound(
area,
&mut binder,
&mut ui_error_keys,
area_table.as_deref(),
&mut server_sounds,
)
}
SessionEvent::Proficiency {
item_class,
subclass_mask,
@@ -1054,56 +960,6 @@ fn apply_unpeeled(
SessionEvent::ReadyCheckAnswer { guid, ready } => {
group.apply_ready_check_answer(guid, ready != 0)
}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
// lines the handlers emit inline; the Era events fire off the mirror's edges in
// `ui_duel::feed_duel`, and the countdown ticks in its own system ──
SessionEvent::DuelRequested {
arbiter,
challenger,
} => crate::ui_duel::apply::requested(
&mut duel,
&mut ui_error_keys,
&net_commands,
arbiter,
challenger,
self_guid.0,
social.is_ignored(challenger),
),
// ── The instance/raid lockout family (decision 1748): four lines the client
// composes itself out of GlobalStrings, and the two-packet latch behind the SELF
// menu's reset row. The lines are QUEUED — resolving them needs the VM, which this
// drain has no access to (decision 0669's split) ──
SessionEvent::RaidInstanceMessage { message } => {
crate::ui_instance::apply::raid_instance_message(&mut instances, message);
}
SessionEvent::InstanceSaveCreated { flag } => {
crate::ui_instance::apply::instance_save_created(&mut instances, flag);
}
SessionEvent::InstanceReset { map } => {
crate::ui_instance::apply::instance_reset(&mut instances, map);
}
SessionEvent::InstanceResetFailed { failure } => {
crate::ui_instance::apply::instance_reset_failed(&mut instances, failure);
}
SessionEvent::UpdateLastInstance { map } => {
crate::ui_instance::apply::update_last_instance(&mut instances, map);
}
SessionEvent::UpdateInstanceOwnership { owns } => {
crate::ui_instance::apply::update_instance_ownership(&mut instances, owns);
}
SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true),
SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false),
SessionEvent::DuelComplete { started } => {
crate::ui_duel::apply::complete(&mut duel, &mut ui_error_keys, started);
}
SessionEvent::DuelWinner {
fled,
winner,
loser,
} => crate::ui_duel::apply::winner(&mut duel, fled, &winner, &loser),
SessionEvent::DuelCountdown { seconds } => {
crate::ui_duel::apply::countdown(&mut duel, seconds);
}
// ── The mirror timers (decision 0874): breath / fatigue / feign-death. Pure queue
// arms — every meaning (which bar, what colour, what caption, how fast it drains)
// is resolved at the UI seam in `ui_mirror`, and the countdown itself is the
@@ -1117,94 +973,6 @@ fn apply_unpeeled(
SessionEvent::MirrorTimerStop { kind } => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Stop { kind }),
// ── The social family (decision 0668): the friend/ignore lists, the `/who`
// answer, and the result codes that print their own chat lines. The lines and the
// Era events fire off the mirror in `ui_social::feed_social` — every one of them
// needs a NAME the drain has no cache handle for.
SessionEvent::FriendList { friends } => {
crate::ui_social::apply::friend_list(&mut social, friends)
}
SessionEvent::IgnoreList { guids } => {
crate::ui_social::apply::ignore_list(&mut social, guids)
}
SessionEvent::FriendStatus(update) => {
crate::ui_social::apply::friend_status(&mut social, update)
}
SessionEvent::WhoResults(results) => crate::ui_social::apply::who(&mut social, results),
// ── The guild family (decision 1257): the identity cache, the roster, and the
// `ERR_GUILD_*` lines the engine composes. Every arm's law lives in
// `ui_guild::apply` beside the state it drives; the guild EVENTS fire off the mirror
// in `ui_guild::feed_guild`, on their edges.
SessionEvent::GuildQueryResponse(response) => {
crate::ui_guild::apply::query_response(&mut guild, response)
}
SessionEvent::GuildRoster(roster) => crate::ui_guild::apply::roster(&mut guild, roster),
// The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the
// four-conjunct display condition on their line — which is why this arm reads
// `social`, the notify knob and our own guid (decision 1589; the condition and its
// byte addresses are on `ui_guild::apply::event`).
SessionEvent::GuildEvent(notice) => crate::ui_guild::apply::event(
&mut guild,
&mut ui_error_keys,
&social,
&guild_notify,
self_guid.0,
notice,
),
SessionEvent::GuildCommandResult(result) => {
crate::ui_guild::apply::command_result(&mut guild, &mut ui_error_keys, result)
}
SessionEvent::GuildInvite { inviter, guild: g } => {
crate::ui_guild::apply::invite(&mut guild, &mut ui_error_keys, inviter, g)
}
SessionEvent::GuildDecline { name } => {
crate::ui_guild::apply::decline(&mut ui_error_keys, &name)
}
SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut guild, info),
// ── The petition family (decision 1672): founding a guild. The registrar half is an
// NPC window, the charter half is item-bound, and they are two resources for that
// reason — see `ui_petition`'s module doc.
SessionEvent::PetitionShowList(list) => {
// The registrar's two `UNIT_NPC_FLAGS` gates are on LIVE NPC state rather than on
// the packet, so the flags are read here — this pass holds the store. An unstreamed
// guid reads `None` and fails the gate, as the client's own resolve does.
let flags = index
.0
.get(&list.npc)
.and_then(|e| stores.get(*e).ok())
.map(|s| s.0.unit_npc_flags());
crate::ui_petition::apply::show_list(&mut registrar, list, flags)
}
SessionEvent::PetitionShowSignatures(sigs) => {
// An ignored owner suppresses the ENTIRE update — no record fetch, no list, no
// event, no error line (`0x5eeefe`). Consulted before anything else happens.
let ignored = social.is_ignored(sigs.owner);
crate::ui_petition::apply::show_signatures(
&mut petition,
sigs,
ignored,
&net_commands,
)
}
SessionEvent::PetitionQueryResponse(response) => {
crate::ui_petition::apply::query_response(&mut petition, response)
}
SessionEvent::PetitionSignResults(results) => crate::ui_petition::apply::sign_results(
&mut petition,
&names,
self_guid.0.unwrap_or(0),
results,
&net_commands,
),
SessionEvent::TurnInPetitionResults { result } => {
crate::ui_petition::apply::turn_in_results(&mut petition, &mut registrar, result)
}
SessionEvent::PetitionDeclined { player } => {
crate::ui_petition::apply::declined(&mut petition, &names, player)
}
SessionEvent::PetitionRenamed(rename) => {
crate::ui_petition::apply::renamed(&mut petition, rename)
}
SessionEvent::LootResponse {
guid,
loot_type,
@@ -1865,31 +1633,6 @@ fn apply_unpeeled(
opcode,
unparseable,
} => session::packet_dropped(opcode, unparseable, &mut dropped),
// The mail arc (decision 0544 P1/P2/P3): the inbox/body/send-result arms fill the
// mailbox session the feed reads (`crate::ui_mail`); the arrival pair feeds
// `MailPending` (`HasNewMail()`/the minimap icon).
SessionEvent::MailList { mails } => {
mail::mail_list(mails, &mut mail_open, &net_commands)
}
SessionEvent::SendMailResult {
mail_id,
action,
error,
equip_error,
item,
} => mail::send_mail_result(
mail_id,
action,
error,
equip_error,
item,
&mut mail_open,
&net_commands,
&mut equip_errors,
),
SessionEvent::MailItemText { text_id, text } => {
mail::mail_item_text(text_id, text, &mut mail_open)
}
// The book-page cache (decision 1105) — one page per packet, the whole chain in
// answer to the first ask; the reader repaints off it on the next feed.
SessionEvent::PageText {
@@ -1897,24 +1640,6 @@ fn apply_unpeeled(
text,
next_page_id,
} => page_texts.insert(page_id, text, next_page_id),
SessionEvent::ReceivedMail { seconds } => {
mail::received_mail(seconds, &mut mail_pending, &mail_open, &net_commands)
}
SessionEvent::NextMailTime { seconds } => {
mail::next_mail_time(seconds, &mut mail_pending)
}
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
SessionEvent::TradeStatus { status } => trade::trade_status(
status,
&mut trade_session,
&mut ui_error_keys,
&net_commands,
),
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
SessionEvent::WorldStates { scope, states } => {
world::world_states(scope, states, &mut world_states)
}
+3 -37
View File
@@ -160,19 +160,14 @@ pub(crate) struct WindowStores<'w> {
pub group: ResMut<'w, crate::ui_party::GroupState>,
/// The taxi-map session (decision 0484 phase 1).
pub taxi: ResMut<'w, crate::ui_taxi::TaxiState>,
/// The mailbox session + its login-scoped arrival countdown (decision 0544).
pub mail_open: ResMut<'w, crate::ui_mail::MailOpen>,
pub mail_pending: ResMut<'w, crate::ui_mail::MailPending>,
/// The player-trade session (decision 0592).
pub trade_session: ResMut<'w, crate::ui_trade::TradeSession>,
/// The bank session and its purchase-refusal queue (decision 0604).
pub bank_open: ResMut<'w, crate::ui_bank::BankOpen>,
pub bank_errors: ResMut<'w, crate::ui_bank::BankErrors>,
/// The world-state table the NPC-text `$<n>w` tokens read.
pub world_states: ResMut<'w, crate::world_state::WorldStates>,
/// The duel session (decision 0633).
pub duel: ResMut<'w, crate::ui_duel::DuelState>,
pub social: ResMut<'w, crate::ui_social::SocialState>,
/// 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>,
@@ -189,13 +184,6 @@ pub(crate) struct WindowStores<'w> {
/// id; the reader session repaints off it.
pub page_texts: ResMut<'w, crate::ui_item_text::PageTexts>,
pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>,
/// The guild session (decision 1257) — the identity/roster mirror the seven
/// `SessionEvent::Guild*` arms drive.
pub guild: ResMut<'w, crate::ui_guild::GuildState>,
/// The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM` parks the
/// innkeeper's guid here and `crate::ui_binder` turns it into the CONFIRM_BINDER dialog,
/// whose Accept is the only thing that binds anything.
pub binder: ResMut<'w, crate::ui_binder::BinderState>,
/// The class trainer's pending respec question (decision 1580) — the inbound
/// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and
/// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose Accept is the
@@ -213,7 +201,6 @@ pub(crate) struct WindowStores<'w> {
pub battlefield: ResMut<'w, crate::ui_battlefield::Battlefield>,
pub tutorials: ResMut<'w, crate::tutorial::Tutorials>,
pub battlefield_positions: ResMut<'w, crate::ui_battlefield_positions::BattlefieldPositions>,
pub tabard: ResMut<'w, crate::ui_tabard::TabardOpen>,
/// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so
/// "where you were standing when the guard told you" is the client's to remember
/// (`crate::poi_marker`).
@@ -225,24 +212,6 @@ pub(crate) struct WindowStores<'w> {
/// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world
/// point here and the minimap renderer derives everything else from it.
pub ping: ResMut<'w, crate::minimap::MinimapPing>,
/// The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held ticket here
/// and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket` diffs on: the Help window
/// re-polls every 10 minutes and an unchanged answer still has to re-fire `UPDATE_TICKET`.
pub gm_ticket: ResMut<'w, crate::ui_gm_ticket::GmTicketState>,
/// The guild-charter session (decision 1672) — two resources because only the registrar
/// half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and
/// `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must survive
/// walking away from the registrar.
pub registrar: ResMut<'w, crate::ui_petition::GuildRegistrarState>,
pub petition: ResMut<'w, crate::ui_petition::PetitionState>,
/// The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the summoner's
/// guid, zone and expiry here and `crate::ui_summon` turns it into the CONFIRM_SUMMON dialog,
/// whose Accept is the only packet in the flow.
pub summon: ResMut<'w, crate::ui_summon::SummonState>,
/// The instance-lockout bookkeeping (decision 1748) — four of its six packets queue a
/// GlobalStrings-templated chat line here for `crate::ui_instance` to resolve against the
/// VM, and two write the latch behind `CanShowResetInstances()`.
pub instances: ResMut<'w, crate::ui_instance::InstanceState>,
}
/// The action bar's family: the cast/cooldown state and every error queue the red line drains
@@ -369,7 +338,4 @@ pub(crate) struct Catalogs<'w> {
pub exploration_sounds: Option<Res<'w, crate::sound::ExplorationSounds>>,
/// The map the guard's directions marker is stamped with.
pub current_map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
/// Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's display
/// condition reads; see `ui_guild::apply::event` for the four conjuncts.
pub guild_notify: Res<'w, crate::ui_guild::GuildMemberNotify>,
}
@@ -0,0 +1,132 @@
//! **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.
use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH};
use benilla_protocol::messages::{ObjectType, SpeedKind, SplineMode};
use benilla_protocol::{EntityKind, MoveSpeeds, ObjectFields, SessionEvent};
use bevy::prelude::*;
use crate::net::{GuidIndex, NetEvents, ObjectStore, UnitMoveModes, UnitSpeeds};
const GUID: u64 = 0xF130_0000_1234_0001;
fn create() -> SessionEvent {
SessionEvent::ObjectCreate {
guid: GUID,
kind: EntityKind::Unit,
display_id: None,
position: [1.0, 2.0, 3.0],
orientation: 0.0,
scale: 1.0,
speeds: Some(MoveSpeeds {
walk: 2.5,
run: 7.0,
run_back: 4.5,
swim: 4.7,
swim_back: 2.5,
turn_rate: std::f32::consts::PI,
}),
mover: None,
transport_progress: None,
transport: None,
spline: None,
fields: ObjectFields::from_pairs(&[
(FIELD_UNIT_HEALTH, 100),
(FIELD_UNIT_MAXHEALTH, 100),
(FIELD_UNIT_LEVEL, 9),
])
.into_created(ObjectType::Unit),
}
}
/// 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.
fn updates() -> Vec<SessionEvent> {
vec![
SessionEvent::ObjectValues {
guid: GUID,
fields: ObjectFields::from_pairs(&[(FIELD_UNIT_HEALTH, 60)]),
},
SessionEvent::SpeedChanged {
guid: GUID,
kind: SpeedKind::Run,
speed: 14.0,
},
SessionEvent::SplineMoveMode {
guid: GUID,
mode: SplineMode::Root,
apply: true,
},
SessionEvent::ObjectValues {
guid: GUID,
fields: ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 10)]),
},
]
}
/// A kind the mailbox has claimed — any table kind would do; this one needs no open window.
fn claimed() -> SessionEvent {
SessionEvent::NextMailTime { seconds: -86400.0 }
}
/// One frame of the real drain over `events`; what the unit ended up as.
fn drained(events: Vec<SessionEvent>) -> (Vec<(u16, u32)>, MoveSpeeds, UnitMoveModes) {
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)
.expect("the create indexed the unit");
let fields = world
.get::<ObjectStore>(e)
.expect("the store landed")
.0
.raw_fields()
.collect();
let speeds = world.get::<UnitSpeeds>(e).expect("the speeds landed").0;
let modes = *world.get::<UnitMoveModes>(e).expect("the modes landed");
(fields, speeds, modes)
}
#[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.
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.
let mut split = vec![create()];
for ev in updates() {
split.push(claimed());
split.push(ev);
}
let (fields, speeds, modes) = drained(one_run);
let (split_fields, split_speeds, split_modes) = drained(split);
// The frame's own meaning, so the comparison below cannot pass on two empty results.
assert!(fields.contains(&(FIELD_UNIT_HEALTH, 60)), "{fields:?}");
assert!(fields.contains(&(FIELD_UNIT_LEVEL, 10)), "{fields:?}");
assert!(fields.contains(&(FIELD_UNIT_MAXHEALTH, 100)), "{fields:?}");
assert_eq!(speeds.run, 14.0);
assert_eq!(speeds.walk, 2.5);
assert!(modes.rooted());
assert_eq!(split_fields, fields);
assert_eq!(split_speeds.run, speeds.run);
assert_eq!(split_speeds.walk, speeds.walk);
assert_eq!(split_modes, modes);
}
+2 -38
View File
@@ -13,7 +13,6 @@ use crate::names::NameCache;
use crate::ui_chat::ChatLog;
use crate::ui_gossip::GossipState;
use crate::ui_loot::LootState;
use crate::ui_mail::MailOpen;
use crate::ui_merchant::MerchantOpen;
use crate::ui_quest::QuestGiver;
use crate::ui_quest_log::QuestLog;
@@ -191,14 +190,7 @@ pub(super) fn disconnected(
death_net: &mut crate::death::DeathNet,
group: &mut crate::ui_party::GroupState,
taxi: &mut TaxiState,
mail: &mut MailOpen,
mail_pending: &mut crate::ui_mail::MailPending,
trade: &mut crate::ui_trade::TradeSession,
bank: &mut crate::ui_bank::BankOpen,
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
guild: &mut crate::ui_guild::GuildState,
gm_ticket: &mut crate::ui_gm_ticket::GmTicketState,
cooldowns: &mut crate::cooldowns::Cooldowns,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
@@ -258,38 +250,10 @@ pub(super) fn disconnected(
quest_share.clear_session();
group.clear_session();
taxi.clear_session();
mail.clear_session();
// 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.
*mail_pending = crate::ui_mail::MailPending::default();
// An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade.
trade.clear_session();
// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
bank.clear_session();
// 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.
*duel = crate::ui_duel::DuelState::default();
// The friend/ignore lists and the last `/who` are session state too (decision 0668): the
// server re-pushes both lists at the next login, and a stale ignore list would silence the
// 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()`.
social.clear_session();
// The guild session is login-scoped the same way (decision 1257) — and more strictly, because
// the next login may be a *different character*, whose guild id, rank, rights and roster share
// nothing with this one's. The identity cache goes too: it is keyed by guild id, so it would
// 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.
*guild = crate::ui_guild::GuildState::default();
// The GM ticket is login-scoped too (decision 1673), and for a sharper reason than most: the
// 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.
gm_ticket.clear_session();
// The cooldown list is session-scoped for the same reason and had been missing from this
// sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown
// 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
// socket answers the old session's records: a second login on the same character reads its
// own stale copy over the wire's fresh remainder, and a login on a different character
+211 -29
View File
@@ -17,13 +17,16 @@
//! 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. One drain runs in two halves: the unclaimed events through the match first, in packet
//! order, then the claimed ones through the table, in packet order — so a window handler always
//! sees this frame's object updates, whatever the interleaving. 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. When the last family leaves the match, the match, the halves and the
//! broadcast list go with it.
//! 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.
use std::collections::HashMap;
@@ -41,8 +44,8 @@ pub(crate) struct NetHandlers {
}
/// 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. Cloned to the table after the
/// match has run. Empty once the session family itself is peeled.
/// 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 {
@@ -61,6 +64,24 @@ impl NetHandlers {
.collect()
}
/// Run **every** registered handler once with `ev` — whatever kind it registered for — and
/// name the ones that could not run. A handler matches on its own kind and ignores the rest,
/// so what this exercises is the part a wrong-kind event still reaches: the system's
/// parameters being fetched from this world.
#[cfg(test)]
pub(crate) fn probe(&self, world: &mut World, ev: &SessionEvent) -> Vec<String> {
let mut failed = Vec::new();
for (kind, list) in &self.by_kind {
for (id, name) in list {
if let Err(e) = world.run_system_with(*id, ev.clone()) {
failed.push(format!("{kind:?}: `{name}` — {e}"));
}
}
}
failed.sort();
failed
}
fn push(&mut self, kind: SessionEventKind, handler: Handler) {
self.by_kind.entry(kind).or_default().push(handler);
}
@@ -115,32 +136,34 @@ impl NetHandlerApp for App {
}
}
/// One drain's dispatch: the events the table does not own go through `unclaimed` first — the
/// dispatch match, in packet order — then the owned ones run through the table, in packet order.
/// A [`BROADCAST`] kind reaches both.
/// 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>,
unclaimed: impl FnOnce(&mut World, Vec<SessionEvent>),
mut unclaimed: impl FnMut(&mut World, Vec<SessionEvent>),
) {
let handlers = world.remove_resource::<NetHandlers>().unwrap_or_default();
let mut to_match = Vec::with_capacity(events.len());
let mut to_table = Vec::new();
let mut run = Vec::new();
for ev in events {
let kind = SessionEventKind::from(&ev);
if !handlers.handles(kind) {
to_match.push(ev);
} else if BROADCAST.contains(&kind) {
to_table.push(ev.clone());
to_match.push(ev);
} else {
to_table.push(ev);
run.push(ev);
continue;
}
if BROADCAST.contains(&kind) {
run.push(ev.clone());
}
if !run.is_empty() {
unclaimed(world, std::mem::take(&mut run));
}
}
unclaimed(world, to_match);
for ev in to_table {
handlers.run(world, ev);
}
if !run.is_empty() {
unclaimed(world, run);
}
world.insert_resource(handlers);
}
@@ -200,27 +223,65 @@ mod tests {
}
#[test]
fn owned_kinds_run_in_packet_order_after_the_match_has_run_the_rest() {
fn the_match_and_the_table_interleave_in_wire_order() {
let mut app = app();
app.net_handler(SessionEventKind::LoginQueued, on_queued)
.net_handler(SessionEventKind::LoggedOut, on_logged_out);
let stage = || SessionEvent::LoginStage {
stage: benilla_protocol::LoginStage::Connecting,
};
let log = run(
&mut app,
vec![
queued(1),
SessionEvent::LoginStage {
stage: benilla_protocol::LoginStage::Connecting,
},
stage(),
stage(),
SessionEvent::LoggedOut,
queued(2),
stage(),
],
);
assert_eq!(
log,
vec!["match:LoginStage", "queued:1", "logged_out", "queued:2"]
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());
}
#[test]
fn several_handlers_on_one_kind_run_in_registration_order_each_with_the_event() {
let mut app = app();
@@ -316,6 +377,127 @@ mod tests {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// **Every registered handler can run on the built client.** The dispatch match's parameters
/// were fetched every frame from the first, so a resource nobody inserted failed at boot; a
/// handler's are fetched when its packet arrives, which for a rare packet is never in a
/// smoke run. This fetches them all, once, on the headless client — every plugin built and
/// finished — by running each handler with an event of a kind it ignores.
#[test]
fn every_registered_handler_can_run_on_the_built_client() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
let world = app.world_mut();
let handlers = world
.remove_resource::<NetHandlers>()
.expect("the built client registers handlers");
let failed = handlers.probe(world, &SessionEvent::LoggedOut);
let count: usize = handlers.census().values().map(Vec::len).sum();
eprintln!(
"net handlers probed on the built client: {count}, {} failed",
failed.len()
);
assert!(failed.is_empty(), "{}", failed.join("\n"));
}
/// **A handler matches the kind it registered for.** `net_handler(K::X, on_y)` routes X to
/// `on_y`, and `on_y` opens with `if let SessionEvent::X` — two spellings of one kind, which
/// nothing else ties together: a handler registered under the wrong kind compiles, runs, and
/// silently ignores its packet. Read off the source: every registration's handler names its
/// kind in its body. A session-end listener takes `In(_)` and is exempt.
#[test]
fn every_handler_names_the_kind_it_registered_for() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
let mut dirs = vec![src];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(dir).expect("src") {
let path = entry.expect("entry").path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let (mut seen, mut problems) = (0, Vec::new());
for path in files {
if path.ends_with("net/handlers.rs") {
continue; // this file's own fixtures and prose
}
let text = regex_lite_strip_comments(&std::fs::read_to_string(&path).expect("source"));
for call in text.split("net_handler(").skip(1) {
let Some((args, _)) = call.split_once(')') else {
continue;
};
let Some((kind, handler)) = args.split_once(',') else {
continue;
};
let kind = kind.trim().rsplit("::").next().unwrap_or_default();
let handler = handler.trim();
seen += 1;
if kind == "Disconnected" {
continue;
}
let Some(at) = text.find(&format!("fn {handler}(")) else {
problems.push(format!(
"{}: `{handler}` is not in the file that registers it",
path.display()
));
continue;
};
// The handler's own lines: from its `fn` to the next item's.
let body: Vec<&str> = text[at..]
.lines()
.enumerate()
.take_while(|(i, l)| {
let l = l.trim_start();
*i == 0
|| !(l.starts_with("fn ")
|| l.starts_with("pub")
|| l.starts_with("#["))
})
.map(|(_, l)| l)
.collect();
if !body.join("\n").contains(&format!("SessionEvent::{kind}")) {
problems.push(format!(
"{}: `{handler}` is registered for {kind} and never names it",
path.display()
));
}
}
}
let registered: usize = crate::game_plugins::schedule_tests::headless_client()
.world()
.resource::<NetHandlers>()
.census()
.values()
.map(Vec::len)
.sum();
assert_eq!(
seen, registered,
"a registration this scan cannot read — spell it `net_handler(K::Kind, handler)`"
);
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[derive(Resource)]
struct Absent;
fn needs_what_nobody_inserted(In(_): In<SessionEvent>, _absent: Res<Absent>) {}
#[test]
fn the_probe_names_a_handler_whose_resource_is_missing() {
let mut app = app();
app.net_handler(SessionEventKind::LoggedOut, on_logged_out)
.net_handler(SessionEventKind::LoginQueued, needs_what_nobody_inserted);
let handlers = app.world_mut().remove_resource::<NetHandlers>().unwrap();
let failed = handlers.probe(app.world_mut(), &SessionEvent::LoggedOut);
assert_eq!(failed.len(), 1, "{failed:?}");
assert!(
failed[0].contains("needs_what_nobody_inserted"),
"{failed:?}"
);
}
/// `//` comments out, so a variant named in prose does not count as an arm.
fn regex_lite_strip_comments(s: &str) -> String {
s.lines()
+39 -3
View File
@@ -207,14 +207,49 @@ fn drain_binder(
/// silent but still audible, which is the reference's own ordering rather than an accident of ours.
const SOUND_PLAYERBOUND: u32 = 1141;
/// The net drain's `SessionEvent::PlayerBound` arm, factored here so the wire law lives beside the
/// state it drives.
pub(crate) mod apply {
/// The innkeeper's packet handlers (in the net handler table since 2312), beside the state they
/// drive.
pub(crate) mod net {
use super::*;
use bevy::ecs::message::MessageWriter;
use crate::net::{ServerSoundKind, ServerSoundMessage};
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
/// Register the binder's handlers — called from [`UiBinderPlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::BinderConfirm, on_confirm)
.net_handler(K::PlayerBound, on_bound);
}
fn on_confirm(In(ev): In<SessionEvent>, mut binder: ResMut<BinderState>) {
if let SessionEvent::BinderConfirm { binder: npc } = ev {
binder.ask(npc);
}
}
fn on_bound(
In(ev): In<SessionEvent>,
mut binder: ResMut<BinderState>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
areas: Option<Res<AreaTableRes>>,
mut sounds: MessageWriter<ServerSoundMessage>,
) {
if let SessionEvent::PlayerBound { binder: npc, area } = ev {
debug!("net: bound to area {area} by {npc:#x}");
bound(
area,
&mut binder,
&mut errors,
areas.as_deref(),
&mut sounds,
);
}
}
/// `SMSG_PLAYERBOUND` — the bind took. Retract the question, play the sound, and queue
/// `DisplayError(0x138)` = `ERR_DEATHBIND_SUCCESS_S` (catalog row 312, `kind 0` — a system
@@ -260,6 +295,7 @@ pub(crate) struct UiBinderPlugin;
impl Plugin for UiBinderPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<BinderState>().add_systems(
Update,
(
+90 -3
View File
@@ -184,10 +184,96 @@ fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option<String>) -> Option<St
(!text.is_empty()).then_some(text)
}
/// The net drain's `SessionEvent::Duel*` arms, factored here so the wire laws live beside the
/// state they drive. `own` is our own guid.
pub(crate) mod apply {
/// The duel's packet handlers (decision 0633; in the net handler table since 2312), beside the
/// state they drive: the session mirror + the two `DisplayError` lines the handlers emit inline;
/// the Era events fire off the mirror's edges in [`feed_duel`], and the countdown ticks in its own
/// system. `own` is our own guid.
pub(crate) mod net {
use super::*;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
/// Register the duel's handlers — called from [`UiDuelPlugin`]. One per kind, plus the
/// session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::DuelRequested, on_requested)
.net_handler(K::DuelOutOfBounds, on_bounds)
.net_handler(K::DuelInBounds, on_bounds)
.net_handler(K::DuelComplete, on_complete)
.net_handler(K::DuelWinner, on_winner)
.net_handler(K::DuelCountdown, on_countdown)
.net_handler(K::Disconnected, on_session_end);
}
fn on_requested(
In(ev): In<SessionEvent>,
mut duel: ResMut<DuelState>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
commands: Res<NetCommands>,
self_guid: Res<crate::net::SelfGuid>,
social: Res<crate::ui_social::SocialState>,
) {
if let SessionEvent::DuelRequested {
arbiter,
challenger,
} = ev
{
requested(
&mut duel,
&mut errors,
&commands,
arbiter,
challenger,
self_guid.0,
social.is_ignored(challenger),
);
}
}
fn on_bounds(In(ev): In<SessionEvent>, mut duel: ResMut<DuelState>) {
match ev {
SessionEvent::DuelOutOfBounds => bounds(&mut duel, true),
SessionEvent::DuelInBounds => bounds(&mut duel, false),
_ => {}
}
}
fn on_complete(
In(ev): In<SessionEvent>,
mut duel: ResMut<DuelState>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
) {
if let SessionEvent::DuelComplete { started } = ev {
complete(&mut duel, &mut errors, started);
}
}
fn on_winner(In(ev): In<SessionEvent>, mut duel: ResMut<DuelState>) {
if let SessionEvent::DuelWinner {
fled,
winner: won,
loser,
} = ev
{
winner(&mut duel, fled, &won, &loser);
}
}
fn on_countdown(In(ev): In<SessionEvent>, mut duel: ResMut<DuelState>) {
if let SessionEvent::DuelCountdown { seconds } = ev {
countdown(&mut duel, seconds);
}
}
/// 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`]).
fn on_session_end(In(_): In<SessionEvent>, mut duel: ResMut<DuelState>) {
*duel = DuelState::default();
}
/// `SMSG_DUEL_REQUESTED` — store the arbiter, and either own the challenge (error line +
/// immediate accept) or hand the popup to the feed.
@@ -461,6 +547,7 @@ pub(crate) struct UiDuelPlugin;
impl Plugin for UiDuelPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<DuelState>().add_systems(
Update,
(
+85 -6
View File
@@ -377,10 +377,88 @@ fn load_gm_ticket_dbc(
}
}
/// The net drain's arms, beside the state they drive.
pub(crate) mod apply {
/// The GM ticket's packet handlers (decision 1673; in the net handler table since 2312), beside
/// the state they drive.
pub(crate) mod net {
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use crate::net::NetHandlerApp;
use super::GmTicketState;
/// Register the ticket's handlers — called from [`super::UiGmTicketPlugin`]. One per kind,
/// plus the session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::GmTicket, on_ticket)
.net_handler(K::GmTicketSystemStatus, on_system_status)
.net_handler(K::GmTicketStatusUpdate, on_status_update)
.net_handler(K::GmTicketCreated, on_written)
.net_handler(K::GmTicketUpdated, on_written)
.net_handler(K::GmTicketDeleted, on_deleted)
.net_handler(K::Disconnected, on_session_end);
}
/// The GETTICKET answer — EVERY one, including `None` ("you have no ticket") and including
/// an unsolicited one pushed by a GM's `.ticket view`/`escalate`/`complete`: they are
/// indistinguishable on the wire and want identical handling.
fn on_ticket(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
if let SessionEvent::GmTicket { ticket: answer } = ev {
ticket.answer(answer);
}
}
fn on_system_status(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
if let SessionEvent::GmTicketSystemStatus { status } = ev {
ticket.answer_queue(status);
}
}
/// A GM touched the ticket. Value 1 makes the reference re-ask (`0x5e7932`), the same leg
/// the create/update success codes take; 2 (closed) and 3 (survey offered) are recorded and
/// not acted on — 3 is the survey trigger and that window is deferred. vmangos never sends
/// this packet at all, so on our server the handler is dead; cmangos makes it the whole
/// notification model, which is why it is parsed rather than dropped.
fn on_status_update(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
if let SessionEvent::GmTicketStatusUpdate { status } = ev {
status_update(status, &mut ticket);
}
}
/// The three response codes have no consumer in the shipped 1.12 UI — no event, no handler.
/// Logged so a refusal is visible in a session log rather than silent; the `ERR_TICKET_*`
/// display path is still unpinned. Create-ok (2) and update-ok (4) make the ENGINE re-ask
/// for the ticket — the reference's own `0x5e4479` arm, and the reason the shipped UI needs
/// no handler for either opcode. Without it a filed ticket goes unseen until the 10-minute
/// poll.
fn on_written(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
match ev {
SessionEvent::GmTicketCreated { response } => {
write_response("create", response, 2, &mut ticket)
}
SessionEvent::GmTicketUpdated { response } => {
write_response("update", response, 4, &mut ticket)
}
_ => {}
}
}
fn on_deleted(In(ev): In<SessionEvent>) {
if let SessionEvent::GmTicketDeleted { response: code } = ev {
response("delete", code);
}
}
/// The GM ticket is login-scoped (decision 1673), and for a sharper reason than most: the
/// 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`]).
fn on_session_end(In(_): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
ticket.clear_session();
}
/// `SMSG_GMTICKET_CREATE` (2 = created) and `SMSG_GMTICKET_UPDATETEXT` (4 = saved) — the two
/// codes the reference engine answers by **re-asking for the ticket**, at `0x5e4479`.
///
@@ -440,6 +518,7 @@ pub(crate) struct UiGmTicketPlugin;
impl Plugin for UiGmTicketPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<GmTicketState>()
.init_resource::<GmTicketFeedState>()
.add_systems(
@@ -535,13 +614,13 @@ mod tests {
#[test]
fn a_landed_write_makes_the_engine_reask_and_a_refused_one_does_not() {
let mut state = GmTicketState::default();
apply::write_response("create", 2, 2, &mut state);
apply::write_response("update", 4, 4, &mut state);
net::write_response("create", 2, 2, &mut state);
net::write_response("update", 4, 4, &mut state);
assert_eq!(state.take_reasks(), 2, "one re-ask per landed write");
assert_eq!(state.take_reasks(), 0, "drained means drained");
apply::write_response("create", 3, 2, &mut state); // CREATE_ERROR
apply::write_response("update", 5, 4, &mut state); // UPDATE_ERROR
net::write_response("create", 3, 2, &mut state); // CREATE_ERROR
net::write_response("update", 5, 4, &mut state); // UPDATE_ERROR
assert_eq!(state.take_reasks(), 0, "a refusal has nothing to fetch");
}
+113 -14
View File
@@ -648,14 +648,112 @@ fn guild_emblem(
emblem.is_designed().then_some(emblem)
}
/// The net drain's `SessionEvent::Guild*` arms, factored here so the wire laws live beside the
/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe a line queue the
/// **message id** [`lines`] named, the way `crate::net::apply`'s group shims do — the surface and
/// the sound come off the catalog at the drain, not from here (decision 2054).
pub(crate) mod apply {
/// The guild family's packet handlers (decision 1257; in the net handler table since 2312),
/// beside the state they drive ([`crate::ui_social::net`]'s shape): the identity cache, the
/// roster, and the `ERR_GUILD_*` lines the engine composes; the guild EVENTS fire off the mirror
/// in [`feed_guild`], on their edges. The ones that owe a line queue the **message id** [`lines`]
/// named, the way `crate::net::apply`'s group shims do — the surface and the sound come off the
/// catalog at the error frame's drain, not from here (decision 2054).
pub(crate) mod net {
use super::*;
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_social::SocialState;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
/// Register the family's handlers — called from [`UiGuildPlugin`]. One per kind, plus the
/// session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::GuildQueryResponse, on_query_response)
.net_handler(K::GuildRoster, on_roster)
.net_handler(K::GuildEvent, on_event)
.net_handler(K::GuildCommandResult, on_command_result)
.net_handler(K::GuildInvite, on_invite)
.net_handler(K::GuildDecline, on_decline)
.net_handler(K::GuildInfo, on_info)
.net_handler(K::Disconnected, on_session_end);
}
fn on_query_response(In(ev): In<SessionEvent>, mut guild: ResMut<GuildState>) {
if let SessionEvent::GuildQueryResponse(response) = ev {
query_response(&mut guild, response);
}
}
fn on_roster(In(ev): In<SessionEvent>, mut guild: ResMut<GuildState>) {
if let SessionEvent::GuildRoster(r) = ev {
roster(&mut guild, r);
}
}
/// The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the
/// four-conjunct display condition on their line — which is why this handler reads the
/// social lists, the notify knob and our own guid (decision 1589; the condition and its byte
/// addresses are on [`event`]).
fn on_event(
In(ev): In<SessionEvent>,
mut guild: ResMut<GuildState>,
mut errors: ResMut<UiErrorKeys>,
social: Res<SocialState>,
notify: Res<GuildMemberNotify>,
self_guid: Res<crate::net::SelfGuid>,
) {
if let SessionEvent::GuildEvent(notice) = ev {
event(
&mut guild,
&mut errors,
&social,
&notify,
self_guid.0,
notice,
);
}
}
fn on_command_result(
In(ev): In<SessionEvent>,
mut guild: ResMut<GuildState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GuildCommandResult(result) = ev {
command_result(&mut guild, &mut errors, result);
}
}
fn on_invite(
In(ev): In<SessionEvent>,
mut guild: ResMut<GuildState>,
mut errors: ResMut<UiErrorKeys>,
) {
if let SessionEvent::GuildInvite { inviter, guild: g } = ev {
invite(&mut guild, &mut errors, inviter, g);
}
}
fn on_decline(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorKeys>) {
if let SessionEvent::GuildDecline { name } = ev {
decline(&mut errors, &name);
}
}
fn on_info(In(ev): In<SessionEvent>, mut guild: ResMut<GuildState>) {
if let SessionEvent::GuildInfo(i) = ev {
info(&mut guild, i);
}
}
/// The guild session is login-scoped (decision 1257) — and strictly, because the next login
/// may be a *different character*, whose guild id, rank, rights and roster share nothing
/// with this one's. The identity cache goes too: it is keyed by guild id, so it would
/// 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`]).
fn on_session_end(In(_): In<SessionEvent>, mut guild: ResMut<GuildState>) {
*guild = GuildState::default();
}
/// Queue what [`lines`] named. The key IS the lookup and the catalog row behind it names the
/// surface and the sound, so nothing here decides either — which is the difference decision
@@ -790,6 +888,7 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut notify: ResMut<Guil
impl Plugin for UiGuildPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.add_observer(on_cvar);
crate::query_cache::register::<GuildState>(app);
app.init_resource::<GuildState>()
@@ -853,37 +952,37 @@ mod tests {
#[test]
fn the_signon_condition_is_all_four_conjuncts() {
let mut social = SocialState::default();
crate::ui_social::apply::friend_list(
crate::ui_social::net::friend_list(
&mut social,
vec![benilla_protocol::messages::FriendEntry {
guid: 7,
..Default::default()
}],
);
crate::ui_social::apply::ignore_list(&mut social, vec![9]);
crate::ui_social::net::ignore_list(&mut social, vec![9]);
let on = GuildMemberNotify(true);
let off = GuildMemberNotify(false);
let me = Some(1);
// 2 · the CVar, which ships OFF — so the default client says nothing at all.
assert!(
!apply::announce_signon(&social, &off, me, Some(5)),
!net::announce_signon(&social, &off, me, Some(5)),
"guildMemberNotify off silences the whole family"
);
assert!(apply::announce_signon(&social, &on, me, Some(5)));
assert!(net::announce_signon(&social, &on, me, Some(5)));
// 3 · not you. vmangos broadcasts the sign-on to EVERY member including the signer
// (`Guild::BroadcastPacket`, Guild.cpp:651-656), so without this you announce yourself at
// every login.
assert!(
!apply::announce_signon(&social, &on, me, Some(1)),
!net::announce_signon(&social, &on, me, Some(1)),
"your own sign-on is not announced to you"
);
// 4 · not a friend — de-duplication against SMSG_FRIEND_STATUS, which says the same thing
// with no CVar gate of its own. THIS is the conjunct benilla read as an ignore check.
assert!(
!apply::announce_signon(&social, &on, me, Some(7)),
!net::announce_signon(&social, &on, me, Some(7)),
"a guildmate who is also a friend is announced by the friend path, not twice"
);
@@ -894,14 +993,14 @@ mod tests {
"the fixture's ignore really is an ignore"
);
assert!(
apply::announce_signon(&social, &on, me, Some(9)),
net::announce_signon(&social, &on, me, Some(9)),
"the reference announces an ignored guildmate — 0x5ae810 is not the ignore check"
);
// 1 · a local player, and the pair's own guid: either missing leaves the condition
// unanswerable, and the reference's silent exit is the answer.
assert!(!apply::announce_signon(&social, &on, None, Some(5)));
assert!(!apply::announce_signon(&social, &on, me, None));
assert!(!net::announce_signon(&social, &on, None, Some(5)));
assert!(!net::announce_signon(&social, &on, me, None));
}
/// `show_offline` changes what `GetNumGuildMembers` counts and how the rows are ordered —
+49 -11
View File
@@ -97,7 +97,7 @@
//! maps it ever names are raids, and a raid is `InstanceType == 2`, which `0x495d33`'s
//! `cmp [rec+8],1` rejects. (1) is the only live writer here, which is why it is also the one
//! that answers term 1. (1) is [`track_instance_state`]'s `CurrentMap` flip; (2) is
//! [`apply::update_last_instance`]'s queue, drained through the same helper so the two cannot
//! [`net::update_last_instance`]'s queue, drained through the same helper so the two cannot
//! drift — [`LatchWriter`] is which of them is speaking.
//!
//! **(2) is therefore unreachable on our server by construction, and no live probe can cover it**
@@ -382,11 +382,48 @@ fn raid_instance_line(message: &RaidInstanceMessage) -> Option<LockoutLine> {
})
}
/// The net drain's six arms, factored here so the wire law lives beside the state it drives.
pub(crate) mod apply {
/// The instance/raid lockout family's six packet handlers (decision 1748; in the net handler
/// table since 2312), beside the state they drive: four lines the client composes itself out of
/// GlobalStrings, and the two-packet latch behind the SELF menu's reset row. The lines are
/// QUEUED — resolving them needs the VM, which a packet handler leaves to the feed (decision
/// 0669's split).
pub(crate) mod net {
use super::*;
use benilla_protocol::messages::InstanceResetFailed;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
/// Register the six handlers — called from [`UiInstancePlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::RaidInstanceMessage, on_packet)
.net_handler(K::InstanceSaveCreated, on_packet)
.net_handler(K::InstanceReset, on_packet)
.net_handler(K::InstanceResetFailed, on_packet)
.net_handler(K::UpdateLastInstance, on_packet)
.net_handler(K::UpdateInstanceOwnership, on_packet);
}
/// One handler for the six: each is a one-line fold into the same state.
fn on_packet(In(ev): In<SessionEvent>, mut state: ResMut<InstanceState>) {
match ev {
SessionEvent::RaidInstanceMessage { message } => {
raid_instance_message(&mut state, message)
}
SessionEvent::InstanceSaveCreated { flag } => instance_save_created(&mut state, flag),
SessionEvent::InstanceReset { map } => instance_reset(&mut state, map),
SessionEvent::InstanceResetFailed { failure } => {
instance_reset_failed(&mut state, failure)
}
SessionEvent::UpdateLastInstance { map } => update_last_instance(&mut state, map),
SessionEvent::UpdateInstanceOwnership { owns } => {
update_instance_ownership(&mut state, owns)
}
_ => {}
}
}
/// `SMSG_RAID_INSTANCE_MESSAGE` — one of four `RAID_INSTANCE_*` lines (`0x49e1c0`).
pub(crate) fn raid_instance_message(state: &mut InstanceState, message: RaidInstanceMessage) {
@@ -583,6 +620,7 @@ pub(crate) struct UiInstancePlugin;
impl Plugin for UiInstancePlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<InstanceState>().add_systems(
Update,
(
@@ -723,7 +761,7 @@ mod tests {
#[test]
fn reset_lines_fill_the_map_name() {
let mut state = InstanceState::default();
apply::instance_reset(&mut state, 36);
net::instance_reset(&mut state, 36);
let lines = state.take_lines();
assert_eq!(lines.len(), 1);
assert_eq!(
@@ -747,7 +785,7 @@ mod tests {
),
] {
let mut state = InstanceState::default();
apply::instance_reset_failed(
net::instance_reset_failed(
&mut state,
benilla_protocol::messages::InstanceResetFailed { reason, map: 36 },
);
@@ -762,7 +800,7 @@ mod tests {
// INSTANCERESET_FAIL_SILENTLY and above: no line, rather than the reference's
// uninitialized-buffer print.
let mut state = InstanceState::default();
apply::instance_reset_failed(
net::instance_reset_failed(
&mut state,
benilla_protocol::messages::InstanceResetFailed { reason: 3, map: 36 },
);
@@ -774,21 +812,21 @@ mod tests {
#[test]
fn save_created_has_three_arms() {
let mut state = InstanceState::default();
apply::instance_save_created(&mut state, 0);
net::instance_save_created(&mut state, 0);
let lines = state.take_lines();
assert_eq!(
line_text(&lines[0], None).as_deref(),
Some("You are now saved to this instance")
);
apply::instance_save_created(&mut state, 1);
net::instance_save_created(&mut state, 1);
let lines = state.take_lines();
assert_eq!(
line_text(&lines[0], None).as_deref(),
Some("(Debug-Only Lock Notice) You are now saved to this instance")
);
apply::instance_save_created(&mut state, 2);
net::instance_save_created(&mut state, 2);
assert!(state.take_lines().is_empty());
}
@@ -976,7 +1014,7 @@ mod tests {
(s.last_dungeon, s.saw_own_dungeon)
};
let queue = |app: &mut App, map: u32| {
apply::update_last_instance(&mut app.world_mut().resource_mut::<InstanceState>(), map);
net::update_last_instance(&mut app.world_mut().resource_mut::<InstanceState>(), map);
};
// Writer 1 — we walk out of the Deadmines ourselves. The first tick only OBSERVES map 36
@@ -1131,7 +1169,7 @@ mod tests {
Some("Welcome to Molten Core. This raid instance is scheduled to reset in 3d 2h 5m.")
);
let mut state = InstanceState::default();
apply::instance_reset_failed(
net::instance_reset_failed(
&mut state,
benilla_protocol::messages::InstanceResetFailed { reason: 0, map: 36 },
);
+5 -3
View File
@@ -9,7 +9,7 @@
//! into the first `CMSG_GET_MAIL_LIST` — one request, Lua-driven, exactly the reference flow
//! (MailFrame.lua l.42).
//!
//! The net bridge ([`crate::net::apply::mail`]) fills [`MailOpen`] from the wire
//! The mailbox's packet handlers ([`net`]) fill [`MailOpen`] from the wire
//! (`SMSG_MAIL_LIST_RESULT` → the inbox rows; `SMSG_ITEM_TEXT_QUERY_RESPONSE` → the body cache;
//! `SMSG_SEND_MAIL_RESULT` → the send/take result queues). Each frame [`feed_mail`] resolves each
//! wire [`MailListEntry`] to a Lua-facing [`MailInboxRow`] (sender via the ask-once name cache, item
@@ -58,6 +58,7 @@ use crate::query_cache::QueryCache;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
mod net;
mod pending;
pub(crate) use pending::MailPending;
@@ -100,7 +101,7 @@ pub(crate) struct MailSendAck {
pub(crate) refusal: Option<&'static str>,
}
/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by
/// The open mailbox, filled by the packet handlers ([`net`]) and read by
/// [`feed_mail`]. Holds the mailbox guid and the inbox rows exactly as the wire delivered them
/// (`SMSG_MAIL_LIST_RESULT`), the ask-once letter-body cache, and the send-result queues; the feed
/// resolves each row to a display row and the drain maps a clicked 1-based row to its wire mail id.
@@ -121,7 +122,7 @@ pub(crate) struct MailOpen {
last_list_query: Option<f64>,
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
pub(crate) send_acks: Vec<MailSendAck>,
/// 1-based inbox rows a take just emptied and purged (`net::apply::mail`) — the feed fires
/// 1-based inbox rows a take just emptied and purged ([`net`]) — the feed fires
/// `CLOSE_INBOX_ITEM(index)` for each, ahead of the `MAIL_INBOX_UPDATE` the purge causes.
pub(crate) close_inbox: Vec<u32>,
/// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the
@@ -206,6 +207,7 @@ pub(crate) struct UiMailPlugin;
impl Plugin for UiMailPlugin {
fn build(&self, app: &mut App) {
crate::query_cache::register::<MailOpen>(app);
net::register(app);
app.init_resource::<MailOpen>()
.init_resource::<MailPending>()
.add_systems(
@@ -1,28 +1,111 @@
//! The mail arc's drain-side arm bodies for [`super::apply_net_updates`]'s dispatch match
//! (decision 0544 P1/P2). Each `pub(super)` fn here is one `SessionEvent` arm's body, filling the
//! [`crate::ui_mail::MailOpen`] session the feed reads; the match at the call site stays the
//! dispatcher. The `UiScript` events these ultimately drive are fired by [`crate::ui_mail::feed_mail`]
//! (the feed owns the VM), so these arms only mutate resources + send the wire re-syncs.
//! The mailbox's packet handlers (decision 0544 P1/P2/P3; in the net handler table since 2306) —
//! the inbox/body/send-result kinds fill the [`MailOpen`] session the feed reads, the arrival
//! pair feeds [`MailPending`] (`HasNewMail()`/the minimap icon). The `UiScript` events these
//! ultimately drive are fired by [`super::feed_mail`] (the feed owns the VM), so nothing here
//! touches it: the handlers only mutate resources + send the wire re-syncs.
use benilla_protocol::messages::{mail_action, mail_error, mail_message_type, MailListEntry};
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands};
use super::{mail_refusal, MailOpen, MailPending, MailSendAck};
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
use crate::ui_items::{EquipError, EquipErrors};
use crate::ui_mail::{mail_refusal, MailOpen, MailPending, MailSendAck};
/// Register the mailbox's handlers — called from [`super::UiMailPlugin`]. One per kind, plus the
/// session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::MailList, on_mail_list)
.net_handler(K::SendMailResult, on_send_mail_result)
.net_handler(K::MailItemText, on_mail_item_text)
.net_handler(K::ReceivedMail, on_received_mail)
.net_handler(K::NextMailTime, on_next_mail_time)
.net_handler(K::Disconnected, on_session_end);
}
fn on_mail_list(In(ev): In<SessionEvent>, mut mail: ResMut<MailOpen>, commands: Res<NetCommands>) {
if let SessionEvent::MailList { mails } = ev {
mail_list(mails, &mut mail, &commands);
}
}
fn on_send_mail_result(
In(ev): In<SessionEvent>,
mut mail: ResMut<MailOpen>,
commands: Res<NetCommands>,
mut equip_errors: ResMut<EquipErrors>,
) {
if let SessionEvent::SendMailResult {
mail_id,
action,
error,
equip_error,
item,
} = ev
{
send_mail_result(
mail_id,
action,
error,
equip_error,
item,
&mut mail,
&commands,
&mut equip_errors,
);
}
}
fn on_mail_item_text(In(ev): In<SessionEvent>, mut mail: ResMut<MailOpen>) {
if let SessionEvent::MailItemText { text_id, text } = ev {
mail_item_text(text_id, text, &mut mail);
}
}
fn on_received_mail(
In(ev): In<SessionEvent>,
mut pending: ResMut<MailPending>,
mail: Res<MailOpen>,
commands: Res<NetCommands>,
) {
if let SessionEvent::ReceivedMail { seconds } = ev {
received_mail(seconds, &mut pending, &mail, &commands);
}
}
fn on_next_mail_time(In(ev): In<SessionEvent>, mut pending: ResMut<MailPending>) {
if let SessionEvent::NextMailTime { seconds } = ev {
next_mail_time(seconds, &mut pending);
}
}
/// 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`]).
fn on_session_end(
In(_): In<SessionEvent>,
mut mail: ResMut<MailOpen>,
mut pending: ResMut<MailPending>,
) {
mail.clear_session();
*pending = MailPending::default();
}
/// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox
/// repaint (via the feed's diff). The inbox handler auto-purges expired mail: any row whose timer ran
/// out (`expire_days <= 0`) is deleted server-side (`CMSG_MAIL_DELETE`) and dropped here (wow-re §5,
/// `ui/scratch/mail-interaction.md`).
///
/// **It does not touch [`crate::ui_mail::MailPending`]** — and that is a positive fact, not an
/// **It does not touch [`MailPending`]** — and that is a positive fact, not an
/// omission (decision 0913). This arm used to clear the countdown when the surviving list had
/// nothing unread, on the inferred grounds that "checking your mail clears the icon" had to be the
/// list's doing. A full write-xref of the countdown float in wow-re says otherwise: nothing on the
/// inbox path writes it. The icon clears because **opening a letter arms the deferred-refresh flag
/// and the mailbox *close* re-asks the server** — modelled in [`crate::ui_mail`], where the close
/// and the mailbox *close* re-asks the server** — modelled in [`super`], where the close
/// edge lives.
pub(super) fn mail_list(mails: Vec<MailListEntry>, mail: &mut MailOpen, commands: &NetCommands) {
fn mail_list(mails: Vec<MailListEntry>, mail: &mut MailOpen, commands: &NetCommands) {
let mailbox = mail.mailbox;
mail.mails = mails
.into_iter()
@@ -76,7 +159,7 @@ fn take_empties(entry: &MailListEntry, action: u32) -> bool {
/// take/return/delete then re-syncs the inbox with a fresh `CMSG_GET_MAIL_LIST` (the reference
/// client's inbox-refresh moment); an EQUIP_ERROR routes to the existing inventory-error surface;
/// any other failure surfaces the red error line.
pub(super) fn send_mail_result(
fn send_mail_result(
mail_id: u32,
action: u32,
error: u32,
@@ -145,7 +228,7 @@ pub(super) fn send_mail_result(
/// `SessionEvent::MailItemText` (`SMSG_ITEM_TEXT_QUERY_RESPONSE`) — land the letter body in the
/// ask-once cache + clear its pending flag; the feed repaints (MAIL_INBOX_UPDATE) on the change.
pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
mail.bodies.insert(text_id, Some(text));
}
@@ -158,12 +241,7 @@ pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
/// 60 s client-side throttle (decision 0544 P3), so a mail arriving while you stand at the mailbox
/// shows up. The reference reaches the same place by its close-time re-query; leaving a mail you
/// were just told about invisible for up to a minute is the worse client, and this costs one packet.
pub(super) fn received_mail(
seconds: f32,
pending: &mut MailPending,
mail: &MailOpen,
commands: &NetCommands,
) {
fn received_mail(seconds: f32, pending: &mut MailPending, mail: &MailOpen, commands: &NetCommands) {
pending.apply_received_mail(seconds, mail.mailbox.is_some());
if let Some(mailbox) = mail.mailbox {
let _ = commands.0.send(ClientCommand::GetMailList { mailbox });
@@ -175,6 +253,47 @@ pub(super) fn received_mail(
/// signal site `0x4ad605`; decision 0913). `0.0` = mail waiting now, negative (vmangos always sends
/// `-86400.0`) = none, a positive value counts down per frame in `crate::ui_mail`'s `feed_mail` and
/// flips `HasNewMail()` true as it lands inside ε.
pub(super) fn next_mail_time(seconds: f32, pending: &mut MailPending) {
fn next_mail_time(seconds: f32, pending: &mut MailPending) {
pending.apply_query_reply(seconds);
}
#[cfg(test)]
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.
#[test]
fn the_table_routes_the_arrival_and_the_session_end_to_the_mailbox() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<MailOpen>()
.init_resource::<MailPending>()
.init_resource::<EquipErrors>()
.insert_resource(NetCommands(tx));
register(&mut app);
app.world_mut().resource_mut::<MailOpen>().click(0x20);
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);
}
}
+108 -5
View File
@@ -52,7 +52,7 @@
//! `SMSG_PETITION_SIGN_RESULTS` to both parties and **no fresh signatures packet**
//! (`:299`, `:312-316`), while the reference's `PetitionFrame` registers only `PETITION_SHOW` and
//! `PETITION_CLOSED`. Nothing would ever repaint the name rows. So an `OK` result naming the open
//! charter re-sends `CMSG_PETITION_SHOW_SIGNATURES` ([`apply::sign_results`]) — INFERRED, and
//! charter re-sends `CMSG_PETITION_SHOW_SIGNATURES` ([`net::sign_results`]) — INFERRED, and
//! flagged as such below.
//! - **Two success paths are silent on the wire and speak locally.** Offering a charter answers the
//! *target*, not us, and finding no charter to turn in is refused before any packet is built —
@@ -414,6 +414,7 @@ pub(crate) struct UiPetitionPlugin;
impl Plugin for UiPetitionPlugin {
fn build(&self, app: &mut App) {
net::register(app);
crate::query_cache::register::<PetitionState>(app);
app.init_resource::<GuildRegistrarState>()
.init_resource::<PetitionState>()
@@ -435,9 +436,111 @@ impl Plugin for UiPetitionPlugin {
///
/// **Nothing here fires an event or writes a chat line directly.** Composed lines go onto
/// [`PetitionState::lines`] and the feed drains them, because the red `UI_ERROR_MESSAGE` channel
/// needs the `script` handle and apply has none (`crate::ui_bank`'s `BankErrors` shape).
pub(crate) mod apply {
/// needs the `script` handle and a packet handler has none (`crate::ui_bank`'s `BankErrors`
/// shape). In the net handler table since 2312.
pub(crate) mod net {
use super::*;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
use crate::net::{GuidIndex, ObjectStore, SelfGuid};
use crate::ui_social::SocialState;
/// Register the family's handlers — called from [`UiPetitionPlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::PetitionShowList, on_show_list)
.net_handler(K::PetitionShowSignatures, on_show_signatures)
.net_handler(K::PetitionQueryResponse, on_query_response)
.net_handler(K::PetitionSignResults, on_sign_results)
.net_handler(K::TurnInPetitionResults, on_turn_in_results)
.net_handler(K::PetitionDeclined, on_declined)
.net_handler(K::PetitionRenamed, on_renamed);
}
/// The registrar's two `UNIT_NPC_FLAGS` gates are on LIVE NPC state rather than on the
/// packet, so the flags are read here, off the store. An unstreamed guid reads `None` and
/// fails the gate, as the client's own resolve does.
fn on_show_list(
In(ev): In<SessionEvent>,
mut registrar: ResMut<GuildRegistrarState>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
) {
if let SessionEvent::PetitionShowList(list) = ev {
let flags = index
.0
.get(&list.npc)
.and_then(|e| stores.get(*e).ok())
.map(|s| s.0.unit_npc_flags());
show_list(&mut registrar, list, flags);
}
}
/// An ignored owner suppresses the ENTIRE update — no record fetch, no list, no event, no
/// error line (`0x5eeefe`). Consulted before anything else happens.
fn on_show_signatures(
In(ev): In<SessionEvent>,
mut petition: ResMut<PetitionState>,
social: Res<SocialState>,
commands: Res<NetCommands>,
) {
if let SessionEvent::PetitionShowSignatures(sigs) = ev {
let ignored = social.is_ignored(sigs.owner);
show_signatures(&mut petition, sigs, ignored, &commands);
}
}
fn on_query_response(In(ev): In<SessionEvent>, mut petition: ResMut<PetitionState>) {
if let SessionEvent::PetitionQueryResponse(response) = ev {
query_response(&mut petition, response);
}
}
fn on_sign_results(
In(ev): In<SessionEvent>,
mut petition: ResMut<PetitionState>,
names: Res<NameCache>,
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
) {
if let SessionEvent::PetitionSignResults(results) = ev {
sign_results(
&mut petition,
&names,
self_guid.0.unwrap_or(0),
results,
&commands,
);
}
}
fn on_turn_in_results(
In(ev): In<SessionEvent>,
mut petition: ResMut<PetitionState>,
mut registrar: ResMut<GuildRegistrarState>,
) {
if let SessionEvent::TurnInPetitionResults { result } = ev {
turn_in_results(&mut petition, &mut registrar, result);
}
}
fn on_declined(
In(ev): In<SessionEvent>,
mut petition: ResMut<PetitionState>,
names: Res<NameCache>,
) {
if let SessionEvent::PetitionDeclined { player } = ev {
declined(&mut petition, &names, player);
}
}
fn on_renamed(In(ev): In<SessionEvent>, mut petition: ResMut<PetitionState>) {
if let SessionEvent::PetitionRenamed(rename) = ev {
renamed(&mut petition, rename);
}
}
/// `SMSG_PETITION_SHOWLIST` — the registrar's charter list, subject to its three gates.
///
@@ -725,7 +828,7 @@ mod tests {
min_signatures: 9,
..Default::default()
});
apply::renamed(
net::renamed(
&mut petition,
PetitionRename {
item: 0x99,
@@ -735,7 +838,7 @@ mod tests {
assert_eq!(open_title(&petition), Some("Second"));
// An echo for a charter we do not have open is not ours to apply.
apply::renamed(
net::renamed(
&mut petition,
PetitionRename {
item: 0xdead,
+55 -3
View File
@@ -288,10 +288,61 @@ fn status_flag_key(status: u8) -> Option<&'static str> {
})
}
/// The net drain's `SessionEvent::Friend*`/`Who*` arms, factored here so the wire laws live
/// beside the state they drive ([`crate::ui_duel::apply`]'s shape).
pub(crate) mod apply {
/// The social family's packet handlers (decision 0668; in the net handler table since 2312),
/// beside the state they drive ([`crate::ui_duel::net`]'s shape): the friend/ignore lists, the
/// `/who` answer, and the result codes that print their own chat lines. The lines and the Era
/// events fire off the mirror in [`feed_social`] — every one of them needs a NAME, which the
/// feed resolves.
pub(crate) mod net {
use super::*;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
/// Register the family's handlers — called from [`UiSocialPlugin`]. One per kind, plus the
/// session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::FriendList, on_friend_list)
.net_handler(K::IgnoreList, on_ignore_list)
.net_handler(K::FriendStatus, on_friend_status)
.net_handler(K::WhoResults, on_who)
.net_handler(K::Disconnected, on_session_end);
}
fn on_friend_list(In(ev): In<SessionEvent>, mut social: ResMut<SocialState>) {
if let SessionEvent::FriendList { friends } = ev {
friend_list(&mut social, friends);
}
}
fn on_ignore_list(In(ev): In<SessionEvent>, mut social: ResMut<SocialState>) {
if let SessionEvent::IgnoreList { guids } = ev {
ignore_list(&mut social, guids);
}
}
fn on_friend_status(In(ev): In<SessionEvent>, mut social: ResMut<SocialState>) {
if let SessionEvent::FriendStatus(update) = ev {
friend_status(&mut social, update);
}
}
fn on_who(In(ev): In<SessionEvent>, mut social: ResMut<SocialState>) {
if let SessionEvent::WhoResults(results) = ev {
who(&mut social, results);
}
}
/// The friend/ignore lists and the last `/who` are session state (decision 0668): the
/// server re-pushes both lists at the next login, and a stale ignore list would silence the
/// 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`]).
fn on_session_end(In(_): In<SessionEvent>, mut social: ResMut<SocialState>) {
social.clear_session();
}
/// `SMSG_FRIEND_LIST`.
pub(crate) fn friend_list(social: &mut SocialState, friends: Vec<FriendEntry>) {
@@ -319,6 +370,7 @@ pub(crate) struct UiSocialPlugin;
impl Plugin for UiSocialPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<SocialState>().add_systems(
Update,
(
+56 -9
View File
@@ -27,7 +27,7 @@
//! resolves the active player (`0x468550` → `0x468460` with typemask `0x10` = player) and, if
//! `0x605f30` says yes, returns without latching *or* firing (`5e6194: jne`). So the request
//! does not merely fail to show a dialog: it does not disturb a previously latched one either,
//! which is why [`apply::request`] is the gate and the popup engine's `whileDead` rule is not.
//! which is why [`net::request`] is the gate and the popup engine's `whileDead` rule is not.
//!
//! **`0x605f30` is not "is dead"** — that reading cost a wrong first cut here. It is
//! `[values+0x40] <= 0` (signed, `605f3b: jle`) **OR** (typemask bit `0x10` **AND** the
@@ -78,7 +78,7 @@ use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::{UiFeed, UiInput};
/// The pending summon question — the reference's four-global bank (module doc, pin 1). Written by
/// the net drain's `SummonRequest` arm through [`apply::request`], read by [`feed_summon`] (which
/// the net drain's `SummonRequest` arm through [`net::request`], read by [`feed_summon`] (which
/// pushes the three getters' answers and fires `CONFIRM_SUMMON`) and by [`drain_summon`] (which
/// turns the dialog's Accept into the response).
#[derive(Resource, Default)]
@@ -246,12 +246,58 @@ fn end_session_summon(
}
}
/// The net drain's `SessionEvent::SummonRequest` arm, factored here so the wire law lives beside
/// the state it drives.
pub(crate) mod apply {
use bevy::prelude::debug;
/// The summon's packet handler (decision 1747; in the net handler table since 2312), beside the
/// state it drives.
pub(crate) mod net {
use bevy::prelude::*;
use super::SummonState;
use benilla_protocol::{SessionEvent, SessionEventKind};
use crate::net::NetHandlerApp;
use crate::net::{GuidIndex, ObjectStore, SelfGuid};
/// Register the handler — called from [`super::UiSummonPlugin`].
pub(super) fn register(app: &mut App) {
app.net_handler(SessionEventKind::SummonRequest, on_request);
}
/// Someone is asking to pull us to them. The reference gates this in the HANDLER, not the
/// dialog: a dead or ghost player's request is dropped before the latch, so it cannot
/// disturb a live question either (`0x5e6194`). The predicate is `0x605f30` — **health ≤ 0
/// OR (is-player AND `PLAYER_FLAGS` ghost bit)**, which is both of these accessors and not
/// the one `unit_is_dead` alone would give (a ghost's wire health is 1). A self object we
/// have not streamed yet reads as alive: the reference's own default (`0x5e6189` sends a
/// NULL object through to the latch).
fn on_request(
In(ev): In<SessionEvent>,
mut summon: ResMut<SummonState>,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
real_clock: Res<Time<Real>>,
) {
if let SessionEvent::SummonRequest {
summoner,
zone,
delay_ms,
} = ev
{
let dead_or_ghost = self_guid
.0
.and_then(|g| index.0.get(&g))
.and_then(|e| stores.get(*e).ok())
.is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost());
request(
summoner,
zone,
delay_ms,
dead_or_ghost,
real_clock.elapsed_secs_f64(),
&mut summon,
);
}
}
/// `SMSG_SUMMON_REQUEST` — latch the offer and owe the UI a dialog, unless we are dead or a
/// ghost.
@@ -295,6 +341,7 @@ pub(crate) struct UiSummonPlugin;
impl Plugin for UiSummonPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<SummonState>().add_systems(
Update,
(
@@ -349,15 +396,15 @@ mod tests {
#[test]
fn a_summon_that_arrives_while_refused_latches_nothing() {
let mut summon = SummonState::default();
apply::request(0x2a, 1519, 120_000, true, 100.0, &mut summon);
net::request(0x2a, 1519, 120_000, true, 100.0, &mut summon);
assert_eq!(summon.pending(), None);
assert!(!summon.ask);
assert_eq!(summon.time_left_ms(100.0), 0);
// A live question, then a second request while refused: the first survives untouched.
apply::request(0x2a, 1519, 120_000, false, 100.0, &mut summon);
net::request(0x2a, 1519, 120_000, false, 100.0, &mut summon);
summon.ask = false;
apply::request(0x99, 1, 120_000, true, 150.0, &mut summon);
net::request(0x99, 1, 120_000, true, 150.0, &mut summon);
assert_eq!(
summon.pending(),
Some(0x2a),
+39
View File
@@ -287,10 +287,49 @@ fn reset_on_world_enter(
design.0 = None;
}
/// The tabard vendor's packet handlers (in the net handler table since 2312), beside the state
/// they drive.
mod net {
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::TabardOpen;
use crate::net::NetHandlerApp;
use crate::ui_guild::GuildState;
/// Register the two handlers — called from [`super::TabardUiPlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::TabardVendorActivate, on_activate)
.net_handler(K::SaveGuildEmblemResult, on_save_result);
}
fn on_activate(In(ev): In<SessionEvent>, mut tabard: ResMut<TabardOpen>) {
if let SessionEvent::TabardVendorActivate(vendor) = ev {
tabard.open(vendor);
}
}
/// A saved emblem evicts our guild's cached record (`0x5e715f`): the next query anywhere
/// re-fetches it — no event, no packet.
fn on_save_result(
In(ev): In<SessionEvent>,
mut tabard: ResMut<TabardOpen>,
mut guild: ResMut<GuildState>,
) {
if let SessionEvent::SaveGuildEmblemResult(result) = ev {
if tabard.apply_result(result) {
guild.evict_own_identity();
}
}
}
}
pub(crate) struct TabardUiPlugin;
impl Plugin for TabardUiPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<TabardOpen>()
.init_resource::<TabardDesign>()
.add_systems(
@@ -2,7 +2,7 @@
//! [`benilla_ui::script`]'s `trade` module, the two-sided twin of [`crate::ui_mail`]'s mailbox seam.
//!
//! Trade is entirely server-driven: a right-click → **Trade** ([`crate::ui_trade`] via the UnitPopup
//! row) sends `CMSG_INITIATE_TRADE`; the net bridge ([`crate::net::apply::trade`]) then folds the two
//! row) sends `CMSG_INITIATE_TRADE`; the packet handlers ([`net`]) then fold the two
//! status packets into [`TradeSession`] — `SMSG_TRADE_STATUS` drives the open/accept/close state
//! machine, `SMSG_TRADE_STATUS_EXTENDED` replaces one side's item/gold snapshot. Each frame
//! [`feed_trade`] resolves both sides' wire [`TradeItem`]s to Lua-facing
@@ -18,12 +18,13 @@
//! `SMSG_TRADE_STATUS(BEGIN_TRADE)` only *records* the request; [`answer_trade_request`] walks the
//! reference's own eight-leg ladder (`0x4bf736`) and either refuses it — on one of three different
//! opcodes, or with no packet at all — or accepts it, which is what the client did unconditionally
//! before. The split is what makes the ladder possible: it reads the ignore list, the cinematic
//! state, player control, the auction house, the initiator's descriptor, an outbound initiate of
//! ours and the [`BlockTrades`] CVar, none of which can reach `apply_net_updates` at Bevy's
//! 16-`SystemParam` ceiling — and its answer may span frames, which a wire decoder cannot do at
//! all. Decision 1725 left the cinematic leg unbuilt for exactly that reason and said so; this is
//! the seam that makes it, and the seven beside it, ordinary.
//! before. The split is what made the ladder possible when it was built: it reads the ignore
//! list, the cinematic state, player control, the auction house, the initiator's descriptor, an
//! outbound initiate of ours and the [`BlockTrades`] CVar, none of which could reach the drain's
//! dispatch match at Bevy's 16-`SystemParam` ceiling (decision 1725 left the cinematic leg unbuilt
//! for exactly that reason and said so). Since 2306 the status packet is this module's own
//! handler ([`net`]) and that ceiling is gone; what keeps the ladder a system of its own is that
//! its answer may **span frames**, which a packet handler cannot do.
//!
//! **There is no consent prompt, and that is faithful rather than missing.** 1.12.1 registers a
//! `TRADE_REQUEST` event and signals it from nowhere, so the `TRADE` StaticPopup that would have
@@ -60,6 +61,8 @@ use crate::ui_party::GroupState;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::NpcSession;
mod net;
/// One side's offer as the wire delivered it — the seven slots (index 0 = trade slot 1 … index 6 =
/// the non-traded / enchant slot) plus the gold and the enchant-slot spell. Filled from
/// `SMSG_TRADE_STATUS_EXTENDED`; the feed resolves the [`TradeItem`]s to display rows.
@@ -71,7 +74,7 @@ struct TradeOffer {
enchant_spell_id: u32,
}
/// The open trade window's session, filled by the net bridge ([`crate::net::apply::trade`]) and read
/// The open trade window's session, filled by the packet handlers ([`net`]) and read
/// by [`feed_trade`]. Holds the partner guid, the open flag, both sides' wire offers, and the two
/// accept flags. Cleared on cancel/complete/close and on disconnect.
///
@@ -119,7 +122,7 @@ pub(crate) struct TradeSession {
/// The partner pressed Trade (their accept glow) — set by `TRADE_STATUS_TRADE_ACCEPT`.
their_accept: bool,
/// **A `TRADE_REQUEST_CANCEL` is owed to the VM** — set by the `CANCELED` arm of
/// [`crate::net::apply::trade::trade_status`], drained by [`feed_trade`] on the next frame.
/// [`net`]'s status handler, drained by [`feed_trade`] on the next frame.
///
/// It is a *pending signal* rather than session state, so it is the one field
/// [`Self::close`] carries across the reset: the reference's own `CANCELED` arm signals the
@@ -455,7 +458,7 @@ impl TradeSession {
}
// ── Test windows onto the private state, for the sibling module that owns the status arm ──
// (`crate::net::apply::trade`, whose tests drive `trade_status` and then have to ask what
// ([`net`], whose tests drive `trade_status` and then have to ask what
// it did). Reading them through named accessors rather than opening the fields keeps the
// session's invariants — `partner`/`request` mutually exclusive, `open` gating `npc()` —
// enforced in exactly one file.
@@ -502,6 +505,7 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut block: ResMut<Block
impl Plugin for UiTradePlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
net::register(app);
app.init_resource::<TradeSession>()
.init_resource::<BlockTrades>()
.add_systems(
@@ -544,14 +548,13 @@ pub(crate) struct BlockTrades(pub(crate) bool);
/// **Answer an incoming trade request** — the arm the reference keeps inside `CGTradeInfo`'s
/// dispatcher (`0x4bf736`, case 1 of the 23-case `0x4bf720`), lifted out to where its inputs live.
///
/// The net drain only *records* the request ([`TradeSession::request`]); this system decides. The
/// split is the point: the decision reads the ignore list, the cinematic state, player control, the
/// auction house, the initiator's own descriptor, an outbound initiate of ours and a CVar, and none
/// of those can reach `apply_net_updates`, whose signature is at Bevy's 16-`SystemParam` ceiling.
/// That ceiling is exactly why decision 1725 left the cinematic leg unbuilt and said so; it is not
/// a ceiling this side of the seam has. The answer can also **span frames**, which a wire decoder
/// cannot do at all: the `BlockTrades` line names the initiator, and that name may need a
/// `CMSG_NAME_QUERY` first.
/// The status handler only *records* the request ([`TradeSession::request`]); this system decides.
/// The split is the point: the answer can **span frames**, which a packet handler cannot do — the
/// `BlockTrades` line names the initiator, and that name may need a `CMSG_NAME_QUERY` first. (It
/// was first cut for a second reason that 2306 retired: the decision reads the ignore list, the
/// cinematic state, player control, the auction house, the initiator's own descriptor, an outbound
/// initiate of ours and a CVar, none of which could reach the drain's dispatch match at Bevy's
/// 16-`SystemParam` ceiling — exactly why decision 1725 left the cinematic leg unbuilt and said so.)
///
/// **The ladder is the reference's, in its order** — eight legs, walked contiguously over
/// `[0x4bf736, 0x4bf7f8)` and cross-checked by four independent derivations (wow-re
@@ -1427,7 +1430,7 @@ mod tests {
assert_eq!(cancels_seen(&mut app), 0, "nothing has cancelled anything");
// A plain close — what `COMPLETE` and `CLOSE_WINDOW` do — signals nothing. (Which
// statuses reach which of these two is `net::apply::trade`'s own test; this is the half
// statuses reach which of these two is `ui_trade::net`'s own test; this is the half
// that turns a signal into an event.)
app.world_mut()
.resource_mut::<TradeSession>()
@@ -1,15 +1,49 @@
//! The player-trade arc's drain-side arm bodies for [`super::apply_net_updates`]'s dispatch match
//! (decision 0592 P1). Each `pub(super)` fn here is one `SessionEvent` arm's body, driving the
//! [`crate::ui_trade::TradeSession`] state machine the feed reads; the match at the call site stays
//! the dispatcher. The `UiScript` events these ultimately drive are fired by
//! [`crate::ui_trade::feed_trade`] (the feed owns the VM), so these arms only mutate the session +
//! send the auto-reply.
//! The player trade's packet handlers (decision 0592 P1; in the net handler table since 2306) —
//! the status packet drives the open/accept/close state machine, the extended snapshot replaces
//! one side's item/gold, both into the [`TradeSession`] the feed reads. The `UiScript` events
//! these ultimately drive are fired by [`super::feed_trade`] (the feed owns the VM), so the
//! handlers only mutate the session + send the auto-reply.
use benilla_protocol::messages::{TradeStatus, TradeStatusExtended};
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands};
use super::{NamedLine, TradeSession};
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_trade::{NamedLine, TradeSession};
/// Register the trade's handlers — called from [`super::UiTradePlugin`]. One per kind, plus the
/// session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::TradeStatus, on_trade_status)
.net_handler(K::TradeStatusExtended, on_trade_status_extended)
.net_handler(K::Disconnected, on_session_end);
}
fn on_trade_status(
In(ev): In<SessionEvent>,
mut trade: ResMut<TradeSession>,
mut errors: ResMut<UiErrorKeys>,
commands: Res<NetCommands>,
) {
if let SessionEvent::TradeStatus { status } = ev {
trade_status(status, &mut trade, &mut errors, &commands);
}
}
fn on_trade_status_extended(In(ev): In<SessionEvent>, mut trade: ResMut<TradeSession>) {
if let SessionEvent::TradeStatusExtended { state } = ev {
trade_status_extended(&state, &mut trade);
}
}
/// 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`]).
fn on_session_end(In(_): In<SessionEvent>, mut trade: ResMut<TradeSession>) {
trade.clear_session();
}
/// `SessionEvent::TradeStatus` (`SMSG_TRADE_STATUS`) — benilla's face of the reference's own
/// 23-case dispatcher `CGTradeInfo 0x4bf720` (`ecx` = the status code; jump table `0x4bfa08`;
@@ -36,9 +70,9 @@ use crate::ui_trade::{NamedLine, TradeSession};
/// unsettled; §11 settles them).
///
/// `BEGIN_TRADE` records the incoming request **without answering it** (decision 1764 — the reply
/// is a ladder of eight gates, so [`crate::ui_trade::answer_trade_request`] owns it, and it is the
/// is a ladder of eight gates, so [`super::answer_trade_request`] owns it, and it is the
/// only arm of the reference's dispatcher that speaks to the wire).
pub(super) fn trade_status(
fn trade_status(
status: TradeStatus,
trade: &mut TradeSession,
errors: &mut UiErrorKeys,
@@ -138,7 +172,7 @@ enum Line {
/// Five arms genuinely say nothing (`OPEN_WINDOW`, `TRADE_ACCEPT`, `BACK_TO_TRADE`, `REJECTED`,
/// `UNKNOWN_13`); the other eighteen print. Two of those eighteen answer `None` *here* for reasons
/// of their own: `BEGIN_TRADE`'s line is the ladder's
/// ([`crate::ui_trade::answer_trade_request`]'s leg 8), and `CLOSE_WINDOW`'s is the gap at the
/// ([`super::answer_trade_request`]'s leg 8), and `CLOSE_WINDOW`'s is the gap at the
/// bottom of this doc.
///
/// **Eleven of the ids are outside the trade block** `0xb9..=0xbf` — which is why the arc shipped
@@ -167,7 +201,7 @@ enum Line {
/// | 22 | `ONLY_CONJURED` | `0x1ca` | `ERR_TRADE_WRONG_REALM` | `2` red |
///
/// Case 1's own line (`ERR_TRADE_BLOCKED_S`, leg 8 only) is
/// [`crate::ui_trade::answer_trade_request`]'s, and case 12's is the gap below.
/// [`super::answer_trade_request`]'s, and case 12's is the gap below.
///
/// **Nothing here decides a surface.** Three of these are chat lines and thirteen are toasts,
/// split between yellow and red, and the split does not follow the English: the two *successful*
@@ -215,7 +249,7 @@ fn status_message(status: TradeStatus) -> Option<Line> {
/// `SessionEvent::TradeStatusExtended` (`SMSG_TRADE_STATUS_EXTENDED`) — replace one side's item/gold
/// snapshot (decision 0592 P1); the feed repaints (`TRADE_UPDATE`) on the change.
pub(super) fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut TradeSession) {
fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut TradeSession) {
bevy::log::info!(
target: "trade",
"SMSG_TRADE_STATUS_EXTENDED their_window={} gold={} items={}",
@@ -304,6 +338,40 @@ mod tests {
trade
}
/// 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.
#[test]
fn the_table_routes_the_status_and_the_session_end_to_the_trade() {
let (_, commands, _rx) = sink();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<TradeSession>()
.init_resource::<UiErrorKeys>()
.insert_resource(commands);
register(&mut app);
crate::net::handlers::dispatch(
app.world_mut(),
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());
}
/// **Eighteen of the twenty-three arms print; this table carries sixteen.** The arc raised
/// four, which is the report.
///