- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178) - GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175) - SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176) - a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176) - a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1037 lines
59 KiB
Rust
1037 lines
59 KiB
Rust
//! **The UI session's lifecycle** — the VM's birth, its identity, its death, and the reload
|
||
//! (decisions 1051/1290/1291). Split from `mod.rs` when the reload verb landed and the file held
|
||
//! two concerns: this, and the per-frame extract/input bridge that stayed behind.
|
||
//!
|
||
//! The shape, end to end: `Startup` installs a **boot VM** (strings, emote tokens, fonts — the
|
||
//! state the glue screens run against); entering the world loads the in-game UI and every addon
|
||
//! onto it under the picked character's identity; leaving the world runs the reference's ordered
|
||
//! shutdown tail and installs a fresh boot VM; and `ReloadUI()` is the two edges back to back
|
||
//! without leaving the world. Every function here is an edge — nothing per-frame lives here.
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_assets::LockRecover;
|
||
use benilla_ui::script::UiScript;
|
||
|
||
use super::{
|
||
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
|
||
UiKeyboardCapture,
|
||
};
|
||
use crate::ui_script::addons;
|
||
|
||
pub(crate) fn setup_script(world: &mut World) {
|
||
install_boot_vm(world);
|
||
}
|
||
|
||
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
|
||
/// font-object registry, and no frames at all.
|
||
///
|
||
/// This is the state the client sits at outside the world — the login and character screens — and
|
||
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
|
||
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
|
||
/// is what makes a second login a genuine second load rather than a re-entry into the first
|
||
/// login's Lua state.
|
||
///
|
||
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
|
||
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
|
||
/// has to exist before the login screen or in-game text loses its outlined variants and its
|
||
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
|
||
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
|
||
/// carrying its own `GlueFonts.xml`.
|
||
///
|
||
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
|
||
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
|
||
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
|
||
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
|
||
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
|
||
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
|
||
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
|
||
/// wow-re's §5 for this change (1291).
|
||
fn install_boot_vm(world: &mut World) {
|
||
let mut script = match UiScript::new() {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
error!("ui_script: VM init failed: {e}");
|
||
world.remove_non_send_resource::<UiScript>();
|
||
return;
|
||
}
|
||
};
|
||
seed_vm_clock(world, &mut script);
|
||
install_addon_asset_resolvers(world, &mut script);
|
||
load_global_strings(world, &script);
|
||
load_emote_tokens(world, &script);
|
||
if ui_wanted(world) {
|
||
// Errors are already logged per file as they happen; the returned list is the test-side
|
||
// assertion (`shipped_xml_tests`), not a second reporting channel.
|
||
let _ = load_font_registry(&script);
|
||
}
|
||
world.insert_non_send_resource(script);
|
||
}
|
||
|
||
/// **`GetTime()` is the PROCESS's clock, not the VM's** (decision 2116) — so a VM built now starts
|
||
/// it where the process already is, and [`UiClock`] is re-anchored to match in the same breath.
|
||
///
|
||
/// The reference's `GetTime` (`0x515ea0`) reads `KERNEL32!GetTickCount` and scales by 0.001 (the
|
||
/// thunk `0x42c010` → `0x42b790`, wow-re's `core` boundary row): an OS clock, with no relationship
|
||
/// to the Lua VM, that cannot restart. Ours is a Lua global the VM owns, and since 1290/1291 the VM
|
||
/// is destroyed and rebuilt at every logout/login and every `ReloadUI` — so without this seed the
|
||
/// clock went back to zero on each of those, and every host value already converted onto it
|
||
/// (`CooldownInfo::ui_triple` and its kin, the aura feed's `expirationTime`) suddenly sat in the
|
||
/// past. Stock `Cooldown.lua`'s `CooldownFrame_SetTimer` gates on `start > 0` and takes the `else`
|
||
/// branch — `this:Hide()` — for anything else, so after a relog **every cooldown still running
|
||
/// drew nothing** while the store, and therefore the cast validator, still held it: no sweep on the
|
||
/// button and a "Spell is not ready yet" on the press.
|
||
///
|
||
/// `Time<Real>` is the right clock because it is the very one [`UiClock`] already anchors on:
|
||
/// `elapsed` is measured at `last_update()`, which is [`UiClock::anchor`], so the pair written here
|
||
/// is atomic in exactly the sense that resource's doc requires. A world with no `Time<Real>` (a
|
||
/// bare test world) starts at zero, which is what it did before this existed.
|
||
fn seed_vm_clock(world: &mut World, script: &mut UiScript) {
|
||
let (anchor, elapsed) = world
|
||
.get_resource::<Time<bevy::time::Real>>()
|
||
.map_or_else(Default::default, |t| {
|
||
(t.last_update(), t.elapsed_secs_f64())
|
||
});
|
||
script.set_now(elapsed);
|
||
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
|
||
*clock = UiClock {
|
||
anchor: anchor.unwrap_or_else(std::time::Instant::now),
|
||
ui_now: elapsed,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite
|
||
/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
|
||
/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the
|
||
/// path form of `SetTexture` can answer the reference's 1|nil load verdict — Atlas picks its map
|
||
/// art by that return), and the VM's **font probe** (the same 1|nil for `SetFont`, whose nil is a
|
||
/// load failure — `!OmniCC` uses it as a font-file validity check). All resolve the same one folder
|
||
/// ([`addons::root`], hermetic-`None` under `$WOW_CAPTURE`), and each probe walks exactly the
|
||
/// store its renderer reads — [`benilla_assets::sprite_candidates`] for art, the chain-then-folder
|
||
/// pair [`crate::ui_text`]'s face loader uses for fonts — so the verdict the Lua caller gets is the
|
||
/// verdict the screen shows.
|
||
///
|
||
/// The **size probe** beside it is the same oracle answering a different question — how many texels
|
||
/// wide and tall is the art — which is what lets a region that authored no size on an axis take
|
||
/// that span from its content, as the client's virtual size getters do (decision 1349, the fix for
|
||
/// B342's page-sized book crest). It goes through the decoder, so the number layout resolves with
|
||
/// is the number the screen shows, and memoises per texture key: the ask is per zero-size region
|
||
/// per resolve, and the answer cannot change for a key that already read.
|
||
///
|
||
/// No `WorldAssets` (no client data) means no backend: nothing to install, so `SetTexture`'s path
|
||
/// form keeps answering nil and `SetFont`'s keeps answering 1 — each store's own engine-less truth
|
||
/// (`Model::texture_probe` / `Model::font_probe` carry why the two defaults differ).
|
||
///
|
||
/// Both probes test **existence**, not decode success: a file that is there but will not decode
|
||
/// answers 1 and draws nothing. That is 1322's stated approximation and it is unchanged here; the
|
||
/// renderer's own `texture miss` / `font miss` WARN is what names such a file.
|
||
fn install_addon_asset_resolvers(world: &mut World, script: &mut UiScript) {
|
||
let root = addons::root();
|
||
let Some(mut assets) = world.get_resource_mut::<benilla_assets::WorldAssets>() else {
|
||
return;
|
||
};
|
||
assets.set_loose_addon_root(root.clone());
|
||
let chain = assets.chain.clone();
|
||
let size_chain = chain.clone();
|
||
let size_root = root.clone();
|
||
let font_chain = chain.clone();
|
||
let font_root = root.clone();
|
||
script.set_texture_probe(Box::new(move |path| {
|
||
benilla_assets::sprite_candidates(path).iter().any(|c| {
|
||
chain.lock_recover().contains(c)
|
||
|| root
|
||
.as_deref()
|
||
.is_some_and(|r| benilla_assets::loose_addon_file(r, c).is_some())
|
||
})
|
||
}));
|
||
// Keyed by the reference string exactly as the region carries it, so the hit path allocates
|
||
// nothing: this is asked from inside the layout sweep, once per zero-size textured region per
|
||
// pass. Two spellings of one file (`Foo` and `Foo.blp`) cost two entries holding the same
|
||
// number, which is cheaper than normalising every ask to avoid it. Misses are cached too — a
|
||
// path that resolves to nothing measures nothing however often we look.
|
||
let sizes: std::cell::RefCell<std::collections::HashMap<String, Option<(u32, u32)>>> =
|
||
std::cell::RefCell::new(std::collections::HashMap::new());
|
||
script.set_texture_size_probe(Box::new(move |path| {
|
||
if let Some(cached) = sizes.borrow().get(path) {
|
||
return *cached;
|
||
}
|
||
let measured = benilla_assets::sprite_dimensions(&size_chain, size_root.as_deref(), path);
|
||
sizes.borrow_mut().insert(path.to_string(), measured);
|
||
measured
|
||
}));
|
||
// The font oracle. Existence over the same two stores, in the same order, that
|
||
// `ui_text::engine`'s face loader reads — a `SetFont` that answers 1 and then draws Friz
|
||
// because the two disagreed would be worse than no probe at all. Memoised for the same reason
|
||
// the size probe is: an addon can call `SetFont` per animated string per frame (MSBT does),
|
||
// and a path's answer cannot change mid-session.
|
||
let seen: std::cell::RefCell<std::collections::HashMap<String, bool>> =
|
||
std::cell::RefCell::new(std::collections::HashMap::new());
|
||
script.set_font_probe(Box::new(move |path| {
|
||
if let Some(&cached) = seen.borrow().get(path) {
|
||
return cached;
|
||
}
|
||
let found = font_chain.lock_recover().contains(path)
|
||
|| font_root.as_deref().is_some_and(|r| {
|
||
benilla_assets::loose_addon_file(r, &benilla_assets::normalize_path(path)).is_some()
|
||
});
|
||
seen.borrow_mut().insert(path.to_string(), found);
|
||
found
|
||
}));
|
||
}
|
||
|
||
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
|
||
/// the WORLD render — unless the UI is opted in, which happens two ways: `WOW_CAPTURE_UI=1` on any
|
||
/// scenario, or the scenario **declaring a `ui:` fixture**, which is the harness saying the window
|
||
/// is the subject — [`crate::run_mode::capture_ui_opted_in`] is the one predicate, and its doc says why all
|
||
/// three consumers of it must agree. A run with no `CaptureMode` is an ordinary client and always
|
||
/// wants its UI.
|
||
fn ui_wanted(world: &World) -> bool {
|
||
!world.contains_resource::<crate::run_mode::CaptureMode>()
|
||
|| crate::run_mode::capture_ui_opted_in()
|
||
}
|
||
|
||
/// The world-entry UI load, armed at `OnEnter(InWorld)` and run by [`run_pending_entry_load`]
|
||
/// once the loading cover is actually ON THE GLASS.
|
||
///
|
||
/// This is 0962's frame accounting, second offender: `OnEnter(InWorld)` fires on exactly the
|
||
/// frame whose render would first present the cover (the raise frame still renders the glue
|
||
/// above it), so a synchronous burst there holds the *previous* present — the frozen character
|
||
/// screen — for its whole duration. 0962 taught the menagerie to wait for
|
||
/// `WARM_COVER_PRESENT_FRAMES`; 1051 then moved the FrameXML load onto the same unprotected
|
||
/// frame, and by 1290+addons the burst had grown to ~0.5 s (measured live: raise 48.317 →
|
||
/// reference sourcing + 55 files + addons + saved vars + `PLAYER_LOGIN` → 48.83) — the
|
||
/// director's "frozen char for 1 sec" at every Enter World.
|
||
///
|
||
/// The wait itself is [`crate::loading_screen::EntryCover`]'s — one count of covered+in-world
|
||
/// frames for the whole client. This used to keep its own, beside the warm pass's identical one,
|
||
/// and the duplication is not incidental: the third consumer of the same rule (the world camera,
|
||
/// which renders the whole new world on that same frame) had no counter at all until it was
|
||
/// measured. So the latch here is a *marker* — is the entry UI still owed? — and the timing
|
||
/// question is answered in one place. The loading screen folds this resource's existence into
|
||
/// its clear condition, so a reveal can never precede the UI it is supposed to reveal.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct PendingEntryUiLoad;
|
||
|
||
/// **The boot VM, parked for the deferral window** (decision 1978). Between the world-entry edge
|
||
/// and the deferred entry load there is no VM in the world at all: the boot VM waits here, out of
|
||
/// every feed's reach, and the load takes it back. Every feed takes the VM as an `Option` and
|
||
/// already returns on `None` — the glue phase's shape — so a feed keyed on a per-VM memo cannot
|
||
/// push into a VM that has no interface yet and then hold the real push back (the chat plate
|
||
/// that stayed white, and 1348's whole class). `ingame_ui_pending` stays for the feeds that
|
||
/// name it; with the VM parked it is belt and braces.
|
||
pub(crate) struct ParkedBootVm(UiScript);
|
||
|
||
/// `OnEnter(InWorld)`: arm the deferred entry load and park the boot VM. The load itself runs a
|
||
/// few frames later — see [`PendingEntryUiLoad`] and [`ParkedBootVm`].
|
||
pub(crate) fn arm_entry_ui_load(world: &mut World) {
|
||
world.insert_resource(PendingEntryUiLoad);
|
||
if let Some(vm) = world.remove_non_send_resource::<UiScript>() {
|
||
world.insert_non_send_resource(ParkedBootVm(vm));
|
||
}
|
||
}
|
||
|
||
/// The parked boot VM back into the world, if one is parked — the session end and the
|
||
/// left-before-the-load arm both want the VM where the tail expects it.
|
||
fn unpark_boot_vm(world: &mut World) {
|
||
if let Some(ParkedBootVm(vm)) = world.remove_non_send_resource::<ParkedBootVm>() {
|
||
world.insert_non_send_resource(vm);
|
||
}
|
||
}
|
||
|
||
/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until
|
||
/// [`run_pending_entry_load`] has built the frame tree — the window in which the VM is still the
|
||
/// *boot* VM: strings, emote tokens and fonts, and not one frame.
|
||
///
|
||
/// The run condition on [`crate::ui_unit::UnitFeed`], and the reason it exists (1348): the feeds
|
||
/// fire the login **one-shots** — `PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE`, the first
|
||
/// `UPDATE_EXHAUSTION` — and every one of them is latched by a [`super::VmMemo`] keyed on the VM's
|
||
/// *session*, which the entry load does not change (it loads files ONTO this VM). So an event
|
||
/// fired in this window is delivered to nobody and then never fires again for the whole session:
|
||
/// the frames built moments later do their first paint with no first paint. That is not a
|
||
/// hypothetical ordering — it is a RACE against the wire, which is why it took some logins and not
|
||
/// others, and why the symptom moved between characters. The self descriptor arriving inside the
|
||
/// deferral window is all it takes.
|
||
///
|
||
/// The reference has no such window: `UI_Init 0x48fbf0` loads all of FrameXML and *then* fires the
|
||
/// world-enter cascade (`PLAYER_LOGIN` at `0x49094b`, `PLAYER_ENTERING_WORLD` at `0x490965`) from
|
||
/// inside itself, so a UI-less client never sees a unit event at all. Gating the feed here is that
|
||
/// same ordering, expressed against our deferred load: nothing is pushed and nothing is fired until
|
||
/// there is a UI to receive it, and the very next frame's feed — running against a fresh, unlatched
|
||
/// world — delivers the full set in order.
|
||
pub(crate) fn ingame_ui_pending(pending: Option<Res<PendingEntryUiLoad>>) -> bool {
|
||
pending.is_some()
|
||
}
|
||
|
||
/// `PreUpdate` (chained after [`run_pending_reload`] — same exclusive slot, and a reload must
|
||
/// not interleave an armed entry load): run the armed entry load once the cover has presented.
|
||
///
|
||
/// No cover at all — a capture booting straight `InWorld`, or the screen's assets missing —
|
||
/// means there is no glass to protect and nothing watching the previous present: load
|
||
/// immediately. Leaving the world first (an instant disconnect) drops the latch unrun;
|
||
/// [`end_ui_session`] treats that as "the in-game UI never existed" and skips the shutdown
|
||
/// writes, so a UI-less VM can never clobber the saved-variables files with its emptiness.
|
||
pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||
if world.get_resource::<PendingEntryUiLoad>().is_none() {
|
||
return;
|
||
}
|
||
let in_world = *world
|
||
.resource::<State<crate::char_select::ClientState>>()
|
||
.get()
|
||
== crate::char_select::ClientState::InWorld;
|
||
if !in_world {
|
||
// Left the world before the load ran — nothing to build a UI for.
|
||
world.remove_resource::<PendingEntryUiLoad>();
|
||
unpark_boot_vm(world);
|
||
return;
|
||
}
|
||
let covering = world
|
||
.get_resource::<crate::loading_screen::LoadingScreen>()
|
||
.is_some_and(|s| s.covering());
|
||
// The wait is [`crate::loading_screen::EntryCover`]'s, not a second count of the same frames
|
||
// (1345 wrote its own beside the warm pass's, and the third consumer — the world camera —
|
||
// then had none at all). `presented()` is already true with no cover up, which is the
|
||
// "nothing watching the previous present, load now" arm.
|
||
if !world
|
||
.get_resource::<crate::loading_screen::EntryCover>()
|
||
.is_none_or(|c| c.presented())
|
||
{
|
||
return;
|
||
}
|
||
world.remove_resource::<PendingEntryUiLoad>();
|
||
let start = std::time::Instant::now();
|
||
load_ingame_ui_on_world_entry(world);
|
||
// The standing instrument for this burst: the one number that says whether the cover is
|
||
// still hiding it, on every entry, in every log.
|
||
info!(
|
||
"ui_script: in-game UI up in {:.0} ms (behind the cover: {})",
|
||
start.elapsed().as_secs_f32() * 1000.0,
|
||
covering
|
||
);
|
||
}
|
||
|
||
/// Install the host's font engine into `script` for the CURRENT raster seam, if the glyph atlas
|
||
/// exists yet — the load edge's half of [`super::extract::seat_text_measurer`].
|
||
///
|
||
/// No atlas means no measure to be had (it bakes on the first `Update`, from the patch chain and
|
||
/// the window's real `scale_factor`); the per-frame pass seats one on the first frame it appears,
|
||
/// exactly as before.
|
||
fn seat_text_measurer_for_load(world: &mut World, script: &mut UiScript) {
|
||
let ui_scale = world
|
||
.get_resource::<super::UiScaleCvar>()
|
||
.map_or(1.0, |c| c.0);
|
||
let h = {
|
||
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||
q.single(world).map_or(0.0, Window::height)
|
||
};
|
||
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
|
||
return;
|
||
};
|
||
super::extract::seat_text_measurer(script, atlas, super::seam_scale(h, ui_scale));
|
||
}
|
||
|
||
/// Materialize the in-game UI for **this** session.
|
||
///
|
||
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
|
||
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
|
||
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
|
||
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
|
||
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
|
||
/// login kept the first login's captured name: the director's "the selected one is always
|
||
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
|
||
/// character's file. [`world_entry_tests`] holds both ends.
|
||
///
|
||
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
|
||
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
|
||
///
|
||
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
|
||
/// capture boots straight into `InWorld`, so before that this would have run ahead of
|
||
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
|
||
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||
// The VM the entry edge parked (1978); the live slot is the fallback for a caller that did
|
||
// not go through the arm.
|
||
unpark_boot_vm(world);
|
||
if !ui_wanted(world) {
|
||
return;
|
||
}
|
||
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
|
||
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
|
||
return;
|
||
};
|
||
// The character whose AddOn enable state applies. Resolved before the load because the
|
||
// enable file gates which addons run at all.
|
||
let identity = world
|
||
.get_resource::<crate::char_select::Roster>()
|
||
.and_then(crate::ui_macro::identity);
|
||
// **The Lua index space, before a single addon file runs** (decision 2175). The reference has
|
||
// the array in hand well before `UI_Init 0x48fbf0` reaches the addon walk — `SMSG_ADDON_INFO`
|
||
// lands during the handshake — so an addon reading `GetNumAddOns()` at file scope sees a
|
||
// populated one. Seated here rather than off a message for exactly that reason: a feed running
|
||
// a frame later would seat it after every addon had already asked.
|
||
//
|
||
// A server that never answered leaves this `None`, and the array then stays empty for the
|
||
// session — which is the reference's behaviour too, not a fallback we chose.
|
||
if let Some(reply) = world
|
||
.get_resource::<crate::net::AddonInfoReply>()
|
||
.and_then(|r| r.0.clone())
|
||
{
|
||
script.note_addon_info_reply(&reply);
|
||
}
|
||
// **The CVar table goes in BEFORE the UI loads**, for the same reason and with a shipped-file
|
||
// consumer rather than an addon one (decision 2115): the reference's own `UIOptionsFrame.xml`
|
||
// — hidden, on the manifest for the addons that name it — reads `cameraSmoothStyle` and
|
||
// `cameraSmoothTrackingStyle` inside its two camera dropdowns' `OnLoad`, and a nil there is a
|
||
// concat error, not a default.
|
||
//
|
||
// The seed normally belongs to `cvars::sync_cvars`, a per-VM `Update` claim (1291), and on a
|
||
// FIRST login that has run many frames earlier. **The window is a VM replacement**: a
|
||
// `/reloadui` builds a fresh VM and loads the whole interface inside one call, before `Update`
|
||
// gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever
|
||
// noticed, because until now no interface file read a CVar at load.
|
||
//
|
||
// The two lines are `sync_cvars`'s own first two, in its own order, and the order is
|
||
// load-bearing: the saved config goes in FIRST so registration starts each key at the player's
|
||
// value rather than the factory one (1291) — reversed, a reload would quietly reset every
|
||
// knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next
|
||
// `Update`; both calls are idempotent, and a re-register never clobbers a live value.
|
||
if let Some(persist) = world.get_resource::<crate::cvars::CvarPersist>() {
|
||
script.set_cvar_saved_base(persist.saved_base());
|
||
}
|
||
script.register_cvars(crate::cvars::registered_pairs());
|
||
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
|
||
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
|
||
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
|
||
let realm = world
|
||
.get_resource::<crate::char_select::Roster>()
|
||
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
|
||
.unwrap_or_default();
|
||
script.set_realm_name(&realm);
|
||
// …and so does the PLAYER, for the same reason and with more riding on it — see
|
||
// [`seat_from_roster`], which is where the why lives.
|
||
if let Some(seat) = world
|
||
.get_resource::<crate::char_select::Roster>()
|
||
.and_then(seat_from_roster)
|
||
{
|
||
script.set_unit("player", Some(seat));
|
||
}
|
||
// The addon version gate (decision 1292): the live table when this VM has one, else the
|
||
// persisted value. On a first login the boot VM was seeded during the glue phase, so a
|
||
// *Load out of date AddOns* click at character select reads back here even inside the
|
||
// save debounce; on a reload the fresh boot VM has no table yet and the persisted value is
|
||
// current by construction (1291's session-edge fold ran moments ago). Absent both (a bare
|
||
// test world) is the registrar default: check ON.
|
||
let version_check = script
|
||
.cvar("checkAddonVersion")
|
||
.map(|v| v != "0")
|
||
.unwrap_or_else(|| {
|
||
world
|
||
.get_resource::<crate::cvars::CvarPersist>()
|
||
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
|
||
});
|
||
// **The VM's font engine, before the first `<OnLoad>` runs** (decision 2028). Every file the
|
||
// walk below loads may measure the text it just set — the era's own tab law is
|
||
// `label:GetStringWidth() + 40` at OnLoad, and the addon corpus writes the same pair — and a
|
||
// `GetStringWidth` with no measurer installed answers 0. Seated only from the per-frame pass
|
||
// (`extract::tick_script`, an `Update` system), a VM that is BORN and LOADED inside one
|
||
// exclusive `PreUpdate` slot never sees it: that is exactly `ReloadUI()`, which mints a fresh
|
||
// boot VM in `end_ui_session` and calls straight into here, so every `/reload` measured 0
|
||
// through its whole load edge and only converged a frame later off whatever poll the caller
|
||
// had written to survive it.
|
||
seat_text_measurer_for_load(world, &mut script);
|
||
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
|
||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
|
||
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
|
||
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
|
||
let zoom = world.resource::<crate::minimap::MinimapZoom>();
|
||
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
|
||
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
|
||
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
|
||
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
|
||
// always win and nothing can ever be remembered.
|
||
//
|
||
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`** —
|
||
// the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst
|
||
// (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It is the
|
||
// sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat frame for
|
||
// any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it landed after
|
||
// the session's first chat had already been routed, and the login MOTD went to a window
|
||
// registered for nothing.
|
||
// The plate pair is read out of the world FIRST: the second closure borrows `world` for the
|
||
// chat-cache restore, and a `Copy` of two bools costs nothing next to fighting that borrow.
|
||
// Absent in a bare test world, where "both off" is also the resource's own default.
|
||
let plates = world
|
||
.get_resource::<crate::vplates::VPlateMode>()
|
||
.copied()
|
||
.unwrap_or_default();
|
||
finish_ui_load_with(
|
||
&mut script,
|
||
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update` feed
|
||
// beside it, is what fixes the bug: `UIParent_OnEvent`'s first `UpdateNameplates()` runs
|
||
// inside the `VARIABLES_LOADED` fired at the end of this very call.
|
||
|script| crate::vplates::push_plate_globals(script, plates),
|
||
|script| {
|
||
crate::ui_chat::restore_chat_looks(world, script);
|
||
},
|
||
);
|
||
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
|
||
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
|
||
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
|
||
// nothing on screen to say which or why. Counted off the retained log rather than the walk's
|
||
// `failures` vec so the number matches what `/errors` will show: the log deduplicates, and one
|
||
// broken addon that fails four files should not read as four broken addons. The VM is fresh
|
||
// per world entry, so every `Load` row here is this load's.
|
||
// Placed AFTER `finish_ui_load` so the count covers the WHOLE load edge — the saved-variables
|
||
// chunks and `ADDON_LOADED`/`VARIABLES_LOADED` handlers it runs are part of loading, and an
|
||
// addon that dies in one of them is as absent as one whose file was missing.
|
||
let failed = script
|
||
.diagnostics()
|
||
.iter()
|
||
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
|
||
.count();
|
||
if failed > 0 {
|
||
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
|
||
// Queued, not drawn: `ChatLog` is a pending buffer the chat feed drains once the UI is
|
||
// up, which is what makes it safe to push from inside the world-entry load edge.
|
||
chat.push_event(crate::ui_chat::ChatEvent::text_only(
|
||
crate::ui_chat::ChatEventKind::System,
|
||
format!(
|
||
"{failed} addon load {} — type /errors to see {}.",
|
||
if failed == 1 { "failure" } else { "failures" },
|
||
if failed == 1 { "it" } else { "them" }
|
||
),
|
||
));
|
||
}
|
||
}
|
||
// **`DAMAGE_TEXT_FONT` binds HERE, at the end of the load edge, and once** (decision 2156):
|
||
// `0x6c8470` runs from `0x401570 + 0x1620`, *after* the UI load `0x401602` — so after
|
||
// FrameXML's `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`, which is
|
||
// where MikScrollingBattleText and pfUI assign it. The reference reads the global's value
|
||
// eagerly, hands it to the font factory, and never looks again: one writer of `[0xce8820]`,
|
||
// no invalidation, and a `/reloadui` does not re-run it.
|
||
//
|
||
// One step later than the reference, deliberately: it reads after `PLAYER_ENTERING_WORLD`
|
||
// too, which here fires from [`crate::ui_unit`] when the self descriptor lands rather than
|
||
// inside this call. Nothing in the corpus assigns a font that late, and moving the seat would
|
||
// mean waiting on the wire for a value the whole load edge has already settled.
|
||
world.insert_resource(crate::combat_text::read_damage_text_font(&script));
|
||
// The load edge is over: disarm the instruction bound `load_ingame_ui` installed (decision
|
||
// 1306). From here every OnUpdate and event handler runs unhooked — a session must not kill
|
||
// a player's addon for being slow; only a load that never returns is fair game.
|
||
script.clear_instruction_budget();
|
||
world.insert_non_send_resource(script);
|
||
world.insert_resource(AddOnIdentity(identity));
|
||
}
|
||
|
||
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
|
||
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
|
||
///
|
||
/// **The reference's invariant is that addon file scope always sees a real character**:
|
||
/// `AddOn_Load 0x51f240` runs from inside `UI_Init 0x48fbf0`, which is after the world is entered.
|
||
/// benilla's does not. `Connected` flips us `InWorld` a whole server round-trip before the self
|
||
/// descriptor streams in ([`crate::ui_unit`]'s own comment measures that gap in *seconds*), and
|
||
/// `feed_units` — the only writer of the `"player"` token — is gated on that descriptor existing.
|
||
/// So until this existed, every addon's file scope ran in a VM where `UnitName("player")` was
|
||
/// **nil**, which is a state a real session cannot present. It is the same argument, at the same
|
||
/// line, as the `set_realm_name` above it (decision 1195) — and this is the more load-bearing half.
|
||
///
|
||
/// **The failure it fixes is silent, which is why it survived every instrument.** The director
|
||
/// installed Bagnon, opened their bags, and got a window with a title, a gold line and **no bag
|
||
/// slots at all**. `Bagnon_Core/core/Utility.lua:5` opens `local currentPlayer =
|
||
/// UnitName("player")`, and every one of Bagnon's "am I looking at a cached snapshot of some OTHER
|
||
/// character?" predicates is `currentPlayer ~= frame.player`. With `currentPlayer` nil, Bagnon
|
||
/// concluded the live player's own bags belonged to somebody else, took every bag size from
|
||
/// Bagnon_Forever's (empty) offline cache instead of `GetContainerNumSlots`, created zero item
|
||
/// buttons — and raised nothing, so `loaded`, `session` and the UI probe all scored it a pass.
|
||
/// Reproduced both ways in [`bagnon_render_tests`].
|
||
///
|
||
/// **What is filled is what the roster actually knows**: name, race, class, gender and level, all
|
||
/// a round-trip ahead of the descriptor (the same fact [`crate::char_select::Roster::pending_entry`]
|
||
/// already exploits for the streamers). Health and power are deliberately left at zero — those are
|
||
/// the descriptor's to say, they land within the second, and inventing them would be a different
|
||
/// lie from the one being fixed.
|
||
pub(crate) fn seat_from_roster(
|
||
roster: &crate::char_select::Roster,
|
||
) -> Option<benilla_ui::script::UnitState> {
|
||
let row = roster.pending_row()?;
|
||
let race = crate::ui_unit::race_names(row.race);
|
||
let class = crate::ui_unit::class_names(row.class);
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some(row.name.clone()),
|
||
level: u32::from(row.level),
|
||
race: race.map(|(n, _)| n.to_string()),
|
||
race_file: race.map(|(_, f)| f.to_string()),
|
||
class: class.map(|(n, _)| n.to_string()),
|
||
class_file: class.map(|(_, f)| f.to_string()),
|
||
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
|
||
sex: match row.gender {
|
||
0 => 2,
|
||
1 => 3,
|
||
_ => 0,
|
||
},
|
||
is_player: true,
|
||
player_controlled: true,
|
||
// Nil here is not "no faction", it is a state a player character cannot be in, and
|
||
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
|
||
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
|
||
..Default::default()
|
||
})
|
||
}
|
||
|
||
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
|
||
/// back to the file it came from — the roster's pick can be gone by then.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||
|
||
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
|
||
/// carves as (`system/ui/ui.md`):
|
||
///
|
||
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
|
||
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
|
||
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
|
||
/// > `_G` (`0x490cba` → `0x490ce0`)
|
||
///
|
||
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
|
||
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
|
||
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
|
||
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
|
||
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
|
||
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
|
||
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
|
||
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
|
||
/// screen gets its own.
|
||
///
|
||
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
|
||
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
|
||
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
|
||
///
|
||
/// One function, called from every root, because the steps are ordered *against each other* —
|
||
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
|
||
/// flat write and the `AddOns.txt` write were exactly that.
|
||
///
|
||
/// **The layout cache is step three, and it was missing until B353.** The quote above has always
|
||
/// carried `layout-cache.txt`; the body skipped it, because [`crate::ui_layout`] had hung its own
|
||
/// saver off `OnExit(InWorld)` instead. Two things follow from being outside the tail, and the
|
||
/// bug report is both of them: a `/reload` never leaves `InWorld` ([`run_pending_reload`] calls
|
||
/// this function and the rebuild back to back), so that saver never ran on the root a player uses
|
||
/// most; and on the roots where it did run it was racing [`end_ui_session`]'s VM replacement on
|
||
/// the same unordered edge — measured (bevy 0.18) to move with nothing but registration
|
||
/// positions. In the tail it is neither: one call, ahead of the replacement, on every root.
|
||
/// (It is also the one step that keeps a writer *outside* this function: a debounced
|
||
/// crash-save, which the reference has not got and which the next paragraph is not about.)
|
||
///
|
||
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
|
||
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
|
||
/// a few times a session, and every file is written whole from the live globals.
|
||
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
|
||
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
|
||
script.fire_event("PLAYER_LOGOUT", vec![]);
|
||
crate::ui_layout::save_now(script, identity);
|
||
crate::ui_saved::save(script);
|
||
addons::save_addon_variables(script, identity);
|
||
addons::save_enable_state(script, identity);
|
||
}
|
||
|
||
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
|
||
/// roots — and, with the writes done, **the end of this session's Lua state**.
|
||
///
|
||
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
|
||
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
|
||
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
|
||
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
|
||
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
|
||
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
|
||
/// previous character's UI.
|
||
///
|
||
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
|
||
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
|
||
pub(crate) fn end_ui_session(world: &mut World) {
|
||
// A VM still parked (the load never ran) goes back into the world first, so the tail below
|
||
// runs against the same slot it always did (1978).
|
||
unpark_boot_vm(world);
|
||
// An armed-but-unrun entry load ([`PendingEntryUiLoad`]) means this session never built an
|
||
// in-game UI: there are no globals to save and no addon state to write, and running the
|
||
// shutdown tail against the boot VM would overwrite the real files with that emptiness.
|
||
let ui_never_loaded = world.remove_resource::<PendingEntryUiLoad>().is_some();
|
||
let identity = world
|
||
.get_resource::<AddOnIdentity>()
|
||
.and_then(|id| id.0.clone());
|
||
if !ui_never_loaded {
|
||
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
|
||
shutdown_ui_state(&mut script, identity.as_ref());
|
||
}
|
||
}
|
||
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
|
||
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
|
||
// that lands in an engine-side store that survives), before the VM is replaced. The next
|
||
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
|
||
crate::cvars::fold_dying_vm_cvars(world);
|
||
world.insert_resource(AddOnIdentity(None));
|
||
|
||
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
|
||
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
|
||
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
|
||
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
|
||
// existing: a hovered frame id, the minimap's extracted hole, and a payload the cursor is
|
||
// carrying.
|
||
//
|
||
// **[`UiClock`] is NOT one of them, and used to be** (decision 2116). It reads like a fact
|
||
// about the dying VM — it is the `GetTime` leg of the conversion pair — but `GetTime` is the
|
||
// reference's OS tick count, not a per-VM clock, so zeroing it here restarted every cooldown
|
||
// and aura conversion at the character screen. [`seed_vm_clock`] writes the pair for the VM
|
||
// that replaces this one, moments below.
|
||
//
|
||
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
|
||
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
|
||
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
|
||
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
|
||
hover.0 = None;
|
||
}
|
||
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
|
||
keys.typing = false;
|
||
keys.arrows_fall_through = false;
|
||
}
|
||
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
|
||
*held = CursorPayloadHeld::default();
|
||
}
|
||
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
|
||
minimap.0 = None;
|
||
}
|
||
|
||
install_boot_vm(world);
|
||
}
|
||
|
||
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
|
||
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
|
||
/// top of the next frame's `Update`.
|
||
///
|
||
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
|
||
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
|
||
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
|
||
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
|
||
/// pass — instead of wherever the drain happens to sit.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct ReloadUiPending(pub(crate) bool);
|
||
|
||
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
|
||
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
|
||
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
|
||
/// back to back without leaving the world (decision 1291).
|
||
///
|
||
/// Everything that makes a login correct makes the reload correct **by construction**: the
|
||
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
|
||
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
|
||
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
|
||
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
|
||
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
|
||
/// feed once it notices the new VM, with the self descriptor already present — the reference's
|
||
/// own ordering, where the event follows the rebuild.
|
||
///
|
||
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
|
||
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
|
||
/// line rather than deferred — a reload asked for at the character screen answers nothing.
|
||
pub(crate) fn run_pending_reload(world: &mut World) {
|
||
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
|
||
return;
|
||
}
|
||
let in_world = *world
|
||
.resource::<State<crate::char_select::ClientState>>()
|
||
.get()
|
||
== crate::char_select::ClientState::InWorld;
|
||
if !in_world {
|
||
info!("ui_script: ReloadUI outside the world — dropped");
|
||
return;
|
||
}
|
||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||
end_ui_session(world);
|
||
load_ingame_ui_on_world_entry(world);
|
||
}
|
||
|
||
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
|
||
/// than a state edge because a quit from in-world never leaves `InWorld`.
|
||
pub(crate) fn shutdown_on_exit(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
id: Res<AddOnIdentity>,
|
||
pending_entry: Option<Res<PendingEntryUiLoad>>,
|
||
mut exits: MessageReader<AppExit>,
|
||
) {
|
||
if exits.read().next().is_none() {
|
||
return;
|
||
}
|
||
// Same guard as [`end_ui_session`]: a quit inside the entry-load window has no UI to save.
|
||
if pending_entry.is_some() {
|
||
return;
|
||
}
|
||
if let Some(mut script) = script {
|
||
shutdown_ui_state(&mut script, id.0.as_ref());
|
||
}
|
||
}
|
||
|
||
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
|
||
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
|
||
///
|
||
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
|
||
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
|
||
/// straight-line address order:
|
||
///
|
||
/// | | |
|
||
/// |---|---|
|
||
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
|
||
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
|
||
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
|
||
///
|
||
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
|
||
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
|
||
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
|
||
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
|
||
///
|
||
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
|
||
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
|
||
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
|
||
/// 1290 is once per world entry here too: this runs from
|
||
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
|
||
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
|
||
/// after this, since it waits on the self descriptor arriving over the wire.
|
||
/// Test-only since 2125: the production edge is [`finish_ui_load_with`], because the chat-cache
|
||
/// restore has to sit inside it. A test that only wants the tail keeps the plain shape.
|
||
#[cfg(test)]
|
||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||
finish_ui_load_with(script, |_| {}, |_| {});
|
||
}
|
||
|
||
/// [`finish_ui_load`] with the one step that has to land **between** `VARIABLES_LOADED` and
|
||
/// `PLAYER_LOGIN`: the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst
|
||
/// (decision 2125, correcting 2119's placement).
|
||
///
|
||
/// The reference's login is `FrameXML → addons + ADDON_LOADED (0x4900a3) → VARIABLES_LOADED
|
||
/// (0x4900b2) → the chat-cache reader's burst (0x4900d6, firing synchronously through the
|
||
/// register-or-fire-now trampoline 0x498a20) → PLAYER_LOGIN (0x490959) → PLAYER_ENTERING_WORLD
|
||
/// (0x49096a)` — byte-derived in wow-re `system/ui/scratch/login-chat-colour-pipeline.md`, §5
|
||
/// cross-checked. 2119 put the restore ahead of `VARIABLES_LOADED`, which is one step too early:
|
||
/// an addon reading its chat colours out of a `VARIABLES_LOADED` handler would see the file's
|
||
/// values where the reference shows it the boot ones.
|
||
///
|
||
/// `host_settings` is the earlier of the two seams — **between the saved-variables chunk and
|
||
/// `VARIABLES_LOADED`** — where the settings benilla keeps in `config.toml` rather than in that
|
||
/// file are pushed into the VM (decision 2132; the rationale for the exact seat is on
|
||
/// [`crate::ui_saved::load_saved_variables`]).
|
||
///
|
||
/// Callbacks rather than a split trio because the budget re-arm and the two fires are one edge,
|
||
/// and a caller that forgets a middle step should not be able to compile.
|
||
pub(crate) fn finish_ui_load_with(
|
||
script: &mut UiScript,
|
||
host_settings: impl FnOnce(&mut UiScript),
|
||
between: impl FnOnce(&mut UiScript),
|
||
) {
|
||
// Still the load edge, so still bounded (1306) — re-armed because the walk's last addon left
|
||
// an arbitrary amount on the counter, and the saved-variables chunk plus every PLAYER_LOGIN
|
||
// handler deserve the full allowance. The entry edge disarms after this returns.
|
||
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
|
||
crate::ui_saved::load_saved_variables(script, host_settings);
|
||
between(script);
|
||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||
}
|
||
|
||
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
|
||
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
|
||
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
|
||
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
|
||
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
|
||
/// absent-key face is faithful data suppression — but only when the file actually loaded).
|
||
fn load_global_strings(world: &mut World, script: &UiScript) {
|
||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
|
||
return;
|
||
};
|
||
let bytes = {
|
||
let mut chain = assets.chain.lock_recover();
|
||
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||
};
|
||
let src = match bytes {
|
||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||
Err(e) => {
|
||
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
|
||
return;
|
||
}
|
||
};
|
||
if let Err(e) = script.run(&src) {
|
||
error!("ui_script: GlobalStrings.lua failed to run: {e}");
|
||
return;
|
||
}
|
||
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
|
||
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
|
||
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
|
||
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
|
||
match sentinel {
|
||
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
|
||
other => {
|
||
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
|
||
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
|
||
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
|
||
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
|
||
/// take that file's **data** and none of its code — only whole lines matching
|
||
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
|
||
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
|
||
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
|
||
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
|
||
fn load_emote_tokens(world: &mut World, script: &UiScript) {
|
||
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
|
||
return; // already WARNed by load_global_strings
|
||
};
|
||
let bytes = {
|
||
let mut chain = assets.chain.lock_recover();
|
||
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
|
||
};
|
||
let src = match bytes {
|
||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||
Err(e) => {
|
||
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
|
||
return;
|
||
}
|
||
};
|
||
let table: Vec<&str> = src
|
||
.lines()
|
||
.map(str::trim)
|
||
.filter(|l| is_emote_token_line(l))
|
||
.collect();
|
||
let count = table.len();
|
||
if let Err(e) = script.run(&table.join("\n")) {
|
||
error!("ui_script: emote token table failed to run: {e}");
|
||
return;
|
||
}
|
||
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
|
||
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
|
||
match sentinel.as_deref() {
|
||
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
|
||
other => error!(
|
||
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
|
||
emote slash commands are broken"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
|
||
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
|
||
/// lines equivalent to reading data (no calls, no expressions, no side effects).
|
||
pub(crate) fn is_emote_token_line(line: &str) -> bool {
|
||
let Some(rest) = line.strip_prefix("EMOTE") else {
|
||
return false;
|
||
};
|
||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
|
||
return false;
|
||
};
|
||
let Some(name) = rest.strip_suffix("\";") else {
|
||
return false;
|
||
};
|
||
digits > 0
|
||
&& !name.is_empty()
|
||
&& name
|
||
.bytes()
|
||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The entry edge parks the boot VM (1978): between the arm and the load there is no VM in
|
||
/// the world for any feed to push into, and the session end puts a never-loaded one back.
|
||
#[test]
|
||
fn the_entry_edge_parks_the_boot_vm_and_the_session_end_returns_it() {
|
||
let mut world = World::new();
|
||
let boot = UiScript::new().unwrap();
|
||
let session = boot.session();
|
||
world.insert_non_send_resource(boot);
|
||
arm_entry_ui_load(&mut world);
|
||
assert!(
|
||
world.get_non_send_resource::<UiScript>().is_none(),
|
||
"no VM in the deferral window"
|
||
);
|
||
assert!(world.get_resource::<PendingEntryUiLoad>().is_some());
|
||
assert!(world.get_non_send_resource::<ParkedBootVm>().is_some());
|
||
// Left the world before the load ran: the VM comes back, untouched.
|
||
unpark_boot_vm(&mut world);
|
||
let vm = world
|
||
.get_non_send_resource::<UiScript>()
|
||
.expect("the parked VM is back");
|
||
assert_eq!(vm.session(), session, "the same VM, no session moved");
|
||
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
|
||
}
|
||
|
||
/// **A rebuilt VM inherits the running `GetTime()` clock, and so does the conversion pair**
|
||
/// (decision 2116) — the bug the director reported as *cooldowns are lost on relog*.
|
||
///
|
||
/// The reference's `GetTime` is `KERNEL32!GetTickCount` × 0.001 (`0x515ea0` → `0x42c010` →
|
||
/// `0x42b790`), an OS clock that cannot restart — which is why stock `Cooldown.lua` can gate
|
||
/// on `start > 0`. Ours lives in the VM, and since 1290/1291 the VM dies at the character
|
||
/// screen; before this, `end_ui_session` zeroed [`UiClock`] too, so on the way back in every
|
||
/// cooldown that was already running converted to a NEGATIVE start,
|
||
/// `CooldownFrame_SetTimer` took its `else` branch and hid the sweep — while the store, and
|
||
/// therefore the cast validator, still held the cooldown.
|
||
///
|
||
/// Against the old shape the two assertions read *"the new VM's GetTime clock restarted at 0
|
||
/// instead of the process's 90 s"* and *"a cooldown armed before the rebuild derived start
|
||
/// -30000 ms; stock Cooldown.lua hides anything but `start > 0`"*.
|
||
#[test]
|
||
fn a_rebuilt_vm_inherits_the_running_gettime_clock() {
|
||
use std::time::{Duration, Instant};
|
||
|
||
let mut world = World::new();
|
||
world.init_resource::<UiClock>();
|
||
// The process has been up 90 s — the clock `Time<Real>` keeps and the one the dying VM
|
||
// is on, which are the same clock.
|
||
let startup = Instant::now() - Duration::from_secs(90);
|
||
let mut time = Time::<bevy::time::Real>::new(startup);
|
||
// `Time<Real>` measures `elapsed` from its FIRST update, not from `startup` — so the app's
|
||
// own clock is "since frame one", and the test's has to be seeded the same way.
|
||
time.update_with_instant(startup);
|
||
time.update_with_instant(startup + Duration::from_secs(90));
|
||
world.insert_resource(time);
|
||
let mut dying = UiScript::new().unwrap();
|
||
dying.set_now(90.0);
|
||
world.insert_non_send_resource(dying);
|
||
// The session never loaded an in-game UI, so the shutdown tail (which writes the player's
|
||
// four files) is skipped: this test is about the VM swap and nothing else.
|
||
world.init_resource::<PendingEntryUiLoad>();
|
||
|
||
end_ui_session(&mut world);
|
||
|
||
let vm = world
|
||
.get_non_send_resource::<UiScript>()
|
||
.expect("the session end installs a fresh boot VM");
|
||
assert_eq!(
|
||
vm.now(),
|
||
90.0,
|
||
"the new VM's GetTime clock restarted at {} instead of the process's 90 s",
|
||
vm.now()
|
||
);
|
||
// …and the pair every `Instant`→`GetTime` conversion runs through moved with it: a
|
||
// 10-minute cooldown armed 30 s before the rebuild still derives its real start.
|
||
let clock = world.resource::<UiClock>();
|
||
let armed = crate::cooldowns::CooldownInfo {
|
||
start: startup + Duration::from_secs(60),
|
||
remaining_ms: 570_000,
|
||
duration_ms: 600_000,
|
||
enabled: true,
|
||
};
|
||
let triple = armed
|
||
.ui_triple(clock.anchor, clock.ui_now)
|
||
.expect("a running cooldown pushes a triple");
|
||
assert_eq!(
|
||
triple,
|
||
(60_000, 600_000, true),
|
||
"a cooldown armed before the rebuild derived start {} ms; stock Cooldown.lua hides \
|
||
anything but `start > 0`",
|
||
triple.0
|
||
);
|
||
}
|
||
}
|