//! 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, state: Res>, mut cover: ResMut, ) { 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, /// **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>, /// `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, } 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, 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::() .init_resource::() // `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>, mut images: ResMut>, ) { // 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, // 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, Res, ), current_map: Option>, maps: Option>, screens: Option>, mut assets: Option>, mut images: ResMut>, mut root: Query<&mut Visibility, (With, Without)>, mut backdrop: Query<(&mut ImageNode, &mut Visibility), With>, mut fill: Query<&mut Node, With>, mut bar_vis: Query< &mut Visibility, ( Or<(With, With)>, Without, Without, ), >, player: Option>, time: Res