//! 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).** The reference *blocks* on //! its world load, so its screen covers from the entry edge until the world is loaded by //! construction; an async-streaming client has to build that observable explicitly. 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 crate::assets::LockRecover; use crate::assets::{AssetSet, WorldAssets}; use crate::schedule::WorldStage; use crate::world_map::{CurrentMap, MapCatalogRes}; // 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 much of the world the streamer wants around the view focus is actually there. Written each /// frame by `terrain_stream::stream_terrain` (tiles + placements) and /// `terrain_stream::finish_colliders` (the attach queue); read here to drive the bar + the clear /// condition, and by the streamer's own settle release (decision 0737 — the physics hold keys on /// this same signal, never on ground contact). #[derive(Resource, Default)] pub(crate) struct WorldLoadProgress { /// Units done, of [`Self::total`]: desired tiles spawned + focus-neighbourhood placements up. pub(crate) ready: usize, pub(crate) total: usize, /// Whether the tile under the view focus (the nearest desired tile) is spawned. `false` until the /// streamer first runs, and again whenever a teleport/worldport drops the focus onto unloaded /// ground — the backstop trigger (covers startup, cross-map worldport, AND far same-map /// teleports like a cross-continent `.tele`, which don't change `CurrentMap`). During normal /// streaming the focus tile is always resident, so it never fires spuriously. pub(crate) focus_resident: bool, /// The scene term (0737): the focus tile **and its 8 neighbours** are spawned with every /// placement they reference (WMOs, doodads, WMO props) — "the buildings and trees around you /// exist", which is what makes a reveal read as a world rather than bare terrain. Collider /// quiet is deliberately *not* folded in here (it is published a stage apart); consumers use /// [`Self::presentable`]. pub(crate) scene_ready: bool, /// Outstanding collider attaches (decision 0610's queue) — published by `finish_colliders`. pub(crate) colliders_pending: usize, /// Focus-neighbourhood placements not yet spawned (the wait instrument's term). pub(crate) placements_pending: usize, } impl WorldLoadProgress { /// 0..1 residency fraction for the bar — **0.0 when nothing is wanted yet** (the cold-start / /// pre-`desired` frame), so the bar starts empty rather than flashing full. (Distinct from the /// clear test [`Self::is_ready`], which treats `total == 0` as not-ready.) fn fraction(&self) -> f32 { if self.total == 0 { 0.0 } else { (self.ready as f32 / self.total as f32).clamp(0.0, 1.0) } } /// True once every wanted unit is resident. Requires `total > 0` so the cold-start / post-swap /// frame (before `desired` is computed) doesn't read as "done". fn is_ready(&self) -> bool { self.total > 0 && self.ready >= self.total } /// The world at the focus is presentable — the scene term plus a quiet collider queue. What /// both consumers (the screen clear here, the settle release in the streamer) key on. pub(crate) fn presentable(&self) -> bool { self.scene_ready && self.colliders_pending == 0 } } /// 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, } 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 } /// 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::() .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)); } } /// 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>, ) { 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, loading screen disabled: {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, loading screen disabled"); 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. // Root: fullscreen black — this IS the pillarbox letterbox. Flex-centres the 4:3 content area. 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, )) .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, progress: 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