//! The session preflight (decision 0649) — one always-on banner naming **what body we just logged //! into**, plus loud warnings for the avatar states that silently invalidate a session's work. //! //! This is instrumentation for the *reader of the log*, not a game system. It exists because three //! things cost a session ten minutes each and none of them announces itself: //! //! 1. **The character is dead or a ghost**, left that way by an earlier session. Nothing can be //! interacted with, attacked, looted or talked to; the world renders through the death filter; //! movement is rooted (unreleased) or ghost-speed (released). Every symptom reads like a client //! bug, and the client is fine. //! 2. **GM mode is on** — which every probe character used to carry by default, as the only guard //! against unattended aggro killing it. vmangos's `Player::SetGameMaster` re-templates the player //! to **faction template 35** and freezes the mirror timers, so nothing is hostile, nothing //! aggros, fall/environmental damage is skipped and breath/fatigue never tick. //! FactionTemplate.dbc row 35 (VERIFIED from the extracted DBC): faction 31 "Friendly", own group //! mask 0, enemy mask 0 — it is hostile to nothing and nothing is hostile to it. Any hostility, //! reaction-colour, nameplate, aggro, threat, damage or drowning measurement taken with GM on is //! simply wrong, quietly. It also silently suspends the **indoor dismount**: the outdoor-only //! aura sweep is `!IsGameMaster()`-gated, so a GM rides into the Goldshire inn and stays mounted //! (decision 0934). It is now **off** by default: [`crate::probe_shield`] keeps the body //! alive without poisoning any of that (decision 0677), and this banner reports which it is. //! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The //! controller ignores input and the honest report is "the mover is broken". //! 4. **The run has no server at all** ([`offline_notice`], decision 0728). Capture mode passes //! `NetPlugin { connect: false }`, so no IO thread is spawned and the drain receives nothing — //! the whole packet path (`net::apply`, the movement wire, every `MSG_MOVE_*`) simply does not //! execute. This one is the mirror image of the other three: they make a healthy client *look* //! broken, while this makes an untested change *look proven*. A capture run over a change to the //! packet path exits clean no matter what that change does, and "clean run" then lands in a //! commit message as evidence it never was (twice in one day — decisions 0725 and 0726, both //! pure wire work, both reported with a capture run behind them). The banner above cannot catch //! it: that one waits on [`EnteredWorldMessage`], which needs a server. So this notice fires at //! **startup**, before anything can be concluded from the run. //! //! The banner is **not env-gated**: a warning nobody knows to switch on is not a warning. It costs //! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a //! `.character race` forced logout, a reconnect) because the state can have changed. //! //! The same audience — the reader of the log — is why [`benilla_world::build_id::banner`] exists. //! It is **not** registered here any more (decision 1179): this module is dev-only since 1174, and //! "which build produced this log" is the first thing a report from *someone else's machine* has to //! establish — which is precisely the player build. The banner now registers beside the stamp //! itself, in `lib::run`, where it is always compiled. //! //! The other half of decision 0649 — the pre-connect **account guard**, which keeps a session //! running inside a worktree pool slot from logging in as somebody else's account — lives in //! [`crate::run_mode`] now, not here: it is consulted by the login policy, and 1174's seam does not //! let gameplay call an instrument. Its reasoning went with it. use bevy::prelude::*; use crate::area::AreaTableRes; use crate::names::NameCache; use crate::net::{EnteredWorldMessage, ObjectStore, SelfGuid, SelfPlayer}; use crate::probe_shield::{ProbeShield, ShieldReport}; use benilla_world::world_map::CurrentMap; /// `PLAYER_FLAGS_GM` (vmangos `Player.h`) — set by `SetGameMaster(true)` alongside the faction-35 /// re-template. PUBLIC, so it rides our own descriptor like any other player flag. const PLAYER_FLAGS_GM: u32 = 0x0000_0008; /// The `UNIT_FIELD_FLAGS` bits that mean "the server is driving, or refusing to let you drive" /// (vmangos `UnitDefines.h`) — each is a distinct reason the mover looks broken. const MOVE_BLOCKERS: &[(u32, &str)] = &[ (0x0002_0000, "PACIFIED (no melee, no pacify-blocked casts)"), (0x0004_0000, "STUNNED"), ( 0x0010_0000, "on a TAXI FLIGHT (input ignored for the whole ride)", ), (0x0020_0000, "DISARMED (weapon abilities refuse)"), (0x0040_0000, "CONFUSED (the server drives the movement)"), (0x0080_0000, "FLEEING (the server drives the movement)"), (0x0100_0000, "POSSESSED (another unit holds the reins)"), ]; /// Worth naming on entry: an unattended probe that logs in already fighting is the exact shape of /// the accident the unattended-combat ban exists for (method.md). The bit itself is declared once /// ([`crate::player`]). use crate::player::UNIT_FLAG_IN_COMBAT; /// `UNIT_FLAG_SILENCED` — casts silently refuse. const UNIT_FLAG_SILENCED: u32 = 0x0000_2000; /// The GM faction template vmangos swaps in with GM mode (`SetFactionTemplateId(35)`). const GM_FACTION_TEMPLATE: u32 = 35; /// How long after world entry we keep waiting for the zone name before reporting without it. The /// descriptor lands in a few hundred ms but the area id only resolves once the tile under us has /// streamed (~2 s), and "Eastern Plaguelands" is the line's whole orienting value — a raw map id /// and a coordinate triple do not tell a reader at a glance that this character is nowhere useful. const ZONE_WAIT_SECS: f32 = 4.0; /// How long we let an in-flight `.gm off` settle before banner-ing the GM flag anyway. /// /// `WOW_GM=off` is a **round trip** and it loses the race with the descriptor: the shield sends /// `.gm off` at world entry and the server's answer lands ~0.9 s later, while the self descriptor /// is here in ~0.3 s. Without this grace the banner shouts "GM MODE IS ON — any damage or drowning /// reading taken now is wrong" at precisely the run that asked for GM off, which reads as *discard /// this measurement* and is exactly backwards (a 2026-08-13 lava probe hit it). Wait, then report /// what actually held — a `.gm off` that never lands is still warned about, one grace later. const GM_OFF_WAIT_SECS: f32 = 3.0; /// The hard backstop: report whatever we have this long after world entry. Only reached when /// something is badly wrong with the stream, and reporting late beats never reporting. const DESCRIPTOR_WAIT_SECS: f32 = 8.0; pub(crate) struct PreflightPlugin; impl Plugin for PreflightPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, offline_notice) .add_systems( Update, report_session.after(benilla_world::schedule::WorldStage::Net), ); } } /// Say out loud, once at startup, that this run has **no server** — so nobody reads its clean exit /// as evidence about code that never ran (module header §4, decision 0728). /// /// Deliberately a `warn!` rather than an `info!`. A netless run is completely normal and completely /// fine — it is what every visual capture wants — so this is not a complaint about the run. It is /// aimed at the *reader*, who is usually a session skimming for the word "error" before pasting /// "clean run" into a commit message, and `warn` is the lowest level that survives that skim. fn offline_notice(net: Option>) { if net.is_none() { return; } warn!( "preflight: NET OFF — no IO thread this run, so the drain gets no packets and NOTHING on \ the wire path executes (net::apply, the movement stream, every MSG_MOVE_*). Fine for a \ visual capture; NOT evidence for a change to any of it. method.md's gate is a clean run \ of the AFFECTED path — for wire work that means a live server run." ); } /// The banner's once-per-entry latch: armed by [`EnteredWorldMessage`], disarmed when the report /// goes out (or when the wait expires). #[derive(Resource, Default)] struct Preflight { /// `Time::elapsed_secs` at the world entry we still owe a report for; `None` = nothing pending. armed_at: Option, } /// Wait for the self descriptor after each world entry, then print the banner once. #[allow(clippy::too_many_arguments)] fn report_session( mut state: ResMut, mut entered: MessageReader, time: Res