- the loading screen's tip of the day is on screen: it was picked and saved but never painted, and once painted it was wiped by the login round trip (2083, 2081) - the backdrop no longer switches mid-load — logging back in to an expired dungeon held its art for a second and then flipped to the continent's; the art latches at the raise, keyed on the texture, not the map id (2081, 2087) - a weapon trail indoors takes the room's warm light instead of the sky's ambient, the wearer's own light being the reference's term (2086) - the glue dialog moves out of the login screen into the shared glue layer, so the select screen's refusals and the login's errors are one widget (2084) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1047 lines
54 KiB
Rust
1047 lines
54 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 **transition edges and only those** — the character pick's `Connected` edge (before
|
||
//! the glue tears down) and `SMSG_TRANSFER_PENDING` (the portal walk-in), which are precisely the
|
||
//! reference's two entries into `0x406640`'s tail — all observed *here*, from the
|
||
//! messages/resources the net bridge already publishes. The destination **snap** is not one of
|
||
//! them: `SMSG_NEW_WORLD` and `SMSG_LOGIN_VERIFY_WORLD` are two handlers there (`0x401b00` /
|
||
//! `0x401de0`) and neither can reach the raise `0x406800` or the screen's map global `[0x82f00c]`,
|
||
//! so a snap only ends the wait its raise armed and can never re-point a live screen
|
||
//! ([`LoadingScreen::far_snap`]). And what a live screen *shows* is latched harder still: the
|
||
//! resolved texture, not the map id ([`LoadingScreen::art_resolved`], decision 2087). 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);
|
||
|
||
/// What a raise means for the tip of the day (decision 2077).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(crate) enum TipEdge {
|
||
/// The glue→world entry: pick the next row and advance the cursor.
|
||
Pick,
|
||
/// Every other raise: this screen carries no tip.
|
||
Clear,
|
||
}
|
||
|
||
/// 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,
|
||
/// **The map to resolve this screen's art from** — the reference's `[0x82f00c]`, set by every
|
||
/// raise: the roster's `Character.map` at the pick edge, the transfer's map at
|
||
/// `SMSG_TRANSFER_PENDING`, the map we are on for a backstop raise. `None` is the reference's
|
||
/// `-1`: *this screen draws no backdrop* — the boot default, and what a failed resolve leaves
|
||
/// behind ([`Self::art_resolved`]).
|
||
///
|
||
/// It is deliberately not `CurrentMap`, which moves under a live screen. Log out inside a
|
||
/// dungeon whose instance is gone by the time you come back and vmangos relocates you *during*
|
||
/// `Player::LoadFromDB` (`Player.cpp`, the `GetGoBackTrigger` arm) — no
|
||
/// `SMSG_TRANSFER_PENDING`, no `SMSG_NEW_WORLD`, just a `SMSG_LOGIN_VERIFY_WORLD` naming a
|
||
/// different map than the roster row the screen was raised from. Reading the art per frame
|
||
/// from `CurrentMap` made the dungeon backdrop switch to the continent one mid-load; nothing
|
||
/// on the wire can do that in the reference ([`Self::snap_landed`]).
|
||
///
|
||
/// **This field is not the latch, though — [`Self::art_resolved`] is** (decision 2087). Five
|
||
/// writers touch `[0x82f00c]` image-wide, all inside the `LoadingScreen.cpp` TU: `0x4067e6`
|
||
/// and `0x4072d3` store a map id (the two transition entries), and `0x406d0e`/`0x4073d4`/
|
||
/// `0x407e59` store `-1`. A raise landing on a screen that is already up re-points it
|
||
/// *unconditionally* — and repaints nothing, because the texture is already resolved.
|
||
map: Option<u32>,
|
||
/// **Has this screen resolved its backdrop yet?** — the reference's `[0x882e04]`, the loaded
|
||
/// texture handle, and the thing the art is actually latched by (decision 2087). `0x406cf0`
|
||
/// reads the map id only to reject `-1`, then `0x406cfc`/`0x406d01`/`0x406d03` skip the
|
||
/// resolver `0x406e20` outright whenever the handle is non-null; the handle's three writers
|
||
/// image-wide are the resolver's two stores and `0x407ed7 = 0` inside the dismiss `0x407e80`.
|
||
///
|
||
/// So the map id decides the picture on **exactly one frame per screen**, and nothing that
|
||
/// happens afterwards can change it. Cleared by [`Self::dismiss`], never by a raise.
|
||
art_resolved: bool,
|
||
/// **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>>,
|
||
/// **Which tip the next raise should carry** — `Pick` on the glue→world entry, `Clear` on every
|
||
/// other raise, taken by [`crate::game_tip::drive_game_tip`] on the same frame. A field rather
|
||
/// than a message because it is a property OF the raise, and the raise is this struct's.
|
||
pub(crate) tip_edge: Option<TipEdge>,
|
||
/// **Capture only** — the clear below is skipped while this is set. A loading screen is a
|
||
/// picture that is up for a second and then gone: nothing in the tree could photograph one,
|
||
/// which is how the tip of the day shipped dead and stayed dead through a live smoke run
|
||
/// (decision 2083). Written by [`Self::hold_for_capture`] and by nothing a player can reach.
|
||
held: bool,
|
||
|
||
/// `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()
|
||
}
|
||
}
|
||
|
||
/// Take the raise's tip edge, if one is pending — read once, by
|
||
/// [`crate::game_tip::drive_game_tip`].
|
||
pub(crate) fn take_tip_edge(&mut self) -> Option<TipEdge> {
|
||
self.tip_edge.take()
|
||
}
|
||
|
||
/// **The capture instrument** (`WOW_CAPTURE=loading-tip`): raise the screen the way the
|
||
/// glue→world entry does — art, bar and a `Pick` edge for the tip — and then never let go, so
|
||
/// the harness's settle window has something to hold still on. The one screen in the client
|
||
/// whose whole existence is measured in the seconds before it disappears, made photographable.
|
||
/// `map` is the destination the art latches to, exactly as a real raise's is ([`Self::map`]):
|
||
/// the harness knows it from the scenario, and with `None` the shot would be a black box with a
|
||
/// tip on it rather than the real `Map.dbc` → `LoadingScreens.dbc` → BLP chain.
|
||
pub(crate) fn hold_for_capture(&mut self, map: Option<u32>) {
|
||
self.active = true;
|
||
self.blackout = false;
|
||
self.awaiting_snap = false;
|
||
self.map = map;
|
||
self.held = true;
|
||
self.tip_edge = Some(TipEdge::Pick);
|
||
}
|
||
|
||
/// Raise the screen for a fresh load. `awaiting_snap` marks a raise whose destination snap is
|
||
/// still in flight; `map` is the destination this screen's art is LATCHED to for its whole life
|
||
/// ([`Self::map`]) — every caller states what it knows, and a caller with no announced
|
||
/// destination passes the map we are on rather than leaving the art to follow `CurrentMap`.
|
||
///
|
||
/// **Only an edge that would reach the reference's own transition entries calls this.** The
|
||
/// destination *snap* is not one of them ([`Self::snap_landed`]).
|
||
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.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:?})");
|
||
}
|
||
|
||
/// **The destination snap landed** — `SMSG_NEW_WORLD` or `SMSG_LOGIN_VERIFY_WORLD`. It ends the
|
||
/// wait a raise armed, and it does **nothing else**: not the art, not the tip, not the bar.
|
||
///
|
||
/// The two are one [`crate::net::WorldportMessage`] here and two handlers in the reference
|
||
/// (`0x401b00` and `0x401de0`, `re/net/opcode-handlers.tsv`), and *neither touches the screen*:
|
||
/// the raise `0x406800` has three call sites and all three are inside the `LoadingScreen.cpp`
|
||
/// TU, so nothing on the wire can re-raise or re-point a screen that is already up. Treating
|
||
/// the snap as a fresh load is what swapped the backdrop mid-load ([`Self::map`]), restarted
|
||
/// the bar, and — a round-trip into *every* world entry — wiped the tip of the day that
|
||
/// decision 2077 had just picked.
|
||
///
|
||
/// `map` is the destination the snap names — `None` for a same-map teleport ack, which cannot
|
||
/// disagree with the screen by construction.
|
||
fn snap_landed(&mut self, map: Option<u32>) {
|
||
// Worth a line on its own: the destination the server actually seated us on is not the one
|
||
// this screen was raised for, i.e. we were relocated between the roster and the login.
|
||
if let (Some(raised), Some(dest)) = (self.map, map) {
|
||
if raised != dest {
|
||
info!("loading screen: snap landed on map {dest} — raised for map {raised}, art holds");
|
||
}
|
||
}
|
||
self.awaiting_snap = false;
|
||
}
|
||
|
||
/// **The far snap's whole rule** — the one place it lives, so the test seam and the system
|
||
/// cannot drift apart (the [`EntryCover::tick`] pattern, two structs up).
|
||
///
|
||
/// A screen that is already up **is this snap's screen** — the entry raise waiting for
|
||
/// `SMSG_LOGIN_VERIFY_WORLD`, the portal's waiting for `SMSG_NEW_WORLD` — so the snap ends its
|
||
/// wait and nothing more. Returns `true` only when no screen is up at all, which is the
|
||
/// caller's cue to raise one: our backstop for a server-initiated port that arrived with no
|
||
/// announcing edge (the reference needs no such backstop — its own world load blocks).
|
||
#[must_use]
|
||
fn far_snap(&mut self, map: u32) -> bool {
|
||
if !self.active {
|
||
return true;
|
||
}
|
||
self.snap_landed(Some(map));
|
||
false
|
||
}
|
||
|
||
/// **The dismiss** (the reference's `0x407e80`) — the one place the screen comes down, so the
|
||
/// things a screen owns for its lifetime come down with it exactly once. The tip is one of
|
||
/// those: `[0x882e10]`'s only writers image-wide are the `EnterWorld` setter `0x406630` and
|
||
/// `0x407f2b` inside this dismiss, so a *raise* never clears a tip — only this does.
|
||
fn dismiss(&mut self) {
|
||
self.active = false;
|
||
self.blackout = false;
|
||
self.tip_edge = Some(TipEdge::Clear);
|
||
// `0x407ed7 mov ds:0x882e04,edi` — the next screen resolves its own art, and only here.
|
||
self.art_resolved = false;
|
||
}
|
||
}
|
||
|
||
// UI entity markers.
|
||
#[derive(Component)]
|
||
struct LoadingRoot;
|
||
#[derive(Component)]
|
||
struct LoadingBackdrop;
|
||
#[derive(Component)]
|
||
struct LoadingBarFill;
|
||
#[derive(Component)]
|
||
struct LoadingBarBorder;
|
||
/// The tip-of-the-day text block (decision 2077) — its `Text` root; the coloured runs are its
|
||
/// children, rebuilt when the shown tip changes.
|
||
#[derive(Component)]
|
||
pub(crate) struct LoadingTip;
|
||
|
||
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()
|
||
},
|
||
));
|
||
// The tip of the day (decision 2077), drawn between the background quad and the
|
||
// progress bar — the reference's own order (`0x406e12`/`0x406e18`). The block is
|
||
// positioned in PERCENT of this 4:3 area, which is exactly the space
|
||
// `crate::game_tip`'s constants are in; only the font size and the shadow need pixels,
|
||
// and those follow the window each frame. Empty and hidden until a glue->world raise
|
||
// fills it.
|
||
area.spawn((LoadingTip, crate::game_tip::tip_bundle()));
|
||
// 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>>,
|
||
// **Real, not virtual** — this clock measures a LOAD, and `Time<Virtual>` clamps any frame
|
||
// longer than 250 ms (`transport.rs` names the same constant for the same reason). A loading
|
||
// screen is made of exactly those frames, so the default clock under-reports it: measured on
|
||
// one smoke re-entry, a cover that was up for 2.24 s wall reported 1.2 s. An instrument whose
|
||
// whole job is "how long was this up, and what was blocking it" cannot run on a clock that
|
||
// discards the stalls.
|
||
time: Res<Time<Real>>,
|
||
// 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>,
|
||
MessageReader<crate::net::CharacterLoginFailedMessage>,
|
||
Option<Res<crate::net::PendingTransfer>>,
|
||
Option<Res<crate::char_select::Roster>>,
|
||
),
|
||
) {
|
||
let (
|
||
state,
|
||
mut entered,
|
||
mut worldports,
|
||
mut teleports,
|
||
mut logouts,
|
||
mut lost,
|
||
mut refusals,
|
||
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);
|
||
// **The tip of the day rides THIS edge and no other** (decision 2077): the reference's
|
||
// setter `0x406630` has exactly one caller, inside `CGlueMgr::EnterWorld`, and neither
|
||
// `SMSG_TRANSFER_PENDING` arm reaches it — so a portal or worldport screen carries no tip.
|
||
// The pick itself is `crate::game_tip`'s, one system over; this list is at Bevy's
|
||
// sixteen-parameter ceiling and the tip needs three resources of its own.
|
||
screen.tip_edge = Some(TipEdge::Pick);
|
||
}
|
||
// 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 ([`LoadingScreen::far_snap`]): it never re-raises a live screen, so the art that
|
||
// screen was raised with holds even when the server seats us on a different map than the one
|
||
// it announced. That is the reference exactly — `0x406800` is reachable from nothing outside
|
||
// the loading-screen TU, so no packet can re-point a screen that is up.
|
||
for w in worldports.read() {
|
||
if screen.far_snap(w.map_id) {
|
||
screen.raise("worldport", false, Some(w.map_id), now);
|
||
}
|
||
}
|
||
// A same-map teleport just ends any awaited snap — whether it needs a screen at all is the
|
||
// backstop's call below (a summon across the room shouldn't flash one).
|
||
let teleported = teleports.read().next().is_some();
|
||
if teleported {
|
||
screen.snap_landed(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);
|
||
// A **refused character login** is the same cut, and it needs the disarm as badly as a dead
|
||
// session does: the entry edge above raised the cover with `awaiting_snap` armed for a snap
|
||
// the server has just told us will never come, and nothing else in this function can end that
|
||
// wait. Left alone it is a loading screen with no world behind it and no way off.
|
||
let refused = refusals.read().next().is_some();
|
||
if logouts.read().next().is_some() || session_over || refused {
|
||
screen.active = true;
|
||
screen.blackout = true;
|
||
screen.awaiting_snap = false;
|
||
screen.ready_frames = 0;
|
||
info!(
|
||
"loading screen: blackout ({})",
|
||
if session_over {
|
||
"session lost"
|
||
} else if refused {
|
||
"character login refused"
|
||
} 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.dismiss();
|
||
}
|
||
|
||
// --- 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
|
||
{
|
||
// Same map by construction — the destination this covers is the ground under the body.
|
||
screen.raise("focus not resident", false, map_id, 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, map_id, 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 && !screen.held {
|
||
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.dismiss();
|
||
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 — `0x406cf0`'s gate in shape (decision 2087): the map id is read ONCE per
|
||
// screen, to resolve the texture; from then on the resolved texture is what draws, and only
|
||
// the dismiss clears it. A raise landing on a live screen re-points [`LoadingScreen::map`] and
|
||
// repaints nothing, which is why "the screen you were given is the screen you keep" survives
|
||
// even a transfer arriving mid-load. Resolution walks the FK chain and caches by path. ---
|
||
if !screen.art_resolved {
|
||
// Until this screen's own art lands, the backdrop is the root's black — never the LAST
|
||
// screen's texture, which is what a stale tint would leave on the glass (the reference
|
||
// holds a null handle here and does not draw the quad at all).
|
||
if let Ok((mut img, _)) = backdrop.single_mut() {
|
||
if img.color != Color::BLACK {
|
||
img.color = Color::BLACK;
|
||
}
|
||
}
|
||
if let (Some(map_id), Some(maps), Some(screens), Some(assets)) =
|
||
(screen.map, maps.as_ref(), screens.as_ref(), assets.as_mut())
|
||
{
|
||
let path = maps
|
||
.0
|
||
.loading_screen_id(map_id)
|
||
.and_then(|id| screens.0.path(id))
|
||
.map(str::to_string);
|
||
let handle = path.and_then(|path| 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());
|
||
}),
|
||
});
|
||
match (handle, backdrop.single_mut()) {
|
||
(Some(handle), Ok((mut img, _))) => {
|
||
img.image = handle;
|
||
img.color = Color::WHITE; // reveal the art (was tinted black)
|
||
screen.art_resolved = true;
|
||
// Once per screen, and the only place the picture is decided — so "which
|
||
// backdrop did that load actually show" is a log line rather than a capture.
|
||
info!("loading screen: backdrop for map {map_id} resolved");
|
||
}
|
||
// The resolve failed — no `LoadingScreens` row, or the BLP is missing. This screen
|
||
// shows no backdrop for the rest of its life and does not retry: `0x406d0e` writes
|
||
// `-1` into the map id for exactly this, and `0x406cf0` early-returns on it after.
|
||
_ => {
|
||
warn!("loading screen: no backdrop for map {map_id} — plain black this load");
|
||
screen.map = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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");
|
||
}
|
||
/// **The director's report, as an assertion.** Log out inside a dungeon, come back hours
|
||
/// later, and the instance is gone: vmangos relocates the character to the dungeon's go-back
|
||
/// trigger *inside* `Player::LoadFromDB`, before the world is entered — so no
|
||
/// `SMSG_TRANSFER_PENDING` and no `SMSG_NEW_WORLD` ever announce it, and the first the client
|
||
/// hears is `SMSG_LOGIN_VERIFY_WORLD` naming a map the roster row did not. The screen keeps
|
||
/// the art it was raised with, for its whole life. The reference could not do otherwise:
|
||
/// `[0x82f00c]` has no writer on either world-load handler's path (`0x401b00`/`0x401de0`), and
|
||
/// the raise `0x406800` is reachable from nothing outside the `LoadingScreen.cpp` TU.
|
||
#[test]
|
||
fn a_relocating_snap_cannot_move_the_art_the_screen_was_raised_with() {
|
||
let mut screen = LoadingScreen::default();
|
||
// The pick edge: art from the roster row, Shadowfang Keep, snap still to come.
|
||
screen.raise("world entry", true, Some(33), 0.0);
|
||
screen.tip_edge = Some(TipEdge::Pick);
|
||
screen.displayed = 0.4;
|
||
|
||
// `SMSG_LOGIN_VERIFY_WORLD`: the server seats us on map 0, at the instance's exit.
|
||
assert!(
|
||
!screen.far_snap(0),
|
||
"a screen is already up — its own snap must not raise a second one"
|
||
);
|
||
assert_eq!(
|
||
screen.map,
|
||
Some(33),
|
||
"the backdrop is the raise's and holds the whole way — the report"
|
||
);
|
||
assert!(
|
||
!screen.awaiting_snap,
|
||
"the snap the raise was waiting for has landed"
|
||
);
|
||
assert_eq!(
|
||
screen.tip_edge,
|
||
Some(TipEdge::Pick),
|
||
"and the tip 2077 just picked survives it — a raise is the only thing that could \
|
||
have cleared it, and no raise happened"
|
||
);
|
||
assert!(
|
||
(screen.displayed - 0.4).abs() < f32::EPSILON,
|
||
"nor does the bar restart mid-load"
|
||
);
|
||
assert!(screen.active, "the screen stays up across its own snap");
|
||
}
|
||
|
||
/// The other half of the same rule: a cross-map port that arrived with **no** announcing edge
|
||
/// still gets a cover. This is ours, not the reference's — its own world load blocks, so it
|
||
/// needs no backstop.
|
||
#[test]
|
||
fn a_snap_with_nothing_covering_it_asks_for_a_raise() {
|
||
let mut screen = LoadingScreen::default();
|
||
assert!(screen.far_snap(1), "nothing is covering this load");
|
||
assert!(
|
||
!screen.active,
|
||
"far_snap answers the question; the caller does the raising, with its own reason"
|
||
);
|
||
}
|
||
|
||
/// **The tip comes down with the screen, never with a raise** — `[0x882e10]`'s only writers
|
||
/// image-wide are `EnterWorld`'s setter `0x406630` and `0x407f2b`, inside the dismiss
|
||
/// `0x407e80`. Ours used to clear it on every non-entry raise, which meant the login snap wiped
|
||
/// the tip a server round-trip into *every* world entry.
|
||
#[test]
|
||
fn only_the_dismiss_clears_the_tip() {
|
||
let mut screen = LoadingScreen::default();
|
||
screen.raise("world entry", true, Some(0), 0.0);
|
||
screen.tip_edge = Some(TipEdge::Pick);
|
||
|
||
screen.raise("transfer pending", true, Some(389), 0.0);
|
||
assert_eq!(
|
||
screen.tip_edge,
|
||
Some(TipEdge::Pick),
|
||
"no raise clears a tip"
|
||
);
|
||
|
||
screen.dismiss();
|
||
assert_eq!(screen.tip_edge, Some(TipEdge::Clear), "the dismiss does");
|
||
assert!(!screen.active);
|
||
}
|
||
|
||
/// **What a live screen SHOWS is latched by the resolved texture, not by the map id**
|
||
/// (decision 2087, correcting 2081's reading). A raise onto a screen that is already up
|
||
/// re-points `[0x82f00c]` unconditionally — the reference's `0x406640`/`0x4072c0` have no
|
||
/// guard on that store — and repaints nothing, because `0x406cf0` skips the resolver whenever
|
||
/// `[0x882e04]` is non-null. Only the dismiss `0x407ed7` clears it.
|
||
#[test]
|
||
fn a_raise_onto_a_live_screen_repoints_the_map_but_never_the_picture() {
|
||
let mut screen = LoadingScreen::default();
|
||
screen.raise("world entry", true, Some(33), 0.0);
|
||
screen.art_resolved = true; // the first plain-draw frame resolved Shadowfang Keep's art
|
||
|
||
screen.raise("transfer pending", true, Some(389), 0.0);
|
||
assert_eq!(screen.map, Some(389), "the map id is re-pointed, unguarded");
|
||
assert!(
|
||
screen.art_resolved,
|
||
"…and the picture is not: the texture is still the one this screen resolved"
|
||
);
|
||
|
||
// The dismiss is the only thing that lets the next screen resolve its own.
|
||
screen.dismiss();
|
||
assert!(!screen.art_resolved);
|
||
}
|
||
}
|