//! **Where interfaces come from** — the addon folder, and the walk that loads what is in it //! (decision 1184, building 1178 step 2). //! //! 1178 step 1 made benilla's own interface load from a `.toc`. This is the other half of the //! claim: the same parser, the same loader, and the same walk now run over folders that are not //! ours. An [`Addon`] is a name, a parsed manifest, and a [`Source`] its files come from — and //! benilla's own interface is simply the one whose source is the compiled-in tree. //! //! ## Where we look — ONE folder //! //! **`/AddOns//.toc`**, and nothing else (decision 1185). 1184 also //! searched the WoW install's own `Interface/AddOns/` and merged the two; the director's call is //! one root, changeable in future *by choice* but never both at once. Resolved through //! [`crate::local_state`], the only module allowed to compute that path (0954), which is already //! `None` under `$WOW_CAPTURE` — so a capture discovers nothing and a deterministic baseline //! cannot depend on what somebody has installed (0008). //! //! What the install's folder actually holds, checked rather than assumed: twelve `Blizzard_*` //! folders containing **only a `.pub` signature file** — their real code is inside the MPQs, which //! we do not read for addons at all — plus whatever the machine's owner has put there. Nothing was //! being gained by searching it. //! //! ## Two kinds of file in a manifest //! //! A `.toc` lists `.lua` **and** `.xml`, and they load differently: Lua is executed as a chunk in //! the shared global state, FrameXML is parsed and materialized. See [`Addon::load_files`]. //! //! ## The load order is the reference's, not a topological sort //! //! `AddOn_Load 0x51f240` is **recursive**, and wow-5875-re has it byte-verified //! (`system/ui/ui.md`, the SavedVariables §5 quad): for each addon, in order — //! **OptionalDeps first (failures ignored)** → **RequiredDeps (a failure ABORTS this addon's //! load)** → **its own `.toc`-listed files, in listed order**. We match that shape, because a //! pre-sorted flat list cannot express "a missing hard dependency drops exactly this addon and //! nothing else". //! //! **Our own interface is not an addon for lifecycle purposes.** The same source records that //! *FrameXML does not go through `AddOn_Load`, so it gets no `ADDON_LOADED`* — and `benilla.toc` //! is excluded exactly as FrameXML is. Structurally, not by a filter: it loads through //! [`super::manifest::load_ingame_ui`], while [`Walk`] only ever walks what [`discover`] found. //! It shares the loader; it does not share the event. //! //! ## Per-addon `Bindings.xml` //! //! Loaded right after the `.toc` files and before the saved variables (`0x51f400`) — 1188 phase 4, //! in [`Walk::load`]. Parsed by [`benilla_ui::bindings_xml`] and registered into the same //! key-binding table the Key Bindings window edits, so an addon's binding is a row like any //! other; the app's dispatch runs its Lua body ([`crate::bindings`]). use std::collections::HashSet; use std::path::{Path, PathBuf}; use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript}; use benilla_ui::toc::Toc; use super::content; /// The folder name under each root, and the reference's own spelling. const ADDONS_DIR: &str = "AddOns"; /// The world-entry load's VM instruction bound, per addon (decision 1306) — and the addon /// harness's survey bound, one number for both so the measurement stays shared. MEASURED, not /// guessed (e463649e): across the 218-addon corpus, 214 addons execute under 1M VM instructions /// and the heaviest legitimate one (Enchantrix) reaches 4M — this is ~50x that. An addon that /// crosses it is not slow, it is not coming back; the raise fails that addon with a distinctive /// message and the walk loads everyone else, where before 1306 the client sat frozen on the /// loading screen with zero diagnostics (B271's class). pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000; /// Where one interface's files come from. /// /// The three arms are the same question asked of three stores, and keeping them one enum is what /// lets [`Addon::load`] be written once: our own interface, a third-party folder and the player's /// own install differ in where the bytes live, not in how they load. pub(super) enum Source { /// benilla's own interface — the compiled-in tree, which a dev build shadows with `assets/ui` /// on disk so editing FrameXML costs no recompile ([`content`], 1175). Builtin, /// **The AddOns root**, not this addon's own folder (decision 1186). Paths handed to /// [`Addon::read`] are relative to it, so `Bagnon/src/main.xml` and the /// `BagBrother/core/core.xml` it includes are both expressible — which is the point, since a /// shared library addon exists to be reached from its dependents. Dir(PathBuf), /// **The player's own installed patch chain** — the reference FrameXML this client sources /// rather than shipping a copy of (decision 1751, [`super::reference_ui`]). Paths are full /// chain-internal paths (`Interface\FrameXML\ContainerFrame.xml`), so the prefix is empty and /// the chain resolves `/` and `\` alike, case-insensitively. Chain, } /// One loadable interface: a name, its parsed manifest, and where its files come from. pub(super) struct Addon { /// The addon's folder name — and, for the builtin, `"benilla"`. This is what /// `GetAddOnInfo`/`IsAddOnLoaded` will key on (1188 phase 2) and what `ADDON_LOADED` carries. pub(super) name: String, /// The parsed `.toc`: the ordered file list plus every directive. pub(super) toc: Toc, source: Source, } impl Addon { /// An interface from an explicit source — [`super::reference_ui::addon`]'s constructor, and /// the only one that is not derived from a `.toc` on some store. pub(super) fn new(name: String, toc: Toc, source: Source) -> Self { Addon { name, toc, source } } /// benilla's own interface. Always present — it is in the binary (1175), so unlike a /// discovered addon it cannot be missing. pub(super) fn builtin() -> Self { let toc = content::read(super::manifest::MANIFEST) .map(|t| Toc::parse(&t)) .unwrap_or_else(|| { error!( "ui_script: {} is not in the shipped UI — no interface will load", super::manifest::MANIFEST ); Toc::default() }); Addon { name: "benilla".to_string(), toc, source: Source::Builtin, } } /// The text of one file, by a path already resolved into the **source's** path space — the /// AddOns root for a `Dir`, the flat shipped tree for the builtin. /// /// The caller does the relative-path arithmetic ([`benilla_ui::loader::join_ref`]); this only /// reads. That split is decision 1186's: only the loader knows the include tree, and only the /// source knows what a resolved path is allowed to reach. /// /// **The sandbox is the AddOns folder, not one addon's subfolder.** `..` that walks above the /// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it — so an addon can /// reach a sibling library addon (which is how `Bagnon` reaches `BagBrother`, and what 1184's /// per-addon guard wrongly blocked) but cannot reach the machine. /// /// The **builtin alone** keeps a basename fallback: its tree is flat, and a transcription that /// writes a Blizzard-style directory path should still find the file. A `Dir` source must not /// have it — a basename fallback silently rescues exactly the escaping path the guard just /// refused. /// Returns **bytes**, not text (decision 1193). A `.lua` chunk is handed to Lua as it sits on /// disk, and only an XML/`.toc` parse decodes — because a `read_to_string` here did not make a /// cp1252 locale file lose a glyph, it made the file *not exist*. fn read(&self, req: &str) -> Option> { match &self.source { Source::Builtin => { let norm = req.replace('\\', "/"); let base = norm.rsplit('/').next().unwrap_or(&norm).to_string(); content::read(&norm) .or_else(|| content::read(&base)) .map(String::into_bytes) } Source::Dir(root) => read_under(root, req), Source::Chain => super::reference_ui::read(req), } } /// This addon's own folder in its source's path space — the `base` its manifest entries are /// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`, and /// `""` again for the chain, whose manifest entries are already full internal paths. fn prefix(&self) -> &str { match &self.source { Source::Builtin | Source::Chain => "", Source::Dir(_) => &self.name, } } /// The name Lua gives a chunk from this source — **not cosmetic**, because addons parse it. /// /// An addon's file is named the way the client names it, `Interface\AddOns\\` /// ([`benilla_ui::script::addon_chunk_name`], whose header carries the FuBar `debugstack` /// story). A **chain** file is named after its own chain path, `@Interface\FrameXML\…`, which /// is both what the real client names it and what keeps FrameXML *out* of the `\AddOns\` /// pattern those addons match against — FrameXML is not an addon. /// /// The loader gives an inline `", ) .unwrap(); // The sibling library addon, reached across the AddOns root — and it includes a file of // its OWN by bare name, so the base has to follow the include down a level rather than // staying on the includer's. std::fs::write( tmp.join("ProbeLib/core/lib.xml"), "", ) .unwrap(); std::fs::write( tmp.join("ProbeLib/core/deep.xml"), "", ) .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::("return ProbeAddonInclude == true").ok(), Some(true), "a bare-name resolved against the INCLUDING FILE's directory (src/), not \ the addon root — Bagnon's `templates.xml` missed entirely before 1186" ); assert_eq!( script.eval::("return ProbeLibLoaded == true").ok(), Some(true), "`..\\..\\ProbeLib\\core\\lib.xml` reached a SIBLING addon — the shared-library \ pattern 1184's per-addon sandbox blocked" ); assert_eq!( script.eval::("return ProbeLibDeep == true").ok(), Some(true), "and the sibling's own bare-name resolved against ITS folder, so the base \ follows the include tree down rather than staying on the includer" ); assert_eq!( script .eval::("return ProbeAddonGreeting == 'hello'") .ok(), Some(true), "a ", ) .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::("return SharedLoads").ok(), Some(1), "the shared dependency's files ran exactly once" ); let _ = std::fs::remove_dir_all(&tmp); } /// A dependency cycle is reported once and does not recurse forever. #[test] fn a_dependency_cycle_is_caught() { let tmp = std::env::temp_dir().join(format!("benilla-addon-cycle-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); let all = vec![ dir_addon(&tmp, "Ping", "## Dependencies: Pong\n"), dir_addon(&tmp, "Pong", "## Dependencies: Ping\n"), ]; let mut script = UiScript::new().unwrap(); let mut w = Walk::default(); for a in &all { let _ = w.load(&mut script, &all, &a.name); } assert!(w.loaded.is_empty(), "neither side of a cycle loads"); assert!( w.failures.iter().any(|f| f.contains("cycle")), "the cycle is named: {:?}", w.failures ); let _ = std::fs::remove_dir_all(&tmp); } /// There is exactly ONE addon root and it is ours (decision 1185) — and a capture has none, /// so a baseline cannot depend on what is installed on the machine that runs it (0008). /// /// The capture arm is `local_state::home()`'s, already hermetic under `$WOW_CAPTURE`, which is /// why passing `None` here IS the capture case rather than a stand-in for it. #[test] fn there_is_one_addon_root_and_it_is_ours() { assert_eq!( root_from(Some(PathBuf::from("/state"))), Some(PathBuf::from("/state/AddOns")) ); assert_eq!(root_from(None), None, "a capture run has no addon root"); } /// A manifest's `.lua` entries run as chunks; everything else is FrameXML. /// /// The classifier, not the load — `\` is a path separator in a `.toc` written for Windows, and /// the extension compare is case-insensitive for the same reason discovery's is. #[test] fn lua_entries_are_told_apart_from_framexml() { assert!(is_lua("Core.lua")); assert!(is_lua("Libs\\LibStub\\LibStub.LUA")); assert!(is_lua("deep/nested/file.Lua")); assert!(!is_lua("Frames.xml")); assert!(!is_lua("Bindings.XML")); assert!(!is_lua("README")); assert!(!is_lua("weird.lua.xml")); } /// An addon folder with no matching `.toc` is not an addon, and one whose `.toc` differs only /// in case still is — the reference is a case-insensitive filesystem and a real addon shipped /// as `MyAddon/myaddon.toc` has to load on Linux too. #[test] fn discovery_matches_the_manifest_case_insensitively() { let tmp = std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(tmp.join("Cased")).unwrap(); std::fs::create_dir_all(tmp.join("Bare")).unwrap(); std::fs::write(tmp.join("Cased/cased.TOC"), "## Interface: 11200\n").unwrap(); std::fs::write(tmp.join("Bare/notes.txt"), "not an addon").unwrap(); assert!(manifest_in(&tmp.join("Cased"), "Cased").is_some()); assert!(manifest_in(&tmp.join("Bare"), "Bare").is_none()); let _ = std::fs::remove_dir_all(&tmp); } // ───────────────────────────── lifecycle events (1188 phase 1) ───────────────────────────── /// A frame that appends `":"` to a global for every event it is registered for — /// the witness the lifecycle tests read. Written as an addon file rather than injected into /// the VM, so the events travel the same `` path a real addon's would. fn recorder_xml(events: &[&str]) -> String { let registers: String = events .iter() .map(|e| format!("this:RegisterEvent(\"{e}\");")) .collect(); format!( r#" {registers} table.insert(EventLog, event .. ":" .. tostring(arg1)); "# ) } /// Read the witness back as a `Vec`. fn event_log(script: &UiScript) -> Vec { script .eval::>("return EventLog") .expect("EventLog") } /// **`ADDON_LOADED` reaches a third-party addon, carries its own name, and fires after its /// files** — 1188 phase 1's acceptance test, and the half of 1178's falsifier that said /// *"today it loads and reads; it does not receive"*. /// /// The `arg1` half is not cosmetic: every addon in the ecosystem opens with /// `if arg1 == "MyAddon" then` and does nothing at all otherwise, so a wrong or missing arg1 /// is indistinguishable from the event never arriving. #[test] fn addon_loaded_carries_the_addons_own_name_and_fires_after_its_files() { let tmp = std::env::temp_dir().join(format!("benilla-addon-loaded-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let dir = tmp.join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.lua\nprobe.xml\n", ) .unwrap(); // File-scope Lua, which the reference runs BEFORE the event. Recording the marker here and // asserting it below is what proves the ordering rather than merely the delivery. std::fs::write( dir.join("probe.lua"), "EventLog = {}\ntable.insert(EventLog, \"files-ran\")\n", ) .unwrap(); std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap(); let all = vec![Addon { name: "EventProbe".into(), toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()), source: Source::Dir(tmp.clone()), }]; let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let mut w = Walk::default(); let _ = w.load(&mut script, &all, "EventProbe"); assert!(w.failures.is_empty(), "load errors: {:?}", w.failures); assert_eq!( event_log(&script), vec!["files-ran", "ADDON_LOADED:EventProbe"], "the addon's own files run first, THEN ADDON_LOADED with its own folder name as arg1 \ (`AddOn_Load 0x51f240`: files 0x51f3fa, event 0x51f5ad)" ); } /// **`benilla` never appears in an `ADDON_LOADED`.** /// /// FrameXML does not go through `AddOn_Load` and gets no such event (wow-5875-re /// `system/ui/ui.md`), and our own interface is FrameXML's counterpart. An addon that watches /// `ADDON_LOADED` to detect *another* addon would otherwise see a name no reference client /// ever sends. /// /// The guard is structural — the builtin loads through [`super::manifest`] and never enters /// [`Walk`] — so this asserts the structure holds rather than that a filter fires: it loads /// the builtin's own manifest entries the way production does, alongside a real walk. #[test] fn the_builtin_interface_never_fires_addon_loaded() { let tmp = std::env::temp_dir().join(format!( "benilla-addon-builtin-silent-test-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&tmp); let dir = tmp.join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.xml\n", ) .unwrap(); std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap(); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script.run("EventLog = {}").unwrap(); let all = vec![Addon { name: "EventProbe".into(), toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()), source: Source::Dir(tmp.clone()), }]; let mut w = Walk::default(); let _ = w.load(&mut script, &all, "EventProbe"); // The builtin, loaded the way production loads it — through its own manifest, not the walk. let builtin = Addon::builtin(); let _ = builtin.load_files(&script, builtin.toc.files.get(..1).unwrap_or_default()); let log = event_log(&script); assert!( !log.iter().any(|e| e.contains("benilla")), "benilla must never appear in an ADDON_LOADED: {log:?}" ); assert_eq!( log, vec!["ADDON_LOADED:EventProbe"], "exactly the one third-party addon announced itself" ); } /// **The three UI-init events fire in the reference's order:** every addon's `ADDON_LOADED`, /// then `VARIABLES_LOADED`, then `PLAYER_LOGIN`. /// /// Byte-verified straight-line order inside `UI_Init 0x48fbf0` — `0x4900a3` loads the addons /// (each `0x51f5ad`), `0x4900b2` fires `VARIABLES_LOADED`, `0x490168` enters the cascade that /// fires `PLAYER_LOGIN`. This drives the production functions in the production order rather /// than re-stating it: [`super::load_third_party`] then [`super::super::finish_ui_load`]. /// /// It is the ordering an addon depends on — restore state on `ADDON_LOADED`, and by /// `PLAYER_LOGIN` everything saved is in place. #[test] fn the_ui_init_events_fire_in_the_reference_order() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = std::env::temp_dir().join(format!("benilla-addon-order-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); let dir = home.join("AddOns").join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.xml\n", ) .unwrap(); std::fs::write( dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED", "VARIABLES_LOADED", "PLAYER_LOGIN"]), ) .unwrap(); // Hermetic: point the whole state folder at the tempdir, so discovery finds exactly this // addon and the saved-variables read cannot touch the machine's real file. let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let _h = crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script.run("EventLog = {}").unwrap(); let failures = load_third_party(&mut script, None, true); assert!(failures.is_empty(), "load errors: {failures:?}"); crate::ui_script::finish_ui_load(&mut script); assert_eq!( event_log(&script), vec![ "ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil", "PLAYER_LOGIN:nil", ], "every non-LoadOnDemand addon's ADDON_LOADED precedes VARIABLES_LOADED, which \ precedes PLAYER_LOGIN" ); let _ = std::fs::remove_dir_all(&tmp); } // ────────────────────────── the AddOn API + enable state (1188 phase 2) ────────────────────── /// Write a whole AddOns root under a temp `benilla-config`, and point `BENILLA_HOME` at it. /// Returns the guards, which must be held for the duration of the test. fn hermetic_root(tag: &str) -> (PathBuf, crate::local_state::test_env::EnvGuard) { // **The pid is load-bearing**, not decoration: two `benilla_app` test binaries can run at // once (a concurrent session, or `--all-targets`), and a fixed path plus the // `remove_dir_all` below means each wipes the other's tree mid-test. `ui_saved.rs` keys // its temp the same way for the same reason. let tmp = std::env::temp_dir().join(format!("benilla-addon-api-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); std::fs::create_dir_all(home.join("AddOns")).unwrap(); let guard = crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); (home, guard) } /// **The version gate holds the startup walk, and force-load opens it** (decision 1292): /// the byte-verified `AddOn_CanLoad` check 6 — exact `==`, missing `## Interface` = 0 = out /// of date, a dependent of a gated addon blocked like a dependent of a disabled one — and /// the `checkAddonVersion` flag (the *Load out of date AddOns* checkbox inverted) loading /// the very same folder in full. #[test] fn the_version_gate_holds_the_walk_and_force_load_opens_it() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("version-gate"); write_addon( &home, "Fresh", "## Interface: 11200\nmain.lua\n", &[("main.lua", "FreshRan = true\n")], ); write_addon( &home, "Old", "## Interface: 11100\nmain.lua\n", &[("main.lua", "OldRan = true\n")], ); write_addon( &home, "Silent", "main.lua\n", // no ## Interface at all — parses as 0, out of date (not "unknown") &[("main.lua", "SilentRan = true\n")], ); write_addon( &home, "NeedsOld", "## Interface: 11200\n## Dependencies: Old\nmain.lua\n", &[("main.lua", "NeedsOldRan = true\n")], ); let mut script = UiScript::new().unwrap(); let failures = load_third_party(&mut script, None, true); assert!( failures.iter().any(|f| f.contains("NeedsOld")), "the gated dependency is the dependent's failure: {failures:?}" ); assert_eq!( script.eval::("return FreshRan == true").ok(), Some(true) ); assert_eq!( script .eval::("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::("return NeedsOldRan == nil").ok(), Some(true), "…and so is their dependent (DEP_INTERFACE_VERSION territory)" ); // Force-load: the same folder, the checkbox's other state — everything loads. let mut open = UiScript::new().unwrap(); let failures = load_third_party(&mut open, None, false); assert!(failures.is_empty(), "force-load load errors: {failures:?}"); assert_eq!( open.eval::( "return OldRan == true and SilentRan == true and NeedsOldRan == true" ) .ok(), Some(true), "'Load out of date AddOns' loads the very same folder in full" ); } fn write_addon(home: &Path, name: &str, toc: &str, files: &[(&str, &str)]) { let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); for (f, body) in files { std::fs::write(dir.join(f), body).unwrap(); } } /// [`write_addon`] with **raw bytes**, because the files this crate must survive are not text. fn write_addon_bytes(home: &Path, name: &str, toc: &[u8], files: &[(&str, &[u8])]) { let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); for (f, body) in files { std::fs::write(dir.join(f), body).unwrap(); } } /// **An addon whose files are not valid UTF-8 loads anyway** (decision 1193) — the corpus's /// single largest blocker, and one nobody would have guessed. /// /// Three separate failures, all of which used to be silent-ish and all of which are here: /// /// 1. a **cp1252 `.toc`** made the addon *invisible to discovery* — `manifest_in` read it with /// `read_to_string`, got `None`, and a folder with no readable manifest is not an addon. 5 /// of a real 218-addon corpus vanished this way, and the harness scored them as clean /// passes because an unparsed manifest lists no files to fail on. /// 2. a **BOM'd `.lua`** reached the lexer with `EF BB BF` in front and died on /// `unexpected symbol`. 160 corpus files carry one. /// 3. a **cp1252 `.lua`** read as absent, so the loader reported "not found" for a file that /// was right there. `AceAddon-2.0.lua` is one, embedded in ~30 addons. /// /// The literal survives as **bytes**, not as decoded text: Lua 5.0 strings are byte strings /// and the reference hands `luaL_loadbuffer` the file unmodified, so `string.len` on a cp1252 /// literal must answer what it answers there. That assertion is the one that would fail if /// somebody "helpfully" transcoded chunks to UTF-8 later. #[test] fn an_addon_whose_files_are_not_utf8_still_loads() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("encoding"); // `## Notes:` holds a cp1252 a-umlaut (0xE4) — one byte, and the whole addon disappeared. let mut toc = b"## Interface: 11200\n## Title: Sch\xE4tze\nlocale.lua\nboot.lua\n".to_vec(); toc.splice(0..0, [0xEFu8, 0xBB, 0xBF]); // ...and a BOM on the manifest too (14 in corpus). write_addon_bytes( &home, "Umlaut", &toc, &[ // A cp1252 locale file: the German/French half of the vanilla ecosystem. ("locale.lua", &b"UmlautWord = \"Sch\xE4tze\"\n"[..]), // A BOM'd chunk: valid UTF-8, three bytes the lexer cannot start on. ( "boot.lua", &b"\xEF\xBB\xBFUmlautLoaded = true\nUmlautLen = string.len(UmlautWord)\n"[..], ), ], ); let mut script = UiScript::new().unwrap(); let failures = load_third_party(&mut script, None, true); assert!(failures.is_empty(), "load errors: {failures:?}"); // Discovery saw it at all — the `.toc` decoded rather than read as absent. // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that // has a chain (1957) — the registry is one list, as the reference's is. assert_eq!( script.eval::("return GetNumAddOns()").ok(), Some(1 + chain_addons().len() as i64) ); assert_eq!( script .eval::("return GetAddOnMetadata('Umlaut', 'Title')") .ok(), Some("Sch\u{e4}tze".to_string()), "the manifest's cp1252 title decoded to the glyph its author typed, not to nothing" ); // Both chunks ran. assert_eq!( script.eval::("return UmlautLoaded == true").ok(), Some(true), "a BOM'd .lua ran — the three-byte mark was stripped, not handed to the lexer" ); // ...and the cp1252 literal is still BYTES, which is the reference's semantics. assert_eq!( script.eval::("return UmlautLen").ok(), Some(7), "`Sch\\xE4tze` is 7 bytes in the file and must be 7 bytes in Lua — transcoding the \ chunk to UTF-8 would make it 8 and silently move every string.sub in the addon" ); } /// The AddOns screen's write **merges** rather than replaces (decision 1197). /// /// A name in the file that is not installed right now belongs to an addon that will be again. /// Rewriting the file from the installed list alone forgets the player's choice on every /// uninstall — the same rule `parse_enable_state` already documents for the read side, and the /// one that has to hold on the write side or the read's carefulness is wasted. #[test] fn the_addons_screen_write_merges_with_what_is_already_on_disk() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("screenwrite"); let id = ("Realm".to_string(), "Char".to_string()); let path = enable_state_path(Some(&id)).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "Gone: disabled\nStays: enabled\n").unwrap(); write_enable_state(Some(&id), &[("Stays".into(), false), ("New".into(), true)]); let written = std::fs::read_to_string(&path).unwrap(); assert!( written.contains("Gone: disabled"), "an uninstalled addon's choice survives — {written:?}" ); assert!( written.contains("Stays: disabled"), "the edit applied — {written:?}" ); assert!( written.contains("New: enabled"), "a new row appended — {written:?}" ); let _ = home; } /// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's /// first acceptance test, and the reason the registry carries directives rather than a name. /// /// The return shape is the **in-game** one — `name, title, notes, enabled, loadable, reason, /// security`, SEVEN values — because that is the VM this runs in. /// /// **The client registers `GetAddOnInfo` twice, as two different functions**, and this test /// used to assert the wrong one. It was written off `Interface\GlueXML\AddonList.lua`, which /// describes glue's `0x46d460`: eight values, `url` at slot 4, `newVersion` at 8. The in-game /// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all /// (wow-re `system/ui/scratch/addon-version-gate.md`). /// /// Its own doc comment named the failure it then failed to catch — *"a row that shifts by one /// puts an addon's notes in its URL field, which nothing would notice"*. The row was shifted at /// slot 4 the whole time, and the test asserted the shift. That is what a falsifier copied from /// the wrong reference buys: it pins the bug. /// /// What it cost: `local name, _, _, enabled, loadable = GetAddOnInfo(major)` is AceLibrary's /// and AceAddon's shape, reached by 70 corpus folders. They read `url` as `enabled`, and since /// `## URL` is rare that is nil for nearly every addon, so `if enabled and loadable` refused to /// load the dependency. Silent — a nil where a flag belongs is legal Lua. /// /// The whole tuple is still asserted rather than the fields the phase names, for the original /// and still-correct reason. #[test] fn get_addon_info_returns_the_manifests_own_title_and_notes() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("info"); write_addon( &home, "Probe", "## Interface: 11200\n## Title: Probe Title\n## Notes: What it does\n## URL: http://example\n## Version: 1.2\n", &[], ); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, None, true); // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that // has a chain (1957) — the registry is one list, as the reference's is. assert_eq!( script.eval::("return GetNumAddOns()").ok(), Some(1 + chain_addons().len() as i64) ); assert_eq!( script .eval::>( "local n,t,no,e,l,r,s,extra = GetAddOnInfo(1) \ return { n, t, no, tostring(e), tostring(l), tostring(r), s, \ tostring(extra) }" ) .ok(), Some(vec![ "Probe".into(), "Probe Title".into(), "What it does".into(), "1".into(), // enabled — slot 4 in-game, NOT url (which glue's binding returns) "1".into(), // loadable "nil".into(), // no reason — it loads "INSECURE".into(), // The in-game binding returns SEVEN values, so there is no eighth. Asserted as // absent rather than trimmed off the query: glue's `newVersion` sat here, and a // future edit that re-adds an eighth return has to face this line. "nil".into(), ]) ); // AceLibrary's and AceAddon's literal shape, which is how 70 corpus folders reach this // verb: `local name, _, _, enabled, loadable = GetAddOnInfo(major)`. Asserted as the // GUARD they actually write, not as a tuple, because the guard is what silently failed — // with `url` in slot 4 and no `## URL` in the manifest, `enabled` was nil and Ace declined // to load its own dependency without erroring. assert_eq!( script .eval::( "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::("return (GetAddOnInfo('probe'))") .ok() .as_deref(), Some("Probe"), "index OR name, case-insensitively — the reference's verbs all take either" ); assert_eq!( script .eval::("return GetAddOnMetadata(1, 'Version')") .ok() .as_deref(), Some("1.2") ); assert_eq!( script .eval::("return IsAddOnLoaded('Probe') == 1") .ok(), Some(true) ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **Disabling an addon in the enable-state file stops it loading** — phase 2's second /// acceptance test. /// /// The file is the reference's own `AddOns.txt` format, `: enabled|disabled` per line, /// confirmed against a real 1.12 install. It also checks the direction that matters more in /// practice: an addon the file does not mention loads, so dropping a folder in just works. #[test] fn a_disabled_addon_does_not_load_and_an_unlisted_one_does() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("disable"); write_addon( &home, "Off", "## Interface: 11200\nran.lua\n", &[("ran.lua", "OffRan = true")], ); write_addon( &home, "On", "## Interface: 11200\nran.lua\n", &[("ran.lua", "OnRan = true")], ); // "On" is deliberately absent from the file. std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); let mut script = UiScript::new().unwrap(); let id = ("Realm".to_string(), "Char".to_string()); let failures = load_third_party(&mut script, Some(&id), true); assert!( failures.is_empty(), "a disabled addon is a player's choice, never a load failure: {failures:?}" ); assert_eq!( script.eval::("return OffRan == nil").ok(), Some(true), "the disabled addon's files must not have run" ); assert_eq!( script.eval::("return OnRan == true").ok(), Some(true), "an addon the file never mentions is enabled — a dropped-in folder just works" ); // And the API agrees with the loader about what happened. assert_eq!( script .eval::>( "local _,_,_,_,l,r = GetAddOnInfo('Off') return { tostring(l), tostring(r) }" ) .ok(), Some(vec!["nil".into(), "DISABLED".into()]), "not loadable, with the reference's own reason token" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **A LoadOnDemand addon loads on `LoadAddOn` and not before** — phase 2's third acceptance /// test, and the one that needs the loader to run from inside a Lua binding. /// /// `LoadAddOn` is called *from Lua*, synchronously, and its side effects are asserted on the /// next line — exactly how `UIParentLoadAddOn` uses it. A deferred implementation would fail /// here, which is the point. #[test] fn a_load_on_demand_addon_loads_only_when_asked() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod"); write_addon( &home, "Demand", "## Interface: 11200\n## Title: Demand\n## LoadOnDemand: 1\nlate.lua\nlate.xml\n", &[ ("late.lua", "DemandRan = true"), ( "late.xml", "\ DemandEvent = arg1\ ", ), ], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, true); // Discovered and described, but NOT run. // The one in the folder, plus the chain's Blizzard LoadOnDemand rows on a machine that // has a chain (1957) — the registry is one list, as the reference's is. assert_eq!( script.eval::("return GetNumAddOns()").ok(), Some(1 + chain_addons().len() as i64) ); assert_eq!( script .eval::("return IsAddOnLoadOnDemand(1) == 1") .ok(), Some(true) ); assert_eq!( script.eval::("return DemandRan == nil").ok(), Some(true), "a LoadOnDemand addon must not run at startup" ); assert_eq!( script .eval::("return IsAddOnLoaded('Demand') == nil") .ok(), Some(true) ); // ...then loads synchronously, from Lua, and is usable on the very next statement. assert_eq!( script .eval::>( "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::("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::("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::("local l = LoadAddOn('Demand') return tostring(l)") .ok() .as_deref(), Some("1") ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names /// an addon the STARTUP walk already loaded. /// /// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text /// ships its options as a separate `## LoadOnDemand: 1` package with /// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through /// `UIParentLoadAddOn`, whose failure message is the red /// `Couldn't load %s: ` dialog. The dependency being **already loaded** is the /// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is /// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands /// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for /// exactly that reason until 2102 gave it the same stamp.) #[test] fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-dep"); write_addon( &home, "Base", "## Interface: 11200\n## Title: Base\nbase.lua\n", &[("base.lua", "BaseRan = true")], ); write_addon( &home, "BaseOptions", "## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n", &[("opts.lua", "OptsRan = BaseRan")], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, true); // The dependency loaded at startup and the registry knows it — the stamp this rests on. assert_eq!( script.eval::("return IsAddOnLoaded('Base') == 1").ok(), Some(true), "a startup-loaded addon must read as loaded, or every LoadOnDemand dependent of it answers DEP_NOT_DEMAND_LOADED" ); assert_eq!( script.eval::("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::>( "local loaded, reason = LoadAddOn('BaseOptions') \ return { tostring(loaded), tostring(reason), tostring(OptsRan), \ tostring(IsAddOnLoaded('BaseOptions')) }" ) .ok(), Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **A `.toc` line naming a file the package does not ship does not stop a demand load** /// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk. /// /// The reference logs `Couldn't open %s` and carries on (wow-re /// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel /// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale: /// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an /// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session /// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus. #[test] fn a_missing_manifest_file_does_not_fail_a_demand_load() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-missing-file"); write_addon( &home, "Holey", "## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\ Locale-koKR.lua\nreal.lua\n", // `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly. &[("real.lua", "HoleyRan = true")], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, true); assert_eq!( script .eval::>( "local loaded, reason = LoadAddOn('Holey') \ return { tostring(loaded), tostring(reason), tostring(HoleyRan), \ tostring(IsAddOnLoaded('Holey')) }" ) .ok(), Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), "the addon loads, its remaining files run, and it reads as loaded" ); // The miss is a WARNING on the host channel, never a script error — the harness's session // column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole // point is that a player's broken package cannot redden either. assert!( script.take_errors().is_empty(), "a missing manifest entry must not reach the script-error channel" ); let warnings = script.take_warnings(); assert!( warnings .iter() .any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")), "…but it must still be said out loud: {warnings:?}" ); // …and retained where the player can read it (1495), as the walk's misses are. assert!( script .diagnostics() .iter() .any(|d| d.message.contains("Holey/Locale-koKR.lua")), "the miss is retained in the diagnostic log" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// The reason tokens are the reference's own, for the cases a caller branches on. /// /// They are not free-form strings: the reference splices them into /// `getglobal("ADDON_"..reason)` to find a localized label, so a token we invent renders as a /// nil global in somebody's addon manager. #[test] fn load_addon_answers_with_the_references_reason_tokens() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("reasons"); write_addon(&home, "Plain", "## Interface: 11200\n", &[]); write_addon( &home, "Off", "## Interface: 11200\n## LoadOnDemand: 1\n", &[], ); std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); let mut script = UiScript::new().unwrap(); let id = ("Realm".to_string(), "Char".to_string()); let _ = load_third_party(&mut script, Some(&id), true); for (call, want) in [ ("LoadAddOn('NoSuchAddon')", "MISSING"), ("LoadAddOn('Off')", "DISABLED"), // Loaded at startup because it is not LoadOnDemand, so this is the success case; the // NOT_DEMAND_LOADED arm needs an addon that is neither loaded nor LoadOnDemand, which // cannot happen once it is enabled — asserted through the disabled one above instead. ("LoadAddOn('Plain')", "nil"), ] { assert_eq!( script .eval::(&format!("local _, r = {call} return tostring(r)")) .ok() .as_deref(), Some(want), "{call}" ); } let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// Enable state survives the session: `DisableAddOn` from Lua is written back in the /// reference's own `AddOns.txt` format, and read back as a disable next time. #[test] fn the_enable_state_round_trips_through_the_file() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("roundtrip"); write_addon(&home, "Keep", "## Interface: 11200\n", &[]); write_addon( &home, "Drop", "## Interface: 11200\nran.lua\n", &[("ran.lua", "DropRan = true")], ); let id = ("Realm".to_string(), "Char".to_string()); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, Some(&id), true); script.run("DisableAddOn('Drop')").unwrap(); save_enable_state(&script, Some(&id)); let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap(); // The folder's two in registry order, then the chain's Blizzard rows (1957) — every // registry row has a line, the reference's own one-line-per-addon format. let mut expected = String::from("Drop: disabled\nKeep: enabled\n"); for a in chain_addons() { expected.push_str(&format!("{}: enabled\n", a.name)); } assert_eq!( written, expected, "the reference's own one-line-per-addon format" ); // A fresh session reads it back and the addon stays off. let mut next = UiScript::new().unwrap(); let _ = load_third_party(&mut next, Some(&id), true); assert_eq!( next.eval::("return DropRan == nil").ok(), Some(true), "the disable survived the session" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } // ───────────────────────────── saved variables (1188 phase 3) ───────────────────────────── /// **An addon sets a saved variable, the session ends, a new session sees it** — 1188 phase 3's /// acceptance test, end to end through the production path. /// /// It also pins the two orderings that make the mechanism work, both byte-verified: /// *account file then per-character file* (`0x51f4b5`, `0x51f53b`), so a per-character value /// wins; and *files → saved variables → `ADDON_LOADED`* (`0x51f5ad`), so the addon's own /// file-scope default is assigned first, overwritten second, and read by the handler third. /// Reverse either and the saved value can never win — which is a bug that looks exactly like /// "settings do not persist". #[test] fn an_addons_saved_variables_survive_the_session() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("saved"); write_addon( &home, "Keeper", "## Interface: 11200\n## SavedVariables: KeeperDB\n\ ## SavedVariablesPerCharacter: KeeperChar\nkeeper.lua\nkeeper.xml\n", &[ // File-scope defaults, exactly where the reference puts them. ( "keeper.lua", "KeeperDB = { count = 0 }\nKeeperChar = 'default'\n", ), ( "keeper.xml", "\ KeeperSawAtEvent = KeeperDB.count\ ", ), ], ); 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::("return KeeperSawAtEvent").ok(), Some(0), "first run: no file yet, so ADDON_LOADED sees the file-scope default" ); script .run("KeeperDB.count = 7 KeeperDB.note = 'hi' KeeperChar = 'mine'") .unwrap(); crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); // ── session two: a fresh VM reads them back ── let mut next = UiScript::new().unwrap(); next.set_screen_size(1024.0, 768.0); let failures = load_third_party(&mut next, Some(&id), true); assert!(failures.is_empty(), "{failures:?}"); assert_eq!( next.eval::("return KeeperDB.count").ok(), Some(7), "the saved value overwrote the addon's own file-scope default" ); assert_eq!( next.eval::("return KeeperDB.note").ok().as_deref(), Some("hi"), "a table round-trips whole, not just the scalar that changed" ); assert_eq!( next.eval::("return KeeperChar").ok().as_deref(), Some("mine"), "the per-character file loaded too" ); assert_eq!( next.eval::("return KeeperSawAtEvent").ok(), Some(7), "ADDON_LOADED handlers see the RESTORED value — the whole reason the event is last" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// The written bytes are the recorded grammar (decision 1128's, itself byte-verified against /// `0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB indent, a trailing comma on /// every entry, and the file split by scope. `["t"]`'s `[1] = 1,` is the reference's shape for /// a list too — its writer emits a bracketed key for every table shape and never a bare /// positional entry (wow-re `system/ui/scratch/lua-table-storage-and-next-order.md` §Q5) — /// and it reloads in index order because of what 1.12's *parser* does with it (decision 2111). /// /// Asserted as bytes rather than by re-reading, because "it round-trips through our own /// loader" is exactly the check that passes for a private format. The reference's own client /// has to be able to read this. #[test] fn the_saved_file_bytes_match_the_recorded_grammar() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("grammar"); write_addon( &home, "Gram", "## Interface: 11200\n## SavedVariables: GramDB\n\ ## SavedVariablesPerCharacter: GramChar\ngram.lua\n", &[("gram.lua", "GramDB = {}\nGramChar = 0\n")], ); let id = ("Realm".to_string(), "Char".to_string()); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, Some(&id), true); script .run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5") .unwrap(); crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); let account = std::fs::read_to_string(home.join("saved/Gram.lua")).unwrap(); let body = account.lines().skip(2).collect::>().join("\n"); assert_eq!( body, "GramDB = {\n\ \t[\"n\"] = 2,\n\ \t[\"on\"] = true,\n\ \t[\"s\"] = \"a\\\"b\",\n\ \t[\"t\"] = {\n\ \t\t[1] = 1,\n\ \t},\n\ }", "keys always bracketed and quoted, TAB indent per level, trailing comma on every \ entry, `\\\"` escaped — the recorded grammar\nGOT:\n{account}" ); // Scope split: the per-character global is in the per-character file and nowhere else. assert!(!account.contains("GramChar"), "account file: {account}"); let per_char = std::fs::read_to_string(home.join("saved/Realm-Char/Gram.lua")).unwrap(); assert!( per_char.ends_with("GramChar = 5\n"), "per-char file: {per_char}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// `PLAYER_LOGOUT` fires **before** the writes — an addon's last chance to mutate a saved /// global, and worthless if it fires after. /// /// The reference's own tail is `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` → the writes /// (`0x490c2a` before `0x490c7e`/`0x490c83`). This asserts the observable consequence rather /// than the call order: a value set *in the handler* has to reach the file. #[test] fn player_logout_fires_before_the_write() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("logout"); write_addon( &home, "Last", "## Interface: 11200\n## SavedVariables: LastDB\nlast.lua\nlast.xml\n", &[ ("last.lua", "LastDB = 'unset'"), ( "last.xml", "\ if event == \"PLAYER_LOGOUT\" then LastDB = 'written at logout' end\ \ ", ), ], ); let id = ("Realm".to_string(), "Char".to_string()); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, Some(&id), true); crate::ui_script::shutdown_ui_state(&mut script, Some(&id)); let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap(); assert!( written.contains("LastDB = \"written at logout\""), "the value the PLAYER_LOGOUT handler set must reach the file — if the write ran \ first this reads 'unset':\n{written}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **The logout write MERGES, and it keeps the file's own spelling** (decision 2139). /// /// The reference's writer `0x51ef20` emits the in-memory enable hash, and the reader /// `0x51ebe0` builds that hash from `AddOns.txt` — an entry per line, both states, each name /// strdup'd as written. The installed-folder list never enters it. So a row survives an /// uninstall, and the spelling that survives is the file's. /// /// Ours rebuilt the file from `addon_enable_states()` instead, and one logout erased /// `Uninstalled: disabled` and rewrote `myaddon:` as `MyAddon:`. #[test] fn the_logout_write_keeps_rows_it_did_not_put_there() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("logout-merge"); write_addon( &home, "MyAddon", "## Interface: 11200\nmain.lua\n", &[("main.lua", "MyAddonRan = true")], ); let id = ("Realm".to_string(), "Char".to_string()); let p = crate::local_state::addons_state_path("Realm", "Char").unwrap(); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, "Uninstalled: disabled\nmyaddon: enabled\n").unwrap(); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, Some(&id), true); script.eval::<()>("DisableAddOn('MyAddon')").unwrap(); save_enable_state(&script, Some(&id)); let after = std::fs::read_to_string(&p).unwrap(); let rows = parse_enable_state(&after); // The uninstalled addon's row is still there, still disabled — the half with teeth. assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("Uninstalled")) .map(|(_, on)| *on), Some(false), "a row for an addon not installed this session must survive the write: {after}" ); // The file's own spelling survives; the folder's case does not overwrite it. assert!( after.contains("myaddon:"), "the file's spelling is what the reference writes back: {after}" ); // …and this session's toggle still landed on it. assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("MyAddon")) .map(|(_, on)| *on), Some(false), "the DisableAddOn must still be persisted: {after}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// **A LoadOnDemand dependency cycle LOADS BOTH and answers `(1, nil)`** (decision 2139) — /// and before the fix this call took the whole process down with SIGABRT. /// /// `AddOn_Load 0x51f240` has no re-entrancy guard: every live site of the visiting byte /// `[UIADDON+0x2d]` is inside `AddOn_CanLoad 0x51e780`, none in the loader. What bounds the /// recursion is the *loaded* byte, stamped **before** the dependency loops — /// `0x51f313 mov byte [rec+0x18],1`, a flag-neutral store between `0x51f311 test eax,eax` and /// `0x51f317 jbe`, therefore unconditional — so the re-entered frame takes the already-loaded /// early-out at `0x51f2d6`/`0x51f2db` and returns 1. We stamped it after the recursion, so /// nothing terminated: Rust-level recursion, stack overflow, `signal 6`, uncatchable by /// `pcall`. /// /// The three assertions below are the reference's own observable consequences, and the last /// two are the ones that would betray a guard bolted on somewhere else instead: /// **no reason token** (a cycle produces none — the reference has no such token), /// `ADDON_LOADED` fires **B then A**, and `IsAddOnLoaded('Ping')` answers 1 throughout Pong's /// execution, because Ping is flagged loaded before its own files run. #[test] fn a_load_on_demand_dependency_cycle_loads_both_sides() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-cycle"); write_addon( &home, "Ping", "## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Pong\nping.lua\n", &[( "ping.lua", "PingRan = true Order = (Order or '') .. 'Ping,'", )], ); write_addon( &home, "Pong", "## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Ping\npong.lua\n", &[( "pong.lua", "PongRan = true Order = (Order or '') .. 'Pong,' \ PingSeenLoaded = IsAddOnLoaded('Ping')", )], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script .eval::<()>( "AddonOrder = '' \ CycleWatch = CreateFrame('Frame') \ CycleWatch:RegisterEvent('ADDON_LOADED') \ CycleWatch:SetScript('OnEvent', function() \ AddonOrder = AddonOrder .. arg1 .. ',' end)", ) .unwrap(); let _ = load_third_party(&mut script, None, true); // Neither ran at startup — the walk skips LoadOnDemand, which is why a cycle among them // is never observed until something demand-loads one. assert_eq!( script .eval::("return PingRan == nil and PongRan == nil") .ok(), Some(true) ); // The call that used to abort the process. assert_eq!( script .eval::>( "local loaded, reason = LoadAddOn('Ping') \ return { tostring(loaded), tostring(reason) }" ) .ok(), Some(vec!["1".into(), "nil".into()]), "a cycle loads and answers (1, nil) — the reference has no cycle reason token" ); // Both sides ran, each `.toc` list exactly once. assert_eq!( script.eval::("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::("return AddonOrder").ok().as_deref(), Some("Pong,Ping,") ); // The stamp is ahead of the files, so the cycle inverts the declared order: Ping reads as // loaded for the whole of Pong's execution. This is the reference's behaviour and it is // the mechanism, not a side effect. assert_eq!( script.eval::("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()); } }