Files
benilla-octo/crates/benilla-app/src/ui_script/addons.rs
T
gromandClaude Opus 5 5e2f1f3ac8 ui: SetClipsChildren, the verb an addon asked for by name and was refused in silence
Era's map canvas is built on one verb this client did not have. Its `ScrollContainer` is a plain
Frame with `SetClipsChildren(true)`, and everything below it is cropped to that rect.
`assets/ui/MapCanvas.lua` substituted a real ScrollFrame, because this client crops a ScrollFrame's
scroll child and every descendant of it — which covers the canvas and nothing parented to the
container beside it. On Era that distinction does not exist, so an addon has no reason to respect
it.

OpenMapClassic parents its pin layer exactly there and asks for the verb by name, behind an `if`
(`Map/Stitch.lua:1070`), so the absence was not a raise. Measured on grom's own config with the map
open: the container sits at L=1151 R=2153 and its seventeen direct children span L=-765 R=2153 —
about 1900 units off the left edge, cropped by nothing.

Implemented as a frame flag with `SetClipsChildren`/`DoesClipChildren` and the `clipChildren` XML
attribute; `effective_clip` now intersects the rect of every ancestor that sets it alongside the
ScrollFrame rule, so the two nest and compose. An ancestor rather than the frame itself: the verb
clips a frame's children, and stock art overhangs its own frame on purpose. `MapCanvas.lua` sets it
on the container too, so a layer parked there is cropped whether or not its addon thought to ask.

Not claimed as the whole of grom's map report: the screenshot behind it has not been reproduced
offline, because a capture's view is not the view his session was in. This is the part that was
provably drawing uncropped.

Also here, both from the same play-test:

- `C_Spell.CancelSpellByID` answered with `SpellStopCasting` only. pfUI's buff bar cancels with it
  on a right click where 1.12's calls `CancelPlayerBuff`, so with nothing in flight the click did
  nothing and said nothing — grom had never been able to right-click a buff off on this client. It
  now finds the id among the player's own auras and queues it on the same CMSG_CANCEL_AURA path,
  behind the same `cancel_authorized` gate, and falls through to the cast only when there is no
  such aura.

- `$WOW_CAPTURE_SAVED=<Realm>-<Character>` points a capture at a player's own saved variables. A
  capture is hermetic by design, but with addons and no saved variables it renders an interface
  nobody is configured to be looking at — which is why the world map came out correct in a capture
  while being wrong on his screen. It doubles as the identity because a capture never logs in and
  `pfUI_config` is SavedVariablesPerCharacter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 09:46:32 -07:00

2943 lines
124 KiB
Rust

//! Where interfaces come from: addon discovery under [`root`] and the load walk ([`Walk::load`]).
//! An [`Addon`] is a name, a parsed `.toc` and the [`Source`] its files come from. benilla's own
//! interface is one too, with the compiled-in tree as its source, but not for the lifecycle: like
//! FrameXML it loads outside `AddOn_Load` ([`super::manifest::load_ingame_ui`]) and gets no
//! `ADDON_LOADED`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use benilla_ui::toc::Toc;
use super::content;
/// The addon folder's name, the reference's own spelling.
const ADDONS_DIR: &str = "AddOns";
/// The VM instruction bound for one addon's load, shared with the addon harness: about 50x the
/// corpus's heaviest (4M), so an addon that crosses it is in a runaway loop and fails alone.
pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000;
/// Where one interface's files come from.
pub(super) enum Source {
/// benilla's own interface: the compiled-in tree, shadowed by `assets/ui` in a dev build.
Builtin,
/// The AddOns root on disk, not this addon's own folder: a request under `Interface/AddOns/`
/// maps onto it, so a dependent can reach a shared library addon beside it.
Dir(PathBuf),
/// The player's patch chain ([`super::reference_ui`]), by full chain path, `/` and `\` alike.
Chain,
}
/// One loadable interface: a name, its parsed manifest, and where its files come from.
pub(super) struct Addon {
/// The folder name (`"benilla"` for the builtin): what the AddOn API keys on 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, for [`super::reference_ui::addon`].
pub(super) fn new(name: String, toc: Toc, source: Source) -> Self {
Addon { name, toc, source }
}
/// benilla's own interface, which is in the binary and so 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,
}
}
/// One file's bytes, by a path already resolved into the source's path space. A `Dir` reads
/// under the AddOns root, then the chain ([`read_addon_file`]); only the flat builtin falls
/// back to the basename, which for a `Dir` would rescue an escaping path. Bytes, not text: a
/// `.lua` reaches Lua as it is on disk.
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_addon_file(root, req),
Source::Chain => super::reference_ui::read(req),
}
}
/// The base this addon's manifest entries resolve against: `Interface/AddOns/<Folder>` for a
/// `Dir`, empty for the builtin's flat tree and the chain's full paths.
fn prefix(&self) -> String {
match &self.source {
Source::Builtin | Source::Chain => String::new(),
Source::Dir(_) => format!("{ADDONS_PREFIX}{}", self.name),
}
}
/// The chunk name, which addons parse: `"@%s"` (`0x8716e0`) over the resolved install path,
/// built by `0x704bc0` for every `.lua`, as for `<Script file=>`. Ace2 libraries find their
/// addon by splitting a `debugstack` frame on `\AddOns\` (`AceDB-2.0.lua:742`). The builtin's
/// flat tree keeps [`benilla_ui::script::addon_chunk_name`].
fn chunk_name(&self, file: &str, path: &str) -> String {
match &self.source {
Source::Builtin => benilla_ui::script::addon_chunk_name(&self.name, file),
Source::Dir(_) | Source::Chain => format!("@{}", path.replace('/', "\\")),
}
}
/// Load this addon's `.toc`-listed files, in listed order. Each error is logged as it happens
/// and also returned, tagged `"<Addon>/<file>: <error>"`, for the tests to assert empty.
fn load(&self, script: &UiScript) -> Vec<String> {
self.load_files(script, &self.toc.files)
}
/// [`Addon::load`] over an explicit slice, for the builtin's two-phase boot. A manifest lists
/// both kinds of file (the reference's `FrameXML.toc` opens with `GlobalStrings.lua`): a `.lua`
/// runs as a chunk in the shared state, anything else is parsed as FrameXML and materialized.
pub(super) fn load_files(&self, script: &UiScript, files: &[String]) -> Vec<String> {
let mut failures = Vec::new();
// The `<Include>` / `<Script file=>` provider; `read` is the sandbox.
let provider = |req: &str| -> Option<Vec<u8>> { self.read(req) };
for file in files {
// Resolved once into the source's path space, for `read` and the loader alike.
let path = benilla_ui::loader::join_ref(&self.prefix(), file);
let Some(bytes) = self.read(&path) else {
// Prose, not a file: a `.toc` line that cannot name one ([`is_file_entry`]).
if !is_file_entry(file) {
debug!(
"ui_script: {}/{file}: not a file reference — a `.toc` line that is a \
comment or a wrapped metadata paragraph, as the reference also reads it",
self.name
);
continue;
}
let e = format!("{}/{file}: not found", self.name);
// Severity follows whose manifest is wrong. Ours (the builtin, or a `benilla.toc`
// line the player's chain lacks) is an ERROR. A player's addon is the package's
// fault, which the reference skips silently, and an ERROR line fails `smoke.sh`.
match self.source {
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
// Retained for the player: the commonest way an addon fails with nothing on screen.
script.report_load_failure(&e);
failures.push(e);
continue;
};
if is_lua(file) {
// What `<Script file=>` gets: one chunk in the one global state, in manifest
// order, as `AddOn_Load 0x51f240` hands every listed file to `0x6edb90`.
// …called with `(addonName, addonPrivateTable)`, the pair modern addons read
// out of `...` (`UiScript::run_addon_chunk`). FrameXML is not an addon and goes
// through the unargumented path.
let ran = match &self.source {
Source::Builtin | Source::Dir(_) => {
script.run_addon_chunk(&bytes, &self.chunk_name(file, &path), &self.name)
}
Source::Chain => script.run_chunk_named(&bytes, &self.chunk_name(file, &path)),
};
match ran {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
error!("ui_script: {e}");
// A script error: it reaches the player through the Lua error handler.
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}");
// No dialog, as the reference only logs it (FrameXML.log); still retained.
script.report_load_failure(&e);
failures.push(e);
continue;
}
};
// The loader resolves relative references against the document's own directory, and
// names the file a raise came from.
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
// `FrameXML_Debug` traces go to the log only: the reference files them at severity 0
// (`0x6ee2bc`) in a per-document record whose surface is untraced.
for t in &report.traces {
info!("ui_script({}/{file}): {t}", self.name);
}
// One document's warnings collapse by shape in the LOG, because a single cause
// speaks many times: the stock inspect panel's sixteen slots each name a frame
// `Blizzard_InspectUI` has not been asked to build, and QuickHeal's one undefined
// template is inherited ten times. The shape is the message without its trailing
// identifier, the first two of each keep their detail, and the rest become a count —
// 78 lines of the player's session were four causes. `/errors` still gets every one:
// the panel is where the detail belongs.
const PER_SHAPE: usize = 2;
// Ordered, so the tail lines read the same run to run.
let mut per_shape: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for w in &report.warnings {
let shape = warning_shape(w);
let seen = per_shape.entry(shape).or_default();
*seen += 1;
if *seen <= PER_SHAPE {
warn!("ui_script({}/{file}): {w}", self.name);
}
// Retained for `/errors` too, here because only this caller knows the file.
script.report_warning(&format!("{}/{file}: {w}", self.name));
}
for (shape, n) in per_shape.iter().filter(|(_, n)| **n > PER_SHAPE) {
warn!(
"ui_script({}/{file}): {} more like \"{shape}: …\" ({n} in all) — /errors \
lists them",
self.name,
n - PER_SHAPE
);
}
// A file the document names and the provider lacks is a `.toc` line naming no file:
// never a script error, as the reference logs `Couldn't open %s` and carries on, but a
// `failures` entry, so our boot tests catch one in a `benilla.toc` document.
for m in &report.missing_files {
let e = format!("{}/{file}: {m}", self.name);
match self.source {
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
script.report_load_failure(&e);
failures.push(e);
}
for e in &report.errors {
error!("ui_script({}/{file}): {e}", self.name);
// A script error, as in the Lua arm; `_ERRORMESSAGE`'s IsVisible guard shows only
// a burst's first, as in the reference.
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
}
}
/// A document warning's shape: the message without its trailing identifier, which is what makes
/// sixteen "Couldn't find relative frame: Inspect<Slot>Slot" lines one cause. A message with no
/// `": "` is its own shape, which is how ten identical "unknown template" lines collapse too.
fn warning_shape(warning: &str) -> &str {
warning.rsplit_once(": ").map_or(warning, |(head, _)| head)
}
/// Whether a `.toc` line is a file reference at all, asked only of a line that resolved to
/// nothing. A `.toc` is read line by line — `#` opens a metadata line and everything else is a
/// filename — so a wrapped `## Notes:` paragraph and the `--` comments many addons write become
/// entries, and the reference tries to open them, fails, and says nothing. Two of the player's own
/// addons do it: `ColorPickerPlus`'s Notes wrap onto two bare lines, `DifficultBulletinBoard`
/// heads each section with `-- API files`.
///
/// Those are not missing files, so they are not load failures: counting them put phantom entries
/// in `/errors` and inflated the "N addon load failures" line at every login. A line that *is*
/// shaped like a file and absent stays a warning, because then the addon really is missing a
/// piece (`BigWigs`'s `Raids\Other\Nerubian.lua`).
pub(crate) fn is_file_entry(entry: &str) -> bool {
let name = entry.trim();
!name.starts_with("--")
&& name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.rsplit_once('.')
.is_some_and(|(stem, ext)| {
!stem.is_empty()
&& ["lua", "xml", "toc"]
.iter()
.any(|k| ext.eq_ignore_ascii_case(k))
})
}
/// Whether a manifest entry is a Lua chunk: by extension, case-insensitively, with `\` a
/// separator like `/` (a `.toc` is written for a Windows client).
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` is already resolved by
/// [`benilla_ui::loader::join_ref`], so an escape shows as a leading `..`, refused with anything
/// absolute before any filesystem call; a symlinked addon folder still works.
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
benilla_ui::loader::read_under(root, rel)
}
/// The one file namespace, install-relative, as in the reference: `0x647e60` answers a name from
/// the install tree's hash index, then the archive chain, after `0x6ede10` collapses `..`. So an
/// addon's `..\Blizzard_AuctionUI\…` reaches the archive behind a `.pub`-only folder. Only a path
/// under `Interface/AddOns/` touches the filesystem, and never above the AddOns root.
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
under_addons(req)
.and_then(|rel| read_under(root, rel))
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
}
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` without it; matched
/// case-insensitively, as the reference is a Windows client (`Interface\Addons\` is the same path).
fn under_addons(req: &str) -> Option<&str> {
let rest = req.get(ADDONS_PREFIX.len()..)?;
req.get(..ADDONS_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
.map(|_| rest)
}
/// A `Dir` addon's base before its folder name ([`Addon::prefix`]), `/`-separated as `join_ref` is.
const ADDONS_PREFIX: &str = "Interface/AddOns/";
/// The addon folder, `<benilla-config>/AddOns/`, or `None` (always under `$WOW_CAPTURE`); addon
/// textures and fonts resolve onto it too. Deviation: the reference reads the install's
/// `Interface\AddOns\`; benilla reads an install and does not live in one, so its addons live in
/// its own state folder, and only there.
pub(crate) fn root() -> Option<PathBuf> {
// `WOW_CAPTURE_ADDONS=1`: put the player's own addons into a capture. A capture is otherwise
// hermetic — `local_state::home` answers `None` under `$WOW_CAPTURE`, so a baseline is the
// shipped interface and nothing else, and `scripts/visual.sh` never sets this. It exists
// because an addon that draws its own art is otherwise unseeable without a server: the addon
// harness has no renderer, and a screen recording from a player has no knobs. With it,
// `WOW_CAPTURE=ui-worldmap WOW_CAPTURE_ADDONS=1` puts a map addon's own drawing in front of
// the real renderer at a chosen zoom (`capture::fixtures`' `WOW_CAPTURE_MAP_ZOOM`).
//
// Saved variables still do not load (that is `local_state::home` too), so an addon runs on
// its shipped defaults, not the player's settings.
if std::env::var("WOW_CAPTURE_ADDONS").as_deref() == Ok("1") {
return root_from(crate::local_state::home_ignoring_capture());
}
root_from(crate::local_state::home())
}
/// [`root`] with its one environment fact passed in, so a test need not set the environment.
fn root_from(home: Option<PathBuf>) -> Option<PathBuf> {
home.map(|h| h.join(ADDONS_DIR))
}
/// The reference's twelve `Blizzard_*` LoadOnDemand addons: on a 1.12 install each folder holds
/// only a `.pub`, with the files in the archive. Probed against the chain.
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, for `UIParentLoadAddOn`;
/// a toc that is not LoadOnDemand is skipped, as the startup walk does not read the chain.
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()
}
/// Every registered addon in registration order, the archive pass before the loose folders and a
/// name's first registration winning: `AddOn_ScanAddOnDir 0x51c760` walks each archive's
/// `(listfile)` in line order (`0x401470`, `0x648fb0`) before the `FindFirstFileW` walk
/// (`0x42ad10`), and `0x51c9b0` returns on a name-hash hit (`0x51ca10`) and tail-inserts
/// (`0x521ad0`). That order is the Lua index space, and in the one shared Lua state (`0x7040d0`)
/// it decides whose copy of a global wins.
fn discover() -> Vec<Addon> {
// The archive pass, in `BLIZZARD_ADDONS` order: the shipped `patch.MPQ` listfile's too.
let mut found = chain_addons();
// The loose pass, minus the names the archive registered.
let seen: HashSet<String> = found.iter().map(|a| a.name.to_ascii_lowercase()).collect();
found.extend(
discover_folder()
.into_iter()
.filter(|a| !seen.contains(&a.name.to_ascii_lowercase())),
);
found
}
/// Sort folder names into NTFS directory order. The reference's loose pass (`0x42ad10`,
/// `FindFirstFileW`) sorts nothing, so its order is the host filesystem's, and the addon corpus was
/// written against NTFS: a directory's `$I30` index is ordered by `COLLATION_FILE_NAME`, UTF-16
/// units compared through `$UpCase`, a tie broken by the raw units. With every addon in one Lua
/// state (`0x7040d0`), this order decides which copy of a shared library wins. Sorted on every
/// host, Windows included, so a capture and the addon harness are deterministic.
pub(crate) fn sort_by_directory_order(names: &mut [String]) {
names.sort_by_cached_key(|n| collation_key(n));
}
/// One name's `COLLATION_FILE_NAME` key: the upcased UTF-16 units, then the raw ones as the
/// tiebreak the rule specifies for names equal but for case.
fn collation_key(name: &str) -> (Vec<u16>, Vec<u16>) {
let upper: String = name.chars().map(upcase_unit).collect();
(
upper.encode_utf16().collect(),
name.encode_utf16().collect(),
)
}
/// `$UpCase`'s mapping for one character, 1:1 or none: the table maps each of the 65536 UTF-16
/// units to one unit, so a character whose uppercase is longer (`ß` to `SS`) stays as it is.
fn upcase_unit(c: char) -> char {
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(u), None) => u,
_ => c,
}
}
/// The player's own addons: every folder under the AddOns root with a `<Name>.toc`, in
/// [`sort_by_directory_order`].
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();
sort_by_directory_order(&mut names);
names
.into_iter()
.filter_map(|name| {
let dir = root.join(&name);
// A folder with no `<Name>.toc` is not an addon; the reference skips it silently too.
let text = manifest_in(&dir, &name)?;
Some(Addon {
name,
toc: Toc::parse(&text),
// The root, not `dir`, so a sibling library addon is reachable.
source: Source::Dir(root.clone()),
})
})
.collect()
}
/// `<dir>/<name>.toc`, matched case-insensitively as on the reference's Windows filesystem
/// (`MyAddon/myaddon.toc` is an addon there). Decoded, not read as UTF-8: one cp1252 byte in a
/// `## Notes:` line must not make the addon vanish.
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())
}
/// The AddOn API's registry row for one discovered addon.
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, shared with `addon_harness` so its survey seats the registry row
/// production does.
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` grants nothing; it only sets `GetAddOnInfo`'s security answer.
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(),
// Visible to `GetAddOnInfo(index)` until `SMSG_ADDON_INFO` hides it: the server's verdict
// on a `## Secure:` record, not a directive.
hidden: false,
enabled: true, // until the enable state says otherwise
saved_enabled: true, // re-stamped from `enabled` at registration
loaded: false,
// The version gate's dword: the leading integer, 0 when absent (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 `AddOns.txt`, the reference's format: one `<AddOnName>: enabled|disabled` per line. Only
/// `disabled` disables, so a malformed line leaves the addon enabled.
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, read from the folder with no VM, as the glue
/// runs before any VM holds addons.
#[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: the leading integer, 0 when absent. The load
/// walk enforces it when `checkAddonVersion` is on (`0x51e876`).
pub(crate) interface: u32,
/// `## LoadOnDemand: 1`: shown as a status, not a checkbox state, as such an addon is not off
/// but waiting for `LoadAddOn`.
pub(crate) load_on_demand: bool,
/// `## DefaultState:`, which [`EnableStore::enabled_for`] falls back to; no enable bit here.
pub(crate) default_state: bool,
}
impl InstalledAddOn {
/// The list's display name: `## Title`, else the folder name (`AddonList_Update`).
pub(crate) fn display_title(&self) -> &str {
self.title.as_deref().unwrap_or(&self.name)
}
}
/// Every installed addon in the glue list's order, by `## Title` (else the folder name): glue
/// `0x46d460` resolves `GetAddOnInfo(i)` through `0x51df00`'s array, sorted by `0x51deb0` on
/// `AddOn_GetTitle 0x51df20` with `SStrCmpI`. The Blizzard addons the server hides are not here.
pub(crate) fn installed_rows() -> Vec<InstalledAddOn> {
let mut rows: Vec<InstalledAddOn> = discover_folder()
.into_iter()
.map(|addon| InstalledAddOn {
default_state: addon.toc.default_state(),
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();
// `SStrCmpI` folds `'A'..'Z'` by `+0x20` (`0x64a4c0`, `_strnicmp 0x414310`): ASCII only.
// Stable, where the reference's `qsort` leaves equal keys in any order.
rows.sort_by_key(|a| a.display_title().to_ascii_lowercase());
rows
}
// ── The enable store: the reference's `ADDONSTATELIST` ──
/// The reference's `ADDONSTATELIST` (`0xbe1bd0`): a node per character in `SMSG_CHAR_ENUM` order,
/// rebuilt whole (`0x51f0b0` clears it, then callback `0x472300` fills a node per record through
/// `AddOnList_LoadCharacter 0x51ebe0`), each holding that character's explicit `AddOns.txt` rows.
/// A character with no file is an empty node, whose enable bit is answered from the others.
#[derive(Default)]
pub(crate) struct EnableStore {
/// `(character, explicit rows)` in character-list order; names lowercased, as every compare in
/// the reference is `SStrCmpI`.
nodes: Vec<(String, HashMap<String, bool>)>,
}
impl EnableStore {
/// A node per character, each from its own `AddOns.txt`
/// (`benilla-config/addons/<Realm>-<Char>.txt`); a character with no file still gets one.
pub(crate) fn load(realm: &str, characters: &[String]) -> Self {
let nodes = characters
.iter()
.map(|character| {
let id = (realm.to_string(), character.clone());
let rows = enable_state_path(Some(&id))
.and_then(|p| std::fs::read(p).ok())
.map(|b| parse_enable_state(&benilla_ui::source::decode(&b)))
.unwrap_or_default();
let hash = rows
.into_iter()
// Last line wins, like the reference's hash insert (`0x51eeef`).
.map(|(name, on)| (name.to_ascii_lowercase(), on))
.collect();
(character.clone(), hash)
})
.collect();
Self { nodes }
}
/// `0x51e470(addon, NULL, useDefault = 0)`, the explicit-only aggregate over the nodes with a
/// row (`0x51e5df je 0x51e60a`): `Some(v)` when all say `v` (the reference's 2 or 0), `None`
/// when they are mixed or there are none.
fn aggregate(&self, addon: &str) -> Option<bool> {
let key = addon.to_ascii_lowercase();
let mut total = 0usize;
let mut on = 0usize;
for (_, hash) in &self.nodes {
if let Some(&v) = hash.get(&key) {
total += 1;
on += usize::from(v);
}
}
match (total, on) {
(0, _) => None,
(_, 0) => Some(false),
(t, o) if o == t => Some(true),
_ => None,
}
}
/// The bit a character gets, `0x51e470(addon, character, useDefault = 1)` (0 or 2 as a bool):
/// * an explicit row in their file wins;
/// * a node with no row takes [`Self::aggregate`] (`0x51e5f0`'s self-recursion), then
/// `## DefaultState` where that is undecided;
/// * no node at all, or no character, takes `## DefaultState`: the walk passes each
/// mismatching node (`0x51e55a jne 0x51e611`) and ends with `total == 0`. From our callers a
/// named character always has a node ([`store_nodes`]).
pub(crate) fn enabled_for(
&self,
addon: &str,
default_state: bool,
character: Option<&str>,
) -> bool {
let Some(node) = character.and_then(|c| self.node(c)) else {
return default_state;
};
match node.get(&addon.to_ascii_lowercase()) {
Some(&explicit) => explicit,
None => self.aggregate(addon).unwrap_or(default_state),
}
}
fn node(&self, character: &str) -> Option<&HashMap<String, bool>> {
self.nodes
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(character))
.map(|(_, hash)| hash)
}
}
/// The store's nodes for a load: the realm's character list, plus the loading character when the
/// caller had no list to give (a test, a capture), so its own file is still read.
fn store_nodes(identity: Option<&(String, String)>, roster: &[String]) -> Vec<String> {
let mut names = roster.to_vec();
if let Some((_, character)) = identity {
if !names.iter().any(|n| n.eq_ignore_ascii_case(character)) {
names.push(character.clone());
}
}
names
}
/// Write a character's enable state, from the AddOns screen and at logout, merged into the file:
/// the reference's writer `0x51ef20` emits its enable hash a line per entry (`0x853968`), built by
/// `0x51ebe0` from the file, so an uninstalled addon's row survives and names keep their spelling.
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
};
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 at shutdown, the reference's last step (`0x490c88`, after the
/// saved-variables files), through the AddOns screen's merging writer.
pub(super) fn save_enable_state(script: &UiScript, identity: Option<&(String, String)>) {
let states = script.addon_enable_states();
if states.is_empty() {
return; // nothing registered: a glue-only run or a capture
}
write_enable_state(identity, &states);
}
/// Write every loaded addon's declared saved variables at shutdown (`0x490c83`, after the flat
/// file, before `AddOns.txt`). No autosave and no dirty bit, as in the reference, whose write gate
/// is the record's loaded byte: an addon that never loaded is not written. An addon declaring
/// nothing is left alone, where the reference deletes its file.
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 path = dir.join(format!("{name}.lua"));
if script.saved_file_held(&path) {
// It failed to load this session, so its globals are the addon's defaults, and
// writing them would replace the player's file.
warn!(
"ui_script: {} did not load this session — left as it is",
path.display()
);
continue;
}
let mut body = SAVED_HEADER.as_bytes().to_vec();
body.extend(script.saved_variables_bytes_for(names));
// Bulk, so unflushed: one `fsync` per addon cost 305 ms of the reload's blocked
// frame ([`crate::local_state::write_atomic_bytes_bulk`]).
match crate::local_state::write_atomic_bytes_bulk(&path, &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. Deviation: the reference writes none (its file opens with a blank
/// line); ours names the file, because players open this folder.
const SAVED_HEADER: &str = "\
-- benilla per-addon saved variables.
-- Written at logout/exit from the live globals; executed as a Lua chunk at addon load.
";
/// Load every discovered third-party addon at world entry (`UI_Init 0x48fbf0` → `0x51f600`),
/// returning every load error tagged by addon. `&mut` because each `ADDON_LOADED` fires
/// ([`UiScript::fire_event`]) before the next addon's files run, as in the reference.
pub(super) fn load_third_party(
script: &mut UiScript,
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
) -> Vec<String> {
// A capture has no identity of its own, because it never logs in. `$WOW_CAPTURE_SAVED` can
// name one, which is how a capture is pointed at a *player's* per-character saved variables —
// the only way to render the interface they are actually looking at. Outside a capture the
// variable is unset and this is the identity the session already had.
let captured = crate::local_state::capture_saved_identity();
let identity = identity.or(captured.as_ref());
let addons = discover();
// Registered even when empty: `GetNumAddOns()` must answer 0, not a previous session's list.
let mut infos: Vec<_> = addons.iter().map(info_for).collect();
// Chain rows read their files off the player's patch chain.
script.set_addon_chain_reader(Box::new(super::reference_ui::read));
// Each enable bit through the reference's query: the character's explicit row, else what the
// realm's other characters agree on, else the manifest's `## DefaultState`.
let store = EnableStore::load(
identity
.map(|(realm, _)| realm.as_str())
.unwrap_or_default(),
&store_nodes(identity, roster),
);
let character = identity.map(|(_, c)| c.as_str());
for (info, addon) in infos.iter_mut().zip(addons.iter()) {
info.enabled = store.enabled_for(&info.name, addon.toc.default_state(), character);
}
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` waits for `LoadAddOn`: `0x51f600` loads only records whose
// LoadOnDemand byte is 0.
if addon.toc.load_on_demand() {
// Said only while it is still true. An addon earlier in the walk can have loaded this
// one already, from its own `ADDON_LOADED`: AI_VoiceOver's `OnInitialize` does
// exactly that for its three data modules, 224 ms of it, and this line then went on
// claiming "no LoadAddOn() yet" for addons that were already in memory — which reads
// as the bug it is not, and cost a debugging session saying so.
if !script.addon_is_loaded(&addon.name) {
info!(
"ui_script: {} is LoadOnDemand — not loaded (no LoadAddOn() yet)",
addon.name
);
}
continue;
}
// Re-armed per addon, so one runaway cannot fail every addon after it; a dependency
// chain loads under its dependent's arming, as in the harness's survey.
script.set_instruction_budget(LOAD_INSTRUCTION_BUDGET);
let _ = state.load(script, &addons, &addon.name);
let spent = script.instructions_used();
if spent > 1_000_000 {
// A heavy load gets a line: this counter is what the budget was chosen from.
info!(
"ui_script: {} spent {spent} VM instructions loading",
addon.name
);
}
}
state.failures
}
/// The recursive load's bookkeeping, for [`Walk::load`].
#[derive(Default)]
struct Walk {
loaded: HashSet<String>,
/// Addons whose load failed, so a later dependent gets the same answer without a rerun.
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 turned off, lowercased; empty means every addon is enabled.
disabled: HashSet<String>,
/// `checkAddonVersion`: when on, an addon whose `## Interface` is not exactly the client's is
/// skipped like a disabled one, and a dependent gets `Err` (`DEP_INTERFACE_VERSION`).
version_check: bool,
}
impl Walk {
/// Load one addon by name; `Err` means it did not load, which a hard dependent propagates. The
/// order is `AddOn_Load 0x51f240`'s: optional dependencies (failures ignored), required ones (a
/// failure aborts only this addon), the `.toc` files in order (`0x51f3fa`), `Bindings.xml`
/// (`0x51f400`), the account then per-character saved variables (`0x51f4b5`, `0x51f53b`), then
/// `ADDON_LOADED` (event 429, `0x51f5ad`), whose handlers see the restored values. The
/// reference then loads the addons whose `## LoadWith` names this one; this walk does not.
fn load(&mut self, script: &mut UiScript, all: &[Addon], name: &str) -> Result<(), ()> {
// Key on the addon's own spelling: a `.toc` may name a dependency in any case, and keying
// on the caller's would load the same addon twice.
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 the player's choice, not a failure: nothing is pushed to `failures`, and a
// dependent still gets `Err` (the reference's `DEP_DISABLED`).
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, `AddOn_CanLoad` check 6: after the enable state, before the dependency
// loop, an exact `==` (a missing `## Interface` is 0). Not a failure either: the AddOns
// screens show it, and the Load out of date AddOns checkbox loads it anyway.
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: a failure is ignored, a present one still loads first.
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}");
// Retained for the player, who can usually fix it by installing the dependency.
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` (`0x51f400`), after the files whose functions a binding calls, through
// the addon's own sandboxed reader; absent is normal.
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 saved variables, account then per-character (`0x51f4b5`, `0x51f53b`): after the
// files set their defaults, before `ADDON_LOADED`.
script.load_addon_saved_variables(&addon.name);
self.loaded.insert(addon.name.clone());
script.mark_addon_loaded(&addon.name);
// `arg1` is the addon's own folder name, whatever case a dependent used. Marked loaded
// first: the reference sets `[rec+0x18]` before `0x51f5ad`, so a handler's
// `IsAddOnLoaded` about itself is true.
script.fire_event("ADDON_LOADED", vec![ScriptValue::Str(addon.name.clone())]);
Ok(())
}
}
#[cfg(test)]
mod tests {
/// A `.toc` line that cannot name a file is prose, and prose is not a missing file. The cases
/// are the real ones: `ColorPickerPlus`'s wrapped `## Notes:` and
/// `DifficultBulletinBoard`'s `--` section headers against the files they sit between.
#[test]
fn a_toc_line_that_is_prose_is_not_a_file_reference() {
for file in [
"DifficultBulletinBoard.lua",
"env\\defaults.lua",
"libs\\AceOO-2.0\\AceOO-2.0.lua",
"FishingOutfitFrame.xml",
"Raids\\Other\\Nerubian.lua",
"Bindings.XML",
] {
assert!(super::is_file_entry(file), "{file} names a file");
}
for prose in [
"-- API files",
"-- Environment files (static data, constants, exclusion rules)",
"standard Color Picker to provide text entry for colors (RGB and hex values),",
"copy and paste, and starting color and copied color swatches.",
"",
".lua",
] {
assert!(!super::is_file_entry(prose), "{prose:?} names no file");
}
}
/// The log's collapse key: one cause, however many frames it names.
#[test]
fn repeated_warnings_share_a_shape_and_distinct_causes_do_not() {
let shape = super::warning_shape;
let inspect = [
"BCP_Inspect: Couldn't find relative frame: InspectHeadSlot",
"BCP_Inspect: Couldn't find relative frame: InspectFeetSlot",
];
assert_eq!(shape(inspect[0]), shape(inspect[1]));
assert_eq!(
shape(inspect[0]),
"BCP_Inspect: Couldn't find relative frame"
);
// No trailing identifier: the whole message is the shape, so ten of them still collapse.
let template = "unknown template 'QuickHealMeleeDPSListEntryTemplate' referenced by \
inherits; skipping";
assert_eq!(shape(template), template);
assert_ne!(shape(template), shape(inspect[0]));
}
/// NTFS `$I30` order, not byte order: case folds away and `_` (0x5F) sorts after `Z` (0x5A).
#[test]
fn the_walk_orders_names_the_way_ntfs_lists_a_directory() {
let mut names: Vec<String> = [
"_LazyPig",
"Zorlen",
"zBar",
"oRA2",
"FuBar_TinyTipFu",
"Fubar_EmoteFu",
"!OmniCC",
"Ace2",
"AceGUI",
]
.iter()
.map(|s| (*s).to_string())
.collect();
sort_by_directory_order(&mut names);
assert_eq!(
names,
vec![
// `!` (0x21) sorts ahead of every letter.
"!OmniCC",
// The digit `2` (0x32) before `G` (0x47).
"Ace2",
"AceGUI",
// Case folded: `E` < `T` decides it, not `u` vs `B`.
"Fubar_EmoteFu",
"FuBar_TinyTipFu",
"oRA2",
"zBar",
"Zorlen",
"_LazyPig",
]
);
// Byte order disagrees, so this fails if the key becomes `str`'s `Ord`.
let mut plain = names.clone();
plain.sort();
assert_ne!(plain, names, "NTFS collation is not byte order");
}
/// The `COLLATION_FILE_NAME` tie: names equal but for case compare the raw units, uppercase
/// (0x41) before lowercase (0x61), so a case-sensitive host still sorts deterministically.
#[test]
fn names_equal_but_for_case_break_the_tie_uppercase_first() {
let mut names: Vec<String> = ["fubar", "FuBar", "FUBAR"]
.iter()
.map(|s| (*s).to_string())
.collect();
sort_by_directory_order(&mut names);
assert_eq!(names, vec!["FUBAR", "FuBar", "fubar"]);
}
/// `$UpCase` maps one unit to one: `ß` stays, where `to_uppercase` gives `SS`.
#[test]
fn the_upcase_fold_is_one_to_one_like_the_table_it_models() {
assert_eq!(upcase_unit('a'), 'A');
assert_eq!(upcase_unit('_'), '_');
assert_eq!(upcase_unit('ß'), 'ß');
assert_eq!(upcase_unit('é'), 'É');
}
/// Glue's `0x51df00` array is `## Title`-sorted by `0x51deb0`, case-insensitively. Folder names
/// and titles are different permutations, and `Middle` is the case control.
#[test]
fn the_addons_screen_lists_in_title_order_not_folder_order() {
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("listorder");
write_addon(
&home,
"Alpha",
"## Interface: 11200\n## Title: zebra\n",
&[],
);
write_addon(
&home,
"Mike",
"## Interface: 11200\n## Title: Middle\n",
&[],
);
write_addon(
&home,
"Zulu",
"## Interface: 11200\n## Title: aardvark\n",
&[],
);
// No `## Title`: sorts under its folder name.
write_addon(&home, "bravo", "## Interface: 11200\n", &[]);
let rows = installed_rows();
assert_eq!(
rows.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
vec!["Zulu", "bravo", "Mike", "Alpha"],
"titles: {:?}",
rows.iter()
.map(InstalledAddOn::display_title)
.collect::<Vec<_>>()
);
}
/// `AddOn_ScanAddOnDir 0x51c760` scans the archives (`0x51c777`) before the loose folders
/// (`0x51c78f`), and `0x51c9b0` returns on a name already registered (`0x51ca10`).
#[test]
fn the_archive_pass_registers_first_and_wins_a_duplicate() {
let _data = benilla_formats::wow_data_or_skip!();
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("scanorder");
let chain = chain_addons();
if chain.is_empty() {
eprintln!("skipping: the chain carries no Blizzard addon rows");
return;
}
let collide = chain[0].name.clone();
write_addon(
&home,
&collide,
"## Interface: 11200\n## Title: Impostor\n",
&[],
);
write_addon(&home, "Loose", "## Interface: 11200\n", &[]);
let found = discover();
let names: Vec<&str> = found.iter().map(|a| a.name.as_str()).collect();
let first_loose = names.iter().position(|n| *n == "Loose").expect("Loose");
assert_eq!(
first_loose,
chain.len(),
"the loose folder follows all {} archive rows: {names:?}",
chain.len()
);
assert_eq!(
names.iter().filter(|n| **n == collide).count(),
1,
"{collide} is registered once: {names:?}"
);
let row = found.iter().find(|a| a.name == collide).unwrap();
assert!(
matches!(row.source, Source::Chain),
"the archive's copy won"
);
assert_ne!(
row.toc.directive("Title"),
Some("Impostor"),
"the loose manifest did not overwrite the archive's"
);
}
/// Every chain row is LoadOnDemand and chain-sourced, the eight windows the interface opens
/// through them are rows, and `benilla.toc` lists none, like the reference's `FrameXML.toc`.
#[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_rows()
.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()),
}
}
/// A sibling library addon is reachable (`Bagnon/src/main.xml` includes
/// `..\..\BagBrother\core\core.xml`), the machine is not; paths go through `join_ref` first.
#[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),
};
assert_eq!(addon.prefix(), "Interface/AddOns/Probe");
let src = "Interface/AddOns/Probe/src";
assert_eq!(
addon.read(&join_ref(src, "own.txt")).as_deref(),
Some(&b"yes"[..])
);
assert_eq!(
addon
.read(&join_ref(src, "..\\..\\ProbeLib\\core\\lib.xml"))
.as_deref(),
Some(&b"sibling"[..]),
"a shared library addon must be reachable from the addon that includes it"
);
// Above the AddOns root touches no file: the collapsed path leaves `Interface/AddOns/` and
// can only ask the archive chain. `secret.txt` beside the root is unreachable.
assert!(addon.read(&join_ref(src, "../../../secret.txt")).is_none());
assert!(addon
.read(&join_ref("Interface/AddOns/Probe", "..\\secret.txt"))
.is_none());
assert!(
addon
.read(&join_ref(src, "../../../../../../../../etc/passwd"))
.is_none(),
"over-consuming `..` cannot walk out of the install either"
);
// `join_ref` re-roots a leading `/` at the base; with none it is a bare relative name, a
// chain lookup that misses.
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 `.toc`-listed `Bindings.xml` is walked as FrameXML, each `<Binding>` an unknown frame
/// type, which the reference logs and carries on from (`0x6ee356`).
#[test]
fn a_toc_listed_bindings_xml_is_not_a_script_error() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-tocbindings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("Probe")).unwrap();
std::fs::write(
root.join("Probe/Bindings.xml"),
r#"<Bindings>
<Binding name="PROBE_STEPUP" header="PROBE">ProbeStep_Inc()</Binding>
<Binding name="PROBE_STEPDOWN">ProbeStep_Dec()</Binding>
</Bindings>"#,
)
.unwrap();
std::fs::write(root.join("Probe/Probe.lua"), "PROBE_RAN = 1").unwrap();
let addon = Addon {
name: "Probe".into(),
toc: Toc::parse("## Interface: 11200\nBindings.xml\nProbe.lua\n"),
source: Source::Dir(root),
};
let script = UiScript::new().unwrap();
let failures = addon.load(&script);
assert!(
failures.is_empty(),
"a listed Bindings.xml costs log lines, not script errors: {failures:?}"
);
assert!(
script.eval::<bool>("return PROBE_RAN == 1").unwrap(),
"and the manifest carries on to the next line"
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// The binding table is found whatever case the addon spelled its file in: the reference's
/// one file namespace is a Windows one, and `0x51f400` asks for the fixed name `Bindings.xml`
/// rather than reading a `.toc` entry, so a package shipping `bindings.xml` has to resolve.
///
/// AlternativeClassicActionBars is that package. On a case-sensitive filesystem the open
/// missed and its 69 `<Binding>` rows never registered — with nothing raised anywhere, since
/// an absent binding table is the normal case. The whole addon's keybinding surface was dead:
/// its hover-bind, its edit-mode escape and every bar page key.
#[test]
fn a_binding_table_resolves_whatever_case_the_package_spelled_it_in() {
// This test moves `BENILLA_HOME` and `WOW_CAPTURE`, which are process-wide: without the
// lock every other env-moving test in this module can see a home that is not its own.
// It is the one such test here that was missing it, and the collision shows up as a
// neighbour reading the wrong roster, not as a failure here.
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-lcbindings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("state");
let dir = home.join("AddOns/Probe");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Probe.toc"), "## Interface: 11200\nProbe.lua\n").unwrap();
std::fs::write(dir.join("Probe.lua"), "PROBE_RAN = 1").unwrap();
// Lower-case `b`, as shipped.
std::fs::write(
dir.join("bindings.xml"),
r#"<Bindings>
<Binding name="PROBEBIND1">Probe_Fire(1)</Binding>
</Bindings>"#,
)
.unwrap();
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);
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
let bodies = script.addon_binding_bodies();
assert!(
bodies.iter().any(|b| b.name == "PROBEBIND1"),
"the binding table did not register: {:?}",
bodies.iter().map(|b| &b.name).collect::<Vec<_>>()
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A `.toc` line resolves against the addon folder, `<Script file=X>` against
/// `dirname(referrer)` (`0x6ee07b`), and both chunks are `"@%s"` of the path (`0x704bc0`).
#[test]
fn an_xml_referenced_lua_is_named_like_a_toc_listed_one() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-chunkname-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("Probe/Core")).unwrap();
std::fs::write(
root.join("Probe/listed.lua"),
"LISTED = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/viaxml.lua"),
"VIAXML = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/doc.xml"),
r#"<Ui><Script file="viaxml.lua"/></Ui>"#,
)
.unwrap();
let toc = "## Interface: 11200\nlisted.lua\nCore\\doc.xml\n";
let addon = Addon {
name: "Probe".into(),
toc: Toc::parse(toc),
source: Source::Dir(root),
};
let script = UiScript::new().unwrap();
assert!(addon.load(&script).is_empty());
// The exact pattern the libraries run, on each file's own traceback.
let folder = |global: &str| -> Option<String> {
script
.eval::<Option<String>>(&format!(
"local _,_,f = string.find({global} or \"\", \"\\\\AddOns\\\\(.-)\\\\\") return f"
))
.unwrap()
};
assert_eq!(
folder("LISTED").as_deref(),
Some("Probe"),
"a manifest-listed file has always been named right"
);
assert_eq!(
folder("VIAXML").as_deref(),
Some("Probe"),
"and an XML-referenced one must be named the same way — this was nil"
);
// Both chunks carry the full install path that `\AddOns\` is split out of.
for g in ["LISTED", "VIAXML"] {
let frame = script.eval::<String>(&format!("return {g}")).unwrap();
assert!(
frame.contains("Interface\\AddOns\\Probe\\"),
"{g} is named after the install path, not the AddOns root: {frame}"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[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);
}
/// End to end through `framexml::parse`, the loader and the VM: a `.toc`, an XML frame, an
/// `<Include>`d file, a sibling library addon and Lua that calls a client-API global.
#[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: the manifest names one file in a subfolder with a backslash path, which
// reaches a sibling file by bare name and a shared library addon by `..\..`.
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 library includes a file of its own by bare name, so the base follows the include.
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> resolves against the including file's directory (src/), not \
the addon root, which is where Bagnon's `templates.xml` is found"
);
assert_eq!(
script.eval::<bool>("return ProbeLibLoaded == true").ok(),
Some(true),
"`..\\..\\ProbeLib\\core\\lib.xml` reaches a sibling addon, the shared-library \
pattern a per-addon sandbox would block"
);
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);
}
#[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();
// `Bump.xml` counts its own runs, so a second load is observable.
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);
}
/// `local_state::home()` is `None` under `$WOW_CAPTURE`, so `None` here is the capture case.
#[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");
}
#[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"));
}
/// A folder with no `.toc` is not an addon; one whose `.toc` differs only in case is.
#[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 ─────────────────────────────
/// A frame that appends `"<event>:<arg1>"` to `EventLog` for each event it registers, loaded as
/// an addon file so the events travel a real addon's `<OnEvent>` path.
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>"#
)
}
fn event_log(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return EventLog")
.expect("EventLog")
}
/// Addons guard their handler with `if arg1 == "MyAddon" then`, so a wrong `arg1` looks like
/// 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 runs before the event; its marker proves the order.
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)"
);
}
/// FrameXML gets no `ADDON_LOADED` in the reference, and our interface loads like it, through
/// [`super::manifest`] and never the walk; this loads both ways and reads the log.
#[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 as production loads it: 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"
);
}
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
#[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: the state folder is the tempdir, so discovery finds only this addon.
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 and enable state ──────────────────────────
/// An AddOns root under a temp `benilla-config`, `BENILLA_HOME` on it while the guard lives.
fn hermetic_root(tag: &str) -> (PathBuf, crate::local_state::test_env::EnvGuard) {
// The pid keeps two test binaries running at once from wiping each other's tree.
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)
}
/// `AddOn_CanLoad` check 6: exact `==`, a missing `## Interface` is 0 and out of date, and a
/// gated addon's dependent is blocked; `checkAddonVersion` off loads the 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: parses as 0, out of date
&[("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, with the checkbox's other state.
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, for files that are not UTF-8.
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();
}
}
/// A cp1252 `.toc` is still a manifest, a BOM'd `.lua` runs, and a cp1252 `.lua` is found. Lua
/// 5.0 strings are bytes and the reference hands `luaL_loadbuffer` the file unmodified, so a
/// cp1252 literal keeps its byte length.
#[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");
// `## Title:` holds a cp1252 a-umlaut (0xE4).
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
write_addon_bytes(
&home,
"Umlaut",
&toc,
&[
("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 found it. One, not thirteen: `SMSG_ADDON_INFO` answers status 2 for the
// chain's twelve secure addons, and the Lua index space drops them.
seat_stock_addon_reply(&mut script);
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
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"
);
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"
);
// The cp1252 literal is still bytes, as in the reference.
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"
);
}
/// A row for an addon not installed now survives the AddOns screen's write.
#[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;
}
/// Seat the `SMSG_ADDON_INFO` reply hiding the secure addons, as world entry does: the Lua
/// index space does not exist until the server answers, as in the reference.
fn seat_stock_addon_reply(script: &mut UiScript) {
let hidden: Vec<String> = benilla_protocol::messages::STOCK_SECURE_ADDONS
.iter()
.map(|a| a.name.to_string())
.collect();
script.note_addon_info_reply(&hidden);
}
/// The in-game `GetAddOnInfo` (`0x48e390`) answers seven values, `name, title, notes, enabled,
/// loadable, reason, security`; glue's (`0x46d460`) answers eight, with `url` at slot 4.
#[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);
// One, not thirteen: the chain's twelve secure addons are hidden by the reply.
seat_stock_addon_reply(&mut script);
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
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, where glue's binding has url
"1".into(), // loadable
"nil".into(), // no reason: it loads
"INSECURE".into(),
// No eighth value in-game (glue's `newVersion`).
"nil".into(),
])
);
// AceLibrary's guard, `local name, _, _, enabled, loadable = GetAddOnInfo(major)`: with
// `url` in slot 4, `enabled` would be nil and Ace would silently skip its dependency.
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());
}
/// An addon the enable file does not mention loads, so a dropped-in folder 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 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"
);
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());
}
/// `LoadAddOn` is synchronous: `UIParentLoadAddOn` uses the addon's frames on the next line.
#[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, not run; one row, the chain's twelve hidden by the reply.
seat_stock_addon_reply(&mut script);
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
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)
);
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());
}
/// A LoadOnDemand options package on a dependency the startup walk loaded (`/msbt`'s shape):
/// without the walk's `mark_addon_loaded` stamp the gate answers `DEP_NOT_DEMAND_LOADED`.
#[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);
assert_eq!(
script.eval::<bool>("return IsAddOnLoaded('Base') == 1").ok(),
Some(true),
"a startup-loaded addon reads as loaded, else a dependent 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 demand load treats a `.toc` line naming a missing file as the startup walk does: the
/// reference logs `Couldn't open %s` and carries on (`0x6edaa0`).
#[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.
&[("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"
);
// A warning, never a script error: the addon harness and `smoke.sh` both count errors.
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:?}"
);
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: `UIParent.lua:642` looks up
/// `getglobal("ADDON_"..reason)` for the label, so an invented token renders as nil.
#[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, as it is not LoadOnDemand: the success case.
("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());
}
/// An on-demand load names itself on the host's notice channel, once. The startup walk logs
/// every addon it loads; this path logged none, so a session log could not say which window
/// was open when the client stalled — and the stock `*_LoadUI` glue calls `LoadAddOn` on every
/// open, so a line per call would have buried the one that did the work.
#[test]
fn an_on_demand_load_names_itself_once_and_a_refusal_names_its_reason() {
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("notices");
write_addon(
&home,
"Window",
"## Interface: 11200\n## LoadOnDemand: 1\nran.lua\n",
&[("ran.lua", "WindowRan = true")],
);
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);
// The startup walk's own notices, out of the way: what follows is the demand path alone.
let _ = script.take_notices();
script.run("LoadAddOn('Window')").unwrap();
assert_eq!(
script.take_notices(),
vec!["LoadAddOn: Window loaded on demand".to_string()],
"the load that ran the files names the addon"
);
assert_eq!(
script.eval::<bool>("return WindowRan == true").ok(),
Some(true),
"and it really loaded"
);
script.run("LoadAddOn('Window')").unwrap();
assert!(
script.take_notices().is_empty(),
"a redundant load is a no-op and says nothing"
);
script.run("LoadAddOn('Off')").unwrap();
assert_eq!(
script.take_notices(),
vec!["LoadAddOn: Off refused (DISABLED)".to_string()],
"a refusal carries the reference's own reason token"
);
script.run("LoadAddOn('NoSuchAddon')").unwrap();
assert_eq!(
script.take_notices(),
vec!["LoadAddOn: no addon named \"NoSuchAddon\" (MISSING)".to_string()],
"a name that is not installed says so"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`. `Contested` is the
/// control: the roster disagrees about it, so the newcomer gets its `## DefaultState`.
#[test]
fn a_character_with_no_file_inherits_the_rosters_unanimous_disable() {
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("inherit");
write_addon(
&home,
"Shunned",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "ShunnedRan = true")],
);
write_addon(
&home,
"Contested",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "ContestedRan = true")],
);
// Two characters with files: both turned `Shunned` off, they disagree about `Contested`.
for (who, contested) in [("Onemage", false), ("Onerogue", true)] {
let id = ("Realm".to_string(), who.to_string());
write_enable_state(
Some(&id),
&[("Shunned".into(), false), ("Contested".into(), contested)],
);
}
let roster = [
"Onemage".to_string(),
"Onerogue".to_string(),
"Freshling".to_string(),
];
let fresh = ("Realm".to_string(), "Freshling".to_string());
assert!(
!enable_state_path(Some(&fresh)).unwrap().exists(),
"the premise: a newly created character has written nothing"
);
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&fresh), &roster, true);
assert_eq!(
script.eval::<bool>("return ShunnedRan == nil").ok(),
Some(true),
"the unanimous disable reaches the character who never expressed one"
);
assert_eq!(
script.eval::<bool>("return ContestedRan == true").ok(),
Some(true),
"…and a contested addon falls to its `## DefaultState`, which is enabled"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// A name on no node passes every node (`0x51e55a jne 0x51e611`) and takes the
/// `DefaultState ? 2 : 0` epilogue; `Known`, with a node and no row, is the control.
#[test]
fn an_unknown_character_takes_the_manifest_default_not_the_aggregate() {
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("unknown-char");
// `OptIn` ships `DefaultState: disabled`, so the epilogue's two outcomes differ.
write_addon(&home, "Shunned", "## Interface: 11200\n", &[]);
write_addon(
&home,
"OptIn",
"## Interface: 11200\n## DefaultState: disabled\n",
&[],
);
for who in ["Onemage", "Onerogue"] {
let id = ("Realm".to_string(), who.to_string());
write_enable_state(
Some(&id),
&[("Shunned".into(), false), ("OptIn".into(), true)],
);
}
let roster = [
"Onemage".to_string(),
"Onerogue".to_string(),
"Known".to_string(),
];
let store = EnableStore::load("Realm", &roster);
// `Known` has a node and no rows: both addons inherit their unanimous aggregate.
assert!(!store.enabled_for("Shunned", true, Some("Known")));
assert!(store.enabled_for("OptIn", false, Some("Known")));
// `Stranger` is on no node: each addon answers its own manifest default.
assert!(store.enabled_for("Shunned", true, Some("Stranger")));
assert!(!store.enabled_for("OptIn", false, Some("Stranger")));
// `None` (nobody picked yet) is the same epilogue.
assert!(store.enabled_for("Shunned", true, None));
assert!(!store.enabled_for("OptIn", false, None));
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
#[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();
// Registry order: the chain's Blizzard rows, then the folder's two (`0x51c777` before
// `0x51c78f`, tail-inserted), one line per registry row.
let mut expected = String::new();
for a in chain_addons() {
expected.push_str(&format!("{}: enabled\n", a.name));
}
expected.push_str("Drop: disabled\nKeep: enabled\n");
assert_eq!(
written, expected,
"the reference's own one-line-per-addon format"
);
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 ─────────────────────────────
/// The account file, then the per-character one (`0x51f4b5`, `0x51f53b`), then `ADDON_LOADED`
/// (`0x51f5ad`): the file-scope default is set, overwritten, then read by the handler.
#[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",
&[
(
"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), true);
// ── 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 reference writer's grammar (`0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB
/// indent, a trailing comma on every entry, a bracketed key for a list entry too (`[1] = 1,`),
/// and the file split by scope. Asserted as bytes, not by reloading.
#[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), true);
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}"
);
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 (`0x490c2a` before `0x490c7e`/`0x490c83`), so a
/// value its handler sets reaches 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), true);
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 writer `0x51ef20` emits the enable hash the reader `0x51ebe0` built from the file, an
/// entry per line as written: a row survives an uninstall, and a name keeps its spelling.
#[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);
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}"
);
assert!(
after.contains("myaddon:"),
"the file's spelling is what the reference writes back: {after}"
);
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());
}
/// `AddOn_Load 0x51f240` has no re-entrancy guard (the visiting byte `[UIADDON+0x2d]` lives in
/// `AddOn_CanLoad 0x51e780`) but stamps the loaded byte before the dependency loops
/// (`0x51f313 mov byte [rec+0x18],1`), so the re-entered frame exits at `0x51f2d6`/`0x51f2db`
/// with 1. A cycle loads both with no reason token, `ADDON_LOADED` fires B then A, and
/// `IsAddOnLoaded('Ping')` is 1 while Pong's 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.
assert_eq!(
script
.eval::<bool>("return PingRan == nil and PongRan == nil")
.ok(),
Some(true)
);
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"
);
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,")
);
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());
}
}