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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user