//! Where interfaces come from: addon discovery under [`root`] and the load walk ([`Walk::load`]). //! An [`Addon`] is a name, a parsed `.toc` and the [`Source`] its files come from. benilla's own //! interface is one too, with the compiled-in tree as its source, but not for the lifecycle: like //! FrameXML it loads outside `AddOn_Load` ([`super::manifest::load_ingame_ui`]) and gets no //! `ADDON_LOADED`. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript}; use benilla_ui::toc::Toc; use super::content; /// The addon folder's name, the reference's own spelling. const ADDONS_DIR: &str = "AddOns"; /// The VM instruction bound for one addon's load, shared with the addon harness: about 50x the /// corpus's heaviest (4M), so an addon that crosses it is in a runaway loop and fails alone. pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000; /// Where one interface's files come from. pub(super) enum Source { /// benilla's own interface: the compiled-in tree, shadowed by `assets/ui` in a dev build. Builtin, /// The AddOns root on disk, not this addon's own folder: a request under `Interface/AddOns/` /// maps onto it, so a dependent can reach a shared library addon beside it. Dir(PathBuf), /// The player's patch chain ([`super::reference_ui`]), by full chain path, `/` and `\` alike. Chain, } /// One loadable interface: a name, its parsed manifest, and where its files come from. pub(super) struct Addon { /// The folder name (`"benilla"` for the builtin): what the AddOn API keys on and what /// `ADDON_LOADED` carries. pub(super) name: String, /// The parsed `.toc`: the ordered file list plus every directive. pub(super) toc: Toc, source: Source, } impl Addon { /// An interface from an explicit source, for [`super::reference_ui::addon`]. pub(super) fn new(name: String, toc: Toc, source: Source) -> Self { Addon { name, toc, source } } /// benilla's own interface, which is in the binary and so cannot be missing. pub(super) fn builtin() -> Self { let toc = content::read(super::manifest::MANIFEST) .map(|t| Toc::parse(&t)) .unwrap_or_else(|| { error!( "ui_script: {} is not in the shipped UI — no interface will load", super::manifest::MANIFEST ); Toc::default() }); Addon { name: "benilla".to_string(), toc, source: Source::Builtin, } } /// One file's bytes, by a path already resolved into the source's path space. A `Dir` reads /// under the AddOns root, then the chain ([`read_addon_file`]); only the flat builtin falls /// back to the basename, which for a `Dir` would rescue an escaping path. Bytes, not text: a /// `.lua` reaches Lua as it is on disk. fn read(&self, req: &str) -> Option> { match &self.source { Source::Builtin => { let norm = req.replace('\\', "/"); let base = norm.rsplit('/').next().unwrap_or(&norm).to_string(); content::read(&norm) .or_else(|| content::read(&base)) .map(String::into_bytes) } Source::Dir(root) => read_addon_file(root, req), Source::Chain => super::reference_ui::read(req), } } /// The base this addon's manifest entries resolve against: `Interface/AddOns/` for a /// `Dir`, empty for the builtin's flat tree and the chain's full paths. fn prefix(&self) -> String { match &self.source { Source::Builtin | Source::Chain => String::new(), Source::Dir(_) => format!("{ADDONS_PREFIX}{}", self.name), } } /// The chunk name, which addons parse: `"@%s"` (`0x8716e0`) over the resolved install path, /// built by `0x704bc0` for every `.lua`, as for `", ) .unwrap(); // The library includes a file of its own by bare name, so the base follows the include. std::fs::write( tmp.join("ProbeLib/core/lib.xml"), "", ) .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 resolves against the including file's directory (src/), not \ the addon root, which is where Bagnon's `templates.xml` is found" ); assert_eq!( script.eval::("return ProbeLibLoaded == true").ok(), Some(true), "`..\\..\\ProbeLib\\core\\lib.xml` reaches a sibling addon, the shared-library \ pattern a per-addon sandbox would block" ); assert_eq!( script.eval::("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); } /// `local_state::home()` is `None` under `$WOW_CAPTURE`, so `None` here is the capture case. #[test] fn there_is_one_addon_root_and_it_is_ours() { assert_eq!( root_from(Some(PathBuf::from("/state"))), Some(PathBuf::from("/state/AddOns")) ); assert_eq!(root_from(None), None, "a capture run has no addon root"); } #[test] fn lua_entries_are_told_apart_from_framexml() { assert!(is_lua("Core.lua")); assert!(is_lua("Libs\\LibStub\\LibStub.LUA")); assert!(is_lua("deep/nested/file.Lua")); assert!(!is_lua("Frames.xml")); assert!(!is_lua("Bindings.XML")); assert!(!is_lua("README")); assert!(!is_lua("weird.lua.xml")); } /// A folder with no `.toc` is not an addon; one whose `.toc` differs only in case is. #[test] fn discovery_matches_the_manifest_case_insensitively() { let tmp = std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(tmp.join("Cased")).unwrap(); std::fs::create_dir_all(tmp.join("Bare")).unwrap(); std::fs::write(tmp.join("Cased/cased.TOC"), "## Interface: 11200\n").unwrap(); std::fs::write(tmp.join("Bare/notes.txt"), "not an addon").unwrap(); assert!(manifest_in(&tmp.join("Cased"), "Cased").is_some()); assert!(manifest_in(&tmp.join("Bare"), "Bare").is_none()); let _ = std::fs::remove_dir_all(&tmp); } // ───────────────────────────── lifecycle events ───────────────────────────── /// A frame that appends `":"` to `EventLog` for each event it registers, loaded as /// an addon file so the events travel a real addon's `` path. 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)); "# ) } fn event_log(script: &UiScript) -> Vec { script .eval::>("return EventLog") .expect("EventLog") } /// Addons guard their handler with `if arg1 == "MyAddon" then`, so a wrong `arg1` looks like /// the event never arriving. #[test] fn addon_loaded_carries_the_addons_own_name_and_fires_after_its_files() { let tmp = std::env::temp_dir().join(format!("benilla-addon-loaded-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let dir = tmp.join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.lua\nprobe.xml\n", ) .unwrap(); // File-scope Lua runs before the event; its marker proves the order. std::fs::write( dir.join("probe.lua"), "EventLog = {}\ntable.insert(EventLog, \"files-ran\")\n", ) .unwrap(); std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap(); let all = vec![Addon { name: "EventProbe".into(), toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()), source: Source::Dir(tmp.clone()), }]; let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let mut w = Walk::default(); let _ = w.load(&mut script, &all, "EventProbe"); assert!(w.failures.is_empty(), "load errors: {:?}", w.failures); assert_eq!( event_log(&script), vec!["files-ran", "ADDON_LOADED:EventProbe"], "the addon's own files run first, THEN ADDON_LOADED with its own folder name as arg1 \ (`AddOn_Load 0x51f240`: files 0x51f3fa, event 0x51f5ad)" ); } /// FrameXML gets no `ADDON_LOADED` in the reference, and our interface loads like it, through /// [`super::manifest`] and never the walk; this loads both ways and reads the log. #[test] fn the_builtin_interface_never_fires_addon_loaded() { let tmp = std::env::temp_dir().join(format!( "benilla-addon-builtin-silent-test-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&tmp); let dir = tmp.join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.xml\n", ) .unwrap(); std::fs::write(dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED"])).unwrap(); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script.run("EventLog = {}").unwrap(); let all = vec![Addon { name: "EventProbe".into(), toc: Toc::parse(&std::fs::read_to_string(dir.join("EventProbe.toc")).unwrap()), source: Source::Dir(tmp.clone()), }]; let mut w = Walk::default(); let _ = w.load(&mut script, &all, "EventProbe"); // The builtin, loaded as production loads it: its own manifest, not the walk. let builtin = Addon::builtin(); let _ = builtin.load_files(&script, builtin.toc.files.get(..1).unwrap_or_default()); let log = event_log(&script); assert!( !log.iter().any(|e| e.contains("benilla")), "benilla must never appear in an ADDON_LOADED: {log:?}" ); assert_eq!( log, vec!["ADDON_LOADED:EventProbe"], "exactly the one third-party addon announced itself" ); } /// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and /// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from /// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`. #[test] fn the_ui_init_events_fire_in_the_reference_order() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = std::env::temp_dir().join(format!("benilla-addon-order-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); let dir = home.join("AddOns").join("EventProbe"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("EventProbe.toc"), "## Interface: 11200\nprobe.xml\n", ) .unwrap(); std::fs::write( dir.join("probe.xml"), recorder_xml(&["ADDON_LOADED", "VARIABLES_LOADED", "PLAYER_LOGIN"]), ) .unwrap(); // Hermetic: the state folder is the tempdir, so discovery finds only this addon. let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let _h = crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script.run("EventLog = {}").unwrap(); let failures = load_third_party(&mut script, None, &[], true); assert!(failures.is_empty(), "load errors: {failures:?}"); crate::ui_script::finish_ui_load(&mut script); assert_eq!( event_log(&script), vec![ "ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil", "PLAYER_LOGIN:nil", ], "every non-LoadOnDemand addon's ADDON_LOADED precedes VARIABLES_LOADED, which \ precedes PLAYER_LOGIN" ); let _ = std::fs::remove_dir_all(&tmp); } // ────────────────────────── the AddOn API and enable state ────────────────────────── /// An AddOns root under a temp `benilla-config`, `BENILLA_HOME` on it while the guard lives. fn hermetic_root(tag: &str) -> (PathBuf, crate::local_state::test_env::EnvGuard) { // The pid keeps two test binaries running at once from wiping each other's tree. let tmp = std::env::temp_dir().join(format!("benilla-addon-api-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); std::fs::create_dir_all(home.join("AddOns")).unwrap(); let guard = crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); (home, guard) } /// `AddOn_CanLoad` check 6: exact `==`, a missing `## Interface` is 0 and out of date, and a /// gated addon's dependent is blocked; `checkAddonVersion` off loads the same folder in full. #[test] fn the_version_gate_holds_the_walk_and_force_load_opens_it() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("version-gate"); write_addon( &home, "Fresh", "## Interface: 11200\nmain.lua\n", &[("main.lua", "FreshRan = true\n")], ); write_addon( &home, "Old", "## Interface: 11100\nmain.lua\n", &[("main.lua", "OldRan = true\n")], ); write_addon( &home, "Silent", "main.lua\n", // no ## Interface: parses as 0, out of date &[("main.lua", "SilentRan = true\n")], ); write_addon( &home, "NeedsOld", "## Interface: 11200\n## Dependencies: Old\nmain.lua\n", &[("main.lua", "NeedsOldRan = true\n")], ); let mut script = UiScript::new().unwrap(); let failures = load_third_party(&mut script, None, &[], true); assert!( failures.iter().any(|f| f.contains("NeedsOld")), "the gated dependency is the dependent's failure: {failures:?}" ); assert_eq!( script.eval::("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, with the checkbox's other state. let mut open = UiScript::new().unwrap(); let failures = load_third_party(&mut open, None, &[], false); assert!(failures.is_empty(), "force-load load errors: {failures:?}"); assert_eq!( open.eval::( "return OldRan == true and SilentRan == true and NeedsOldRan == true" ) .ok(), Some(true), "'Load out of date AddOns' loads the very same folder in full" ); } fn write_addon(home: &Path, name: &str, toc: &str, files: &[(&str, &str)]) { let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); for (f, body) in files { std::fs::write(dir.join(f), body).unwrap(); } } /// [`write_addon`] with raw bytes, for files that are not UTF-8. fn write_addon_bytes(home: &Path, name: &str, toc: &[u8], files: &[(&str, &[u8])]) { let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap(); for (f, body) in files { std::fs::write(dir.join(f), body).unwrap(); } } /// A cp1252 `.toc` is still a manifest, a BOM'd `.lua` runs, and a cp1252 `.lua` is found. Lua /// 5.0 strings are bytes and the reference hands `luaL_loadbuffer` the file unmodified, so a /// cp1252 literal keeps its byte length. #[test] fn an_addon_whose_files_are_not_utf8_still_loads() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("encoding"); // `## Title:` holds a cp1252 a-umlaut (0xE4). let mut toc = b"## Interface: 11200\n## Title: Sch\xE4tze\nlocale.lua\nboot.lua\n".to_vec(); toc.splice(0..0, [0xEFu8, 0xBB, 0xBF]); // and a BOM on the manifest too write_addon_bytes( &home, "Umlaut", &toc, &[ ("locale.lua", &b"UmlautWord = \"Sch\xE4tze\"\n"[..]), // A BOM'd chunk: valid UTF-8, three bytes the lexer cannot start on. ( "boot.lua", &b"\xEF\xBB\xBFUmlautLoaded = true\nUmlautLen = string.len(UmlautWord)\n"[..], ), ], ); let mut script = UiScript::new().unwrap(); let failures = load_third_party(&mut script, None, &[], true); assert!(failures.is_empty(), "load errors: {failures:?}"); // Discovery found it. One, not thirteen: `SMSG_ADDON_INFO` answers status 2 for the // chain's twelve secure addons, and the Lua index space drops them. seat_stock_addon_reply(&mut script); assert_eq!(script.eval::("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" ); 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" ); // The cp1252 literal is still bytes, as in the reference. 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" ); } /// A row for an addon not installed now survives the AddOns screen's write. #[test] fn the_addons_screen_write_merges_with_what_is_already_on_disk() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("screenwrite"); let id = ("Realm".to_string(), "Char".to_string()); let path = enable_state_path(Some(&id)).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "Gone: disabled\nStays: enabled\n").unwrap(); write_enable_state(Some(&id), &[("Stays".into(), false), ("New".into(), true)]); let written = std::fs::read_to_string(&path).unwrap(); assert!( written.contains("Gone: disabled"), "an uninstalled addon's choice survives — {written:?}" ); assert!( written.contains("Stays: disabled"), "the edit applied — {written:?}" ); assert!( written.contains("New: enabled"), "a new row appended — {written:?}" ); let _ = home; } /// Seat the `SMSG_ADDON_INFO` reply hiding the secure addons, as world entry does: the Lua /// index space does not exist until the server answers, as in the reference. fn seat_stock_addon_reply(script: &mut UiScript) { let hidden: Vec = benilla_protocol::messages::STOCK_SECURE_ADDONS .iter() .map(|a| a.name.to_string()) .collect(); script.note_addon_info_reply(&hidden); } /// The in-game `GetAddOnInfo` (`0x48e390`) answers seven values, `name, title, notes, enabled, /// loadable, reason, security`; glue's (`0x46d460`) answers eight, with `url` at slot 4. #[test] fn get_addon_info_returns_the_manifests_own_title_and_notes() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("info"); write_addon( &home, "Probe", "## Interface: 11200\n## Title: Probe Title\n## Notes: What it does\n## URL: http://example\n## Version: 1.2\n", &[], ); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, None, &[], true); // One, not thirteen: the chain's twelve secure addons are hidden by the reply. seat_stock_addon_reply(&mut script); assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); 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, where glue's binding has url "1".into(), // loadable "nil".into(), // no reason: it loads "INSECURE".into(), // No eighth value in-game (glue's `newVersion`). "nil".into(), ]) ); // AceLibrary's guard, `local name, _, _, enabled, loadable = GetAddOnInfo(major)`: with // `url` in slot 4, `enabled` would be nil and Ace would silently skip its dependency. assert_eq!( script .eval::( "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()); } /// An addon the enable file does not mention loads, so a dropped-in folder just works. #[test] fn a_disabled_addon_does_not_load_and_an_unlisted_one_does() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("disable"); write_addon( &home, "Off", "## Interface: 11200\nran.lua\n", &[("ran.lua", "OffRan = true")], ); write_addon( &home, "On", "## Interface: 11200\nran.lua\n", &[("ran.lua", "OnRan = true")], ); // "On" is absent from the file. std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); let mut script = UiScript::new().unwrap(); let id = ("Realm".to_string(), "Char".to_string()); let failures = load_third_party(&mut script, Some(&id), &[], true); assert!( failures.is_empty(), "a disabled addon is a player's choice, never a load failure: {failures:?}" ); assert_eq!( script.eval::("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" ); 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()); } /// `LoadAddOn` is synchronous: `UIParentLoadAddOn` uses the addon's frames on the next line. #[test] fn a_load_on_demand_addon_loads_only_when_asked() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod"); write_addon( &home, "Demand", "## Interface: 11200\n## Title: Demand\n## LoadOnDemand: 1\nlate.lua\nlate.xml\n", &[ ("late.lua", "DemandRan = true"), ( "late.xml", "\ 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, not run; one row, the chain's twelve hidden by the reply. seat_stock_addon_reply(&mut script); 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) ); 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()); } /// A LoadOnDemand options package on a dependency the startup walk loaded (`/msbt`'s shape): /// without the walk's `mark_addon_loaded` stamp the gate answers `DEP_NOT_DEMAND_LOADED`. #[test] fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-dep"); write_addon( &home, "Base", "## Interface: 11200\n## Title: Base\nbase.lua\n", &[("base.lua", "BaseRan = true")], ); write_addon( &home, "BaseOptions", "## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n", &[("opts.lua", "OptsRan = BaseRan")], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, &[], true); assert_eq!( script.eval::("return IsAddOnLoaded('Base') == 1").ok(), Some(true), "a startup-loaded addon reads as loaded, else a dependent answers DEP_NOT_DEMAND_LOADED" ); assert_eq!( script.eval::("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 demand load treats a `.toc` line naming a missing file as the startup walk does: the /// reference logs `Couldn't open %s` and carries on (`0x6edaa0`). #[test] fn a_missing_manifest_file_does_not_fail_a_demand_load() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-missing-file"); write_addon( &home, "Holey", "## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\ Locale-koKR.lua\nreal.lua\n", // `Locale-koKR.lua` is listed and not written. &[("real.lua", "HoleyRan = true")], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, &[], true); assert_eq!( script .eval::>( "local loaded, reason = LoadAddOn('Holey') \ return { tostring(loaded), tostring(reason), tostring(HoleyRan), \ tostring(IsAddOnLoaded('Holey')) }" ) .ok(), Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), "the addon loads, its remaining files run, and it reads as loaded" ); // A warning, never a script error: the addon harness and `smoke.sh` both count errors. assert!( script.take_errors().is_empty(), "a missing manifest entry must not reach the script-error channel" ); let warnings = script.take_warnings(); assert!( warnings .iter() .any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")), "…but it must still be said out loud: {warnings:?}" ); assert!( script .diagnostics() .iter() .any(|d| d.message.contains("Holey/Locale-koKR.lua")), "the miss is retained in the diagnostic log" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// The reason tokens are the reference's own: `UIParent.lua:642` looks up /// `getglobal("ADDON_"..reason)` for the label, so an invented token renders as nil. #[test] fn load_addon_answers_with_the_references_reason_tokens() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("reasons"); write_addon(&home, "Plain", "## Interface: 11200\n", &[]); write_addon( &home, "Off", "## Interface: 11200\n## LoadOnDemand: 1\n", &[], ); std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); let mut script = UiScript::new().unwrap(); let id = ("Realm".to_string(), "Char".to_string()); let _ = load_third_party(&mut script, Some(&id), &[], true); for (call, want) in [ ("LoadAddOn('NoSuchAddon')", "MISSING"), ("LoadAddOn('Off')", "DISABLED"), // Loaded at startup, as it is not LoadOnDemand: the success case. ("LoadAddOn('Plain')", "nil"), ] { assert_eq!( script .eval::(&format!("local _, r = {call} return tostring(r)")) .ok() .as_deref(), Some(want), "{call}" ); } let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// An on-demand load names itself on the host's notice channel, once. The startup walk logs /// every addon it loads; this path logged none, so a session log could not say which window /// was open when the client stalled — and the stock `*_LoadUI` glue calls `LoadAddOn` on every /// open, so a line per call would have buried the one that did the work. #[test] fn an_on_demand_load_names_itself_once_and_a_refusal_names_its_reason() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("notices"); write_addon( &home, "Window", "## Interface: 11200\n## LoadOnDemand: 1\nran.lua\n", &[("ran.lua", "WindowRan = true")], ); write_addon( &home, "Off", "## Interface: 11200\n## LoadOnDemand: 1\n", &[], ); std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); let mut script = UiScript::new().unwrap(); let id = ("Realm".to_string(), "Char".to_string()); let _ = load_third_party(&mut script, Some(&id), &[], true); // The startup walk's own notices, out of the way: what follows is the demand path alone. let _ = script.take_notices(); script.run("LoadAddOn('Window')").unwrap(); assert_eq!( script.take_notices(), vec!["LoadAddOn: Window loaded on demand".to_string()], "the load that ran the files names the addon" ); assert_eq!( script.eval::("return WindowRan == true").ok(), Some(true), "and it really loaded" ); script.run("LoadAddOn('Window')").unwrap(); assert!( script.take_notices().is_empty(), "a redundant load is a no-op and says nothing" ); script.run("LoadAddOn('Off')").unwrap(); assert_eq!( script.take_notices(), vec!["LoadAddOn: Off refused (DISABLED)".to_string()], "a refusal carries the reference's own reason token" ); script.run("LoadAddOn('NoSuchAddon')").unwrap(); assert_eq!( script.take_notices(), vec!["LoadAddOn: no addon named \"NoSuchAddon\" (MISSING)".to_string()], "a name that is not installed says so" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`. `Contested` is the /// control: the roster disagrees about it, so the newcomer gets its `## DefaultState`. #[test] fn a_character_with_no_file_inherits_the_rosters_unanimous_disable() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("inherit"); write_addon( &home, "Shunned", "## Interface: 11200\nran.lua\n", &[("ran.lua", "ShunnedRan = true")], ); write_addon( &home, "Contested", "## Interface: 11200\nran.lua\n", &[("ran.lua", "ContestedRan = true")], ); // Two characters with files: both turned `Shunned` off, they disagree about `Contested`. for (who, contested) in [("Onemage", false), ("Onerogue", true)] { let id = ("Realm".to_string(), who.to_string()); write_enable_state( Some(&id), &[("Shunned".into(), false), ("Contested".into(), contested)], ); } let roster = [ "Onemage".to_string(), "Onerogue".to_string(), "Freshling".to_string(), ]; let fresh = ("Realm".to_string(), "Freshling".to_string()); assert!( !enable_state_path(Some(&fresh)).unwrap().exists(), "the premise: a newly created character has written nothing" ); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, Some(&fresh), &roster, true); assert_eq!( script.eval::("return ShunnedRan == nil").ok(), Some(true), "the unanimous disable reaches the character who never expressed one" ); assert_eq!( script.eval::("return ContestedRan == true").ok(), Some(true), "…and a contested addon falls to its `## DefaultState`, which is enabled" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// A name on no node passes every node (`0x51e55a jne 0x51e611`) and takes the /// `DefaultState ? 2 : 0` epilogue; `Known`, with a node and no row, is the control. #[test] fn an_unknown_character_takes_the_manifest_default_not_the_aggregate() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("unknown-char"); // `OptIn` ships `DefaultState: disabled`, so the epilogue's two outcomes differ. write_addon(&home, "Shunned", "## Interface: 11200\n", &[]); write_addon( &home, "OptIn", "## Interface: 11200\n## DefaultState: disabled\n", &[], ); for who in ["Onemage", "Onerogue"] { let id = ("Realm".to_string(), who.to_string()); write_enable_state( Some(&id), &[("Shunned".into(), false), ("OptIn".into(), true)], ); } let roster = [ "Onemage".to_string(), "Onerogue".to_string(), "Known".to_string(), ]; let store = EnableStore::load("Realm", &roster); // `Known` has a node and no rows: both addons inherit their unanimous aggregate. assert!(!store.enabled_for("Shunned", true, Some("Known"))); assert!(store.enabled_for("OptIn", false, Some("Known"))); // `Stranger` is on no node: each addon answers its own manifest default. assert!(store.enabled_for("Shunned", true, Some("Stranger"))); assert!(!store.enabled_for("OptIn", false, Some("Stranger"))); // `None` (nobody picked yet) is the same epilogue. assert!(store.enabled_for("Shunned", true, None)); assert!(!store.enabled_for("OptIn", false, None)); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } #[test] fn the_enable_state_round_trips_through_the_file() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("roundtrip"); write_addon(&home, "Keep", "## Interface: 11200\n", &[]); write_addon( &home, "Drop", "## Interface: 11200\nran.lua\n", &[("ran.lua", "DropRan = true")], ); let id = ("Realm".to_string(), "Char".to_string()); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, Some(&id), &[], true); script.run("DisableAddOn('Drop')").unwrap(); save_enable_state(&script, Some(&id)); let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap(); // Registry order: the chain's Blizzard rows, then the folder's two (`0x51c777` before // `0x51c78f`, tail-inserted), one line per registry row. let mut expected = String::new(); for a in chain_addons() { expected.push_str(&format!("{}: enabled\n", a.name)); } expected.push_str("Drop: disabled\nKeep: enabled\n"); assert_eq!( written, expected, "the reference's own one-line-per-addon format" ); let mut next = UiScript::new().unwrap(); let _ = load_third_party(&mut next, Some(&id), &[], true); assert_eq!( next.eval::("return DropRan == nil").ok(), Some(true), "the disable survived the session" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } // ───────────────────────────── saved variables ───────────────────────────── /// The account file, then the per-character one (`0x51f4b5`, `0x51f53b`), then `ADDON_LOADED` /// (`0x51f5ad`): the file-scope default is set, overwritten, then read by the handler. #[test] fn an_addons_saved_variables_survive_the_session() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("saved"); write_addon( &home, "Keeper", "## Interface: 11200\n## SavedVariables: KeeperDB\n\ ## SavedVariablesPerCharacter: KeeperChar\nkeeper.lua\nkeeper.xml\n", &[ ( "keeper.lua", "KeeperDB = { count = 0 }\nKeeperChar = 'default'\n", ), ( "keeper.xml", "\ 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), true); // ── session two: a fresh VM reads them back ── let mut next = UiScript::new().unwrap(); next.set_screen_size(1024.0, 768.0); let failures = load_third_party(&mut next, Some(&id), &[], true); assert!(failures.is_empty(), "{failures:?}"); assert_eq!( next.eval::("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 reference writer's grammar (`0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB /// indent, a trailing comma on every entry, a bracketed key for a list entry too (`[1] = 1,`), /// and the file split by scope. Asserted as bytes, not by reloading. #[test] fn the_saved_file_bytes_match_the_recorded_grammar() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("grammar"); write_addon( &home, "Gram", "## Interface: 11200\n## SavedVariables: GramDB\n\ ## SavedVariablesPerCharacter: GramChar\ngram.lua\n", &[("gram.lua", "GramDB = {}\nGramChar = 0\n")], ); let id = ("Realm".to_string(), "Char".to_string()); let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, Some(&id), &[], true); script .run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5") .unwrap(); crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true); let account = std::fs::read_to_string(home.join("saved/Gram.lua")).unwrap(); let body = account.lines().skip(2).collect::>().join("\n"); assert_eq!( body, "GramDB = {\n\ \t[\"n\"] = 2,\n\ \t[\"on\"] = true,\n\ \t[\"s\"] = \"a\\\"b\",\n\ \t[\"t\"] = {\n\ \t\t[1] = 1,\n\ \t},\n\ }", "keys always bracketed and quoted, TAB indent per level, trailing comma on every \ entry, `\\\"` escaped — the recorded grammar\nGOT:\n{account}" ); assert!(!account.contains("GramChar"), "account file: {account}"); let per_char = std::fs::read_to_string(home.join("saved/Realm-Char/Gram.lua")).unwrap(); assert!( per_char.ends_with("GramChar = 5\n"), "per-char file: {per_char}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// `PLAYER_LOGOUT` fires before the writes (`0x490c2a` before `0x490c7e`/`0x490c83`), so a /// value its handler sets reaches the file. #[test] fn player_logout_fires_before_the_write() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("logout"); write_addon( &home, "Last", "## Interface: 11200\n## SavedVariables: LastDB\nlast.lua\nlast.xml\n", &[ ("last.lua", "LastDB = 'unset'"), ( "last.xml", "\ 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), true); let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap(); assert!( written.contains("LastDB = \"written at logout\""), "the value the PLAYER_LOGOUT handler set must reach the file — if the write ran \ first this reads 'unset':\n{written}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// The writer `0x51ef20` emits the enable hash the reader `0x51ebe0` built from the file, an /// entry per line as written: a row survives an uninstall, and a name keeps its spelling. #[test] fn the_logout_write_keeps_rows_it_did_not_put_there() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("logout-merge"); write_addon( &home, "MyAddon", "## Interface: 11200\nmain.lua\n", &[("main.lua", "MyAddonRan = true")], ); let id = ("Realm".to_string(), "Char".to_string()); let p = crate::local_state::addons_state_path("Realm", "Char").unwrap(); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, "Uninstalled: disabled\nmyaddon: enabled\n").unwrap(); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, Some(&id), &[], true); script.eval::<()>("DisableAddOn('MyAddon')").unwrap(); save_enable_state(&script, Some(&id)); let after = std::fs::read_to_string(&p).unwrap(); let rows = parse_enable_state(&after); assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("Uninstalled")) .map(|(_, on)| *on), Some(false), "a row for an addon not installed this session must survive the write: {after}" ); assert!( after.contains("myaddon:"), "the file's spelling is what the reference writes back: {after}" ); assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("MyAddon")) .map(|(_, on)| *on), Some(false), "the DisableAddOn must still be persisted: {after}" ); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } /// `AddOn_Load 0x51f240` has no re-entrancy guard (the visiting byte `[UIADDON+0x2d]` lives in /// `AddOn_CanLoad 0x51e780`) but stamps the loaded byte before the dependency loops /// (`0x51f313 mov byte [rec+0x18],1`), so the re-entered frame exits at `0x51f2d6`/`0x51f2db` /// with 1. A cycle loads both with no reason token, `ADDON_LOADED` fires B then A, and /// `IsAddOnLoaded('Ping')` is 1 while Pong's files run. #[test] fn a_load_on_demand_dependency_cycle_loads_both_sides() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("lod-cycle"); write_addon( &home, "Ping", "## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Pong\nping.lua\n", &[( "ping.lua", "PingRan = true Order = (Order or '') .. 'Ping,'", )], ); write_addon( &home, "Pong", "## Interface: 11200\n## LoadOnDemand: 1\n## Dependencies: Ping\npong.lua\n", &[( "pong.lua", "PongRan = true Order = (Order or '') .. 'Pong,' \ PingSeenLoaded = IsAddOnLoaded('Ping')", )], ); let mut script = UiScript::new().unwrap(); script.set_screen_size(1024.0, 768.0); script .eval::<()>( "AddonOrder = '' \ CycleWatch = CreateFrame('Frame') \ CycleWatch:RegisterEvent('ADDON_LOADED') \ CycleWatch:SetScript('OnEvent', function() \ AddonOrder = AddonOrder .. arg1 .. ',' end)", ) .unwrap(); let _ = load_third_party(&mut script, None, &[], true); // Neither ran at startup: the walk skips LoadOnDemand. assert_eq!( script .eval::("return PingRan == nil and PongRan == nil") .ok(), Some(true) ); 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" ); 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,") ); 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()); } }