- 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>
761 lines
38 KiB
Rust
761 lines
38 KiB
Rust
//! 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");
|
||
}
|
||
}
|