Files
benilla-octo/crates/benilla-app/src/loading_screen.rs
T
Sam SungandClaude Opus 5.5 4f8350c9ed String literals and test names in benilla-app carry no record numbers (#368)
The strings pass for `benilla-app`, the last crate: string literals and test names lose the private record numbers and the workspace's words that a comment pass cannot touch.

Fixes #92. Fixes #36.

- **Messages.** About 230 assertion, panic, `expect` and log messages, printed tool labels, and comments inside embedded Lua fixtures drop their record numbers; the eight that named "the director" keep their fact in plain words. Stale claims in a few messages are corrected on the way (a cast-bar step, a font line, the every-shot recompute).
- **Names.** Seven test and helper names that carried a record number or "director" now say what they check. `git grep` found no other use of any of them.
- **Player files.** The key-bindings file's header and `config.toml`'s header each lose a record citation. Neither header is compared when the file is read back: the bindings reader skips every `#` line, and `config.toml`'s header is a TOML comment.
- **Fixtures.** Test fixtures use neutral character, account and frame names.
- **#36.** The quest-material test labels GameObject type 10 GOOBER (in 1.12, 3 is CHEST, 9 TEXT, 10 GOOBER); the code under test was already right, and the comments were fixed with the comment pass.
- **Verification.** With comments stripped and every literal's contents masked, each file is identical to main and keeps its literals' line counts, apart from the renamed functions and one rustfmt reflow. `cargo fmt --check` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:06:28 +07:00

792 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The loading screen: the full-screen splash and progress bar over world entry and cross-map
//! teleports. The art resolves `Map.dbc` → `LoadingScreens.dbc` → BLP for every map kind; the bar
//! is two layers, `Loading-BarBorder` and `Loading-BarFill` (Background, Glow and Glass are never
//! drawn in 1.12).
//!
//! Event-raised and readiness-cleared, as in the reference: it rises at the character pick's
//! `Connected` edge and at `SMSG_TRANSFER_PENDING` (the reference's two entries into `0x406640`'s
//! tail) and clears on a per-frame readiness poll. The reference's one blocking stretch
//! (`SMSG_NEW_WORLD`'s `0x401b00` defers `0x401bc0` onto the deadline heap, drained at `0x420d0c`)
//! also suppresses input, which this client builds explicitly ([`input`]).
//!
//! A destination snap only ends a raise's wait ([`LoadingScreen::snap_landed`]). The screen clears
//! when the destination is scene-presentable ([`WorldLoadProgress`]), never on the body reaching
//! the ground.
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's input half: while it is up the client takes no input (a source cut in `PreUpdate`).
mod input;
pub(crate) use input::CoverInput;
// The bar, from the `LoadingScreen.cpp` descriptor table at `0x7ffd34` (read by `0x407150`):
// entry {cx, cy, halfW, halfH}, rect [cx ± halfW·0.5] × [cy ± halfH·0.5], fill right edge
// left + progress·halfW; Border {0.5, 0.075, 0.600, 0.050}, Fill {0.5, 0.075, 0.525, 0.025}.
// Viewport fractions, `cy` measured up 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 and loading screens are authored 4:3; on a wider window the reference fits them to
/// height and pillarboxes. The square BLP is stretched to this aspect.
const BACKDROP_ASPECT: f32 = 4.0 / 3.0;
/// Consecutive fully resident frames before the clear: debounces the post-teleport frame where
/// `ready` reads 0 against a nonzero `total`.
const CLEAR_AFTER_READY_FRAMES: u32 = 3;
/// Once the screen has been up this long, log which term still blocks the clear, every
/// [`WAIT_LOG_EVERY`] seconds.
const WAIT_LOG_AFTER: f32 = 3.0;
const WAIT_LOG_EVERY: f32 = 2.0;
/// How far (yd) a same-map snap must move the body to count as a load. It clears every combat
/// relocation (charge and intercept 25 yd, blink 20, knockbacks under 30), which also ends in a
/// server teleport and must never black out the screen.
const SNAP_LOAD_MIN_YD: f32 = 100.0;
/// Consecutive covered, in-world frames before the cover is on the glass: renders are serial, so
/// at 3 the two frames before have presented.
const COVER_PRESENT_FRAMES: u32 = 3;
/// Whether the entry cover has reached the glass. The first covered frame is the first whose
/// render can draw the cover, so synchronous work on it freezes character select on screen.
/// Counted in `First` so `PreUpdate` and `Update` readers 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 {
/// Whether the cover has had enough frames to reach the glass; true when no cover is up, since
/// a consumer would otherwise wait forever (a capture that boots straight in world).
pub(crate) fn presented(&self) -> bool {
self.frames == 0 || self.frames >= COVER_PRESENT_FRAMES
}
/// A cover is up and not yet presented: a renderer draws nothing but the cover this frame.
pub(crate) fn owes_a_present(&self) -> bool {
self.frames > 0 && self.frames < COVER_PRESENT_FRAMES
}
/// A cover is up and the client is in world.
pub(crate) fn covering(&self) -> bool {
self.frames > 0
}
/// Covered frames so far.
#[cfg(test)]
pub(crate) fn frames(&self) -> u32 {
self.frames
}
}
impl EntryCover {
/// One frame's count; the system and the tests both go through this.
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.
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);
}
/// The `LoadingScreenID` → BLP path table; [`MapCatalogRes`] maps a map to its `LoadingScreenID`.
#[derive(Resource)]
struct LoadingScreenCatalogRes(LoadingScreenCatalog);
/// What a raise means for the tip of the day.
#[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 (event-raised, readiness-cleared).
#[derive(Resource, Default)]
pub(crate) struct LoadingScreen {
active: bool,
/// A raise from an entry edge holds until the destination snap lands, so the old location's
/// residency cannot clear a screen raised for the new one (at world entry the login vista is
/// resident well before `SMSG_LOGIN_VERIFY_WORLD`).
awaiting_snap: bool,
/// The map this screen's art resolves from, the reference's `[0x82f00c]`, set by every raise;
/// `None` is its `-1`, no backdrop. Not `CurrentMap`: vmangos can relocate a character inside
/// `Player::LoadFromDB` (`Player.cpp:15020`), under a live screen. Its writers: `0x4067e6` and
/// `0x4072d3` store a map id, `0x406d0e`, `0x4073d4` and `0x407e59` store `-1`.
map: Option<u32>,
/// The reference's `[0x882e04]` texture handle, which latches the art: `0x406cf0` reads the map
/// id only to reject `-1`, and `0x406cfc`/`0x406d01`/`0x406d03` skip the resolver `0x406e20`
/// while it is set. Cleared by [`Self::dismiss`], never by a raise.
art_resolved: bool,
/// Plain black cover with no art or bar, for the world→glue cut; dropped once the state leaves
/// `InWorld`.
blackout: bool,
/// Consecutive presentable frames while active ([`CLEAR_AFTER_READY_FRAMES`]).
ready_frames: u32,
/// Bar fill, 0..1, monotonic within a load: the raw residency ratio dips as the view moves.
displayed: f32,
/// Decoded backdrop art by BLP path.
art_cache: HashMap<String, Handle<Image>>,
/// Which tip the next raise carries, taken by [`crate::game_tip::drive_game_tip`].
pub(crate) tip_edge: Option<TipEdge>,
/// Capture only: the clear is skipped while set ([`Self::hold_for_capture`]).
held: bool,
/// `Time::elapsed_secs` at the last raise and at the last wait log line.
active_since: f32,
last_wait_log: f32,
/// The avatar's position last frame, to measure a snap (applied in `Input`, a stage earlier).
last_pos: Option<Vec3>,
}
impl LoadingScreen {
/// Whether the opaque cover is up; the world camera renders under it, never behind the glue.
pub(crate) fn covering(&self) -> bool {
self.active
}
/// An active cover, for tests.
#[cfg(test)]
pub(crate) fn test_covering() -> Self {
Self {
active: true,
..Self::default()
}
}
/// Take the pending raise's tip edge; 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 as the glue→world entry does,
/// with art, bar and a `Pick` tip, and never clear. `map` latches the art as a raise's does.
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, its art latched to `map` for its life (a caller with no
/// announced destination passes the current map). Only an edge that reaches the reference's
/// transition entries calls this, never a snap ([`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 (`SMSG_NEW_WORLD` or `SMSG_LOGIN_VERIFY_WORLD`) landed: it ends the
/// raise's wait and nothing else, not the art, the tip or the bar. The reference's handlers
/// (`0x401b00`, `0x401de0`) never touch the screen: the raise `0x406800` has three call sites,
/// all inside `LoadingScreen.cpp`. `map` is `None` for a same-map teleport ack.
fn snap_landed(&mut self, map: Option<u32>) {
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;
}
/// A far snap: a screen already up is this snap's own, so the snap only ends its wait. Returns
/// `true` when no screen is up and the caller should raise one, our backstop for a port with no
/// announcing edge (the reference's blocking world load needs none).
#[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 only place a tip clears: `[0x882e10]`'s
/// writers are the `EnterWorld` setter `0x406630` and `0x407f2b` here.
fn dismiss(&mut self) {
self.active = false;
self.blackout = false;
self.tip_edge = Some(TipEdge::Clear);
// `0x407ed7` clears `[0x882e04]`: the next screen resolves its own art.
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` root; its coloured runs are children, rebuilt when the 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>()
// In `First`, ahead of every consumer in `PreUpdate` and `Update`.
.add_systems(First, count_entry_cover)
.add_systems(Startup, setup_loading_screen.after(AssetSet::Open))
// In `WorldStage::Present`, after Input and Stream: a teleport snaps in Input and the
// streamer refocuses in Stream, so the cover lands on the same frame, never a flash.
.add_systems(Update, drive_loading_screen.in_set(WorldStage::Present));
// The cover's input rule, registered here so the two cannot be registered apart.
input::build(app);
}
}
/// Startup: load the LoadingScreens catalog and the bar textures, and spawn the hidden UI tree.
fn setup_loading_screen(
mut commands: Commands,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
// The black pillarbox root spawns unconditionally: `covering()` consumers act as covered while
// the screen is active, so missing art must still leave a black cover.
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),
// UI already paints after the 3D scene; 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 draws, as sRGB clamp sprites.
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();
commands.entity(root).with_children(|root| {
// One 4:3 area fit to the viewport height and centred; the bar's fractions are relative
// to it. Paint order is spawn order: backdrop, tip, fill, border.
root.spawn(Node {
width: Val::Vh(100.0 * BACKDROP_ASPECT),
height: Val::Vh(100.0),
position_type: PositionType::Relative,
..default()
})
.with_children(|area| {
// Tinted black 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, between the backdrop and the bar as in the reference (`0x406e12`,
// `0x406e18`), placed in percent of this area, the space of `crate::game_tip`.
area.spawn((LoadingTip, crate::game_tip::tip_bundle()));
// The fill art is a horizontally uniform gradient, so scaling its width is a 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 raise and clear rules, resolve the backdrop and
/// set the bar.
#[allow(clippy::type_complexity)]
fn drive_loading_screen(
mut screen: ResMut<LoadingScreen>,
// Bundled to stay under Bevy's 16-parameter ceiling.
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 time: `Time<Virtual>` clamps frames over 250 ms, and a load is made of those.
time: Res<Time<Real>>,
// The cover holds until the warm pass has compiled its pipelines.
warm: Res<crate::pipe_warm::WarmPass>,
// The world-entry UI load runs behind this cover; the reference's reveal always has the UI up.
entry_ui_pending: Option<Res<crate::ui_script::PendingEntryUiLoad>>,
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, so the body's world is live first.
let player_settling = player.as_ref().is_some_and(|p| p.settling);
// --- The edges. ---
// A glue entry (not the seamless in-world reconnect), raised on the frame the glue tears down
// and held across the server's character load; the art comes from the roster's 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 rides this edge alone: the reference's setter `0x406630` has one caller, in
// `CGlueMgr::EnterWorld`, which neither `SMSG_TRANSFER_PENDING` arm reaches.
screen.tip_edge = Some(TipEdge::Pick);
}
// A portal (`SMSG_TRANSFER_PENDING`, no transport): cover now, before the server unloads us, as
// the reference does. A transport crossing rides visibly until its worldport lands.
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);
}
// Cleared with no snap in flight is `SMSG_TRANSFER_ABORTED` (a worldport lands below
// this frame): stop waiting, and the resident old world clears the screen.
None => screen.awaiting_snap = false,
Some(_) => {}
}
}
// A snap never re-raises a live screen ([`LoadingScreen::far_snap`]).
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 ends any awaited snap; whether it needs a screen is decided below.
let teleported = teleports.read().next().is_some();
if teleported {
screen.snap_landed(None);
}
// Logout, a lost session or a refused login: `CharSelect` applies next frame, so cover the torn
// down world with black (the reference's world→glue cut), and disarm a snap that will not come.
let session_over = lost.read().any(|m| m.session_over);
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: the ground under the focus is not resident and nothing raised us. In world
// only, since behind the glue nothing loads; and only while the focus is the body, since in a
// cinematic or free-fly residency describes the camera's tile. ---
if !screen.active
&& !progress.focus_resident
&& focus.follows_body()
&& *state.get() == crate::char_select::ClientState::InWorld
{
// Same map by construction: the ground under the body.
screen.raise("focus not resident", false, map_id, now);
}
// --- At a snap, the same test against the clear's own predicate: a teleport inside the keep
// band lands on resident terrain while the destination's buildings may still be arriving. ---
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 (the entry frame): treat it as a relocation.
(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, 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;
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: under blackout only the root's black shows. ---
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: the map id is read once per screen to resolve the
// texture, so a raise onto a live screen repaints nothing. ---
if !screen.art_resolved {
// Black, never the last screen's texture (the reference holds a null handle, no quad).
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; // untint to reveal the art
screen.art_resolved = true;
info!("loading screen: backdrop for map {map_id} resolved");
}
// No retry: `0x406d0e` writes `-1` into the map id and `0x406cf0` returns on it.
_ => {
warn!("loading screen: no backdrop for map {map_id} — plain black this load");
screen.map = None;
}
}
}
}
// --- Progress bar: zero until the snap lands (residency is still the old place's). ---
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::*;
#[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 drops the count to zero in one frame; the next raise earns its own.
cover.tick(false);
assert_eq!(cover.frames(), 0);
assert!(cover.presented());
cover.tick(true);
assert!(!cover.presented(), "the next raise starts its own count");
}
/// vmangos can relocate a character out of an expired instance inside `Player::LoadFromDB`, so
/// `SMSG_LOGIN_VERIFY_WORLD` names another map than the roster did.
#[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 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");
}
/// A cross-map port with no announcing edge still gets a cover: our backstop, which the
/// reference's blocking world load does not need.
#[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"
);
}
#[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);
}
/// The reference's raises (`0x406640`/`0x4072c0`) store the map id with no guard.
#[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);
}
}