Files
benilla-octo/crates/benilla-app/src/ui_script/addons.rs
T
SamandClaude Fable 5.1 3f3cf21afa a slider snaps to its step, a pushed button releases, and warnings show
- a slider with a value step snaps to it: a stepped drag fires once per row instead of once per pixel, a scrollbar cannot rest between two rows, and one wheel notch on the trainer list reaches the bottom (2133)
- setting a step on a live slider moves its value onto the new lattice and runs its handler, which an addon on the real client can count on (2143)
- a button pushed from script releases on the next mouse-up, so Tablet-2.0's rows stop staying pressed forever; a release off the button's rect un-presses it, and a locked state holds (2134)
- the mail body and the GM ticket box scroll as you type past their bottom, and the Use UI Scale checkbox reads a registered setting (2141)
- an engine warning, a call that was accepted and did nothing, reaches the /errors window in grey instead of one terminal line; a text row trimmed mid-character no longer kills the window's repaint (2135, 2138)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:52:20 +07:00

2613 lines
123 KiB
Rust

//! **Where interfaces come from** — the addon folder, and the walk that loads what is in it
//! (decision 1184, building 1178 step 2).
//!
//! 1178 step 1 made benilla's own interface load from a `.toc`. This is the other half of the
//! claim: the same parser, the same loader, and the same walk now run over folders that are not
//! ours. An [`Addon`] is a name, a parsed manifest, and a [`Source`] its files come from — and
//! benilla's own interface is simply the one whose source is the compiled-in tree.
//!
//! ## Where we look — ONE folder
//!
//! **`<benilla-config>/AddOns/<Name>/<Name>.toc`**, and nothing else (decision 1185). 1184 also
//! searched the WoW install's own `Interface/AddOns/` and merged the two; the director's call is
//! one root, changeable in future *by choice* but never both at once. Resolved through
//! [`crate::local_state`], the only module allowed to compute that path (0954), which is already
//! `None` under `$WOW_CAPTURE` — so a capture discovers nothing and a deterministic baseline
//! cannot depend on what somebody has installed (0008).
//!
//! What the install's folder actually holds, checked rather than assumed: twelve `Blizzard_*`
//! folders containing **only a `.pub` signature file** — their real code is inside the MPQs, which
//! we do not read for addons at all — plus whatever the machine's owner has put there. Nothing was
//! being gained by searching it.
//!
//! ## Two kinds of file in a manifest
//!
//! A `.toc` lists `.lua` **and** `.xml`, and they load differently: Lua is executed as a chunk in
//! the shared global state, FrameXML is parsed and materialized. See [`Addon::load_files`].
//!
//! ## The load order is the reference's, not a topological sort
//!
//! `AddOn_Load 0x51f240` is **recursive**, and wow-5875-re has it byte-verified
//! (`system/ui/ui.md`, the SavedVariables §5 quad): for each addon, in order —
//! **OptionalDeps first (failures ignored)** → **RequiredDeps (a failure ABORTS this addon's
//! load)** → **its own `.toc`-listed files, in listed order**. We match that shape, because a
//! pre-sorted flat list cannot express "a missing hard dependency drops exactly this addon and
//! nothing else".
//!
//! **Our own interface is not an addon for lifecycle purposes.** The same source records that
//! *FrameXML does not go through `AddOn_Load`, so it gets no `ADDON_LOADED`* — and `benilla.toc`
//! is excluded exactly as FrameXML is. Structurally, not by a filter: it loads through
//! [`super::manifest::load_ingame_ui`], while [`Walk`] only ever walks what [`discover`] found.
//! It shares the loader; it does not share the event.
//!
//! ## Per-addon `Bindings.xml`
//!
//! Loaded right after the `.toc` files and before the saved variables (`0x51f400`) — 1188 phase 4,
//! in [`Walk::load`]. Parsed by [`benilla_ui::bindings_xml`] and registered into the same
//! key-binding table the Key Bindings window edits, so an addon's binding is a row like any
//! other; the app's dispatch runs its Lua body ([`crate::bindings`]).
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use benilla_ui::toc::Toc;
use super::content;
/// The folder name under each root, and the reference's own spelling.
const ADDONS_DIR: &str = "AddOns";
/// The world-entry load's VM instruction bound, per addon (decision 1306) — and the addon
/// harness's survey bound, one number for both so the measurement stays shared. MEASURED, not
/// guessed (e463649e): across the 218-addon corpus, 214 addons execute under 1M VM instructions
/// and the heaviest legitimate one (Enchantrix) reaches 4M — this is ~50x that. An addon that
/// crosses it is not slow, it is not coming back; the raise fails that addon with a distinctive
/// message and the walk loads everyone else, where before 1306 the client sat frozen on the
/// loading screen with zero diagnostics (B271's class).
pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000;
/// Where one interface's files come from.
///
/// The three arms are the same question asked of three stores, and keeping them one enum is what
/// lets [`Addon::load`] be written once: our own interface, a third-party folder and the player's
/// own install differ in where the bytes live, not in how they load.
pub(super) enum Source {
/// benilla's own interface — the compiled-in tree, which a dev build shadows with `assets/ui`
/// on disk so editing FrameXML costs no recompile ([`content`], 1175).
Builtin,
/// **The AddOns root**, not this addon's own folder (decision 1186). Paths handed to
/// [`Addon::read`] are relative to it, so `Bagnon/src/main.xml` and the
/// `BagBrother/core/core.xml` it includes are both expressible — which is the point, since a
/// shared library addon exists to be reached from its dependents.
Dir(PathBuf),
/// **The player's own installed patch chain** — the reference FrameXML this client sources
/// rather than shipping a copy of (decision 1751, [`super::reference_ui`]). Paths are full
/// chain-internal paths (`Interface\FrameXML\ContainerFrame.xml`), so the prefix is empty and
/// the chain resolves `/` and `\` alike, case-insensitively.
Chain,
}
/// One loadable interface: a name, its parsed manifest, and where its files come from.
pub(super) struct Addon {
/// The addon's folder name — and, for the builtin, `"benilla"`. This is what
/// `GetAddOnInfo`/`IsAddOnLoaded` will key on (1188 phase 2) and what `ADDON_LOADED` carries.
pub(super) name: String,
/// The parsed `.toc`: the ordered file list plus every directive.
pub(super) toc: Toc,
source: Source,
}
impl Addon {
/// An interface from an explicit source — [`super::reference_ui::addon`]'s constructor, and
/// the only one that is not derived from a `.toc` on some store.
pub(super) fn new(name: String, toc: Toc, source: Source) -> Self {
Addon { name, toc, source }
}
/// benilla's own interface. Always present — it is in the binary (1175), so unlike a
/// discovered addon it cannot be missing.
pub(super) fn builtin() -> Self {
let toc = content::read(super::manifest::MANIFEST)
.map(|t| Toc::parse(&t))
.unwrap_or_else(|| {
error!(
"ui_script: {} is not in the shipped UI — no interface will load",
super::manifest::MANIFEST
);
Toc::default()
});
Addon {
name: "benilla".to_string(),
toc,
source: Source::Builtin,
}
}
/// The text of one file, by a path already resolved into the **source's** path space — the
/// AddOns root for a `Dir`, the flat shipped tree for the builtin.
///
/// The caller does the relative-path arithmetic ([`benilla_ui::loader::join_ref`]); this only
/// reads. That split is decision 1186's: only the loader knows the include tree, and only the
/// source knows what a resolved path is allowed to reach.
///
/// **The sandbox is the AddOns folder, not one addon's subfolder.** `..` that walks above the
/// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it — so an addon can
/// reach a sibling library addon (which is how `Bagnon` reaches `BagBrother`, and what 1184's
/// per-addon guard wrongly blocked) but cannot reach the machine.
///
/// The **builtin alone** keeps a basename fallback: its tree is flat, and a transcription that
/// writes a Blizzard-style directory path should still find the file. A `Dir` source must not
/// have it — a basename fallback silently rescues exactly the escaping path the guard just
/// refused.
/// Returns **bytes**, not text (decision 1193). A `.lua` chunk is handed to Lua as it sits on
/// disk, and only an XML/`.toc` parse decodes — because a `read_to_string` here did not make a
/// cp1252 locale file lose a glyph, it made the file *not exist*.
fn read(&self, req: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Builtin => {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm).to_string();
content::read(&norm)
.or_else(|| content::read(&base))
.map(String::into_bytes)
}
Source::Dir(root) => read_under(root, req),
Source::Chain => super::reference_ui::read(req),
}
}
/// This addon's own folder in its source's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`, and
/// `""` again for the chain, whose manifest entries are already full internal paths.
fn prefix(&self) -> &str {
match &self.source {
Source::Builtin | Source::Chain => "",
Source::Dir(_) => &self.name,
}
}
/// The name Lua gives a chunk from this source — **not cosmetic**, because addons parse it.
///
/// An addon's file is named the way the client names it, `Interface\AddOns\<Folder>\<File>`
/// ([`benilla_ui::script::addon_chunk_name`], whose header carries the FuBar `debugstack`
/// story). A **chain** file is named after its own chain path, `@Interface\FrameXML\…`, which
/// is both what the real client names it and what keeps FrameXML *out* of the `\AddOns\`
/// pattern those addons match against — FrameXML is not an addon.
///
/// The loader gives an inline `<Script>` and a `<Script file=>` exactly this shape already
/// (`loader::Loader::run`), so a chain document's Lua is named identically whether it arrives
/// as a manifest entry or through its own `<Script file=>`.
fn chunk_name(&self, file: &str) -> String {
match &self.source {
Source::Builtin | Source::Dir(_) => {
benilla_ui::script::addon_chunk_name(&self.name, file)
}
Source::Chain => format!("@{}", file.replace('/', "\\")),
}
}
/// Load this addon's `.toc`-listed files into the VM, in listed order. Returns per-file
/// errors, each tagged `"<Addon>/<file>: <error>"`.
///
/// Errors are logged as they happen *and* returned: the app ignores the value (it has already
/// been logged) while the tests assert it empty, which is the shape 1178 step 1 established
/// for the builtin and the reason a broken entry can no longer reach a run behind a log line.
fn load(&self, script: &UiScript) -> Vec<String> {
self.load_files(script, &self.toc.files)
}
/// [`Addon::load`] over an explicit slice — the builtin's two-phase boot split (1051) is the
/// only caller that needs less than the whole manifest.
///
/// **A manifest lists two kinds of file and they load differently** (decision 1185). `.lua` is
/// executed as a chunk in the shared global state; anything else is FrameXML — parsed, then
/// materialized. 1184 sent every entry through the XML parser, which was invisible only
/// because `benilla.toc` happens to list nothing but `.xml`: the reference's own `FrameXML.toc`
/// opens with `GlobalStrings.lua`, and a bare Lua file is not a document.
pub(super) fn load_files(&self, script: &UiScript, files: &[String]) -> Vec<String> {
let mut failures = Vec::new();
// The `<Include>` / `<Script file=>` seam. The loader hands us paths it has already
// resolved against the including file's directory (1186), so this only reads — and the
// sandbox lives in `read`, at the source, rather than being spread over call sites.
let provider = |req: &str| -> Option<Vec<u8>> { self.read(req) };
for file in files {
// A manifest entry is relative to the addon's own folder; `read` and the loader both
// work in the source's path space, so resolve once here and use it for both.
let path = benilla_ui::loader::join_ref(self.prefix(), file);
let Some(bytes) = self.read(&path) else {
let e = format!("{}/{file}: not found", self.name);
// Severity follows whose manifest lied. For the builtin that is us — a client
// bug, and the boot tests assert none. For a player's addon it is the package
// (the director's AtlasLoot copy lists `Bossnames\BossNames.xml`; no such folder
// ships in it), and the reference client silently skips a missing toc entry — so
// a broken addon must not read as a client ERROR, which is gate-fatal to
// `smoke.sh`'s zero-ERROR count (1450).
match self.source {
// Ours, and the chain list is ours too: a `benilla.toc` line naming a file the
// player's install does not hold is our manifest being wrong about the 1.12
// chain, not the player's copy being wrong. (No install at all is a different
// thing, warned once by `reference_ui::chain`.)
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
// Retained where the player can read it (1495). This is the single commonest way
// an addon "doesn't work" with nothing on screen — the director's own AtlasLoot
// copy lists `Bossnames\BossNames.xml` and ships no such folder.
script.report_load_failure(&e);
failures.push(e);
continue;
};
if is_lua(file) {
// The same execution `<Script file=>` gets (`loader::mod.rs`): one chunk, run in
// the one global state, in manifest order. The reference does exactly this —
// `AddOn_Load 0x51f240` hands each listed file to `0x6edb90` regardless of kind.
match script.run_chunk_named(&bytes, &self.chunk_name(file)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
error!("ui_script: {e}");
// A file-scope failure is a script error, and script errors reach the
// player (1305): queued for the Lua error handler — `_ERRORMESSAGE`'s
// red dialog once BasicControls has run — never only a terminal line.
script.report_script_error(&e);
failures.push(e);
}
}
continue;
}
let doc = match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(d) => d,
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
error!("ui_script: parsing {e}");
// Still log-only as far as the *dialog* goes — the reference answers an
// unparseable document with a FrameXML.log line and silence, and 1495 does not
// change that. What it changes is that the silence is no longer total.
script.report_load_failure(&e);
failures.push(e);
continue;
}
};
// The document's OWN directory is what its relative references resolve against — a
// manifest listing `src\main.xml` means `<Include file="templates.xml">` inside it is
// `<Addon>/src/templates.xml`, and `..\..\Lib\lib.xml` is a sibling addon (1186). The
// loader takes the path and does that itself, so it can also name the file a raise
// came from (1217).
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
for w in &report.warnings {
warn!("ui_script({}/{file}): {w}", self.name);
// …and retained where a player can read it (2135). The `warn!` above is the
// terminal's copy and it is gone the moment the line scrolls; this is the one
// that survives to `/errors`, and the prefix is why it is recorded here rather
// than inside the loader — only this caller knows which file the warning is from.
script.report_warning(&format!("{}/{file}: {w}", self.name));
}
for e in &report.errors {
error!("ui_script({}/{file}): {e}", self.name);
// Same contract as the Lua arm above: a `<Script file=>` chunk that raised or an
// OnLoad that errored is a script error the player gets to see (1305).
// `_ERRORMESSAGE`'s own IsVisible guard shows a burst's FIRST failure only,
// which is the reference's behaviour too. Document *parse* failures stay
// log-only, like the reference's FrameXML.log.
script.report_script_error(&format!("{}/{file}: {e}", self.name));
failures.push(format!("{}/{file}: {e}", self.name));
}
info!(
"ui_script: {}/{file} loaded ({} frames materialized)",
self.name, report.frames
);
}
failures
}
}
/// Is this manifest entry a Lua chunk rather than a FrameXML document?
///
/// By extension, case-insensitively, and `\` is a path separator like `/` — a `.toc` is written
/// for a Windows client and `Libs\LibStub\LibStub.lua` is the normal spelling.
fn is_lua(entry: &str) -> bool {
entry
.rsplit(['/', '\\'])
.next()
.unwrap_or(entry)
.rsplit_once('.')
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
}
/// `root/rel`, refusing to escape `root`.
///
/// `rel` has already been lexically resolved by [`benilla_ui::loader::join_ref`], so a path that
/// stays inside is free of `.`/`..` and one that escapes carries a **leading `..`** — which is
/// exactly what this rejects, along with anything absolute or non-UTF-8-normal. The check is
/// lexical and happens before any filesystem call, so a symlinked addon folder still works and a
/// traversal never opens a file at all.
///
/// `root` is the **AddOns folder** (1186), so a sibling addon is reachable and the machine is not.
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
let rel = Path::new(rel);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return None;
}
std::fs::read(root.join(rel)).ok()
}
/// **The** addon folder — `<benilla-config>/AddOns/` — or `None` when there is none to read.
///
/// **One root, never two** (decision 1185, the director's call). 1184 searched the WoW install's
/// `Interface/AddOns/` as well and merged the results. Two roots means an addon's identity depends
/// on which folder won, a name can be shadowed, and "where is this addon loaded from" stops having
/// one answer. If the install's folder ever becomes reachable it will be *instead of* this one and
/// by explicit choice, not alongside it.
///
/// It is ours for the same reason 1180 moved our state out of the install: benilla reads a WoW
/// install, it does not live in one. Resolved through [`crate::local_state`], the only module
/// allowed to compute that path (0954) — which is already `None` under `$WOW_CAPTURE`, so a
/// deterministic baseline cannot depend on what somebody has installed (0008).
///
/// `pub(crate)` since 1322: every asset an addon ships needs the same folder — the sprite
/// decoder's `Interface\AddOns\` loose-file resolve, the `SetTexture` probe, and (2103) the font
/// engine's face loader plus the `SetFont` probe all map that virtual prefix onto this root, so
/// there is exactly one answer to "where do addons live" however the question is asked. The first
/// three are wired in one place (`ui_script::lifecycle::install_addon_asset_resolvers`); the face
/// loader reads it here directly, at engine construction, because it is not a VM concern.
pub(crate) fn root() -> Option<PathBuf> {
root_from(crate::local_state::home())
}
/// [`root`] with its one environment fact passed in, so it is testable without touching the
/// process environment.
///
/// Not tidiness: `install::candidates_from` exists for exactly this reason (1175's own flake — a
/// test that *sets* an env var poisons every other test running concurrently in the same process,
/// and the failure then moves around with scheduling).
fn root_from(home: Option<PathBuf>) -> Option<PathBuf> {
home.map(|h| h.join(ADDONS_DIR))
}
/// Every third-party addon we can see, in load order: alphabetical by folder name.
///
/// Alphabetical is **our** choice, not a fidelity claim — the reference enumerates its own record
/// list and we have not established that order. What matters is that it is deterministic, so two
/// runs on one machine load the same interface. Real ordering constraints between addons are
/// expressed by `## Dependencies:` and honoured by [`load_third_party`]'s recursion, which is the
/// mechanism the reference actually uses.
/// The reference's own LoadOnDemand addons, as its `Interface\\AddOns\\` folder names them — the
/// twelve `Blizzard_*` folders on a 1.12 install, each a `.pub` decoy on disk with the real files
/// inside the archive (the InspectUI header in the manifest). Probed against the chain, so a
/// chain without one simply has no such addon.
const BLIZZARD_ADDONS: [&str; 12] = [
"Blizzard_AuctionUI",
"Blizzard_BattlefieldMinimap",
"Blizzard_BindingUI",
"Blizzard_CombatText",
"Blizzard_CraftUI",
"Blizzard_GMSurveyUI",
"Blizzard_InspectUI",
"Blizzard_MacroUI",
"Blizzard_RaidUI",
"Blizzard_TalentUI",
"Blizzard_TradeSkillUI",
"Blizzard_TrainerUI",
];
/// The Blizzard addons the chain carries as LoadOnDemand registry rows — the ones the reference
/// reaches through `LoadAddOn` from `UIParentLoadAddOn` (1957). `benilla.toc` lists none of them,
/// like the reference's own `FrameXML.toc` (1967). One exclusion, by shape: an addon whose toc is
/// not LoadOnDemand would need the startup walk to read the chain, which it does not yet — that
/// one stays with its own file until its window migrates.
fn chain_addons() -> Vec<Addon> {
BLIZZARD_ADDONS
.iter()
.filter_map(|name| {
let bytes = super::reference_ui::read(&format!("Interface/AddOns/{name}/{name}.toc"))?;
let toc = Toc::parse(&benilla_ui::source::decode(&bytes));
toc.load_on_demand().then(|| Addon {
name: (*name).to_string(),
toc,
source: Source::Chain,
})
})
.collect()
}
fn discover() -> Vec<Addon> {
let mut found = discover_folder();
found.extend(chain_addons());
found
}
/// The player's own addons — every folder under the AddOns root with a `<Name>.toc`.
fn discover_folder() -> Vec<Addon> {
let Some(root) = root() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&root) else {
return Vec::new(); // no addon folder is the normal case, not an error
};
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.collect();
names.sort();
names
.into_iter()
.filter_map(|name| {
let dir = root.join(&name);
// A folder with no `<Name>.toc` is not an addon. The reference ignores these too (a
// stray `Backup/` folder is the common case), so this is not a warning.
let text = manifest_in(&dir, &name)?;
Some(Addon {
name,
toc: Toc::parse(&text),
// The ROOT, not `dir` — every path this addon resolves is relative to the AddOns
// folder so a sibling library addon is reachable (1186).
source: Source::Dir(root.clone()),
})
})
.collect()
}
/// `<dir>/<name>.toc`, matched case-insensitively.
///
/// The reference is a Windows client on a case-insensitive filesystem, so a folder named `MyAddon`
/// containing `myaddon.toc` is an addon there and has to be one here — on Linux an exact-name
/// probe would silently not find it.
/// A `.toc` that is not valid UTF-8 is still a manifest (decision 1193): before it was decoded
/// rather than `read_to_string`'d, five addons in a 218-addon corpus were **invisible to
/// discovery** — not broken, not reported, simply not addons — because a German `## Notes:` line
/// held one cp1252 byte.
fn manifest_in(dir: &Path, name: &str) -> Option<String> {
let want = format!("{name}.toc");
let entry = std::fs::read_dir(dir).ok()?.flatten().find(|e| {
e.file_name()
.to_str()
.is_some_and(|f| f.eq_ignore_ascii_case(&want))
})?;
let bytes = std::fs::read(entry.path()).ok()?;
Some(benilla_ui::source::decode(&bytes).into_owned())
}
/// Build the AddOn API's registry row for one discovered addon — every `.toc` directive the
/// eleven verbs can be asked about, read once here rather than re-parsed per call.
fn info_for(addon: &Addon) -> benilla_ui::script::AddOnInfo {
let mut info = info_from_toc(&addon.name, &addon.toc);
info.chain = matches!(addon.source, Source::Chain);
info
}
/// [`info_for`]'s body, over the two things it actually reads.
///
/// Split out because `addon_harness` needs the same row and must not build its own: a survey that
/// seats a DIFFERENT registry shape from production is measuring a client nobody runs, which is the
/// fault 1193 was written to fix. One converter, two callers — the harness's own `Toc` goes through
/// this, so a directive added here reaches the survey for free.
pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnInfo {
benilla_ui::script::AddOnInfo {
name: name.to_owned(),
title: toc.directive("Title").map(str::to_owned),
notes: toc.directive("Notes").map(str::to_owned),
url: toc.directive("URL").map(str::to_owned),
// `## Secure: 1` is Blizzard's own marker; nothing a player installs carries it honestly,
// and we do not treat it as granting anything — it only picks the glue's icon.
secure: toc.directive("Secure").map(str::trim) == Some("1"),
load_on_demand: toc.load_on_demand(),
dependencies: toc.dependencies().into_iter().map(str::to_owned).collect(),
directives: toc.directives.clone(),
files: toc.files.clone(),
saved_variables: toc
.list("SavedVariables")
.into_iter()
.map(str::to_owned)
.collect(),
saved_variables_per_character: toc
.list("SavedVariablesPerCharacter")
.into_iter()
.map(str::to_owned)
.collect(),
enabled: true, // an addon nobody has disabled is enabled; the file below overrides
saved_enabled: true, // re-stamped from `enabled` at registration — see `register_addons`
loaded: false,
// The version gate's dword (decision 1292) — the client's own parse: leading integer,
// 0 when the manifest is silent (and 0 is out of date, not "unknown").
interface: toc.interface_version(),
chain: false, // `info_for` stamps the chain rows; the harness's rows are folder addons
}
}
/// Parse the enable-state file — one `<AddOnName>: enabled|disabled` per line, the reference's own
/// `AddOns.txt` format (confirmed against a real 1.12 install, not remembered).
///
/// Unknown names are ignored rather than dropped-and-rewritten: a file listing an addon that is
/// not installed right now belongs to an addon that will be again, and silently forgetting the
/// player's choice on every uninstall is the behaviour nobody wants. Only `disabled` disables — a
/// malformed line leaves the addon enabled, which is the safe direction.
fn parse_enable_state(text: &str) -> Vec<(String, bool)> {
text.lines()
.filter_map(|line| {
let (name, state) = line.split_once(':')?;
let name = name.trim();
(!name.is_empty()).then(|| {
(
name.to_string(),
!state.trim().eq_ignore_ascii_case("disabled"),
)
})
})
.collect()
}
/// Render the enable state back out in the reference's format.
fn render_enable_state(states: &[(String, bool)]) -> String {
let mut out = String::new();
for (name, enabled) in states {
out.push_str(name);
out.push_str(if *enabled {
": enabled\n"
} else {
": disabled\n"
});
}
out
}
/// Where this character's enable state lives, or `None` with no identity yet / no install.
pub(crate) fn enable_state_path(identity: Option<&(String, String)>) -> Option<PathBuf> {
let (realm, character) = identity?;
crate::local_state::addons_state_path(realm, character)
}
/// One installed addon as the **AddOns screens** need it — no VM, no load (decision 1197).
///
/// The glue screen runs before any UI VM has addons in it (`load_third_party` is a world-entry
/// step), so the AddOns list cannot ask `GetAddOnInfo`. It asks the same *folder* instead, through
/// the same discovery and the same manifest parser, so the two views cannot disagree about what is
/// installed. Same shape as the reference's `GetAddOnInfo` return, minus what only a live session
/// knows (`loaded`).
#[derive(Clone, Debug)]
pub(crate) struct InstalledAddOn {
pub(crate) name: String,
pub(crate) title: Option<String>,
pub(crate) notes: Option<String>,
pub(crate) url: Option<String>,
pub(crate) dependencies: Vec<String>,
/// `## Interface` as the version gate reads it (decision 1292): the leading integer, `0`
/// when absent — and now ENFORCED by the load walk when `checkAddonVersion` is on,
/// superseding 1191 §6's report-only interim (the RE answer it was waiting for landed:
/// wow-re `addon-version-gate.md`).
pub(crate) interface: u32,
/// `## LoadOnDemand: 1` — shown as a status hint rather than a checkbox state, because a
/// LoadOnDemand addon is not "off", it is waiting for a `LoadAddOn` call (1191 §6).
/// Read by the char-select AddOns screen's rows (`char_select::addons`).
pub(crate) load_on_demand: bool,
/// From this character's enable file; an addon the file never mentions is **enabled** (1191 §7).
pub(crate) enabled: bool,
}
impl InstalledAddOn {
/// The list's display name — `## Title` when it has one, else the folder name. The reference's
/// own `if (title) … else name` (`AddonList_Update`).
///
/// (An `out_of_date()` convenience lived here too until 1293 moved the glue screen onto the
/// gate — the version compare has exactly one home now, `addon_gate::can_load`'s check 6,
/// and a screen-side copy is the per-asker drift 1292 §Rejected names.)
pub(crate) fn display_title(&self) -> &str {
self.title.as_deref().unwrap_or(&self.name)
}
}
/// Every installed addon, in load order, with `character`'s enable state applied.
///
/// The AddOns screens' whole data source. `identity` is `(realm, character)`; `None` reads the
/// folder with nobody's enable file, which is the "no character picked yet" case and shows
/// everything as enabled.
pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAddOn> {
let disabled = disabled_set(identity);
// The player's own folder only: the glue's list is what a player can toggle, and the
// reference's Blizzard addons are not in it.
discover_folder()
.into_iter()
.map(|addon| InstalledAddOn {
enabled: !disabled.contains(&addon.name.to_ascii_lowercase()),
title: addon.toc.directive("Title").map(str::to_owned),
notes: addon.toc.directive("Notes").map(str::to_owned),
url: addon.toc.directive("URL").map(str::to_owned),
dependencies: addon
.toc
.dependencies()
.into_iter()
.map(str::to_owned)
.collect(),
interface: addon.toc.interface_version(),
load_on_demand: addon.toc.load_on_demand(),
name: addon.name,
})
.collect()
}
/// The lowercased names this character has turned off.
fn disabled_set(identity: Option<&(String, String)>) -> HashSet<String> {
enable_state_path(identity)
.and_then(|p| std::fs::read(p).ok())
.map(|b| {
parse_enable_state(&benilla_ui::source::decode(&b))
.into_iter()
.filter(|(_, on)| !on)
.map(|(n, _)| n.to_ascii_lowercase())
.collect()
})
.unwrap_or_default()
}
/// Write a character's enable state — the AddOns screen's write (decision 1197) and, since 2139,
/// the in-world logout write too.
///
/// **Merges rather than replaces, because that is what the reference's writer structurally IS.**
/// `0x51ef20` walks the in-memory enable **hash** and emits one `"%s: %s\r\n"` line per entry
/// (`0x853968`), and that hash is built by the reader `0x51ebe0` from `AddOns.txt` itself — which
/// "creates entries for every line, both states", strdup'ing each name as written. So a row for an
/// addon that is not installed right now was loaded, is never removed, and is written straight back
/// out; and the spelling that survives is the FILE's, not any folder's, because the folder list
/// never enters the hash at all (wow-5875-re `system/ui/scratch/addon-enable-store.md` §5/§6).
///
/// Both halves matter and both were divergent on the logout path, which used to rebuild the file
/// from `addon_enable_states()` — the installed-folder registry. One logout erased
/// `Uninstalled: disabled` outright and rewrote `myaddon:` as `MyAddon:`. The uninstall case is the
/// one with teeth: a player who removes a folder for a week loses the choice they made about it.
pub(crate) fn write_enable_state(identity: Option<&(String, String)>, states: &[(String, bool)]) {
let Some(path) = enable_state_path(identity) else {
return; // no character picked, or no state folder — nothing to write to
};
let mut merged: Vec<(String, bool)> = std::fs::read(&path)
.ok()
.map(|b| parse_enable_state(&benilla_ui::source::decode(&b)))
.unwrap_or_default();
for (name, on) in states {
match merged
.iter_mut()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
{
Some(row) => row.1 = *on,
None => merged.push((name.clone(), *on)),
}
}
match crate::local_state::write_atomic(&path, &render_enable_state(&merged)) {
Ok(()) => info!("addons: wrote {} ({} rows)", path.display(), merged.len()),
Err(e) => warn!("addons: cannot write {}: {e}", path.display()),
}
}
/// Write the enable state back — the reference's own last shutdown step (`0x490c88`, after the
/// saved-variables files), so a `DisableAddOn` from Lua survives the session.
///
/// **The same writer the AddOns screen uses** (2139). This used to render
/// `addon_enable_states()` — the installed-folder registry — over the whole file, which is not the
/// shape of `0x51ef20`: the reference emits the enable *hash*, and that hash is the file's own
/// contents plus this session's toggles. Two things followed from the difference, both reproduced
/// before the change: a row for an addon not installed this session was dropped, and every name
/// was rewritten into its folder's case rather than the spelling the file already had.
pub(super) fn save_enable_state(script: &UiScript, identity: Option<&(String, String)>) {
let states = script.addon_enable_states();
if states.is_empty() {
return; // nothing was ever registered — a glue-only run or a capture; nothing to merge in
}
write_enable_state(identity, &states);
}
/// Write every loaded addon's declared saved variables — the reference's own shutdown step
/// (`0x490c83`, right after the flat file and just before `AddOns.txt`).
///
/// **There is no autosave and no dirty bit**, deliberately: the reference has neither (`ds:0xb4b3f4`
/// has three references image-wide, and the write gate is the record's *loaded* byte). An addon
/// that never loaded this session has no globals to write, and writing it would blank a file it
/// never read.
///
/// An addon declaring nothing gets no file. The reference *deletes* one in that case; we simply do
/// not write, because our folder is a place a player looks and a stale file there is confusing
/// either way — recorded rather than silently different.
pub(super) fn save_addon_variables(script: &mut UiScript, identity: Option<&(String, String)>) {
let account = crate::local_state::addon_saved_account_dir();
let character = identity.and_then(|(r, c)| crate::local_state::addon_saved_character_dir(r, c));
for (name, account_names, character_names) in script.addon_saved_variable_sets() {
for (dir, names) in [(&account, &account_names), (&character, &character_names)] {
if names.is_empty() {
continue;
}
let Some(dir) = dir else { continue };
let body = script.saved_variables_text_for(names);
let path = dir.join(format!("{name}.lua"));
match crate::local_state::write_atomic(&path, &format!("{SAVED_HEADER}{body}")) {
Ok(()) => info!("ui_script: wrote {}", path.display()),
Err(e) => warn!("ui_script: cannot write {}: {e}", path.display()),
}
}
}
for w in script.take_warnings() {
warn!("ui_script: saved variables: {w}");
}
}
/// The per-addon file's header. The reference writes none (its files open with a bare blank line
/// from the `"\r\n"`-per-variable prefix); ours says what the file is, because a visible folder
/// invites a look — the same call [`crate::ui_saved`] made for the flat file.
const SAVED_HEADER: &str = "\
-- benilla per-addon saved variables (decision 1188 phase 3).
-- Written at logout/exit from the live globals; executed as a Lua chunk at addon load.
";
/// Load every discovered third-party addon, honouring `## Dependencies:` the way the reference
/// does. Returns every load error, tagged by addon.
///
/// Called at world entry, after the built-in interface's own files — the reference loads addons
/// from `UI_Init 0x48fbf0` → `0x51f600`, which is the same seam our in-game UI materializes at
/// (1051).
///
/// **`&mut` is what lets `ADDON_LOADED` fire in the right place.** [`UiScript::fire_event`] needs
/// it, and the alternative — collecting names and firing them all after the walk — is a different
/// behaviour, not a shortcut: the reference fires each addon's event before the *next* addon's
/// files run, so a deferred batch would let B's file-scope code run ahead of A's handler.
pub(super) fn load_third_party(
script: &mut UiScript,
identity: Option<&(String, String)>,
version_check: bool,
) -> Vec<String> {
let addons = discover();
// Register the AddOn API's view even when the list is empty: `GetNumAddOns()` must answer 0
// rather than answer whatever a previous session left, and an addon that asks before any
// exist is asking a real question.
let mut infos: Vec<_> = addons.iter().map(info_for).collect();
// The chain-sourced rows read through the reference's own store (1957).
script.set_addon_chain_reader(Box::new(super::reference_ui::read));
if let Some(text) = enable_state_path(identity).and_then(|p| std::fs::read_to_string(p).ok()) {
for (name, enabled) in parse_enable_state(&text) {
if let Some(i) = infos
.iter_mut()
.find(|i| i.name.eq_ignore_ascii_case(&name))
{
i.enabled = enabled;
}
}
}
let disabled_names: HashSet<String> = infos
.iter()
.filter(|i| !i.enabled)
.map(|i| i.name.to_ascii_lowercase())
.collect();
script.register_addons(
infos,
root(),
crate::local_state::addon_saved_account_dir(),
identity.and_then(|(r, c)| crate::local_state::addon_saved_character_dir(r, c)),
);
if addons.is_empty() {
return Vec::new();
}
info!(
"ui_script: {} addon(s) found: {}",
addons.len(),
addons
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
let mut state = Walk {
disabled: disabled_names,
version_check,
..Walk::default()
};
for addon in &addons {
// `## LoadOnDemand: 1` means "not at startup" — the reference's `0x51f600` loads only
// records whose LoadOnDemand byte is 0. Without `LoadAddOn()` (1178 step 4) that means
// never, which is why it is said out loud rather than silently skipped.
if addon.toc.load_on_demand() {
info!(
"ui_script: {} is LoadOnDemand — not loaded (no LoadAddOn() yet)",
addon.name
);
continue;
}
// Re-arm the load bound PER ADDON (decision 1306): the budget is a per-addon fact (the
// corpus was measured per addon), and without the reset one runaway would spend the
// whole allowance and fail every addon after it for somebody else's loop. A dependency
// chain loads under its dependent's arming, which is the same accounting the harness's
// per-survey arming gives it.
script.set_instruction_budget(LOAD_INSTRUCTION_BUDGET);
let _ = state.load(script, &addons, &addon.name);
let spent = script.instructions_used();
if spent > 1_000_000 {
// Heavy is worth a line: this counter is how the budget was chosen, and a report
// like B271's needs exactly this number without a harness run.
info!(
"ui_script: {} spent {spent} VM instructions loading",
addon.name
);
}
}
state.failures
}
/// The recursive load's bookkeeping — see [`Walk::load`].
#[derive(Default)]
struct Walk {
loaded: HashSet<String>,
/// Addons whose load already failed, so a second dependent gets the same answer without
/// re-running (and without re-reporting) the failure.
failed: HashSet<String>,
/// The current dependency chain, for cycle detection and for naming the cycle when it happens.
loading: Vec<String>,
failures: Vec<String>,
/// Addons the player has turned off, lowercased. **Passed in rather than read back out of the
/// VM**: the walk's inputs stay explicit, so recursion cannot depend on registration having
/// happened first, and an empty set means "everything enabled" — which is exactly what an
/// absent enable-state file means too.
disabled: HashSet<String>,
/// The `checkAddonVersion` gate (decision 1292), resolved by the caller from the persisted
/// CVar — passed in for the same explicitness reason as `disabled`. When on, an addon whose
/// `## Interface` is not exactly the client's is skipped like a disabled one (the
/// reference's `AddOn_CanLoad` check 6, before the dependency loop), and a dependent gets
/// `Err` — its `DEP_INTERFACE_VERSION`.
version_check: bool,
}
impl Walk {
/// Load one addon by name, its dependencies first. `Err` means "this addon did not load", and
/// is what a hard dependency's failure propagates.
///
/// The order inside is `AddOn_Load 0x51f240`'s, byte-verified in wow-5875-re (`system/ui/ui.md`):
///
/// > OptionalDeps (failures ignored) → RequiredDeps (a failure aborts) → **this addon's own
/// > `.toc`-listed files, in listed order** (`0x51f3fa`) → `Bindings.xml` (`0x51f400`) → the
/// > account SavedVariables file (`0x51f4b5`) → the per-character file (`0x51f53b`) →
/// > **`ADDON_LOADED` (event 429) at `0x51f5ad`** → the reverse-`LoadWith` dependents.
///
/// All of it is built (`Bindings.xml` is 1188 phase 4, the two saved files phase 3), and the
/// two middle steps are why the event is fired at the very end rather than beside the load.
/// That position is the mechanism, not a detail: a saved value overwrites the addon's own
/// file-scope default, and `ADDON_LOADED` handlers are specified to see the *restored* value.
fn load(&mut self, script: &mut UiScript, all: &[Addon], name: &str) -> Result<(), ()> {
// Resolve to the addon's OWN spelling before anything is keyed on it. A `.toc` may name a
// dependency in any case (`## Dependencies: probeaddon` against a folder `ProbeAddon`),
// and lookup here is case-insensitive — so keying the sets on the caller's spelling would
// let two differently-cased dependents each miss the "already loaded" check and load the
// same addon twice, running every one of its files a second time.
let Some(addon) = all.iter().find(|a| a.name.eq_ignore_ascii_case(name)) else {
return Err(()); // not installed — the caller decides whether that is fatal
};
let key = addon.name.as_str();
if self.loaded.contains(key) {
return Ok(());
}
if self.failed.contains(key) {
return Err(());
}
// **Disabled is a player's choice, not a failure** — it is skipped silently and nothing is
// pushed onto `failures`. A dependent still gets `Err`, which is the reference's own
// `DEP_DISABLED`: an addon whose hard dependency the player turned off cannot load either.
if self.disabled.contains(&key.to_ascii_lowercase()) {
info!("ui_script: {key} is disabled — not loaded");
self.failed.insert(addon.name.clone());
return Err(());
}
// **The version gate** (decision 1292; `AddOn_CanLoad` check 6, in the checks' own order —
// after the enable state, before the dependency loop). Exact `==` against the client's
// build; a missing `## Interface` parses as 0 and is out of date. Not a `failures` entry
// for the same reason disabled is not: the state is the player's to see on the AddOns
// screens (`ADDON_INTERFACE_VERSION`), and the *Load out of date AddOns* checkbox — the
// `checkAddonVersion` CVar this flag carries — is the reference's own escape.
if self.version_check
&& addon.toc.interface_version() != benilla_ui::script::addon_gate::CLIENT_INTERFACE
{
info!(
"ui_script: {key} is out of date (## Interface: {}, client {}) — not loaded \
(the AddOns screen's 'Load out of date AddOns' loads it anyway)",
addon.toc.interface_version(),
benilla_ui::script::addon_gate::CLIENT_INTERFACE
);
self.failed.insert(addon.name.clone());
return Err(());
}
if self.loading.iter().any(|n| n == key) {
let chain = self.loading.join(" → ");
let e = format!("{key}: dependency cycle ({chain} → {key})");
error!("ui_script: {e}");
script.report_load_failure(&e);
self.failures.push(e);
self.failed.insert(addon.name.clone());
return Err(());
}
self.loading.push(addon.name.clone());
// Optional dependencies first, and a failure is genuinely ignored — that is what makes
// them optional. It still orders the load when the dependency IS present.
for dep in addon.toc.optional_dependencies() {
let _ = self.load(script, all, dep);
}
// Hard dependencies. A failure aborts THIS addon and nothing else.
let mut blocked = None;
for dep in addon.toc.dependencies() {
if self.load(script, all, dep).is_err() {
blocked = Some(dep.to_string());
break;
}
}
self.loading.pop();
if let Some(dep) = blocked {
let e = format!(
"{}: required dependency {dep} is missing or failed",
addon.name
);
error!("ui_script: {e}");
// The one failure a player can usually FIX themselves — install the dependency — and
// until 1495 the only place it was said was the terminal.
script.report_load_failure(&e);
self.failures.push(e);
self.failed.insert(addon.name.clone());
return Err(());
}
self.failures.extend(addon.load(script));
// ── `Bindings.xml` (1188 phase 4) ── the verified position `0x51f400`: after this addon's
// own files (whose functions a binding body calls), before its saved variables. Read
// through the addon's own reader, so the AddOns-root sandbox (1186) covers it like every
// other file it loads; absent is the normal case and silent.
let bindings_xml = benilla_ui::loader::join_ref(addon.prefix(), "Bindings.xml");
if let Some(bytes) = addon.read(&bindings_xml) {
match benilla_ui::bindings_xml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(bindings) => script.register_addon_bindings(&addon.name, &bindings),
Err(e) => {
let e = format!("{}/Bindings.xml: {e}", addon.name);
error!("ui_script: {e}");
script.report_load_failure(&e);
self.failures.push(e);
}
}
}
// The two saved-variables files, account then per-character — the verified position
// (`0x51f4b5`, `0x51f53b`): after the addon's own files assigned their defaults, before
// `ADDON_LOADED` whose handlers must see the restored value.
script.load_addon_saved_variables(&addon.name);
self.loaded.insert(addon.name.clone());
script.mark_addon_loaded(&addon.name);
// `ADDON_LOADED`, `arg1` = the addon's own folder name — the spelling the addon knows
// itself by, not the caller's (a dependent may name it in any case). Marked loaded first,
// because a handler is free to ask `IsAddOnLoaded` about itself and the honest answer is
// yes: the reference sets `[rec+0x18]` before `0x51f5ad`.
//
// **The builtin cannot reach this**, which is the rule rather than a filter: FrameXML does
// not go through `AddOn_Load` and gets no `ADDON_LOADED` (same source), and our
// `benilla.toc` is loaded by `manifest::load_ingame_ui` while this walk only ever sees
// what `discover()` found under the AddOns root.
script.fire_event("ADDON_LOADED", vec![ScriptValue::Str(addon.name.clone())]);
Ok(())
}
}
#[cfg(test)]
mod tests {
/// The chain's Blizzard LoadOnDemand addons are registry rows (1957): every row is
/// LoadOnDemand and read off the chain, the eight windows the interface opens through them
/// are among the rows, and the manifest lists no addon at all — the reference's own
/// `FrameXML.toc` has none, every Blizzard addon loads on demand (1967).
#[test]
fn the_chain_carries_blizzards_load_on_demand_addons_as_registry_rows() {
let _data = benilla_formats::wow_data_or_skip!();
let rows = chain_addons();
assert!(
rows.iter().any(|a| a.name == "Blizzard_TrainerUI"),
"the trainer addon is a row: {:?}",
rows.iter().map(|a| &a.name).collect::<Vec<_>>()
);
for a in &rows {
assert!(a.toc.load_on_demand(), "{} is LoadOnDemand", a.name);
assert!(
matches!(a.source, Source::Chain),
"{} comes off the chain",
a.name
);
assert!(
info_for(a).chain,
"{}'s registry row is marked chain-sourced",
a.name
);
}
for name in [
"Blizzard_AuctionUI",
"Blizzard_CraftUI",
"Blizzard_InspectUI",
"Blizzard_MacroUI",
"Blizzard_RaidUI",
"Blizzard_TalentUI",
"Blizzard_TradeSkillUI",
"Blizzard_TrainerUI",
] {
assert!(
rows.iter().any(|a| a.name == name),
"{name} is a row the interface opens on demand"
);
}
assert!(
!Addon::builtin()
.toc
.files
.iter()
.any(|f| f.replace('/', "\\").starts_with("Interface\\AddOns\\")),
"benilla.toc lists a Blizzard addon eagerly; the reference loads every one on demand"
);
// The glue's list is the player's folder alone.
assert!(installed(None)
.iter()
.all(|a| !a.name.starts_with("Blizzard_")));
}
use super::*;
/// Build an addon over a temp AddOns root, so the walk can be tested without an install.
fn dir_addon(root: &Path, name: &str, toc: &str) -> Addon {
let folder = root.join(name);
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join(format!("{name}.toc")), toc).unwrap();
Addon {
name: name.to_string(),
toc: Toc::parse(toc),
source: Source::Dir(root.to_path_buf()),
}
}
/// **The sandbox is the AddOns folder** (decision 1186): a sibling addon is reachable, the
/// machine is not.
///
/// 1184 drew the line at each addon's own folder, which reads as the safer choice and breaks
/// the single most common structure in the ecosystem — a shared library addon exists precisely
/// to be included by its dependents (`Bagnon/src/main.xml` reaches
/// `..\..\BagBrother\core\core.xml`, and BagBrother's own `.toc` lists no files at all).
///
/// The escape cases go through [`benilla_ui::loader::join_ref`] first, exactly as the loader
/// feeds them, because that is what turns an escape into the leading `..` the guard sees.
#[test]
fn the_sandbox_is_the_addons_root_not_one_addon() {
use benilla_ui::loader::join_ref;
let tmp =
std::env::temp_dir().join(format!("benilla-addon-escape-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("Probe/src")).unwrap();
std::fs::create_dir_all(root.join("ProbeLib/core")).unwrap();
std::fs::write(tmp.join("secret.txt"), "no").unwrap();
std::fs::write(root.join("Probe/src/own.txt"), "yes").unwrap();
std::fs::write(root.join("ProbeLib/core/lib.xml"), "sibling").unwrap();
let addon = Addon {
name: "Probe".into(),
toc: Toc::default(),
source: Source::Dir(root),
};
// Its own file, and a sibling library addon reached the way a real addon reaches one.
assert_eq!(
addon.read(&join_ref("Probe/src", "own.txt")).as_deref(),
Some(&b"yes"[..])
);
assert_eq!(
addon
.read(&join_ref("Probe/src", "..\\..\\ProbeLib\\core\\lib.xml"))
.as_deref(),
Some(&b"sibling"[..]),
"a shared library addon must be reachable — this is what 1184 wrongly blocked"
);
// Above the AddOns root is refused: the escape survives `join_ref` as a leading `..` and
// `read_under` will not join it.
assert!(addon
.read(&join_ref("Probe/src", "../../../secret.txt"))
.is_none());
assert!(addon.read(&join_ref("Probe", "..\\secret.txt")).is_none());
// A leading `/` is not "the filesystem root" — it re-roots at AddOns, which is the only
// root a FrameXML path has. So this looks for `<AddOns>/etc/hosts` and finds nothing.
assert_eq!(join_ref("", "/etc/hosts"), "etc/hosts");
assert!(addon.read(&join_ref("", "/etc/hosts")).is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
/// A missing hard dependency drops exactly the dependent, and an optional one drops nothing.
///
/// This is the behaviour a flat topological sort cannot express, and the reason the walk is
/// recursive like `AddOn_Load 0x51f240` rather than a pre-sort.
#[test]
fn a_missing_required_dep_drops_only_its_dependent() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-deps-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let all = vec![
dir_addon(&tmp, "Alone", "## Interface: 11200\n"),
dir_addon(&tmp, "NeedsGhost", "## Dependencies: Ghost\n"),
dir_addon(&tmp, "WantsGhost", "## OptionalDeps: Ghost\n"),
];
let mut script = UiScript::new().unwrap();
let mut w = Walk::default();
for a in &all {
let _ = w.load(&mut script, &all, &a.name);
}
assert!(w.loaded.contains("Alone"), "an independent addon loads");
assert!(
w.loaded.contains("WantsGhost"),
"a MISSING OPTIONAL dependency must not block its dependent"
);
assert!(
!w.loaded.contains("NeedsGhost"),
"a missing REQUIRED dependency must block its dependent"
);
assert_eq!(
w.failures.len(),
1,
"and reports exactly that one: {:?}",
w.failures
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **1178's falsifier, as far as step 2 can carry it**: a real third-party addon — a `.toc`, a
/// frame in XML, a second file reached by `<Include>`, and Lua that calls a client-API global
/// our own interface uses — loads from a folder, with no Rust written for it.
///
/// This is the check the unit tests above structurally cannot give. They exercise the walk
/// with empty manifests; this one goes through `framexml::parse` → `loader::load` → the VM, so
/// it is the first thing that would fail if per-addon file scoping, the `<Include>` provider,
/// or the shared global namespace were wrong. `ADDON_LOADED` is covered by
/// [`addon_loaded_carries_the_addons_own_name_and_fires_after_its_files`]; what remains of the
/// falsifier is saved variables (1188 phase 3).
#[test]
fn a_third_party_addon_loads_from_a_folder_with_no_rust() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-e2e-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let dir = tmp.join("ProbeAddon");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(tmp.join("ProbeLib/core")).unwrap();
// **Bagnon's shape, deliberately** — the manifest names ONE file in a subfolder with a
// backslash path, and that file reaches a sibling by bare name and a shared library addon
// by `..\..`. Every part of this is what the real addon does, and every part of it failed
// before 1186.
std::fs::write(
dir.join("ProbeAddon.toc"),
"## Interface: 11200\n## Title: Probe\nsrc\\main.xml\n",
)
.unwrap();
std::fs::write(
dir.join("src/main.xml"),
r#"<Ui>
<Include file="templates.xml"/>
<Include file="..\..\ProbeLib\core\lib.xml"/>
<Script file="core.lua"/>
<Frame name="ProbeAddonFrame" parent="UIParent">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Scripts><OnLoad>ProbeAddonLoaded = GetTime() ~= nil and ProbeAddonGreeting == 'hello'</OnLoad></Scripts>
</Frame>
</Ui>"#,
)
.unwrap();
std::fs::write(dir.join("src/core.lua"), "ProbeAddonGreeting = 'hello'\n").unwrap();
std::fs::write(
dir.join("src/templates.xml"),
"<Ui><Script>ProbeAddonInclude = true</Script></Ui>",
)
.unwrap();
// The sibling library addon, reached across the AddOns root — and it includes a file of
// its OWN by bare name, so the base has to follow the include down a level rather than
// staying on the includer's.
std::fs::write(
tmp.join("ProbeLib/core/lib.xml"),
"<Ui><Include file=\"deep.xml\"/><Script>ProbeLibLoaded = true</Script></Ui>",
)
.unwrap();
std::fs::write(
tmp.join("ProbeLib/core/deep.xml"),
"<Ui><Script>ProbeLibDeep = true</Script></Ui>",
)
.unwrap();
let addon = Addon {
name: "ProbeAddon".into(),
toc: Toc::parse(&std::fs::read_to_string(dir.join("ProbeAddon.toc")).unwrap()),
source: Source::Dir(tmp.clone()),
};
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let failures = addon.load(&script);
assert!(failures.is_empty(), "addon load errors: {failures:#?}");
assert_eq!(
script.eval::<bool>("return ProbeAddonInclude == true").ok(),
Some(true),
"a bare-name <Include> resolved against the INCLUDING FILE's directory (src/), not \
the addon root — Bagnon's `templates.xml` missed entirely before 1186"
);
assert_eq!(
script.eval::<bool>("return ProbeLibLoaded == true").ok(),
Some(true),
"`..\\..\\ProbeLib\\core\\lib.xml` reached a SIBLING addon — the shared-library \
pattern 1184's per-addon sandbox blocked"
);
assert_eq!(
script.eval::<bool>("return ProbeLibDeep == true").ok(),
Some(true),
"and the sibling's own bare-name <Include> resolved against ITS folder, so the base \
follows the include tree down rather than staying on the includer"
);
assert_eq!(
script
.eval::<bool>("return ProbeAddonGreeting == 'hello'")
.ok(),
Some(true),
"a <Script file=> ran, resolved the same relative way as an <Include>"
);
assert_eq!(
script.eval::<bool>("return ProbeAddonLoaded == true").ok(),
Some(true),
"the frame's OnLoad ran, reached a client-API global (GetTime), AND saw the value the \
manifest's earlier .lua file set — so the two kinds load into one shared state, in \
manifest order"
);
assert_eq!(
script
.eval::<bool>("return getglobal('ProbeAddonFrame') ~= nil")
.ok(),
Some(true),
"the addon's frame materialized under the same global namespace ours use"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// An addon named in two different cases by two dependents loads **once**.
///
/// Dependency lookup is case-insensitive (a `.toc` may spell a dependency however it likes),
/// so the loaded/failed sets have to be keyed on the addon's own spelling. Keyed on the
/// caller's, each differently-cased dependent misses the "already loaded" check and runs every
/// one of the shared addon's files again — re-registering its templates and re-materializing
/// its frames on top of themselves.
#[test]
fn a_dependency_named_in_another_case_loads_once() {
let tmp = std::env::temp_dir().join(format!(
"benilla-addon-case-key-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
// Shared.xml increments a counter, so "loaded twice" is observable rather than inferred.
let shared = tmp.join("Shared");
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(shared.join("Shared.toc"), "## Interface: 11200\nBump.xml\n").unwrap();
std::fs::write(
shared.join("Bump.xml"),
"<Ui><Script>SharedLoads = (SharedLoads or 0) + 1</Script></Ui>",
)
.unwrap();
let all = vec![
Addon {
name: "Shared".into(),
toc: Toc::parse(&std::fs::read_to_string(shared.join("Shared.toc")).unwrap()),
source: Source::Dir(tmp.clone()),
},
dir_addon(&tmp, "UpperDep", "## Dependencies: SHARED\n"),
dir_addon(&tmp, "LowerDep", "## Dependencies: shared\n"),
];
let mut script = UiScript::new().unwrap();
let mut w = Walk::default();
for a in &all {
let _ = w.load(&mut script, &all, &a.name);
}
assert!(w.failures.is_empty(), "no failures: {:?}", w.failures);
assert_eq!(
script.eval::<i64>("return SharedLoads").ok(),
Some(1),
"the shared dependency's files ran exactly once"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A dependency cycle is reported once and does not recurse forever.
#[test]
fn a_dependency_cycle_is_caught() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-cycle-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let all = vec![
dir_addon(&tmp, "Ping", "## Dependencies: Pong\n"),
dir_addon(&tmp, "Pong", "## Dependencies: Ping\n"),
];
let mut script = UiScript::new().unwrap();
let mut w = Walk::default();
for a in &all {
let _ = w.load(&mut script, &all, &a.name);
}
assert!(w.loaded.is_empty(), "neither side of a cycle loads");
assert!(
w.failures.iter().any(|f| f.contains("cycle")),
"the cycle is named: {:?}",
w.failures
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// There is exactly ONE addon root and it is ours (decision 1185) — and a capture has none,
/// so a baseline cannot depend on what is installed on the machine that runs it (0008).
///
/// The capture arm is `local_state::home()`'s, already hermetic under `$WOW_CAPTURE`, which is
/// why passing `None` here IS the capture case rather than a stand-in for it.
#[test]
fn there_is_one_addon_root_and_it_is_ours() {
assert_eq!(
root_from(Some(PathBuf::from("/state"))),
Some(PathBuf::from("/state/AddOns"))
);
assert_eq!(root_from(None), None, "a capture run has no addon root");
}
/// A manifest's `.lua` entries run as chunks; everything else is FrameXML.
///
/// The classifier, not the load — `\` is a path separator in a `.toc` written for Windows, and
/// the extension compare is case-insensitive for the same reason discovery's is.
#[test]
fn lua_entries_are_told_apart_from_framexml() {
assert!(is_lua("Core.lua"));
assert!(is_lua("Libs\\LibStub\\LibStub.LUA"));
assert!(is_lua("deep/nested/file.Lua"));
assert!(!is_lua("Frames.xml"));
assert!(!is_lua("Bindings.XML"));
assert!(!is_lua("README"));
assert!(!is_lua("weird.lua.xml"));
}
/// An addon folder with no matching `.toc` is not an addon, and one whose `.toc` differs only
/// in case still is — the reference is a case-insensitive filesystem and a real addon shipped
/// as `MyAddon/myaddon.toc` has to load on Linux too.
#[test]
fn discovery_matches_the_manifest_case_insensitively() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("Cased")).unwrap();
std::fs::create_dir_all(tmp.join("Bare")).unwrap();
std::fs::write(tmp.join("Cased/cased.TOC"), "## Interface: 11200\n").unwrap();
std::fs::write(tmp.join("Bare/notes.txt"), "not an addon").unwrap();
assert!(manifest_in(&tmp.join("Cased"), "Cased").is_some());
assert!(manifest_in(&tmp.join("Bare"), "Bare").is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
// ───────────────────────────── lifecycle events (1188 phase 1) ─────────────────────────────
/// A frame that appends `"<event>:<arg1>"` to a global for every event it is registered for —
/// the witness the lifecycle tests read. Written as an addon file rather than injected into
/// the VM, so the events travel the same `<OnEvent>` path a real addon's would.
fn recorder_xml(events: &[&str]) -> String {
let registers: String = events
.iter()
.map(|e| format!("this:RegisterEvent(\"{e}\");"))
.collect();
format!(
r#"<Ui>
<Frame name="EventProbeFrame" parent="UIParent">
<Scripts>
<OnLoad>{registers}</OnLoad>
<OnEvent>table.insert(EventLog, event .. ":" .. tostring(arg1));</OnEvent>
</Scripts>
</Frame>
</Ui>"#
)
}
/// Read the witness back as a `Vec<String>`.
fn event_log(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return EventLog")
.expect("EventLog")
}
/// **`ADDON_LOADED` reaches a third-party addon, carries its own name, and fires after its
/// files** — 1188 phase 1's acceptance test, and the half of 1178's falsifier that said
/// *"today it loads and reads; it does not receive"*.
///
/// The `arg1` half is not cosmetic: every addon in the ecosystem opens with
/// `if arg1 == "MyAddon" then` and does nothing at all otherwise, so a wrong or missing arg1
/// is indistinguishable from the event never arriving.
#[test]
fn addon_loaded_carries_the_addons_own_name_and_fires_after_its_files() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-loaded-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let dir = tmp.join("EventProbe");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("EventProbe.toc"),
"## Interface: 11200\nprobe.lua\nprobe.xml\n",
)
.unwrap();
// File-scope Lua, which the reference runs BEFORE the event. Recording the marker here and
// asserting it below is what proves the ordering rather than merely the delivery.
std::fs::write(
dir.join("probe.lua"),
"EventLog = {}\ntable.insert(EventLog, \"files-ran\")\n",
)
.unwrap();
std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap();
let all = vec![Addon {
name: "EventProbe".into(),
toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()),
source: Source::Dir(tmp.clone()),
}];
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let mut w = Walk::default();
let _ = w.load(&mut script, &all, "EventProbe");
assert!(w.failures.is_empty(), "load errors: {:?}", w.failures);
assert_eq!(
event_log(&script),
vec!["files-ran", "ADDON_LOADED:EventProbe"],
"the addon's own files run first, THEN ADDON_LOADED with its own folder name as arg1 \
(`AddOn_Load 0x51f240`: files 0x51f3fa, event 0x51f5ad)"
);
}
/// **`benilla` never appears in an `ADDON_LOADED`.**
///
/// FrameXML does not go through `AddOn_Load` and gets no such event (wow-5875-re
/// `system/ui/ui.md`), and our own interface is FrameXML's counterpart. An addon that watches
/// `ADDON_LOADED` to detect *another* addon would otherwise see a name no reference client
/// ever sends.
///
/// The guard is structural — the builtin loads through [`super::manifest`] and never enters
/// [`Walk`] — so this asserts the structure holds rather than that a filter fires: it loads
/// the builtin's own manifest entries the way production does, alongside a real walk.
#[test]
fn the_builtin_interface_never_fires_addon_loaded() {
let tmp = std::env::temp_dir().join(format!(
"benilla-addon-builtin-silent-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&tmp);
let dir = tmp.join("EventProbe");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("EventProbe.toc"),
"## Interface: 11200\nprobe.xml\n",
)
.unwrap();
std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script.run("EventLog = {}").unwrap();
let all = vec![Addon {
name: "EventProbe".into(),
toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()),
source: Source::Dir(tmp.clone()),
}];
let mut w = Walk::default();
let _ = w.load(&mut script, &all, "EventProbe");
// The builtin, loaded the way production loads it — through its own manifest, not the walk.
let builtin = Addon::builtin();
let _ = builtin.load_files(&script, builtin.toc.files.get(..1).unwrap_or_default());
let log = event_log(&script);
assert!(
!log.iter().any(|e| e.contains("benilla")),
"benilla must never appear in an ADDON_LOADED: {log:?}"
);
assert_eq!(
log,
vec!["ADDON_LOADED:EventProbe"],
"exactly the one third-party addon announced itself"
);
}
/// **The three UI-init events fire in the reference's order:** every addon's `ADDON_LOADED`,
/// then `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
///
/// Byte-verified straight-line order inside `UI_Init 0x48fbf0` — `0x4900a3` loads the addons
/// (each `0x51f5ad`), `0x4900b2` fires `VARIABLES_LOADED`, `0x490168` enters the cascade that
/// fires `PLAYER_LOGIN`. This drives the production functions in the production order rather
/// than re-stating it: [`super::load_third_party`] then [`super::super::finish_ui_load`].
///
/// It is the ordering an addon depends on — restore state on `ADDON_LOADED`, and by
/// `PLAYER_LOGIN` everything saved is in place.
#[test]
fn the_ui_init_events_fire_in_the_reference_order() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp =
std::env::temp_dir().join(format!("benilla-addon-order-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
let dir = home.join("AddOns").join("EventProbe");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("EventProbe.toc"),
"## Interface: 11200\nprobe.xml\n",
)
.unwrap();
std::fs::write(
dir.join("probe.xml"),
recorder_xml(&["ADDON_LOADED", "VARIABLES_LOADED", "PLAYER_LOGIN"]),
)
.unwrap();
// Hermetic: point the whole state folder at the tempdir, so discovery finds exactly this
// addon and the saved-variables read cannot touch the machine's real file.
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _h =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script.run("EventLog = {}").unwrap();
let failures = load_third_party(&mut script, None, true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
assert_eq!(
event_log(&script),
vec![
"ADDON_LOADED:EventProbe",
"VARIABLES_LOADED:nil",
"PLAYER_LOGIN:nil",
],
"every non-LoadOnDemand addon's ADDON_LOADED precedes VARIABLES_LOADED, which \
precedes PLAYER_LOGIN"
);
let _ = std::fs::remove_dir_all(&tmp);
}
// ────────────────────────── the AddOn API + enable state (1188 phase 2) ──────────────────────
/// Write a whole AddOns root under a temp `benilla-config`, and point `BENILLA_HOME` at it.
/// Returns the guards, which must be held for the duration of the test.
fn hermetic_root(tag: &str) -> (PathBuf, crate::local_state::test_env::EnvGuard) {
// **The pid is load-bearing**, not decoration: two `benilla_app` test binaries can run at
// once (a concurrent session, or `--all-targets`), and a fixed path plus the
// `remove_dir_all` below means each wipes the other's tree mid-test. `ui_saved.rs` keys
// its temp the same way for the same reason.
let tmp =
std::env::temp_dir().join(format!("benilla-addon-api-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
std::fs::create_dir_all(home.join("AddOns")).unwrap();
let guard =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
(home, guard)
}
/// **The version gate holds the startup walk, and force-load opens it** (decision 1292):
/// the byte-verified `AddOn_CanLoad` check 6 — exact `==`, missing `## Interface` = 0 = out
/// of date, a dependent of a gated addon blocked like a dependent of a disabled one — and
/// the `checkAddonVersion` flag (the *Load out of date AddOns* checkbox inverted) loading
/// the very same folder in full.
#[test]
fn the_version_gate_holds_the_walk_and_force_load_opens_it() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (home, _guard) = hermetic_root("version-gate");
write_addon(
&home,
"Fresh",
"## Interface: 11200\nmain.lua\n",
&[("main.lua", "FreshRan = true\n")],
);
write_addon(
&home,
"Old",
"## Interface: 11100\nmain.lua\n",
&[("main.lua", "OldRan = true\n")],
);
write_addon(
&home,
"Silent",
"main.lua\n", // no ## Interface at all — parses as 0, out of date (not "unknown")
&[("main.lua", "SilentRan = true\n")],
);
write_addon(
&home,
"NeedsOld",
"## Interface: 11200\n## Dependencies: Old\nmain.lua\n",
&[("main.lua", "NeedsOldRan = true\n")],
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None, true);
assert!(
failures.iter().any(|f| f.contains("NeedsOld")),
"the gated dependency is the dependent's failure: {failures:?}"
);
assert_eq!(
script.eval::<bool>("return FreshRan == true").ok(),
Some(true)
);
assert_eq!(
script
.eval::<bool>("return OldRan == nil and SilentRan == nil")
.ok(),
Some(true),
"out-of-date and interface-less addons are held by the gate"
);
assert_eq!(
script.eval::<bool>("return NeedsOldRan == nil").ok(),
Some(true),
"…and so is their dependent (DEP_INTERFACE_VERSION territory)"
);
// Force-load: the same folder, the checkbox's other state — everything loads.
let mut open = UiScript::new().unwrap();
let failures = load_third_party(&mut open, None, false);
assert!(failures.is_empty(), "force-load load errors: {failures:?}");
assert_eq!(
open.eval::<bool>(
"return OldRan == true and SilentRan == true and NeedsOldRan == true"
)
.ok(),
Some(true),
"'Load out of date AddOns' loads the very same folder in full"
);
}
fn write_addon(home: &Path, name: &str, toc: &str, files: &[(&str, &str)]) {
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
for (f, body) in files {
std::fs::write(dir.join(f), body).unwrap();
}
}
/// [`write_addon`] with **raw bytes**, because the files this crate must survive are not text.
fn write_addon_bytes(home: &Path, name: &str, toc: &[u8], files: &[(&str, &[u8])]) {
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
for (f, body) in files {
std::fs::write(dir.join(f), body).unwrap();
}
}
/// **An addon whose files are not valid UTF-8 loads anyway** (decision 1193) — the corpus's
/// single largest blocker, and one nobody would have guessed.
///
/// Three separate failures, all of which used to be silent-ish and all of which are here:
///
/// 1. a **cp1252 `.toc`** made the addon *invisible to discovery* — `manifest_in` read it with
/// `read_to_string`, got `None`, and a folder with no readable manifest is not an addon. 5
/// of a real 218-addon corpus vanished this way, and the harness scored them as clean
/// passes because an unparsed manifest lists no files to fail on.
/// 2. a **BOM'd `.lua`** reached the lexer with `EF BB BF` in front and died on
/// `unexpected symbol`. 160 corpus files carry one.
/// 3. a **cp1252 `.lua`** read as absent, so the loader reported "not found" for a file that
/// was right there. `AceAddon-2.0.lua` is one, embedded in ~30 addons.
///
/// The literal survives as **bytes**, not as decoded text: Lua 5.0 strings are byte strings
/// and the reference hands `luaL_loadbuffer` the file unmodified, so `string.len` on a cp1252
/// literal must answer what it answers there. That assertion is the one that would fail if
/// somebody "helpfully" transcoded chunks to UTF-8 later.
#[test]
fn an_addon_whose_files_are_not_utf8_still_loads() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (home, _guard) = hermetic_root("encoding");
// `## Notes:` holds a cp1252 a-umlaut (0xE4) — one byte, and the whole addon disappeared.
let mut toc = b"## Interface: 11200\n## Title: Sch\xE4tze\nlocale.lua\nboot.lua\n".to_vec();
toc.splice(0..0, [0xEFu8, 0xBB, 0xBF]); // ...and a BOM on the manifest too (14 in corpus).
write_addon_bytes(
&home,
"Umlaut",
&toc,
&[
// A cp1252 locale file: the German/French half of the vanilla ecosystem.
("locale.lua", &b"UmlautWord = \"Sch\xE4tze\"\n"[..]),
// A BOM'd chunk: valid UTF-8, three bytes the lexer cannot start on.
(
"boot.lua",
&b"\xEF\xBB\xBFUmlautLoaded = true\nUmlautLen = string.len(UmlautWord)\n"[..],
),
],
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None, true);
assert!(failures.is_empty(), "load errors: {failures:?}");
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
// has a chain (1957) — the registry is one list, as the reference's is.
assert_eq!(
script.eval::<i64>("return GetNumAddOns()").ok(),
Some(1 + chain_addons().len() as i64)
);
assert_eq!(
script
.eval::<String>("return GetAddOnMetadata('Umlaut', 'Title')")
.ok(),
Some("Sch\u{e4}tze".to_string()),
"the manifest's cp1252 title decoded to the glyph its author typed, not to nothing"
);
// Both chunks ran.
assert_eq!(
script.eval::<bool>("return UmlautLoaded == true").ok(),
Some(true),
"a BOM'd .lua ran — the three-byte mark was stripped, not handed to the lexer"
);
// ...and the cp1252 literal is still BYTES, which is the reference's semantics.
assert_eq!(
script.eval::<i64>("return UmlautLen").ok(),
Some(7),
"`Sch\\xE4tze` is 7 bytes in the file and must be 7 bytes in Lua — transcoding the \
chunk to UTF-8 would make it 8 and silently move every string.sub in the addon"
);
}
/// The AddOns screen's write **merges** rather than replaces (decision 1197).
///
/// A name in the file that is not installed right now belongs to an addon that will be again.
/// Rewriting the file from the installed list alone forgets the player's choice on every
/// uninstall — the same rule `parse_enable_state` already documents for the read side, and the
/// one that has to hold on the write side or the read's carefulness is wasted.
#[test]
fn the_addons_screen_write_merges_with_what_is_already_on_disk() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (home, _guard) = hermetic_root("screenwrite");
let id = ("Realm".to_string(), "Char".to_string());
let path = enable_state_path(Some(&id)).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "Gone: disabled\nStays: enabled\n").unwrap();
write_enable_state(Some(&id), &[("Stays".into(), false), ("New".into(), true)]);
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.contains("Gone: disabled"),
"an uninstalled addon's choice survives — {written:?}"
);
assert!(
written.contains("Stays: disabled"),
"the edit applied — {written:?}"
);
assert!(
written.contains("New: enabled"),
"a new row appended — {written:?}"
);
let _ = home;
}
/// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's
/// first acceptance test, and the reason the registry carries directives rather than a name.
///
/// The return shape is the **in-game** one — `name, title, notes, enabled, loadable, reason,
/// security`, SEVEN values — because that is the VM this runs in.
///
/// **The client registers `GetAddOnInfo` twice, as two different functions**, and this test
/// used to assert the wrong one. It was written off `Interface\GlueXML\AddonList.lua`, which
/// describes glue's `0x46d460`: eight values, `url` at slot 4, `newVersion` at 8. The in-game
/// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all
/// (wow-re `system/ui/scratch/addon-version-gate.md`).
///
/// Its own doc comment named the failure it then failed to catch — *"a row that shifts by one
/// puts an addon's notes in its URL field, which nothing would notice"*. The row was shifted at
/// slot 4 the whole time, and the test asserted the shift. That is what a falsifier copied from
/// the wrong reference buys: it pins the bug.
///
/// What it cost: `local name, _, _, enabled, loadable = GetAddOnInfo(major)` is AceLibrary's
/// and AceAddon's shape, reached by 70 corpus folders. They read `url` as `enabled`, and since
/// `## URL` is rare that is nil for nearly every addon, so `if enabled and loadable` refused to
/// load the dependency. Silent — a nil where a flag belongs is legal Lua.
///
/// The whole tuple is still asserted rather than the fields the phase names, for the original
/// and still-correct reason.
#[test]
fn get_addon_info_returns_the_manifests_own_title_and_notes() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("info");
write_addon(
&home,
"Probe",
"## Interface: 11200\n## Title: Probe Title\n## Notes: What it does\n## URL: http://example\n## Version: 1.2\n",
&[],
);
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, None, true);
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
// has a chain (1957) — the registry is one list, as the reference's is.
assert_eq!(
script.eval::<i64>("return GetNumAddOns()").ok(),
Some(1 + chain_addons().len() as i64)
);
assert_eq!(
script
.eval::<Vec<String>>(
"local n,t,no,e,l,r,s,extra = GetAddOnInfo(1) \
return { n, t, no, tostring(e), tostring(l), tostring(r), s, \
tostring(extra) }"
)
.ok(),
Some(vec![
"Probe".into(),
"Probe Title".into(),
"What it does".into(),
"1".into(), // enabled — slot 4 in-game, NOT url (which glue's binding returns)
"1".into(), // loadable
"nil".into(), // no reason — it loads
"INSECURE".into(),
// The in-game binding returns SEVEN values, so there is no eighth. Asserted as
// absent rather than trimmed off the query: glue's `newVersion` sat here, and a
// future edit that re-adds an eighth return has to face this line.
"nil".into(),
])
);
// AceLibrary's and AceAddon's literal shape, which is how 70 corpus folders reach this
// verb: `local name, _, _, enabled, loadable = GetAddOnInfo(major)`. Asserted as the
// GUARD they actually write, not as a tuple, because the guard is what silently failed —
// with `url` in slot 4 and no `## URL` in the manifest, `enabled` was nil and Ace declined
// to load its own dependency without erroring.
assert_eq!(
script
.eval::<bool>(
"local _, _, _, enabled, loadable = GetAddOnInfo('Probe') \
if enabled and loadable then return true else return false end"
)
.ok(),
Some(true),
"Ace's `if enabled and loadable` must pass for an enabled, loadable addon"
);
// Both spellings of the argument, and the raw directives.
assert_eq!(
script
.eval::<String>("return (GetAddOnInfo('probe'))")
.ok()
.as_deref(),
Some("Probe"),
"index OR name, case-insensitively — the reference's verbs all take either"
);
assert_eq!(
script
.eval::<String>("return GetAddOnMetadata(1, 'Version')")
.ok()
.as_deref(),
Some("1.2")
);
assert_eq!(
script
.eval::<bool>("return IsAddOnLoaded('Probe') == 1")
.ok(),
Some(true)
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **Disabling an addon in the enable-state file stops it loading** — phase 2's second
/// acceptance test.
///
/// The file is the reference's own `AddOns.txt` format, `<Name>: enabled|disabled` per line,
/// confirmed against a real 1.12 install. It also checks the direction that matters more in
/// practice: an addon the file does not mention loads, so dropping a folder in just works.
#[test]
fn a_disabled_addon_does_not_load_and_an_unlisted_one_does() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("disable");
write_addon(
&home,
"Off",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "OffRan = true")],
);
write_addon(
&home,
"On",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "OnRan = true")],
);
// "On" is deliberately absent from the file.
std::fs::create_dir_all(home.join("addons")).unwrap();
std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap();
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let failures = load_third_party(&mut script, Some(&id), true);
assert!(
failures.is_empty(),
"a disabled addon is a player's choice, never a load failure: {failures:?}"
);
assert_eq!(
script.eval::<bool>("return OffRan == nil").ok(),
Some(true),
"the disabled addon's files must not have run"
);
assert_eq!(
script.eval::<bool>("return OnRan == true").ok(),
Some(true),
"an addon the file never mentions is enabled — a dropped-in folder just works"
);
// And the API agrees with the loader about what happened.
assert_eq!(
script
.eval::<Vec<String>>(
"local _,_,_,_,l,r = GetAddOnInfo('Off') return { tostring(l), tostring(r) }"
)
.ok(),
Some(vec!["nil".into(), "DISABLED".into()]),
"not loadable, with the reference's own reason token"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A LoadOnDemand addon loads on `LoadAddOn` and not before** — phase 2's third acceptance
/// test, and the one that needs the loader to run from inside a Lua binding.
///
/// `LoadAddOn` is called *from Lua*, synchronously, and its side effects are asserted on the
/// next line — exactly how `UIParentLoadAddOn` uses it. A deferred implementation would fail
/// here, which is the point.
#[test]
fn a_load_on_demand_addon_loads_only_when_asked() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod");
write_addon(
&home,
"Demand",
"## Interface: 11200\n## Title: Demand\n## LoadOnDemand: 1\nlate.lua\nlate.xml\n",
&[
("late.lua", "DemandRan = true"),
(
"late.xml",
"<Ui><Frame name=\"DemandFrame\" parent=\"UIParent\"><Scripts>\
<OnEvent>DemandEvent = arg1</OnEvent></Scripts></Frame>\
<Script>DemandFrame:RegisterEvent(\"ADDON_LOADED\")</Script></Ui>",
),
],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
// Discovered and described, but NOT run.
// The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that
// has a chain (1957) — the registry is one list, as the reference's is.
assert_eq!(
script.eval::<i64>("return GetNumAddOns()").ok(),
Some(1 + chain_addons().len() as i64)
);
assert_eq!(
script
.eval::<bool>("return IsAddOnLoadOnDemand(1) == 1")
.ok(),
Some(true)
);
assert_eq!(
script.eval::<bool>("return DemandRan == nil").ok(),
Some(true),
"a LoadOnDemand addon must not run at startup"
);
assert_eq!(
script
.eval::<bool>("return IsAddOnLoaded('Demand') == nil")
.ok(),
Some(true)
);
// ...then loads synchronously, from Lua, and is usable on the very next statement.
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('Demand') \
return { tostring(loaded), tostring(reason), tostring(DemandRan), \
tostring(getglobal('DemandFrame') ~= nil) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "true".into()]),
"LoadAddOn returns loaded=1 and its files have ALREADY run when it returns — the \
reference's UIParentLoadAddOn uses the addon's frames on the next line"
);
assert_eq!(
script
.eval::<bool>("return IsAddOnLoaded('Demand') == 1")
.ok(),
Some(true)
);
// A demand load fires ADDON_LOADED too, at the same position: after the files.
assert_eq!(
script.eval::<String>("return DemandEvent").ok().as_deref(),
Some("Demand"),
"the addon's own frame, registered by its own XML, saw its own ADDON_LOADED"
);
// A second load is a no-op that still answers success, as the reference does.
assert_eq!(
script
.eval::<String>("local l = LoadAddOn('Demand') return tostring(l)")
.ok()
.as_deref(),
Some("1")
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names
/// an addon the STARTUP walk already loaded.
///
/// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text
/// ships its options as a separate `## LoadOnDemand: 1` package with
/// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through
/// `UIParentLoadAddOn`, whose failure message is the red
/// `Couldn't load %s: <ADDON_ + reason>` dialog. The dependency being **already loaded** is the
/// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is
/// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands
/// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for
/// exactly that reason until 2102 gave it the same stamp.)
#[test]
fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-dep");
write_addon(
&home,
"Base",
"## Interface: 11200\n## Title: Base\nbase.lua\n",
&[("base.lua", "BaseRan = true")],
);
write_addon(
&home,
"BaseOptions",
"## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n",
&[("opts.lua", "OptsRan = BaseRan")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
// The dependency loaded at startup and the registry knows it — the stamp this rests on.
assert_eq!(
script.eval::<bool>("return IsAddOnLoaded('Base') == 1").ok(),
Some(true),
"a startup-loaded addon must read as loaded, or every LoadOnDemand dependent of it answers DEP_NOT_DEMAND_LOADED"
);
assert_eq!(
script.eval::<bool>("return OptsRan == nil").ok(),
Some(true),
"and the LoadOnDemand package has not run"
);
// `UIParentLoadAddOn`'s own two lines, in order: load, then use what it created.
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('BaseOptions') \
return { tostring(loaded), tostring(reason), tostring(OptsRan), \
tostring(IsAddOnLoaded('BaseOptions')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A `.toc` line naming a file the package does not ship does not stop a demand load**
/// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk.
///
/// The reference logs `Couldn't open %s` and carries on (wow-re
/// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel
/// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale:
/// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an
/// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session
/// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus.
#[test]
fn a_missing_manifest_file_does_not_fail_a_demand_load() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-missing-file");
write_addon(
&home,
"Holey",
"## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\
Locale-koKR.lua\nreal.lua\n",
// `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly.
&[("real.lua", "HoleyRan = true")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('Holey') \
return { tostring(loaded), tostring(reason), tostring(HoleyRan), \
tostring(IsAddOnLoaded('Holey')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
"the addon loads, its remaining files run, and it reads as loaded"
);
// The miss is a WARNING on the host channel, never a script error — the harness's session
// column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole
// point is that a player's broken package cannot redden either.
assert!(
script.take_errors().is_empty(),
"a missing manifest entry must not reach the script-error channel"
);
let warnings = script.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")),
"…but it must still be said out loud: {warnings:?}"
);
// …and retained where the player can read it (1495), as the walk's misses are.
assert!(
script
.diagnostics()
.iter()
.any(|d| d.message.contains("Holey/Locale-koKR.lua")),
"the miss is retained in the diagnostic log"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The reason tokens are the reference's own, for the cases a caller branches on.
///
/// They are not free-form strings: the reference splices them into
/// `getglobal("ADDON_"..reason)` to find a localized label, so a token we invent renders as a
/// nil global in somebody's addon manager.
#[test]
fn load_addon_answers_with_the_references_reason_tokens() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("reasons");
write_addon(&home, "Plain", "## Interface: 11200\n", &[]);
write_addon(
&home,
"Off",
"## Interface: 11200\n## LoadOnDemand: 1\n",
&[],
);
std::fs::create_dir_all(home.join("addons")).unwrap();
std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap();
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let _ = load_third_party(&mut script, Some(&id), true);
for (call, want) in [
("LoadAddOn('NoSuchAddon')", "MISSING"),
("LoadAddOn('Off')", "DISABLED"),
// Loaded at startup because it is not LoadOnDemand, so this is the success case; the
// NOT_DEMAND_LOADED arm needs an addon that is neither loaded nor LoadOnDemand, which
// cannot happen once it is enabled — asserted through the disabled one above instead.
("LoadAddOn('Plain')", "nil"),
] {
assert_eq!(
script
.eval::<String>(&format!("local _, r = {call} return tostring(r)"))
.ok()
.as_deref(),
Some(want),
"{call}"
);
}
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// Enable state survives the session: `DisableAddOn` from Lua is written back in the
/// reference's own `AddOns.txt` format, and read back as a disable next time.
#[test]
fn the_enable_state_round_trips_through_the_file() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("roundtrip");
write_addon(&home, "Keep", "## Interface: 11200\n", &[]);
write_addon(
&home,
"Drop",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "DropRan = true")],
);
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id), true);
script.run("DisableAddOn('Drop')").unwrap();
save_enable_state(&script, Some(&id));
let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap();
// The folder's two in registry order, then the chain's Blizzard rows (1957) — every
// registry row has a line, the reference's own one-line-per-addon format.
let mut expected = String::from("Drop: disabled\nKeep: enabled\n");
for a in chain_addons() {
expected.push_str(&format!("{}: enabled\n", a.name));
}
assert_eq!(
written, expected,
"the reference's own one-line-per-addon format"
);
// A fresh session reads it back and the addon stays off.
let mut next = UiScript::new().unwrap();
let _ = load_third_party(&mut next, Some(&id), true);
assert_eq!(
next.eval::<bool>("return DropRan == nil").ok(),
Some(true),
"the disable survived the session"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
// ───────────────────────────── saved variables (1188 phase 3) ─────────────────────────────
/// **An addon sets a saved variable, the session ends, a new session sees it** — 1188 phase 3's
/// acceptance test, end to end through the production path.
///
/// It also pins the two orderings that make the mechanism work, both byte-verified:
/// *account file then per-character file* (`0x51f4b5`, `0x51f53b`), so a per-character value
/// wins; and *files → saved variables → `ADDON_LOADED`* (`0x51f5ad`), so the addon's own
/// file-scope default is assigned first, overwritten second, and read by the handler third.
/// Reverse either and the saved value can never win — which is a bug that looks exactly like
/// "settings do not persist".
#[test]
fn an_addons_saved_variables_survive_the_session() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("saved");
write_addon(
&home,
"Keeper",
"## Interface: 11200\n## SavedVariables: KeeperDB\n\
## SavedVariablesPerCharacter: KeeperChar\nkeeper.lua\nkeeper.xml\n",
&[
// File-scope defaults, exactly where the reference puts them.
(
"keeper.lua",
"KeeperDB = { count = 0 }\nKeeperChar = 'default'\n",
),
(
"keeper.xml",
"<Ui><Frame name=\"KeeperFrame\"><Scripts>\
<OnEvent>KeeperSawAtEvent = KeeperDB.count</OnEvent></Scripts></Frame>\
<Script>KeeperFrame:RegisterEvent(\"ADDON_LOADED\")</Script></Ui>",
),
],
);
let id = ("Realm".to_string(), "Char".to_string());
// ── session one: defaults, then the addon changes them ──
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut script, Some(&id), true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
script.eval::<i64>("return KeeperSawAtEvent").ok(),
Some(0),
"first run: no file yet, so ADDON_LOADED sees the file-scope default"
);
script
.run("KeeperDB.count = 7 KeeperDB.note = 'hi' KeeperChar = 'mine'")
.unwrap();
crate::ui_script::shutdown_ui_state(&mut script, Some(&id));
// ── session two: a fresh VM reads them back ──
let mut next = UiScript::new().unwrap();
next.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut next, Some(&id), true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
next.eval::<i64>("return KeeperDB.count").ok(),
Some(7),
"the saved value overwrote the addon's own file-scope default"
);
assert_eq!(
next.eval::<String>("return KeeperDB.note").ok().as_deref(),
Some("hi"),
"a table round-trips whole, not just the scalar that changed"
);
assert_eq!(
next.eval::<String>("return KeeperChar").ok().as_deref(),
Some("mine"),
"the per-character file loaded too"
);
assert_eq!(
next.eval::<i64>("return KeeperSawAtEvent").ok(),
Some(7),
"ADDON_LOADED handlers see the RESTORED value — the whole reason the event is last"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The written bytes are the recorded grammar (decision 1128's, itself byte-verified against
/// `0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB indent, a trailing comma on
/// every entry, and the file split by scope. `["t"]`'s `[1] = 1,` is the reference's shape for
/// a list too — its writer emits a bracketed key for every table shape and never a bare
/// positional entry (wow-re `system/ui/scratch/lua-table-storage-and-next-order.md` §Q5) —
/// and it reloads in index order because of what 1.12's *parser* does with it (decision 2111).
///
/// Asserted as bytes rather than by re-reading, because "it round-trips through our own
/// loader" is exactly the check that passes for a private format. The reference's own client
/// has to be able to read this.
#[test]
fn the_saved_file_bytes_match_the_recorded_grammar() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("grammar");
write_addon(
&home,
"Gram",
"## Interface: 11200\n## SavedVariables: GramDB\n\
## SavedVariablesPerCharacter: GramChar\ngram.lua\n",
&[("gram.lua", "GramDB = {}\nGramChar = 0\n")],
);
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id), true);
script
.run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5")
.unwrap();
crate::ui_script::shutdown_ui_state(&mut script, Some(&id));
let account = std::fs::read_to_string(home.join("saved/Gram.lua")).unwrap();
let body = account.lines().skip(2).collect::<Vec<_>>().join("\n");
assert_eq!(
body,
"GramDB = {\n\
\t[\"n\"] = 2,\n\
\t[\"on\"] = true,\n\
\t[\"s\"] = \"a\\\"b\",\n\
\t[\"t\"] = {\n\
\t\t[1] = 1,\n\
\t},\n\
}",
"keys always bracketed and quoted, TAB indent per level, trailing comma on every \
entry, `\\\"` escaped — the recorded grammar\nGOT:\n{account}"
);
// Scope split: the per-character global is in the per-character file and nowhere else.
assert!(!account.contains("GramChar"), "account file: {account}");
let per_char = std::fs::read_to_string(home.join("saved/Realm-Char/Gram.lua")).unwrap();
assert!(
per_char.ends_with("GramChar = 5\n"),
"per-char file: {per_char}"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// `PLAYER_LOGOUT` fires **before** the writes — an addon's last chance to mutate a saved
/// global, and worthless if it fires after.
///
/// The reference's own tail is `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` → the writes
/// (`0x490c2a` before `0x490c7e`/`0x490c83`). This asserts the observable consequence rather
/// than the call order: a value set *in the handler* has to reach the file.
#[test]
fn player_logout_fires_before_the_write() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("logout");
write_addon(
&home,
"Last",
"## Interface: 11200\n## SavedVariables: LastDB\nlast.lua\nlast.xml\n",
&[
("last.lua", "LastDB = 'unset'"),
(
"last.xml",
"<Ui><Frame name=\"LastFrame\"><Scripts>\
<OnEvent>if event == \"PLAYER_LOGOUT\" then LastDB = 'written at logout' end</OnEvent>\
</Scripts></Frame>\
<Script>LastFrame:RegisterEvent(\"PLAYER_LOGOUT\")</Script></Ui>",
),
],
);
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, Some(&id), true);
crate::ui_script::shutdown_ui_state(&mut script, Some(&id));
let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap();
assert!(
written.contains("LastDB = \"written at logout\""),
"the value the PLAYER_LOGOUT handler set must reach the file — if the write ran \
first this reads 'unset':\n{written}"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **The logout write MERGES, and it keeps the file's own spelling** (decision 2139).
///
/// The reference's writer `0x51ef20` emits the in-memory enable hash, and the reader
/// `0x51ebe0` builds that hash from `AddOns.txt` — an entry per line, both states, each name
/// strdup'd as written. The installed-folder list never enters it. So a row survives an
/// uninstall, and the spelling that survives is the file's.
///
/// Ours rebuilt the file from `addon_enable_states()` instead, and one logout erased
/// `Uninstalled: disabled` and rewrote `myaddon:` as `MyAddon:`.
#[test]
fn the_logout_write_keeps_rows_it_did_not_put_there() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("logout-merge");
write_addon(
&home,
"MyAddon",
"## Interface: 11200\nmain.lua\n",
&[("main.lua", "MyAddonRan = true")],
);
let id = ("Realm".to_string(), "Char".to_string());
let p = crate::local_state::addons_state_path("Realm", "Char").unwrap();
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "Uninstalled: disabled\nmyaddon: enabled\n").unwrap();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, Some(&id), true);
script.eval::<()>("DisableAddOn('MyAddon')").unwrap();
save_enable_state(&script, Some(&id));
let after = std::fs::read_to_string(&p).unwrap();
let rows = parse_enable_state(&after);
// The uninstalled addon's row is still there, still disabled — the half with teeth.
assert_eq!(
rows.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("Uninstalled"))
.map(|(_, on)| *on),
Some(false),
"a row for an addon not installed this session must survive the write: {after}"
);
// The file's own spelling survives; the folder's case does not overwrite it.
assert!(
after.contains("myaddon:"),
"the file's spelling is what the reference writes back: {after}"
);
// …and this session's toggle still landed on it.
assert_eq!(
rows.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("MyAddon"))
.map(|(_, on)| *on),
Some(false),
"the DisableAddOn must still be persisted: {after}"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A LoadOnDemand dependency cycle LOADS BOTH and answers `(1, nil)`** (decision 2139) —
/// and before the fix this call took the whole process down with SIGABRT.
///
/// `AddOn_Load 0x51f240` has no re-entrancy guard: every live site of the visiting byte
/// `[UIADDON+0x2d]` is inside `AddOn_CanLoad 0x51e780`, none in the loader. What bounds the
/// recursion is the *loaded* byte, stamped **before** the dependency loops —
/// `0x51f313 mov byte [rec+0x18],1`, a flag-neutral store between `0x51f311 test eax,eax` and
/// `0x51f317 jbe`, therefore unconditional — so the re-entered frame takes the already-loaded
/// early-out at `0x51f2d6`/`0x51f2db` and returns 1. We stamped it after the recursion, so
/// nothing terminated: Rust-level recursion, stack overflow, `signal 6`, uncatchable by
/// `pcall`.
///
/// The three assertions below are the reference's own observable consequences, and the last
/// two are the ones that would betray a guard bolted on somewhere else instead:
/// **no reason token** (a cycle produces none — the reference has no such token),
/// `ADDON_LOADED` fires **B then A**, and `IsAddOnLoaded('Ping')` answers 1 throughout Pong's
/// execution, because Ping is flagged loaded before its own files run.
#[test]
fn a_load_on_demand_dependency_cycle_loads_both_sides() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-cycle");
write_addon(
&home,
"Ping",
"## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Pong\nping.lua\n",
&[(
"ping.lua",
"PingRan = true Order = (Order or '') .. 'Ping,'",
)],
);
write_addon(
&home,
"Pong",
"## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Ping\npong.lua\n",
&[(
"pong.lua",
"PongRan = true Order = (Order or '') .. 'Pong,' \
PingSeenLoaded = IsAddOnLoaded('Ping')",
)],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script
.eval::<()>(
"AddonOrder = '' \
CycleWatch = CreateFrame('Frame') \
CycleWatch:RegisterEvent('ADDON_LOADED') \
CycleWatch:SetScript('OnEvent', function() \
AddonOrder = AddonOrder .. arg1 .. ',' end)",
)
.unwrap();
let _ = load_third_party(&mut script, None, true);
// Neither ran at startup — the walk skips LoadOnDemand, which is why a cycle among them
// is never observed until something demand-loads one.
assert_eq!(
script
.eval::<bool>("return PingRan == nil and PongRan == nil")
.ok(),
Some(true)
);
// The call that used to abort the process.
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('Ping') \
return { tostring(loaded), tostring(reason) }"
)
.ok(),
Some(vec!["1".into(), "nil".into()]),
"a cycle loads and answers (1, nil) — the reference has no cycle reason token"
);
// Both sides ran, each `.toc` list exactly once.
assert_eq!(
script.eval::<String>("return Order").ok().as_deref(),
Some("Pong,Ping,"),
"the dependency's files run first, and neither list runs twice"
);
// `ADDON_LOADED` fires B then A.
assert_eq!(
script.eval::<String>("return AddonOrder").ok().as_deref(),
Some("Pong,Ping,")
);
// The stamp is ahead of the files, so the cycle inverts the declared order: Ping reads as
// loaded for the whole of Pong's execution. This is the reference's behaviour and it is
// the mechanism, not a side effect.
assert_eq!(
script.eval::<i64>("return PingSeenLoaded").ok(),
Some(1),
"inside a cycle an addon is flagged loaded before its own files run"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
}