Files
benilla-octo/crates/benilla-app/src/net/session.rs
T
SamandClaude Opus 5.5 c2d6f7c858 the spell owns its state, and a malformed file is refused, not a crash
- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:08:33 +07:00

790 lines
32 KiB
Rust

//! The bridge's own session handlers (in the net handler table since 2326, moved out of the
//! drain's session arm file) — the connection edges (the login stages, character select,
//! entering the world, logout, the disconnect teardown), our own teleport/worldport snaps and
//! control edges, the server clock, the login reputation store, and the player's login-scoped
//! stores the bridge defines (the home bind, the proficiencies). Registered from
//! [`super::NetPlugin`], ahead of every window plugin, so the teardown runs before the windows'
//! session-end listeners — each a second handler on `Disconnected`.
use benilla_protocol::messages::Character;
use benilla_protocol::JumpInfo;
use bevy::prelude::*;
use crate::items::Items;
use crate::names::NameCache;
use crate::ui_quest::QuestGiver;
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::ecs::system::SystemParam;
use super::{
CharActionResultMessage, CharListMessage, CharacterLoginFailedMessage,
CinematicTriggeredMessage, ClientControlMessage, DisconnectedMessage, DroppedOpcodes,
EnteredWorldMessage, GameTime, GuidIndex, HomeBind, KnockBackMessage, LoggedOutMessage,
LoginFailedMessage, LoginQueuedMessage, LoginStageMessage, NetCommands, NetHandlerApp,
NetStatus, ObjectStore, PendingTransfer, Proficiencies, RealmListMessage, Reputations,
SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
};
/// Register the session handlers — called from [`super::NetPlugin`].
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::LoginStage, on_login_edge)
.net_handler(K::LoginQueued, on_login_edge)
.net_handler(K::LoginFailed, on_login_edge)
.net_handler(K::RealmList, on_login_edge)
.net_handler(K::CharacterList, on_character_list)
.net_handler(K::CharActionResult, on_login_edge)
.net_handler(K::CharacterLoginFailed, on_login_edge)
.net_handler(K::CinematicTriggered, on_login_edge)
.net_handler(K::Connected, on_connected)
.net_handler(K::LoggedOut, on_logged_out)
.net_handler(K::Disconnected, on_disconnected)
.net_handler(K::Teleport, on_mover_edge)
.net_handler(K::KnockBack, on_mover_edge)
.net_handler(K::ClientControl, on_mover_edge)
.net_handler(K::Worldport, on_worldport)
.net_handler(K::TransferPending, on_transfer)
.net_handler(K::TransferAborted, on_transfer)
.net_handler(K::TimeSpeed, on_clock)
.net_handler(K::ServerUnixTime, on_clock)
.net_handler(K::Reputations, on_reputations)
.net_handler(K::ReputationDelta, on_reputation_delta)
.net_handler(K::ReputationVisible, on_reputations)
.net_handler(K::BindPoint, on_player_store)
.net_handler(K::Proficiency, on_player_store)
.net_handler(K::PacketDropped, on_packet_dropped)
.net_handler(K::Pong, on_pong);
}
/// The edges the bridge publishes as messages — the login screen's, the character screen's,
/// the controller's — and the two resources they park in.
#[derive(SystemParam)]
pub(crate) struct Edges<'w> {
teleports: MessageWriter<'w, TeleportMessage>,
worldports: MessageWriter<'w, WorldportMessage>,
char_lists: MessageWriter<'w, CharListMessage>,
realm_lists: MessageWriter<'w, RealmListMessage>,
char_actions: MessageWriter<'w, CharActionResultMessage>,
char_login_failures: MessageWriter<'w, CharacterLoginFailedMessage>,
entered_world: MessageWriter<'w, EnteredWorldMessage>,
addon_reply: ResMut<'w, super::AddonInfoReply>,
logged_out: MessageWriter<'w, LoggedOutMessage>,
knockbacks: MessageWriter<'w, KnockBackMessage>,
login_stages: MessageWriter<'w, LoginStageMessage>,
login_queued: MessageWriter<'w, LoginQueuedMessage>,
login_failures: MessageWriter<'w, LoginFailedMessage>,
disconnects: MessageWriter<'w, DisconnectedMessage>,
client_control: MessageWriter<'w, ClientControlMessage>,
cinematics: MessageWriter<'w, CinematicTriggeredMessage>,
transfer: ResMut<'w, PendingTransfer>,
}
/// The bridge's own state the session edges write: the guid index and our guid, the status,
/// the ask-once caches the teardown clears, the clocks, the reputations and the player's stores.
#[derive(SystemParam)]
pub(crate) struct Bridge<'w, 's> {
commands: Commands<'w, 's>,
index: ResMut<'w, GuidIndex>,
self_guid: ResMut<'w, SelfGuid>,
status: ResMut<'w, NetStatus>,
names: ResMut<'w, NameCache>,
items: ResMut<'w, Items>,
cooldowns: ResMut<'w, crate::spell::Cooldowns>,
reputations: ResMut<'w, Reputations>,
server_time: ResMut<'w, ServerTime>,
wall_clock: ResMut<'w, ServerWallClock>,
home_bind: ResMut<'w, HomeBind>,
proficiencies: ResMut<'w, Proficiencies>,
dropped: ResMut<'w, DroppedOpcodes>,
net: Res<'w, NetCommands>,
stores: Query<'w, 's, &'static mut ObjectStore>,
transports: Query<'w, 's, &'static crate::transport::Transport>,
}
fn on_login_edge(In(ev): In<SessionEvent>, mut e: Edges) {
match ev {
SessionEvent::LoginStage { stage } => login_stage(stage, &mut e.login_stages),
SessionEvent::LoginQueued { position, realm } => {
e.login_queued.write(LoginQueuedMessage { position, realm });
}
SessionEvent::LoginFailed {
refusal,
reason,
terminal,
dial,
} => login_failed(refusal, reason, terminal, dial, &mut e.login_failures),
SessionEvent::RealmList { realms } => {
e.realm_lists.write(RealmListMessage { realms });
}
SessionEvent::CharActionResult { action, code } => {
char_action_result(action, code, &mut e.char_actions)
}
SessionEvent::CharacterLoginFailed { result } => {
character_login_failed(result, &mut e.char_login_failures)
}
SessionEvent::CinematicTriggered { cinematic_id } => {
cinematic_triggered(cinematic_id, &mut e.cinematics)
}
_ => {}
}
}
fn on_character_list(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
if let SessionEvent::CharacterList { characters, realm } = ev {
character_list(characters, realm, &mut b.status, &mut e.char_lists);
}
}
fn on_connected(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
if let SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
tutorial_flags,
addon_info,
} = ev
{
connected(
guid,
name,
billing_time_rested,
tutorial_flags,
addon_info,
&mut e.addon_reply,
&mut b.self_guid,
&mut b.status,
&mut b.names,
&mut e.entered_world,
);
}
}
fn on_logged_out(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
if let SessionEvent::LoggedOut = ev {
logged_out(
&mut b.commands,
&mut b.index,
&mut b.self_guid,
&mut e.logged_out,
);
}
}
/// The teardown — the bridge's own half of the session end. Every window that dies with the
/// socket has its own listener on this kind, registered after this one and so run after it.
fn on_disconnected(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
if let SessionEvent::Disconnected { reason, end } = ev {
disconnected(
reason,
end,
&mut b.commands,
&mut b.index,
&mut b.self_guid,
&mut b.status,
&mut b.names,
&mut b.items,
&mut b.cooldowns,
&mut e.transfer,
&mut e.disconnects,
);
}
}
/// The three server-authored mover edges the controller both *applies* and *answers*: a
/// teleport snap, a knockback launch (decision 1702) and possession's control half (B211),
/// forwarded whole and unjudged — only the controller can act on them.
fn on_mover_edge(In(ev): In<SessionEvent>, mut e: Edges, b: Bridge) {
match ev {
SessionEvent::Teleport {
guid,
counter,
position,
orientation,
} => teleport(
guid,
counter,
position,
orientation,
&b.self_guid,
&mut e.teleports,
),
SessionEvent::KnockBack {
guid,
counter,
launch,
} => knock_back(guid, counter, launch, &b.self_guid, &mut e.knockbacks),
SessionEvent::ClientControl { mover, allow_move } => {
e.client_control
.write(ClientControlMessage { mover, allow_move });
}
_ => {}
}
}
fn on_worldport(
In(ev): In<SessionEvent>,
mut e: Edges,
mut b: Bridge,
mut group: ResMut<crate::ui_party::GroupState>,
) {
if let SessionEvent::Worldport {
map_id,
position,
orientation,
needs_ack,
} = ev
{
// Every streamed roster member's object is about to be purged — the same deactivation
// the reference runs one object at a time (decision 1640).
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.net);
worldport(
map_id,
position,
orientation,
needs_ack,
&mut b.commands,
&mut b.index,
&mut e.transfer,
&b.transports,
&mut e.worldports,
);
}
}
fn on_transfer(In(ev): In<SessionEvent>, mut e: Edges) {
match ev {
SessionEvent::TransferPending {
map_id,
transport_entry,
} => transfer_pending(map_id, transport_entry, &mut e.transfer),
SessionEvent::TransferAborted { reason } => transfer_aborted(reason, &mut e.transfer),
_ => {}
}
}
fn on_clock(In(ev): In<SessionEvent>, mut b: Bridge) {
match ev {
SessionEvent::TimeSpeed {
hours,
minutes,
day_serial,
timescale,
} => time_speed(hours, minutes, day_serial, timescale, &mut b.server_time),
SessionEvent::ServerUnixTime { unix_time } => {
server_unix_time(unix_time, &mut b.wall_clock)
}
_ => {}
}
}
fn on_reputations(In(ev): In<SessionEvent>, mut b: Bridge) {
match ev {
SessionEvent::Reputations { standings } => reputations(standings, &mut b.reputations),
SessionEvent::ReputationVisible { list_id } => {
reputation_visible(list_id, &mut b.reputations)
}
_ => {}
}
}
/// The chat line reads the deltas against the store, so it runs BEFORE the overwrite — after
/// it, every delta is zero. One handler for both legs, for that order.
fn on_reputation_delta(
In(ev): In<SessionEvent>,
mut b: Bridge,
factions: Option<Res<crate::target::ring::Factions>>,
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
mut quest: ResMut<QuestGiver>,
) {
if let SessionEvent::ReputationDelta { standings } = ev {
crate::combat_log::chat::faction_standing(
&standings,
&b.reputations,
factions.as_deref(),
&mut chat_log,
);
reputation_delta(standings, &mut b.reputations, &mut quest);
}
}
/// The player's login-scoped stores the bridge defines.
fn on_player_store(In(ev): In<SessionEvent>, mut b: Bridge) {
match ev {
SessionEvent::BindPoint { area } => b.home_bind.0 = Some(area),
SessionEvent::Proficiency {
item_class,
subclass_mask,
} => {
b.proficiencies.0.insert(item_class, subclass_mask);
}
_ => {}
}
}
fn on_packet_dropped(In(ev): In<SessionEvent>, mut b: Bridge) {
if let SessionEvent::PacketDropped {
opcode,
unparseable,
} = ev
{
packet_dropped(opcode, unparseable, &mut b.dropped);
}
}
/// **The pong never gets here** — the read thread measures it against the ping clock the
/// instant it lands and stops it, the way the reference's `OnData 0x537b10` hands `SMSG_PONG`
/// to `HandlePong 0x537d60` inline instead of queueing it (`net::io`). Reaching this handler
/// means that bypass was undone and every latency reading is a client frame too slow again,
/// which is B346 exactly — so it says so out loud rather than measuring here and hiding it.
fn on_pong(In(ev): In<SessionEvent>) {
if let SessionEvent::Pong { sequence } = ev {
warn!("net: pong seq={sequence} reached the drain — the read thread's RTT bypass is gone (B346)");
}
}
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
fn login_stage(stage: benilla_protocol::LoginStage, out: &mut MessageWriter<LoginStageMessage>) {
out.write(LoginStageMessage { stage });
}
/// A login attempt failed before the roster (decision 0539): the IO thread is back at its pre-logon
/// park, and [`crate::login`]'s policy decides what happens next.
fn login_failed(
refusal: Option<benilla_protocol::LoginRefusal>,
reason: String,
terminal: bool,
dial: Option<benilla_protocol::DialFailure>,
out: &mut MessageWriter<LoginFailedMessage>,
) {
out.write(LoginFailedMessage {
refusal,
reason,
terminal,
dial,
});
}
/// The verdict on a character create/delete (`SMSG_CHAR_CREATE`/`SMSG_CHAR_DELETE`) — the glue
/// screen turns the code into its own refusal string.
fn char_action_result(
action: benilla_protocol::CharAction,
code: u8,
out: &mut MessageWriter<CharActionResultMessage>,
) {
out.write(CharActionResultMessage { action, code });
}
/// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and parked
/// at character select — surface the list (+ the connected realm's identity) to the glue screen.
fn character_list(
characters: Vec<Character>,
realm: Option<benilla_protocol::RealmInfo>,
status: &mut NetStatus,
char_lists: &mut MessageWriter<CharListMessage>,
) {
info!(
"net: character select — {} character(s) on the account",
characters.len()
);
// A roster in hand IS a live link: clear the last failure so the select banner drops its
// "Server down" note. Without this, the logout path sticks it on permanently — the relist
// cycle synthesizes `Disconnected("logged out")` for the world teardown (decision 0065's
// path), and nothing else clears `last_reason` until the next world entry.
status.last_reason = None;
char_lists.write(CharListMessage { characters, realm });
}
/// The server refused the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — the entry
/// announced a moment ago is void. `crate::char_select` takes the screen back and says why.
fn character_login_failed(result: u8, out: &mut MessageWriter<CharacterLoginFailedMessage>) {
warn!("net: character login refused (result {result:#04x})");
out.write(CharacterLoginFailedMessage { result });
}
/// A cinematic sequence was triggered (`SMSG_TRIGGER_CINEMATIC`) — hand it to
/// [`crate::cinematic`], which plays it and owns the ack.
///
/// **The ack no longer goes out from here, and that is the load-bearing part.** While a cinematic
/// runs unacked, vmangos re-anchors object visibility to the flying camera
/// (`Player::UpdateCinematic`) and everything around the body despawns until relog (decision 0196)
/// — so the ack must still happen, at the *end* of playback rather than instantly. The cinematic
/// plugin sends it on a natural end, on an ESC skip, and immediately for a trigger it cannot
/// resolve to a shot, so no path drops it.
fn cinematic_triggered(
cinematic_id: u32,
triggered: &mut MessageWriter<CinematicTriggeredMessage>,
) {
info!("net: cinematic {cinematic_id} triggered");
triggered.write(CinematicTriggeredMessage { cinematic_id });
}
/// We are in the world (the IO thread's first in-world event): record our guid, flip the status,
/// and seed the name cache with our own name.
fn connected(
guid: u64,
name: String,
billing_time_rested: u32,
tutorial_flags: Option<Vec<u8>>,
addon_info: Option<Vec<String>>,
addon_reply: &mut crate::net::AddonInfoReply,
self_guid: &mut SelfGuid,
status: &mut NetStatus,
names: &mut NameCache,
entered_world: &mut MessageWriter<EnteredWorldMessage>,
) {
self_guid.0 = Some(guid);
status.connected = true;
status.last_reason = None;
info!("net: in world as {name} (guid {guid})");
// **The reference's world-session wipe, first** (`0x555740`'s `0x5557ad` arm): the player-name
// and pet-name stores are cleared at every world entry, because a guid names one character and
// a pet number one spawn, and nothing on the wire says either has been handed to somebody else
// since we last looked (decision 2223 — a wiped server's new character wearing a deleted one's
// name, B386). Creature templates are keyed by an entry that means the same thing forever and
// survive this, exactly as they survive the process.
names.clear_world_session();
// Our own name came with the login — seed the cache so "player" never queries.
names.insert_player(guid, name, None);
// Seated before the world-entry UI load reads it (2175), and overwritten every login so a
// server that answers nothing cannot inherit the previous one's verdict.
addon_reply.0 = addon_info;
entered_world.write(EnteredWorldMessage {
billing_time_rested,
tutorial_flags,
});
}
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — back to character select.
fn logged_out(
commands: &mut Commands,
index: &mut GuidIndex,
self_guid: &mut SelfGuid,
logged_out: &mut MessageWriter<LoggedOutMessage>,
) {
// A deliberate logout ends this *character's* session, not just the socket: unlike
// the disconnect teardown below (which keeps the self avatar as the local puppet for
// a seamless same-char reconnect, decision 0065), the avatar goes too — the next
// login may be a different character. Clearing `SelfGuid` first makes the follow-up
// Disconnected teardown total.
info!("net: logged out — back to character select");
if let Some(guid) = self_guid.0.take() {
if let Some(e) = index.0.remove(&guid) {
commands.entity(e).despawn();
}
}
logged_out.write(LoggedOutMessage);
}
/// The session ended (socket closed / handshake failure): tear down the streamed world and clear
/// every session-scoped cache.
fn disconnected(
reason: String,
end: benilla_protocol::SessionEnd,
commands: &mut Commands,
index: &mut GuidIndex,
self_guid: &mut SelfGuid,
status: &mut NetStatus,
names: &mut NameCache,
items: &mut Items,
cooldowns: &mut crate::spell::Cooldowns,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
// The reconnect-policy feed first (decision 0539): [`crate::login`] reads it as "the IO thread
// is back at its pre-logon park".
// Is the session over, or is this the pause inside one? Settled once, here, and carried on the
// message to every other reader (decision 1262).
let msg = DisconnectedMessage::new(reason.clone(), end);
let over = msg.session_over;
disconnects.write(msg);
warn!("net: {reason} — tearing down the streamed world");
// An announced-but-unfinished far teleport died with the socket.
pending_transfer.0 = None;
status.connected = false;
// The RTT ring is NOT cleared here. It belongs to the connection, and the read thread wipes
// it as it re-enters its cycle loop (`net::io`) — same instant, one thread, no race with a
// reconnect that has already begun measuring.
// Teardown (decision 0065): despawn every streamed entity except the self avatar —
// it stays the local puppet (controller + camera keep working); the reconnect's
// re-create refreshes it in place. Immediate despawn, not `DespawnFade`: a
// connection loss is not a world event, and index-less fading entities would race
// the reconnect's re-creates. Entities already mid-fade left the index earlier and
// finish fading on their own.
//
// **Unless the session is over** (decision 1262), and then the avatar goes too, exactly as
// [`logged_out`] takes it: 0065 spares it *for the reconnect*, and with no reconnect coming
// that spared body is a puppet with no server behind it. Keeping it was the free camera —
// `SelfGuid` set, no entity, an entry that never finished — so the fact that decides whether
// anything reconnects has to be the same fact that decides whether the body stays.
let keep = if over { None } else { self_guid.0 };
if over {
self_guid.0 = None;
}
index.0.retain(|guid, e| {
if Some(*guid) == keep {
return true;
}
commands.entity(*e).despawn();
false
});
status.last_reason = Some(reason);
// In-flight name queries died with the socket; let the next resolve re-ask.
names.clear_pending();
items.clear_session();
// The cooldown list is session-scoped — the next login may be a different character — and
// had been missing from this sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown
// still running at every world entry and `seed_initial` APPENDS, so a list that outlives the
// 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
// inherits cooldowns that were never theirs.
cooldowns.clear_session();
}
/// A teleport ack request (`MSG_MOVE_TELEPORT_ACK`) — only our own matters (the ack resumes our
/// movement); the controller consumes the message.
fn teleport(
guid: u64,
counter: u32,
position: [f32; 3],
orientation: f32,
self_guid: &SelfGuid,
teleports: &mut MessageWriter<TeleportMessage>,
) {
// Only our own teleports matter to the controller (the ack resumes our movement).
if self_guid.0 == Some(guid) {
teleports.write(TeleportMessage {
guid,
counter,
position,
orientation,
});
}
}
/// **A knockback the server aimed at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) — a
/// ballistic launch the controlling client flies itself, not a spline and not a teleport. Forwarded
/// to the controller, which owns the take-off, the arc, and the ack the launch owes.
///
/// The guid guard is the same one every self-addressed movement edge here carries. The reference
/// registers this opcode for the **controller** only; the observer's knockback arrives on a
/// different opcode with a different handler (`MSG_MOVE_KNOCK_BACK`, wow-re
/// `system/collision/scratch/knockback-law.md`), so a knockback naming somebody else is not ours to
/// fly.
fn knock_back(
guid: u64,
counter: u32,
launch: JumpInfo,
self_guid: &SelfGuid,
knockbacks: &mut MessageWriter<KnockBackMessage>,
) {
if self_guid.0 == Some(guid) {
knockbacks.write(KnockBackMessage {
guid,
counter,
launch,
});
}
}
/// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): latch it for the coming worldport —
/// its transport block decides whether NEW_WORLD's coordinates are boat-local (decision 0455).
fn transfer_pending(map_id: u32, transport_entry: Option<u32>, pending: &mut PendingTransfer) {
match transport_entry {
Some(entry) => info!("net: transfer pending → map {map_id} riding transport {entry}"),
None => info!("net: transfer pending → map {map_id}"),
}
pending.0 = Some(crate::net::PendingTransferInfo {
map_id,
transport_entry,
});
}
/// `SMSG_TRANSFER_ABORTED`: the announced transfer won't happen — clear the latch.
fn transfer_aborted(reason: u8, pending: &mut PendingTransfer) {
warn!("net: transfer aborted (reason {reason})");
pending.0 = None;
}
/// A cross-map transfer (`SMSG_NEW_WORLD` / `SMSG_LOGIN_VERIFY_WORLD`): the new map streams a
/// fresh object set — drop everything we were tracking, then hand the app the destination.
///
/// One exception to the purge (decision 0455): an armed transport whose timetable touches the
/// destination map is **spared**, entity and index entry both. Transports are client-simulated
/// global objects on one continuous two-continent clock — a spared boat sails straight through
/// the seam (the `CurrentMap` flip itself flips which legs render), keeping the ride attachment
/// and the deck collider valid the whole way; the server's post-ack re-create then refreshes its
/// anchor in place. Boats whose paths never reach the new map despawn like everything else.
fn worldport(
map_id: u32,
position: [f32; 3],
orientation: f32,
needs_ack: bool,
commands: &mut Commands,
index: &mut GuidIndex,
pending: &mut PendingTransfer,
transports: &Query<&crate::transport::Transport>,
worldports: &mut MessageWriter<WorldportMessage>,
) {
index.0.retain(|guid, e| {
if transports.get(*e).is_ok_and(|t| t.touches_map(map_id)) {
// info, not debug: rare (worldports only) and load-bearing — the crossing's whole
// mechanism hangs on this line firing for the ridden boat.
info!("worldport: sparing transport {guid:#x} (its path touches map {map_id})");
return true;
}
commands.entity(*e).try_despawn();
false
});
let announced = pending.0.take();
if let Some(p) = announced {
// vmangos pairs every NEW_WORLD with a same-map TRANSFER_PENDING; a mismatch means we
// mis-latched (or the server changed its mind) — worth a line, not a failure.
if p.map_id != map_id {
warn!(
"worldport: NEW_WORLD map {map_id} ≠ announced transfer map {} — using {map_id}",
p.map_id
);
}
}
let transport_entry = announced.and_then(|p| p.transport_entry);
worldports.write(WorldportMessage {
map_id,
position,
orientation,
needs_ack,
transport_entry,
});
}
/// The server game clock (`SMSG_LOGIN_SETTIMESPEED`) — drives the day/night lighting.
fn time_speed(
hours: u8,
minutes: u8,
day_serial: u32,
timescale: f32,
server_time: &mut ServerTime,
) {
if server_time.0.is_none() {
info!("net: server game-time {hours:02}:{minutes:02} (drives lighting)");
}
server_time.0 = Some(GameTime::new(hours, minutes, day_serial, timescale));
}
/// The server **wall** clock (`SMSG_QUERY_TIME_RESPONSE`, answering the world-enter
/// `CMSG_QUERY_TIME`) — the epoch the absolute descriptor stamps are dated in, and so the origin of
/// every countdown drawn from one (today: the timed-quest timer, decision 1150). Nothing to do with
/// [`time_speed`] above, which is the in-game day/night clock.
///
/// Logged once per session at info, with the skew against our own clock: that number is exactly
/// what a "the countdown is wrong by a constant" report would be about, and it costs one line.
fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) {
if clock.0.is_none() {
let local = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
info!(
"net: server wall clock {unix_time} (local clock differs by {}s)",
local - i64::from(unix_time)
);
}
clock.sample(unix_time);
}
/// The login reputation store (`SMSG_INITIALIZE_FACTIONS`).
fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputations) {
info!("net: reputation store ({} slots)", standings.len());
reputations.0 = standings;
}
/// A mid-session standing delta (`SMSG_SET_FACTION_STANDING`): overwrite the changed slots,
/// growing the store for a list id past the login snapshot (flags default 0 — the delta carries
/// none), and **auto-reveal** each one.
///
/// The auto-reveal is the client's own (wow-5875-re `reputation-panel-law.md`, the `0x124` handler):
/// gaining reputation with a faction makes it visible, unless the slot carries `HIDDEN` — which is
/// exactly what that bit is for, and is why it is not a list gate. The server pushes an
/// `SMSG_SET_FACTION_VISIBLE` for the same slot in most cases (vmangos `SetOneFactionReputation`
/// calls `SetVisible`), so this is usually belt to that braces; it matters when the reveal and the
/// standing arrive in the other order, and it is what the client does regardless.
fn reputation_delta(
standings: Vec<(u32, i32)>,
reputations: &mut Reputations,
quest: &mut QuestGiver,
) {
use benilla_formats::faction_flags as flag;
for (list_id, standing) in standings {
let Some(i) = reputation_slot(list_id, "SMSG_SET_FACTION_STANDING") else {
continue;
};
if reputations.0.len() <= i {
reputations.0.resize(i + 1, (0, 0));
}
reputations.0[i].1 = standing;
if reputations.0[i].0 & flag::HIDDEN == 0 {
reputations.0[i].0 |= flag::VISIBLE;
}
}
// A standing change is a questgiver-status input (`SatisfyQuestReputation`, and the reaction
// gate): the reference sweeps from this handler too (0654).
quest.bump_reask();
}
/// A faction became visible (`SMSG_SET_FACTION_VISIBLE`): lift `FACTION_FLAG_VISIBLE` on that slot
/// and nothing else.
///
/// The server pushes this the first time the player meets a faction, and it carries **no
/// standing** — the slot's standing was already correct and stays untouched. Dropping it is the
/// silent failure it exists to prevent: the pane keys row membership off this bit, so a faction met
/// mid-session would keep accruing reputation the player could never see.
fn reputation_visible(list_id: u32, reputations: &mut Reputations) {
let Some(i) = reputation_slot(list_id, "SMSG_SET_FACTION_VISIBLE") else {
return;
};
if reputations.0.len() <= i {
reputations.0.resize(i + 1, (0, 0));
}
reputations.0[i].0 |= benilla_formats::faction_flags::VISIBLE;
}
/// A wire `repListId` as a store index, or `None` (logged) when it is not one. The list is
/// positional in a `FACTION_LIST_LEN`-entry array (vmangos `MAX_FACTION_COUNT` 64), so a slot
/// past it is not a faction — and resizing the store to it was the one wire value that could
/// abort the process instead of dropping a packet (`0xFFFF_FFFF` → a 34 GB resize; decision
/// 2265 §B1).
fn reputation_slot(list_id: u32, opcode: &str) -> Option<usize> {
let i = usize::try_from(list_id).ok()?;
if i >= benilla_protocol::messages::FACTION_LIST_LEN {
warn!(
"net: {opcode} names reputation slot {list_id}, past the {}-entry faction list — dropped",
benilla_protocol::messages::FACTION_LIST_LEN
);
return None;
}
Some(i)
}
/// The dropped-packet tally (the wire-coverage instrument): count it, and announce each opcode's
/// FIRST drop at info — visible in any log without the panel open, and one line per opcode per
/// run, so it can never flood.
fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes) {
let tally = dropped.0.entry(opcode).or_default();
if tally.unknown + tally.unparseable == 0 {
info!(
"net: dropped packet {opcode:#06x} ({}) — {} (first occurrence; tallying)",
benilla_protocol::messages::opcode_name(opcode).unwrap_or("?"),
if unparseable {
"parser errored"
} else {
"no parser"
},
);
}
if unparseable {
tally.unparseable += 1;
} else {
tally.unknown += 1;
}
}