the addon arc: the AddOns folder opens

- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
+188 -3
View File
@@ -26,13 +26,18 @@ use crate::ui_unit::UnitFeed;
use benilla_assets::LockRecover;
use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file, and the load walk. `pub(crate)`
/// because the AddOns screens read the same folder without a VM (decision 1196).
pub(crate) mod addons;
mod content;
mod extract;
mod input;
mod manifest;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
#[cfg(test)]
// `load_default_ui` is no longer test-only: the addon harness (1188 phase 6) loads the whole
// shipped interface under each surveyed addon, because roughly half of what an addon calls is
// FrameXML's Lua rather than the engine's (decision 1190).
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::{load_font_registry, load_ingame_ui};
@@ -170,6 +175,12 @@ impl Default for UiClock {
/// mid-chunk on a missing `EditBox:SetTextColor` every single frame and nothing ever said so
/// (the /w caret bug). A chunk failure is always an app or engine defect; this is the mandatory
/// form for any run whose `Result` isn't otherwise consumed.
/// The FrameXML digest of the interface this process loads — see [`content::digest`]. Re-exported
/// so the corpus harness can stamp every report with the tree it measured.
pub(crate) fn framexml_digest() -> String {
content::digest()
}
pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
if let Err(e) = script.run(chunk) {
warn!("ui_script: chunk failed: {e}");
@@ -239,6 +250,7 @@ impl Plugin for UiScriptPlugin {
.init_resource::<CursorPayloadHeld>()
.init_resource::<UiClock>()
.init_resource::<IngameUiLoaded>()
.init_resource::<AddOnIdentity>()
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
// cast-fail display (0427) resolves its messages from.
@@ -249,6 +261,17 @@ impl Plugin for UiScriptPlugin {
OnEnter(crate::char_select::ClientState::InWorld),
load_ingame_ui_on_world_entry,
)
// The whole shutdown tail — events then writes, in the reference's order. See
// [`shutdown_ui_state`]; the two edges here are its five roots as far as our session
// has them.
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
shutdown_on_session_end,
)
.add_systems(Update, shutdown_on_exit)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
@@ -364,7 +387,20 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
let _ = load_ingame_ui(&script);
// 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 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);
let _ = load_ingame_ui(&mut script, identity.as_ref());
// 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
@@ -376,11 +412,128 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
// 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.
crate::ui_saved::load_saved_variables(&mut script);
finish_ui_load(&mut script);
world.insert_non_send_resource(script);
world.insert_resource(AddOnIdentity(identity));
world.resource_mut::<IngameUiLoaded>().0 = true;
}
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
///
/// Smoothed, not `1.0 / delta`, for the reason the reference smooths it too: 71 corpus addons put
/// this number on a panel, and a raw per-frame reciprocal reads as a flickering three-digit mess
/// that nobody can take a value off. The pole is a one-second time constant — fast enough that a
/// real stall is visible within a frame or two, slow enough to read.
///
/// `Time<Real>` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and
/// a virtual clock that clamps its delta would report a rate the machine is not achieving.
fn feed_framerate(
script: Option<NonSendMut<UiScript>>,
time: Res<Time<bevy::time::Real>>,
mut smoothed: Local<f64>,
) {
let Some(mut script) = script else {
return;
};
let dt = time.delta_secs_f64();
if dt <= 0.0 {
return; // the first frame, and any paused one — nothing to average in
}
let instant = 1.0 / dt;
// One-pole IIR with a 1 s time constant, frame-rate independent.
let alpha = (dt / 1.0).min(1.0);
*smoothed = if *smoothed <= 0.0 {
instant
} else {
*smoothed + alpha * (instant - *smoothed)
};
script.set_framerate(*smoothed);
}
/// 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` →
/// > destroy the Lua state
///
/// **`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.
///
/// **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_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.
fn shutdown_on_session_end(script: Option<NonSendMut<UiScript>>, id: Res<AddOnIdentity>) {
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
}
/// `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`.
fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
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", once, which is exactly the
/// [`IngameUiLoaded`] latch this sits behind. `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.
pub(crate) fn finish_ui_load(script: &mut UiScript) {
crate::ui_saved::load_saved_variables(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).
@@ -716,6 +869,30 @@ mod panel_tests;
#[cfg(test)]
mod merchant_tests;
#[cfg(test)]
mod money_frame_tests;
#[cfg(test)]
mod faux_scroll_tests;
/// The reference's shared widget kit (UIPanelTemplates.xml + OptionsFrameTemplates.xml), driven
/// through `CreateFrame`'s fourth argument the way an addon drives it — decision 1203's queue.
#[cfg(test)]
mod panel_template_tests;
/// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog,
/// none of which benilla itself calls: every test enters from Lua the way an addon does.
#[cfg(test)]
mod basic_controls_tests;
#[cfg(test)]
mod color_picker_tests;
/// `UIParent.xml`'s loose addon-facing helpers (`MouseIsOver` and kin) — the panel and ESC halves
/// of that file live in `panel_tests` / `escape_tests`.
#[cfg(test)]
mod uiparent_tests;
#[cfg(test)]
mod trainer_tests;
@@ -745,9 +922,17 @@ mod tooltip_anchor_tests;
#[cfg(test)]
mod tooltip_compare_tests;
/// `GameTooltipTemplate` as an ADDON sees it — the corpus's most-wanted template, driven through
/// `inherits=` and `CreateFrame` the way the 27 addons that name it do.
#[cfg(test)]
mod tooltip_template_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod addon_list_tests;
#[cfg(test)]
mod game_menu_tests;