Files
benilla-octo/crates/benilla-app/src/loading_screen.rs
T
SamandClaude Fable 5.1 dbcf60fc31 the loading screen lets nothing through, and the micro buttons go stock
- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990)
- the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987)
- hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989)
- a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:01:38 +07:00

761 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Loading screen — the faithful full-screen world-load splash + progress bar shown on initial
//! world entry and on cross-map teleport (the load latency async streaming can't hide). Built on the
//! VERIFIED reference mechanism: per-map art via the
//! `Map.dbc` → `LoadingScreens.dbc` → BLP FK chain (resolved by `benilla-formats`), under the engine's
//! bar — which in 1.12 is exactly two layers, `Loading-BarBorder` + `Loading-BarFill`, at the
//! screen-fraction rects byte-verified from `LoadingScreen.cpp` (NOT the 5-texture stack the asset
//! names suggest; Background/Glow/Glass are never composited in vanilla).
//!
//! **Foundation, not a one-off.** The art lookup is the *same* mechanism for every map kind (open
//! world / instance / battleground) — only the BLP row differs — so this hosts all of them for free.
//! The two cases out of current scope (taxi/boat/zeppelin's moving flight-path icon; a richer
//! progress model) slot in as an extra overlay layer / a different progress source without restructure.
//!
//! **The lifecycle is event-raised, readiness-cleared (decision 0737)** — and the reference's is
//! too, which 0737 assumed it was not. This header claimed for months that the reference *blocks*
//! on its world load and so covers "by construction"; the wow-re round behind decision 1990 says
//! half of that is wrong. The reference runs ordinary frames with **one** blocking stretch inside
//! them (`SMSG_NEW_WORLD`'s `0x401b00` defers `0x401bc0` onto the deadline heap, which drains at
//! `0x420d0c` and runs to completion in that one iteration, `Sleep(1)` residency spins and all) —
//! but the screen is raised *frames earlier*, at `TRANSFER_PENDING`, and dismissed *frames later*
//! by a per-frame readiness poll. Which is this file's shape. What an async-streaming client has to
//! build explicitly is not the lifecycle after all: it is the **input suppression** the blocking
//! stretch does not provide either (see [`input`], decision 1990). The screen
//! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears
//! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*,
//! from the messages/resources the net bridge already publishes; a residency backstop catches any
//! path with no edge, **in world only** — it used to fire at boot too, and that is what warmed the
//! world's pipelines behind the glue (0540), which only worked while a world was being streamed
//! there at all. Since 0777 none is, and the warm-up rides the entry cover instead. It clears when
//! the destination is **scene-presentable** ([`WorldLoadProgress`],
//! published by `terrain_stream` + the collider queue): every wanted tile spawned, the focus
//! neighbourhood's placements up, colliders quiet, the snap no longer awaited. Never anything
//! about the *body* — feet-on-ground is not a load condition (the flying-teleport hang, 0737).
use bevy::prelude::*;
use std::collections::HashMap;
use benilla_formats::{load_loading_screens, LoadingScreenCatalog};
use benilla_assets::LockRecover;
use benilla_assets::MapCatalogRes;
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::terrain_stream::WorldLoadProgress;
use benilla_world::world_map::CurrentMap;
/// **The cover takes the input plane** — the loading screen's input half, in its own file because
/// it is a different mechanism (a source cut in `PreUpdate`) from the state machine below, and one
/// file should not have to explain both.
mod input;
pub(crate) use input::CoverInput;
// Bar layout — VERIFIED THREE ways (build 5875): the `WoW.exe` `LoadingScreen.cpp` bar descriptor
// table (@0x7ffd34, `FUN_00407150`) gives entry = {cx, cy, halfW, halfH} with rect = [cx ± halfW·0.5]
// × [cy ± halfH·0.5] and fill right edge = left + progress·halfW; Border {0.5,0.075,0.600,0.050}, Fill
// {0.5,0.075,0.525,0.025}. An apitrace of the live reference (WoW.12/WoW.17) confirmed these rects, and
// a reference screenshot measured the fill at left=0.245 (≈0.2375), ~9% from the BOTTOM. The bar sits
// at the BOTTOM — `cy=0.075` is measured from the bottom (the engine's GL ortho origin); the trace's
// "top" reading was the wined3d D3D→GL y-flip (a host representation, see memory
// `apitrace-is-crossover-translated`), refuted by the screenshot + the binary. ONLY Border + Fill are
// drawn in 1.12 (Background/Glow/Glass are never composited). Rects are viewport fractions; y is the
// distance from the BOTTOM edge (Bevy UI `bottom:`).
const BORDER_LEFT: f32 = 0.200; // 0.5 − 0.600·0.5
const BORDER_WIDTH: f32 = 0.600;
const BORDER_BOTTOM: f32 = 0.050; // 0.075 − 0.050·0.5
const BORDER_HEIGHT: f32 = 0.050;
const FILL_LEFT: f32 = 0.2375; // 0.5 − 0.525·0.5
const FILL_BOTTOM: f32 = 0.0625; // 0.075 − 0.025·0.5
const FILL_HEIGHT: f32 = 0.025;
const FILL_MAX_WIDTH: f32 = 0.525; // halfW; fill width = progress · FILL_MAX_WIDTH
/// The glue/loading screen is authored 4:3; on a wider window the reference fits it to height and
/// letterboxes (black bars L/R). Measured from a reference screenshot (content ≈ 1878×1385 ≈ 4:3 in a
/// 1999-wide window). The square BLP is stretched to this aspect (a mild widen).
const BACKDROP_ASPECT: f32 = 4.0 / 3.0;
/// Frames the world must read fully-resident before we clear the screen — debounces the post-teleport
/// frame where `loaded` is drained (`total > 0`, `ready` momentarily 0) so we don't flicker off/on.
const CLEAR_AFTER_READY_FRAMES: u32 = 3;
/// The wait instrument (decision 0737): once the screen has been up this long, say *which term*
/// still blocks the clear, every [`WAIT_LOG_EVERY`] seconds — so a "loading screen stuck" report is
/// a one-line diagnosis instead of a session. Ordinary loads finish under the threshold and log
/// nothing extra.
const WAIT_LOG_AFTER: f32 = 3.0;
const WAIT_LOG_EVERY: f32 = 2.0;
/// How far a same-map snap must move the body before its destination is treated as a **load** at
/// all (yards). Under this, a teleport cannot outrun the streamer — the ground and buildings you
/// land on are the ones you left — and the raise below would only be able to flash a cover over
/// content that happened to be arriving anyway. Sized to clear every *combat* relocation with
/// room to spare (charge/intercept 25 yd, blink 20 yd, the knockbacks under 30), because those
/// end in a server teleport too and a black screen mid-fight is the one thing this must never
/// do; the reported case — `.tele` across a city — is 458 yd.
const SNAP_LOAD_MIN_YD: f32 = 100.0;
/// Consecutive covered+in-world frames before the cover counts as **on the glass**.
///
/// Renders are serial, so at 3 the two intermediate frames' renders have committed their
/// presents. This was 0962's argument and its constant; it now lives beside the fact it defines
/// rather than being restated in each consumer (it had already been copied twice, and the third
/// consumer — the world camera — never got it at all).
const COVER_PRESENT_FRAMES: u32 = 3;
/// **Is the entry cover ON THE GLASS?** — the one fact 0962's rule is written in terms of, held
/// in one place because restating it is exactly how it gets forgotten.
///
/// The world-entry raise happens in `Update`; the state flips a frame later, so the FIRST
/// covered+in-world frame is also the first frame whose render can draw the cover. Anything
/// synchronous on that frame holds the *previous* present — the character-select screen, frozen
/// — for its whole duration. That is the director's report, three times now (0962, 1345, and the
/// world camera below), and each time it was one consumer nobody had counted:
///
/// - the **pipeline-warm menagerie** (0962) — its own `covered_frames`, now this;
/// - the **world-entry FrameXML load** (1345) — its own `covered_frames`, now this;
/// - the **world camera and the booth wake** — never deferred at all, and measured (this record's
/// round) as **57 of the flip frame's 60 ms**: the first render of a 3 000-entity world with
/// cold pipelines, plus fifteen booth cameras woken by the warm pass, all on the one frame that
/// owes the glass a loading screen.
///
/// Counted in `First` so a `PreUpdate` reader (the entry load, an exclusive system) and an
/// `Update` reader (the warm pass, the camera gate) see the same frame's answer.
#[derive(Resource, Default)]
pub(crate) struct EntryCover {
/// Consecutive covered+in-world frames; reset the moment either goes false.
frames: u32,
}
impl EntryCover {
/// Has the cover had enough frames to reach the glass? **True whenever no cover is up** —
/// there is then no glass to protect, and a consumer that waited would wait forever (the
/// capture that boots straight in-world is exactly this case).
pub(crate) fn presented(&self) -> bool {
self.frames == 0 || self.frames >= COVER_PRESENT_FRAMES
}
/// Is a cover up and still owed its first present? The inverse of the arm above that a
/// *renderer* wants: "do not draw anything but the cover this frame".
pub(crate) fn owes_a_present(&self) -> bool {
self.frames > 0 && self.frames < COVER_PRESENT_FRAMES
}
/// **Is a world cover up right now?** — `LoadingScreen::covering()` ∧ in world, which is
/// the pair every cover consumer actually means, counted once here.
pub(crate) fn covering(&self) -> bool {
self.frames > 0
}
/// Covered frames so far — what the tests assert on.
#[cfg(test)]
pub(crate) fn frames(&self) -> u32 {
self.frames
}
}
impl EntryCover {
/// One frame's worth of counting — the whole rule, so the test seam and the system cannot
/// drift apart.
pub(crate) fn tick(&mut self, covered: bool) {
self.frames = if covered {
self.frames.saturating_add(1)
} else {
0
};
}
}
/// `First`: advance (or reset) the covered-frame count. One writer, read by every consumer.
fn count_entry_cover(
screen: Res<LoadingScreen>,
state: Res<State<crate::char_select::ClientState>>,
mut cover: ResMut<EntryCover>,
) {
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
cover.tick(screen.covering() && in_world);
}
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
/// path table). Paired with [`MapCatalogRes`] (the `mapId` → `LoadingScreenID` FK) to resolve art.
#[derive(Resource)]
struct LoadingScreenCatalogRes(LoadingScreenCatalog);
/// Loading-screen state machine (decision 0737: event-raised, readiness-cleared).
#[derive(Resource, Default)]
pub(crate) struct LoadingScreen {
active: bool,
/// A raise from an entry edge (the pick's `Connected`, `SMSG_TRANSFER_PENDING`) holds until the
/// destination snap (worldport/teleport) actually lands — so the OLD location's readiness can
/// never clear a screen raised for the NEW one. This is what closes the world-entry flash: the
/// login-vista tiles are fully resident the moment the glue tears down, and without this hold
/// that residency would clear the screen seconds before `SMSG_LOGIN_VERIFY_WORLD` arrives.
awaiting_snap: bool,
/// Destination art before `CurrentMap` catches up: the roster's `Character.map` at the pick
/// edge, the transfer's map at `SMSG_TRANSFER_PENDING`. Cleared by the snap (from which frame
/// `CurrentMap` itself is correct).
pending_map: Option<u32>,
/// **Plain black cover, no art, no bar** — the logout transition (decision 0738). The logout
/// teardown despawns the avatar and the streamed world in the same frame's Net stage, but
/// `CharSelect` (and the glue screen with it) only applies at the NEXT frame's state
/// transition — without this the dead world renders uncovered for that frame, and it is
/// exactly the teardown-burst frame, so it lingers. The ref's world→glue swap shows black
/// there too. Dropped the moment the state leaves `InWorld` (the glue owns the screen from
/// then, at a higher z); any real raise replaces it with the full screen.
blackout: bool,
/// Consecutive presentable frames while active (see [`CLEAR_AFTER_READY_FRAMES`]).
ready_frames: u32,
/// Monotonic bar fill, 0..1 — only ever advances within a load (reset to 0 on each activation), so
/// the bar reads as one continuous stream even though the raw residency ratio dips as `desired`
/// shifts while the view moves. A real loading bar never goes backwards.
displayed: f32,
/// Decoded backdrop art by BLP path, so repeated teleports to a continent don't re-decode.
art_cache: HashMap<String, Handle<Image>>,
/// `Time::elapsed_secs` at the last raise + at the last wait-instrument line (see
/// [`WAIT_LOG_AFTER`]).
active_since: f32,
last_wait_log: f32,
/// The avatar's position as of LAST frame — so a snap's own displacement is knowable here
/// (the snap is applied in `Input`, a stage before this one). Read only by the snap raise
/// below, which uses it to tell a relocation from a spell's little hop.
last_pos: Option<Vec3>,
}
impl LoadingScreen {
/// Whether the opaque loading backdrop currently covers the frame — the world camera renders
/// under it (pipeline warm-up), never behind the glue screens (decision 0540).
pub(crate) fn covering(&self) -> bool {
self.active
}
/// An active cover, for tests that drive covered-frame accounting (the entry-load deferral)
/// and the cursor's covered arm.
#[cfg(test)]
pub(crate) fn test_covering() -> Self {
Self {
active: true,
..Self::default()
}
}
/// Raise (or re-arm) the screen for a fresh load. `awaiting_snap` marks a raise whose
/// destination snap is still in flight; `map` is the destination when the edge knows it.
fn raise(&mut self, reason: &str, awaiting_snap: bool, map: Option<u32>, now: f32) {
self.active = true;
self.blackout = false;
self.awaiting_snap = awaiting_snap;
self.pending_map = map;
self.ready_frames = 0;
self.displayed = 0.0; // restart the fill for the new load
self.active_since = now;
self.last_wait_log = now;
info!("loading screen: up ({reason}, map {map:?})");
}
}
// UI entity markers.
#[derive(Component)]
struct LoadingRoot;
#[derive(Component)]
struct LoadingBackdrop;
#[derive(Component)]
struct LoadingBarFill;
#[derive(Component)]
struct LoadingBarBorder;
pub(crate) struct LoadingScreenPlugin;
impl Plugin for LoadingScreenPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LoadingScreen>()
.init_resource::<EntryCover>()
// `First`, ahead of every consumer in `PreUpdate` and `Update` — see [`EntryCover`].
.add_systems(First, count_entry_cover)
.add_systems(Startup, setup_loading_screen.after(AssetSet::Open))
// In `WorldStage::Present` (after Input + Stream): we read residency for the SAME frame's
// player position — a teleport snaps in Input → the streamer recomputes focus in Stream →
// we cover it here. Visibility set now propagates in PostUpdate and renders this frame, so
// the swap never flashes.
.add_systems(Update, drive_loading_screen.in_set(WorldStage::Present));
// …and the other half of what a cover *is*: while it is up, the client takes no input
// (`input`). Wired here rather than as a plugin of its own so the cover and its input rule
// can never be registered apart.
input::build(app);
}
}
/// Startup: load the LoadingScreens catalog + the bar texture stack off the shared chain, and spawn
/// the (initially hidden, then activated on the first `drive` frame) UI tree.
fn setup_loading_screen(
mut commands: Commands,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
// Root: fullscreen black — this IS the pillarbox letterbox, and it spawns UNCONDITIONALLY.
// Every `covering()` consumer (the world camera stays active to warm pipelines, the warm
// pass, the settle hold, and now the audio hold) behaves as if covered whenever
// `LoadingScreen::active` is true — so a missing catalog or bar texture must degrade to a
// plain black cover, never to "no cover node at all" while the whole client pretends one is
// up (the naked-world failure this used to be). Flex-centres the 4:3 content area.
let root = commands
.spawn((
LoadingRoot,
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::BLACK),
// Above the 3D scene; UI already paints after the main pass, this orders within UI.
GlobalZIndex(1000),
Visibility::Hidden,
))
.id();
let Some(mut assets) = world_assets else {
return;
};
// Catalog (LoadingScreenID → BLP path).
match load_loading_screens(&mut assets.chain.lock_recover()) {
Ok(c) => {
info!("LoadingScreens.dbc: {} screens catalogued", c.len());
commands.insert_resource(LoadingScreenCatalogRes(c));
}
Err(e) => {
error!("LoadingScreens.dbc unavailable — plain black cover, no art: {e:#}");
return;
}
}
// The two layers 1.12 actually draws (sRGB clamp sprites — UI art, not tiling world art).
let mut tex = |path: &str| assets.sprite_texture(path, &mut images);
let Some(border) = tex("Interface\\Glues\\LoadingBar\\Loading-BarBorder.blp") else {
error!("loading bar textures missing — plain black cover, no art");
return;
};
let fill = tex("Interface\\Glues\\LoadingBar\\Loading-BarFill.blp").unwrap_or_default();
// The spawned ImageNodes below own these handles (the root is never despawned, only hidden), so
// the textures stay resident without a separate holder resource.
commands.entity(root).with_children(|root| {
// The whole loading screen renders in one 4:3 area fit to viewport height, centred, with
// the root's black showing as pillarbox bars L/R (the reference's behaviour — the glue/load
// screen is authored 4:3; on a wider window it letterboxes). Width = height·4/3 in vh.
// The backdrop + bar are children, so their verified fractions are relative to THIS area.
// Sibling paint order = spawn order: backdrop → fill → border (border on top).
root.spawn(Node {
width: Val::Vh(100.0 * BACKDROP_ASPECT),
height: Val::Vh(100.0),
position_type: PositionType::Relative,
..default()
})
.with_children(|area| {
// Backdrop art: the square BLP stretched to fill the 4:3 area (mild widen — what the
// reference does). Starts black (default-white texture tinted) until the art resolves.
area.spawn((
LoadingBackdrop,
ImageNode {
color: Color::BLACK,
image_mode: NodeImageMode::Stretch,
..default()
},
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
// Fill — left-anchored; width = progress·FILL_MAX_WIDTH (set each frame). y from the
// BOTTOM (verified by screenshot + binary). The fill art is a horizontally-uniform
// gradient, so width-scaling reads as a left→right reveal.
area.spawn((
LoadingBarFill,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(FILL_LEFT * 100.0),
bottom: Val::Percent(FILL_BOTTOM * 100.0),
width: Val::Percent(0.0), // set each frame
height: Val::Percent(FILL_HEIGHT * 100.0),
..default()
},
ImageNode {
image: fill,
image_mode: NodeImageMode::Stretch,
..default()
},
));
// Border frame, on top.
area.spawn((
LoadingBarBorder,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(BORDER_LEFT * 100.0),
bottom: Val::Percent(BORDER_BOTTOM * 100.0),
width: Val::Percent(BORDER_WIDTH * 100.0),
height: Val::Percent(BORDER_HEIGHT * 100.0),
..default()
},
ImageNode {
image: border,
image_mode: NodeImageMode::Stretch,
..default()
},
));
});
});
}
/// Per-frame: observe the lifecycle edges, run the trigger/clear state machine, resolve backdrop
/// art, and push the progress fraction into the bar.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn drive_loading_screen(
mut screen: ResMut<LoadingScreen>,
// The streamer's two published facts, bundled into one param (Bevy's 16-element system-param
// ceiling, the same squeeze `player::control` and `feed_ui_input` already pay): what is
// resident, and whether that residency is even ABOUT the player's own ground this frame.
stream: (
Res<WorldLoadProgress>,
Res<benilla_world::terrain_stream::ViewFocus>,
),
current_map: Option<Res<CurrentMap>>,
maps: Option<Res<MapCatalogRes>>,
screens: Option<Res<LoadingScreenCatalogRes>>,
mut assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut root: Query<&mut Visibility, (With<LoadingRoot>, Without<LoadingBackdrop>)>,
mut backdrop: Query<(&mut ImageNode, &mut Visibility), With<LoadingBackdrop>>,
mut fill: Query<&mut Node, With<LoadingBarFill>>,
mut bar_vis: Query<
&mut Visibility,
(
Or<(With<LoadingBarFill>, With<LoadingBarBorder>)>,
Without<LoadingRoot>,
Without<LoadingBackdrop>,
),
>,
player: Option<Res<crate::player::Player>>,
time: Res<Time>,
// The 0837 warm pass: the cover holds until the menagerie's pipelines have compiled — the
// point of the cover is that NOTHING first-sight-compiles after it lifts.
warm: Res<crate::pipe_warm::WarmPass>,
// The deferred world-entry UI load (0962's frame accounting, applied to 1051's burst):
// armed at the entry edge, run behind this cover a few frames in. While it is still
// pending the reveal would show a world with no interface — the reference's reveal always
// has the UI up, because its UI load happens inside its blocking world load.
entry_ui_pending: Option<Res<crate::ui_script::PendingEntryUiLoad>>,
// The lifecycle edges (decisions 0737/0738), all observed from what the net bridge already
// publishes — this module owns the whole state machine, nothing else is instrumented for it.
edges: (
Res<State<crate::char_select::ClientState>>,
MessageReader<crate::net::EnteredWorldMessage>,
MessageReader<crate::net::WorldportMessage>,
MessageReader<crate::net::TeleportMessage>,
MessageReader<crate::net::LoggedOutMessage>,
MessageReader<crate::net::DisconnectedMessage>,
Option<Res<crate::net::PendingTransfer>>,
Option<Res<crate::char_select::Roster>>,
),
) {
let (
state,
mut entered,
mut worldports,
mut teleports,
mut logouts,
mut lost,
transfer,
roster,
) = edges;
let (progress, focus) = (&stream.0, &stream.1);
let now = time.elapsed_secs();
let map_id = current_map.as_ref().map(|m| m.0);
// The physics hold releases on the same residency signal that clears this screen (decision
// 0737 — never on ground contact), so waiting for it costs nothing and guarantees the safe
// order: the body's world is live before the reveal, never a reveal of a body about to drop.
let player_settling = player.as_ref().is_some_and(|p| p.settling);
// --- The edges. ---
// A real glue entry (not 0065's seamless in-world reconnect, which must stay seamless): the
// same frame `enter_on_connected` flips the state that tears the glue down, this raise puts
// the cover up — raise and teardown are atomic by construction. The destination snap
// (`SMSG_LOGIN_VERIFY_WORLD`) is still a server character-load away; `awaiting_snap` holds the
// screen across that gap. Art resolves at once from the roster's own `Character.map`.
if entered.read().next().is_some() && *state.get() != crate::char_select::ClientState::InWorld {
let map = roster.as_ref().and_then(|r| r.pending_map());
screen.raise("world entry", true, map, now);
}
// A portal walk-in (`SMSG_TRANSFER_PENDING`, no transport): the server is about to unload us
// and the `SMSG_NEW_WORLD` snap follows after its own load — cover now, like the reference.
// Transport crossings (0455) keep riding visibly until their worldport lands below.
if let Some(t) = transfer.as_ref().filter(|t| t.is_changed()) {
match &t.0 {
Some(info) if info.transport_entry.is_none() => {
screen.raise("transfer pending", true, Some(info.map_id), now);
}
// The latch cleared with no snap in flight = `SMSG_TRANSFER_ABORTED` (a worldport
// clears it too, but that path also lands below this frame): stop awaiting, and the
// still-resident old world clears the screen through the ordinary debounce.
None => screen.awaiting_snap = false,
Some(_) => {}
}
}
// The snap: a worldport IS a fresh cross-map load (raise covers server-initiated ports that
// had no preceding edge); a same-map teleport just ends any awaited snap — whether it needs a
// screen at all is the backstop's call (a summon across the room shouldn't flash one).
for w in worldports.read() {
screen.raise("worldport", false, Some(w.map_id), now);
}
let teleported = teleports.read().next().is_some();
if teleported {
screen.awaiting_snap = false;
screen.pending_map = None;
}
// Logout (decision 0738): the same frame's Net stage despawned the avatar and tore the world
// down, but `CharSelect` only applies at the NEXT frame's state transition — cover the dead
// world with plain black until the glue owns the screen. No art, no bar: the ref's
// world→glue swap is a black cut, not a loading screen.
// A dead session is the same world→glue cut (decision 1262), and it needs this arm more than
// logout does: the entry race raises the cover on `EnteredWorldMessage` a few lines up and
// arms `awaiting_snap` for a snap the dead socket will never send, so without the disarm here
// the cover is what the player would have been left staring at.
let session_over = lost.read().any(|m| m.session_over);
if logouts.read().next().is_some() || session_over {
screen.active = true;
screen.blackout = true;
screen.awaiting_snap = false;
screen.pending_map = None;
screen.ready_frames = 0;
info!(
"loading screen: blackout ({})",
if session_over {
"session lost"
} else {
"logout"
}
);
}
if screen.blackout && *state.get() != crate::char_select::ClientState::InWorld {
// The glue is up (it renders above this root); the cover's job is done.
screen.blackout = false;
screen.active = false;
}
// --- Backstop trigger: the ground under the view focus isn't resident and nothing raised us
// (a far same-map `.tele`, or any path with no edge). Normal streaming keeps the focus tile
// resident, so this never fires while walking.
//
// **In world only** (decision 0777). It used to fire at boot as well, and that was load-bearing
// by accident: a raised screen keeps the world camera active (0540), which is what compiled the
// world's pipelines behind the glue. But it only worked because the world was being streamed
// behind the glue in the first place — with no world to load there, the same trigger would put
// an invisible screen up forever against residency that never arrives. The warm-up is not lost,
// it MOVED: the world now loads under the cover this same function raises on world entry, which
// is where a warm-up belongs. ---
//
// **And only while the focus IS the body** ([`ViewFocus::follows_body`]). The trigger's whole
// premise is "residency says the ground under the player is missing and no edge announced it,
// so a load must be happening". While the eye is deliberately elsewhere — a cinematic fly-by,
// a free-fly — residency describes the *camera's* tile, and reading it as a fact about the
// player is reading someone else's ground (decision 1336's lesson, one consumer over).
// Measured: every cinematic raised this cover the instant it started (`.debug play cinematic
// 41` → "loading screen: up (focus not resident, map None)" in the same millisecond as the
// shot), covering the opening seconds of the fly-by the deferred-start latch exists to protect
// — and, once the body took the settle hold for the same detachment, covering ALL of it,
// because this cover's clear waits on that hold and that hold waits on the focus coming home.
if !screen.active
&& !progress.focus_resident
&& focus.follows_body()
&& *state.get() == crate::char_select::ClientState::InWorld
{
screen.raise("focus not resident", false, None, now);
}
// --- …and the same test at the SNAP, on everything the reveal actually needs. The backstop
// above watches one term — the focus TERRAIN tile — and a teleport inside the streamer's keep
// band (up to ~1600 yd, three tiles) lands on ground that is already resident, so it never
// fires. The destination's *buildings* are a different question: their placements may still be
// spawning, and since the retained pass (1429) a spawned building still has a bake between it
// and the screen. That is how `.tele` across a city put the player down in a Stormwind with no
// Stormwind in it, uncovered, for the frames it took to arrive.
//
// Only at a snap, and only against the same predicate that CLEARS the screen: a summon across
// a room whose world is already there raises nothing (no flash), and ordinary walking — which
// crosses tile lines with placements pending all day — is not a snap and cannot reach this. ---
let body = player.as_ref().map(|p| p.pos);
let relocated = match (teleported, screen.last_pos, body) {
(true, Some(was), Some(now_pos)) => was.distance(now_pos) >= SNAP_LOAD_MIN_YD,
// No previous position to compare (the entry frame): treat the snap as a relocation, the
// conservative arm — a covered load is recoverable, a naked one is what this closes.
(true, _, _) => true,
_ => false,
};
screen.last_pos = body;
if relocated
&& !screen.active
&& *state.get() == crate::char_select::ClientState::InWorld
&& !(progress.is_ready() && progress.presentable())
{
screen.raise("teleport, destination not presentable", false, None, now);
}
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
// placements + colliders — [`WorldLoadProgress::presentable`]), and the physics hold is done,
// sustained a few frames. ---
if screen.active {
if progress.is_ready()
&& progress.presentable()
&& !screen.awaiting_snap
&& !player_settling
&& warm.satisfied()
&& entry_ui_pending.is_none()
{
screen.ready_frames += 1;
if screen.ready_frames >= CLEAR_AFTER_READY_FRAMES {
screen.active = false;
info!(
"loading screen: cleared ({}/{} resident, {:.1}s)",
progress.ready,
progress.total,
now - screen.active_since
);
}
} else {
screen.ready_frames = 0;
// The wait instrument (0737): a screen up past the threshold names the blocking term.
if now - screen.active_since > WAIT_LOG_AFTER
&& now - screen.last_wait_log >= WAIT_LOG_EVERY
{
screen.last_wait_log = now;
info!(
"loading screen: waiting {:.1}s — {}/{} resident, {} placements pending, \
{} colliders pending, {} merges pending, awaiting_snap={}, settling={}, \
warm={}, ui_pending={}",
now - screen.active_since,
progress.ready,
progress.total,
progress.placements_pending,
progress.colliders_pending,
progress.merge_pending,
screen.awaiting_snap,
player_settling,
warm.satisfied(),
entry_ui_pending.is_some(),
);
}
}
}
// --- Visibility. The root is the cover; the backdrop art + bar hide under blackout (0738's
// plain black cut — the root's black background IS the frame then). ---
if let Ok(mut vis) = root.single_mut() {
*vis = if screen.active {
Visibility::Visible
} else {
Visibility::Hidden
};
}
let content_vis = if screen.blackout {
Visibility::Hidden
} else {
Visibility::Inherited
};
if let Ok((_, mut vis)) = backdrop.single_mut() {
if *vis != content_vis {
*vis = content_vis;
}
}
for mut vis in &mut bar_vis {
if *vis != content_vis {
*vis = content_vis;
}
}
if !screen.active || screen.blackout {
return;
}
// --- Backdrop art (resolve via the FK chain; cached by path). Before the snap lands the
// destination is `pending_map` (the roster/transfer told us); after it, `CurrentMap`. ---
let art_map = screen.pending_map.or(map_id);
if let (Some(map_id), Some(maps), Some(screens), Some(assets)) =
(art_map, maps.as_ref(), screens.as_ref(), assets.as_mut())
{
if let Some(path) = maps
.0
.loading_screen_id(map_id)
.and_then(|id| screens.0.path(id))
{
let path = path.to_string();
let handle = match screen.art_cache.get(&path) {
Some(h) => Some(h.clone()),
None => assets.sprite_texture(&path, &mut images).inspect(|h| {
screen.art_cache.insert(path.clone(), h.clone());
}),
};
if let (Some(handle), Ok((mut img, _))) = (handle, backdrop.single_mut()) {
if img.image != handle {
img.image = handle;
img.color = Color::WHITE; // reveal the art (was tinted black)
}
}
}
}
// --- Progress bar: monotonic fill, reveals left→right, width = progress·FILL_MAX_WIDTH.
// While the snap is still in flight the residency being published is the OLD location's
// (fully resident) — hold the bar at zero until the destination's own numbers exist. ---
if !screen.awaiting_snap {
screen.displayed = screen.displayed.max(progress.fraction());
}
let frac = screen.displayed;
if let Ok(mut node) = fill.single_mut() {
node.width = Val::Percent(frac * FILL_MAX_WIDTH * 100.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **The one fact three consumers now share** ([`EntryCover`]). Two of them each kept their
/// own copy of this count (0962's menagerie, 1345's FrameXML load) and the third — the world
/// camera, which renders the whole arriving world on the same frame — never had one at all,
/// which is the freeze this round measured. The arithmetic is trivial; that it has exactly one
/// home is the point.
#[test]
fn the_cover_counts_frames_to_the_glass_and_resets_the_moment_it_drops() {
let mut cover = EntryCover::default();
assert!(
cover.presented(),
"no cover up: nothing is watching the previous present, so nothing waits"
);
assert!(!cover.owes_a_present(), "no cover owes no present");
cover.tick(true);
assert_eq!(cover.frames(), 1);
assert!(
!cover.presented(),
"the cover's own render has not committed yet"
);
assert!(cover.owes_a_present());
cover.tick(true);
assert!(!cover.presented(), "one render committed, not two");
cover.tick(true);
assert!(
cover.presented(),
"at COVER_PRESENT_FRAMES the cover is provably on the glass"
);
assert!(!cover.owes_a_present());
// A reveal (or leaving the world) drops it back to the uncovered arm in one frame — the
// next raise must not inherit the last one's credit.
cover.tick(false);
assert_eq!(cover.frames(), 0);
assert!(cover.presented());
cover.tick(true);
assert!(!cover.presented(), "the next raise starts its own count");
}
}