//! **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"; /// Where one addon's files come from. /// /// The two arms are the same question asked of two stores, and keeping them one enum is what lets /// [`Addon::load`] be written once: our own interface and a third-party folder differ in where the /// bytes live, not in how they load. 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), } /// 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 { /// 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), } } /// 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`. fn prefix(&self) -> &str { match &self.source { Source::Builtin => "", Source::Dir(_) => &self.name, } } /// Load this addon's `.toc`-listed files into the VM, in listed order. Returns per-file /// errors, each tagged `"/: "`. /// /// Errors are logged as they happen *and* returned: the app ignores the value (it has already /// been logged) while the tests assert it empty, which is the shape 1178 step 1 established /// for the builtin and the reason a broken entry can no longer reach a run behind a log line. fn load(&self, script: &UiScript) -> Vec { self.load_files(script, &self.toc.files) } /// [`Addon::load`] over an explicit slice — the builtin's two-phase boot split (1051) is the /// only caller that needs less than the whole manifest. /// /// **A manifest lists two kinds of file and they load differently** (decision 1185). `.lua` is /// executed as a chunk in the shared global state; anything else is FrameXML — parsed, then /// materialized. 1184 sent every entry through the XML parser, which was invisible only /// because `benilla.toc` happens to list nothing but `.xml`: the reference's own `FrameXML.toc` /// opens with `GlobalStrings.lua`, and a bare Lua file is not a document. pub(super) fn load_files(&self, script: &UiScript, files: &[String]) -> Vec { let mut failures = Vec::new(); // The `` / `", ) .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); 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) } 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); assert!(failures.is_empty(), "load errors: {failures:?}"); // Discovery saw it at all — the `.toc` decoded rather than read as absent. assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); 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 reference's, read off `Interface\GlueXML\AddonList.lua`: /// `name, title, notes, url, loadable, reason, security, newVersion`. A row that shifts by one /// puts an addon's notes in its URL field, which nothing would notice until a manager rendered /// it, so the whole tuple is asserted rather than the two fields the phase names. #[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); assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( script .eval::>( "local n,t,no,u,l,r,s,nv = GetAddOnInfo(1) \ return { n, t, no, u, tostring(l), tostring(r), s, tostring(nv) }" ) .ok(), Some(vec![ "Probe".into(), "Probe Title".into(), "What it does".into(), "http://example".into(), "1".into(), // loadable "nil".into(), // no reason — it loads "INSECURE".into(), "nil".into(), // no version-check service to report a new version ]) ); // 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)); 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); // Discovered and described, but NOT run. assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); 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 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)); 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)); 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(); assert_eq!( written, "Drop: disabled\nKeep: enabled\n", "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)); 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)); 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)); 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. /// /// 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)); 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)); 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()); } }