idle sits you down and camps you, and the addons anchor, parse and draw

- five idle minutes sit you down and flag you AFK, thirty log you out through the ordinary countdown; holding a key is idle, moving the mouse is not, and a swimmer stays afloat (2092)
- a knockdown no longer wedges: the landing froze the clip that held the base-animation lock, leaving a knocked-down player on their back for the session (2098); the perf cost pill starts hidden (2099)
- Lua is exactly 1.12's 5.0 — no vararg expression, no #, no %, the remembered table size — so Cartographer loads without errors and Mik's Scrolling Battle Text shows its text, in its own shipped font now that addon TTFs load (2101, 2102, 2103)
- a widget name is a Lua global for SetPoint and its kin, so Bartender2's bars spread out; a button's NormalText disowns its justify, the raise renumbers hidden frames too, and a missing toc file warns instead of erroring (2105, 2100, 2104, 2107, 2106)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:35:03 +07:00
co-authored by Claude Fable 5.1
parent 057a3061c4
commit d8de29bd9b
52 changed files with 3713 additions and 359 deletions
+131 -4
View File
@@ -345,10 +345,12 @@ fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
/// allowed to compute that path (0954) — which is already `None` under `$WOW_CAPTURE`, so a
/// deterministic baseline cannot depend on what somebody has installed (0008).
///
/// `pub(crate)` since 1322: the texture side needs the same folder — the sprite decoder's
/// `Interface\AddOns\` loose-file resolve and the `SetTexture` probe both map that virtual prefix
/// onto this root (`ui_script::lifecycle::install_texture_resolvers`), so there is exactly one
/// answer to "where do addons live" however the question is asked.
/// `pub(crate)` since 1322: every asset an addon ships needs the same folder — the sprite
/// decoder's `Interface\AddOns\` loose-file resolve, the `SetTexture` probe, and (2103) the font
/// engine's face loader plus the `SetFont` probe all map that virtual prefix onto this root, so
/// there is exactly one answer to "where do addons live" however the question is asked. The first
/// three are wired in one place (`ui_script::lifecycle::install_addon_asset_resolvers`); the face
/// loader reads it here directly, at engine construction, because it is not a VM concern.
pub(crate) fn root() -> Option<PathBuf> {
root_from(crate::local_state::home())
}
@@ -2039,6 +2041,131 @@ mod tests {
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names
/// an addon the STARTUP walk already loaded.
///
/// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text
/// ships its options as a separate `## LoadOnDemand: 1` package with
/// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through
/// `UIParentLoadAddOn`, whose failure message is the red
/// `Couldn't load %s: <ADDON_ + reason>` dialog. The dependency being **already loaded** is the
/// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is
/// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands
/// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for
/// exactly that reason until 2102 gave it the same stamp.)
#[test]
fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-dep");
write_addon(
&home,
"Base",
"## Interface: 11200\n## Title: Base\nbase.lua\n",
&[("base.lua", "BaseRan = true")],
);
write_addon(
&home,
"BaseOptions",
"## Interface: 11200\n## Title: Base Options\n## Dependencies: Base\n ## LoadOnDemand: 1\nopts.lua\n",
&[("opts.lua", "OptsRan = BaseRan")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
// The dependency loaded at startup and the registry knows it — the stamp this rests on.
assert_eq!(
script.eval::<bool>("return IsAddOnLoaded('Base') == 1").ok(),
Some(true),
"a startup-loaded addon must read as loaded, or every LoadOnDemand dependent of it answers DEP_NOT_DEMAND_LOADED"
);
assert_eq!(
script.eval::<bool>("return OptsRan == nil").ok(),
Some(true),
"and the LoadOnDemand package has not run"
);
// `UIParentLoadAddOn`'s own two lines, in order: load, then use what it created.
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('BaseOptions') \
return { tostring(loaded), tostring(reason), tostring(OptsRan), \
tostring(IsAddOnLoaded('BaseOptions')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A `.toc` line naming a file the package does not ship does not stop a demand load**
/// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk.
///
/// The reference logs `Couldn't open %s` and carries on (wow-re
/// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel
/// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale:
/// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an
/// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session
/// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus.
#[test]
fn a_missing_manifest_file_does_not_fail_a_demand_load() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("lod-missing-file");
write_addon(
&home,
"Holey",
"## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\
Locale-koKR.lua\nreal.lua\n",
// `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly.
&[("real.lua", "HoleyRan = true")],
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
assert_eq!(
script
.eval::<Vec<String>>(
"local loaded, reason = LoadAddOn('Holey') \
return { tostring(loaded), tostring(reason), tostring(HoleyRan), \
tostring(IsAddOnLoaded('Holey')) }"
)
.ok(),
Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]),
"the addon loads, its remaining files run, and it reads as loaded"
);
// The miss is a WARNING on the host channel, never a script error — the harness's session
// column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole
// point is that a player's broken package cannot redden either.
assert!(
script.take_errors().is_empty(),
"a missing manifest entry must not reach the script-error channel"
);
let warnings = script.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")),
"…but it must still be said out loud: {warnings:?}"
);
// …and retained where the player can read it (1495), as the walk's misses are.
assert!(
script
.diagnostics()
.iter()
.any(|d| d.message.contains("Holey/Locale-koKR.lua")),
"the miss is retained in the diagnostic log"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// The reason tokens are the reference's own, for the cases a caller branches on.
///
/// They are not free-form strings: the reference splices them into
@@ -203,10 +203,13 @@ fn delete_item_confirm_no_clears_without_destroying() {
);
// Count repaints from here — the No-click path must trigger one via the event, not a click.
// 5.0's vararg spelling: the implicit `arg` table forwarded with `unpack`, because `...` as a
// VALUE is not in this VM's grammar — it is not in the 1.12 client's (decision 2101).
s.run(
"repaints = 0\n\
local real = ContainerFrame_Update\n\
ContainerFrame_Update = function(...) repaints = repaints + 1; return real(...) end",
ContainerFrame_Update = function(...) repaints = repaints + 1; \
return real(unpack(arg, 1, arg.n)) end",
)
.unwrap();
+40 -12
View File
@@ -56,7 +56,7 @@ fn install_boot_vm(world: &mut World) {
return;
}
};
install_texture_resolvers(world, &mut script);
install_addon_asset_resolvers(world, &mut script);
load_global_strings(world, &script);
load_emote_tokens(world, &script);
if ui_wanted(world) {
@@ -67,13 +67,16 @@ fn install_boot_vm(world: &mut World) {
world.insert_non_send_resource(script);
}
/// Wire up the halves of `Interface\AddOns\` texture art (decision 1322): the sprite decoder's
/// **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_sprite_root`]) and the VM's **texture probe** (so the
/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite
/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the
/// path form of `SetTexture` can answer the reference's 1|nil load verdict — Atlas picks its map
/// art by that return). Both resolve the same one folder ([`addons::root`], hermetic-`None` under
/// `$WOW_CAPTURE`), and the probe walks the same [`benilla_assets::sprite_candidates`] the
/// renderer decodes with, so the verdict the Lua caller gets is the verdict the screen shows.
/// art by that return), and the VM's **font probe** (the same 1|nil for `SetFont`, whose nil is a
/// load failure — `!OmniCC` uses it as a font-file validity check). All resolve the same one folder
/// ([`addons::root`], hermetic-`None` under `$WOW_CAPTURE`), and each probe walks exactly the
/// store its renderer reads — [`benilla_assets::sprite_candidates`] for art, the chain-then-folder
/// pair [`crate::ui_text`]'s face loader uses for fonts — so the verdict the Lua caller gets is the
/// verdict the screen shows.
///
/// The **size probe** beside it is the same oracle answering a different question — how many texels
/// wide and tall is the art — which is what lets a region that authored no size on an axis take
@@ -82,23 +85,30 @@ fn install_boot_vm(world: &mut World) {
/// is the number the screen shows, and memoises per texture key: the ask is per zero-size region
/// per resolve, and the answer cannot change for a key that already read.
///
/// No `WorldAssets` (no client data) means no backend: nothing to install, and the VM's path form
/// keeps answering nil — the engine-less truth.
fn install_texture_resolvers(world: &mut World, script: &mut UiScript) {
/// No `WorldAssets` (no client data) means no backend: nothing to install, so `SetTexture`'s path
/// form keeps answering nil and `SetFont`'s keeps answering 1 — each store's own engine-less truth
/// (`Model::texture_probe` / `Model::font_probe` carry why the two defaults differ).
///
/// Both probes test **existence**, not decode success: a file that is there but will not decode
/// answers 1 and draws nothing. That is 1322's stated approximation and it is unchanged here; the
/// renderer's own `texture miss` / `font miss` WARN is what names such a file.
fn install_addon_asset_resolvers(world: &mut World, script: &mut UiScript) {
let root = addons::root();
let Some(mut assets) = world.get_resource_mut::<benilla_assets::WorldAssets>() else {
return;
};
assets.set_loose_sprite_root(root.clone());
assets.set_loose_addon_root(root.clone());
let chain = assets.chain.clone();
let size_chain = chain.clone();
let size_root = root.clone();
let font_chain = chain.clone();
let font_root = root.clone();
script.set_texture_probe(Box::new(move |path| {
benilla_assets::sprite_candidates(path).iter().any(|c| {
chain.lock_recover().contains(c)
|| root
.as_deref()
.is_some_and(|r| benilla_assets::loose_sprite_file(r, c).is_some())
.is_some_and(|r| benilla_assets::loose_addon_file(r, c).is_some())
})
}));
// Keyed by the reference string exactly as the region carries it, so the hit path allocates
@@ -116,6 +126,24 @@ fn install_texture_resolvers(world: &mut World, script: &mut UiScript) {
sizes.borrow_mut().insert(path.to_string(), measured);
measured
}));
// The font oracle. Existence over the same two stores, in the same order, that
// `ui_text::engine`'s face loader reads — a `SetFont` that answers 1 and then draws Friz
// because the two disagreed would be worse than no probe at all. Memoised for the same reason
// the size probe is: an addon can call `SetFont` per animated string per frame (MSBT does),
// and a path's answer cannot change mid-session.
let seen: std::cell::RefCell<std::collections::HashMap<String, bool>> =
std::cell::RefCell::new(std::collections::HashMap::new());
script.set_font_probe(Box::new(move |path| {
if let Some(&cached) = seen.borrow().get(path) {
return cached;
}
let found = font_chain.lock_recover().contains(path)
|| font_root.as_deref().is_some_and(|r| {
benilla_assets::loose_addon_file(r, &benilla_assets::normalize_path(path)).is_some()
});
seen.borrow_mut().insert(path.to_string(), found);
found
}));
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
@@ -1206,14 +1206,15 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string.
// 5.0's `arg.n`, not `select("#", ...)`: `...` as a value is not in this VM's grammar, because
// it is not in the 1.12 client's (decision 2101).
s.run(
r##"
r#"
BENILLA_TEST_TOGGLE_ARGC = nil
function SetActionBarToggles(...)
BENILLA_TEST_TOGGLE_ARGC = select("#", ...)
BENILLA_TEST_TOGGLE_ARGC = arg.n
end
"##,
"#,
)
.unwrap();
// The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is