benilla's own interface loads as one layer after the stock FrameXML and before the addons; the core loads StatsFrame.xml and runs LocaleProperties.lua once, as FrameXML.toc does (#562)

benilla's own interface now loads as one layer on top of the stock UI, as `docs/METHOD.md` now states: the core loads the stock FrameXML rows, the layer (`assets/ui/layer.toc`) loads after the last of them and before the third-party addons, and like FrameXML it has no AddOn API row and fires no `ADDON_LOADED` (the reference loads `FrameXML.toc` outside `AddOn_Load`, `UI_Init` `0x48fbf0`).

- Moved into the layer unchanged: `ScrollTemplates.xml`, `KeyBindingsPage.xml`, `OptionsFrame.xml`, `ContainerFrameAdapters.xml`, `SpellBookAdapters.xml`, `ScriptLogFrame.xml`. `GameMenuFrame.xml` stays the core's last row until #553.
- The host's Lua fixes to stock code (`FCF_ChatTabFadeFinished`, the `DurabilityFrame` re-seat) move into the layer's `FrameXMLFixes.xml`; the resize re-seat stays host-side as an engine adaptation.
- The core now loads `StatsFrame.xml` and runs `LocaleProperties.lua` once, from `UIParent.xml`, so its chain rows are exactly the stock `FrameXML.toc` rows but the menu; the order is #555.
- A dev build boots without the layer under `WOW_STOCK_UI=1`; the host's calls into the layer are guarded.

Verified: new tests for the layer's file list, the core list against the stock toc, the load position (a `_G` definition log), absence from the AddOn API and the stock-only boot, each failing with the change undone; a before/after dump of every global, frame, draw order and saved variable differs only by `StatsFrame` and the moved fixes' source; `scripts/gates.sh` and `scripts/smoke.sh` green, and a live logout/re-login under `WOW_STOCK_UI=1` clean. Built on macOS; no platform seam touched.

Part of #552
Fixes #542
Fixes #543

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 21:01:28 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 44fa1813ac
commit 22634279bd
20 changed files with 602 additions and 185 deletions
@@ -0,0 +1,33 @@
<Ui>
<!--
benilla's fixes to the stock FrameXML Lua, loaded after every stock file.
-->
<Script><![CDATA[
-- Deviation: the stock FCF_ChatTabFadeFinished (FloatingChatFrame.lua:989-992) nils
-- oldAlpha even under a live hover, so leaving and re-entering within
-- CHAT_FRAME_FADE_TIME (a flick to the scroll buttons, outside the hover box, does it)
-- stops the plate fading in (l.873) for the rest of the session; this keeps oldAlpha while
-- hover is set.
if FCF_ChatTabFadeFinished then
function FCF_ChatTabFadeFinished(chatTab, chatFrame)
chatTab:Hide()
if not chatFrame.hover then
chatFrame.oldAlpha = nil
end
end
end
-- Deviation: re-runs UIParent_ManageFramePositions after DurabilityFrame's own OnEvent. The
-- pass seats the frame 20 further in while a side glyph shows (UIParent.lua:1759-1768),
-- but the stock frame runs it only from OnShow/OnHide, so a glyph appearing while the frame
-- is shown leaves the shield's overhang off the screen edge. The stock handler runs first,
-- unchanged, on the this/event/arg1 already set for the dispatch.
if DurabilityFrame then
local refOnEvent = DurabilityFrame:GetScript("OnEvent")
DurabilityFrame:SetScript("OnEvent", function()
if refOnEvent then refOnEvent() end
UIParent_ManageFramePositions()
end)
end
]]></Script>
</Ui>
+11 -24
View File
@@ -1,6 +1,7 @@
# The built-in interface, in load order: a bare filename is ours, from `assets/ui`; a path is
# the reference's own file, off the player's patch chain. Entry 0, Fonts.xml, loads alone into
# every boot VM, from Startup on; the rest loads on entering the world, then third-party addons.
# The core interface, in load order: the reference's own `FrameXML.toc` files, off the player's
# patch chain, and one bare filename of ours from `assets/ui`. Entry 0, Fonts.xml, loads alone
# into every boot VM, from Startup on; the rest loads on entering the world, then benilla's layer
# (`layer.toc`), then the third-party addons.
# The font objects thus exist before the login screen; the reference loads Fonts.xml with the
# rest of FrameXML.
# `inherits=`, `parent=` and a named `relativeTo=` resolve only against names declared above; a
@@ -19,8 +20,6 @@ Interface\FrameXML\Fonts.xml
Interface\FrameXML\GlobalStrings.lua
# `Localize()` runs at load and overwrites GlobalStrings keys, so this follows them.
Interface\FrameXML\Localization.xml
# `GetText` is a Lua global, not an engine binding: the client's `0x703bf0` calls it.
Interface\FrameXML\LocaleProperties.lua
Interface\FrameXML\BasicControls.xml
# Before UIParent.xml, whose `LowHealthFrame` has `parent="WorldFrame"`.
Interface\FrameXML\WorldFrame.xml
@@ -52,15 +51,12 @@ Interface\FrameXML\TargetFrame.xml
Interface\FrameXML\PetFrame.xml
# Anchors `relativeTo="TargetFrame"`.
Interface\FrameXML\ComboFrame.xml
# Ours: the scroll parts of our options window and script log.
ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# The reference's three options windows, loaded hidden so addons find their frames by name.
# Deviation: the player opens our own options window, `OptionsFrame.xml` below, because 1.12's
# settings screens are much worse to use; the stock one holds `OptionsFrame`.
# Deviation: the player opens the layer's own options window, because 1.12's settings screens are
# much worse to use; the stock one holds `OptionsFrame`.
Interface\FrameXML\OptionsFrame.xml
Interface\FrameXML\SoundOptionsFrame.xml
# Inherits `OptionsCheckButtonTemplate`, and declares `UIOptionsFrameCheckButtons`, which
@@ -70,12 +66,6 @@ Interface\FrameXML\UIOptionsFrame.xml
Interface\FrameXML\StaticPopup.xml
Interface\FrameXML\CoinPickupFrame.xml
# Ours: the Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# Ours: `BenillaOptionsFrame`; its rows read the option globals the stock windows above set.
OptionsFrame.xml
# `MainMenuBar`, `MainMenuBarArtFrame` and `MainMenuExpBar`, which later files read at load.
Interface\FrameXML\MainMenuBar.xml
@@ -115,16 +105,14 @@ Interface\FrameXML\PaperDollFrame.xml
Interface\FrameXML\PetPaperDollFrame.xml
Interface\FrameXML\MainMenuBarBagButtons.xml
Interface\FrameXML\StackSplitFrame.xml
# Ours: wraps the stock bag verbs; the keyring is a container (id -2).
ContainerFrameAdapters.xml
Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
# Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits.
Interface\FrameXML\HonorFrame.xml
Interface\FrameXML\DressUpFrame.xml
# `FrameXML.toc` has it between ItemButtonTemplate.xml and SpellBookFrame.xml.
Interface\FrameXML\StatsFrame.xml
Interface\FrameXML\SpellBookFrame.xml
# Ours: the pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# RaidFrame.xml names `parent="FriendsFrame"` and MailFrame.xml inherits `FriendsFrameTabTemplate`.
Interface\FrameXML\FriendsFrame.xml
Interface\FrameXML\RaidFrame.xml
@@ -156,12 +144,11 @@ Interface\FrameXML\BonusActionBarFrame.xml
Interface\FrameXML\WorldStateFrame.xml
# `ClassTrainerListScrollFrameTemplate`, which on-demand addons inherit.
Interface\FrameXML\ClassTrainerFrameTemplates.xml
# Ours: the `/errors` script error log and our slash commands.
ScriptLogFrame.xml
Interface\FrameXML\HelpFrame.xml
Interface\FrameXML\CinematicFrame.xml
Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# Ours: the ESC game menu. Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and
# `UpdateMicroButtons` to count `BenillaOptionsFrame`.
# Ours, in place of the stock GameMenuFrame.xml until the layer reshapes the stock menu (#553).
# Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and `UpdateMicroButtons` to count
# `BenillaOptionsFrame`.
GameMenuFrame.xml
+21
View File
@@ -0,0 +1,21 @@
# benilla's own interface, one layer on the stock one (docs/METHOD.md): every file is ours, from
# `assets/ui`, and loads in this order after every `benilla.toc` row and before the third-party
# addons. It is listed in no addon list, fires no `ADDON_LOADED` and cannot be switched off; a dev
# build boots without it under `WOW_STOCK_UI=1`. A test names its files, so it does not grow.
## Interface: 11200
# The scroll parts of our options window and script log.
ScrollTemplates.xml
# The Keybindings page's templates and script, which OptionsFrame.xml uses at load.
KeyBindingsPage.xml
# `BenillaOptionsFrame`; its rows read the option globals the stock options windows set.
OptionsFrame.xml
# Wraps the stock bag verbs; the keyring is a container (id -2).
ContainerFrameAdapters.xml
# The pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
# The `/errors` script error log and our slash commands.
ScriptLogFrame.xml
# Our fixes to the stock Lua.
FrameXMLFixes.xml
+1 -1
View File
@@ -484,7 +484,7 @@ fn latch_and_dispatch(
if let Some(chord_str) = captured {
// The Keybindings page's own handler, not a binding body.
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(&format!("KeyBindings_OnHostKey(\"{chord_str}\")")) {
if let Err(e) = s.run(&crate::ui_script::host_key_capture(&chord_str)) {
warn!("bindings(capture): {e}");
}
}
+2 -1
View File
@@ -1731,9 +1731,10 @@ pub(crate) static ABSENT: &[Absent] = &[
// ── Misc ────────────────────────────────────────────────────────────────────────────
// The nine `hidden="true" debug="true"` rows: benilla's instruments for these answer to the
// dev plane's chords, not to a binding.
// Stock `StatsFrame.lua` defines `ToggleStats`; the readout it shows is `GetDebugStats`.
absent!(
"TOGGLESTATS",
["ToggleStats"],
["GetDebugStats"],
"a dev-plane instrument on a dev chord"
),
absent!(
@@ -291,13 +291,13 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
// `/reload` is the rebuild `/console reloadUI` runs (`0x4035f0`, which reads no arguments).
S::ReloadUi => ParsedChat::ReloadUi,
S::ConvertRaid => ParsedChat::ConvertRaid,
// `/errors`, benilla's own: toggles the script error log, a FrameXML window;
// `/errors clear` empties it.
// `/errors`, benilla's own: toggles the layer's script error log; `/errors clear` empties
// it. A no-op when the run boots without the layer.
S::ScriptErrors => ParsedChat::Lua {
body: if args.trim().eq_ignore_ascii_case("clear") {
"BenillaScriptLog_Clear()".into()
crate::ui_script::ERRORS_CLEAR.into()
} else {
"BenillaScriptLog_Toggle()".into()
crate::ui_script::ERRORS_TOGGLE.into()
},
},
// `SlashCmdList["CONSOLE"]` (`ChatFrame.lua:671`) is `ConsoleExec(msg)`. A typed line runs
+32 -23
View File
@@ -1,8 +1,8 @@
//! 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`.
//! An [`Addon`] is a name, a parsed `.toc` and the [`Source`] its files come from. The core
//! interface and benilla's layer are two more, with the compiled-in tree as their source, but not
//! for the lifecycle: like FrameXML they load outside `AddOn_Load`
//! ([`super::manifest::load_ingame_ui`]), get no `ADDON_LOADED` and have no registry row.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
@@ -23,7 +23,8 @@ 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.
/// The core's and the layer's own files: 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.
@@ -34,8 +35,8 @@ pub(super) enum Source {
/// 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.
/// The folder name (`"benilla"` for the core and the layer, which have no registry row): 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,
@@ -48,15 +49,23 @@ impl Addon {
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)
/// The core interface, [`super::manifest::MANIFEST`], which is in the binary and so cannot
/// be missing.
pub(super) fn core() -> Self {
Self::shipped(super::manifest::MANIFEST)
}
/// benilla's layer, [`super::manifest::LAYER_MANIFEST`], in the binary as the core is.
pub(super) fn layer() -> Self {
Self::shipped(super::manifest::LAYER_MANIFEST)
}
/// The interface one shipped `.toc` names.
fn shipped(manifest: &str) -> Self {
let toc = content::read(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
);
error!("ui_script: {manifest} is not in the shipped UI — it will not load");
Toc::default()
});
Addon {
@@ -67,7 +76,7 @@ impl Addon {
}
/// 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
/// under the AddOns root, then the chain ([`read_addon_file`]); only the flat shipped tree 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<Vec<u8>> {
@@ -85,7 +94,7 @@ impl Addon {
}
/// The base this addon's manifest entries resolve against: `Interface/AddOns/<Folder>` for a
/// `Dir`, empty for the builtin's flat tree and the chain's full paths.
/// `Dir`, empty for the shipped flat tree and the chain's full paths.
fn prefix(&self) -> String {
match &self.source {
Source::Builtin | Source::Chain => String::new(),
@@ -95,7 +104,7 @@ impl Addon {
/// The chunk name, which addons parse: `"@%s"` (`0x8716e0`) over the resolved install path,
/// built by `0x704bc0` for every `.lua`, as for `<Script file=>`. Ace2 libraries find their
/// addon by splitting a `debugstack` frame on `\AddOns\` (`AceDB-2.0.lua:742`). The builtin's
/// addon by splitting a `debugstack` frame on `\AddOns\` (`AceDB-2.0.lua:742`). The shipped
/// flat tree keeps [`benilla_ui::script::addon_chunk_name`].
fn chunk_name(&self, file: &str, path: &str) -> String {
match &self.source {
@@ -110,7 +119,7 @@ impl Addon {
self.load_files(script, &self.toc.files)
}
/// [`Addon::load`] over an explicit slice, for the builtin's two-phase boot. A manifest lists
/// [`Addon::load`] over an explicit slice, for the core's two-phase boot. A manifest lists
/// both kinds of file (the reference's `FrameXML.toc` opens with `GlobalStrings.lua`): a `.lua`
/// runs as a chunk in the shared state, anything else is parsed as FrameXML and materialized.
pub(super) fn load_files(&self, script: &UiScript, files: &[String]) -> Vec<String> {
@@ -122,7 +131,7 @@ impl Addon {
let path = benilla_ui::loader::join_ref(&self.prefix(), file);
let Some(bytes) = self.read(&path) else {
let e = format!("{}/{file}: not found", self.name);
// Severity follows whose manifest is wrong. Ours (the builtin, or a `benilla.toc`
// Severity follows whose manifest is wrong. Ours (a shipped file, or a `benilla.toc`
// line the player's chain lacks) is an ERROR. A player's addon is the package's
// fault, which the reference skips silently, and an ERROR line fails `smoke.sh`.
match self.source {
@@ -1127,7 +1136,7 @@ mod tests {
);
}
assert!(
!Addon::builtin()
!Addon::core()
.toc
.files
.iter()
@@ -1641,9 +1650,9 @@ mod tests {
}];
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());
// The core, loaded as production loads it: its own manifest, not the walk.
let core = Addon::core();
let _ = core.load_files(&script, core.toc.files.get(..1).unwrap_or_default());
let log = event_log(&script);
assert!(
@@ -1111,7 +1111,7 @@ fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
/// Leaving and re-entering within `CHAT_FRAME_FADE_TIME` traps the stock Lua (the tab fade's
/// finished callback nils `oldAlpha` under a live hover): the bare chat stack must trap, and the
/// shipped manifest, with `install_chat_plate_guard`, must not.
/// shipped interface, with the layer's `FrameXMLFixes.xml`, must not.
#[test]
fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
benilla_formats::wow_data_or_skip!();
@@ -1178,7 +1178,7 @@ fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
"the trapped state: tab up, oldAlpha nil, hover stuck"
);
// The shipped interface, with the guard the manifest load installs.
// The shipped interface, with the guard the layer installs.
let mut shipped = UiScript::new().unwrap();
shipped.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&shipped, "Interface\\FrameXML\\GlobalStrings.lua");
+22 -12
View File
@@ -2,7 +2,7 @@
//! missing; `load_ui_files` and its `<Include>` provider read them here, never through `std::fs`.
//! A dev build reads the source tree first, so an edit needs no recompile.
/// The shipped UI tree: `benilla.toc` and every file it names.
/// The shipped UI tree: `benilla.toc`, `layer.toc` and every file of ours they name.
static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/ui");
/// The text of one shipped UI file, by path relative to `assets/ui`; the basename fallback for a
@@ -20,8 +20,9 @@ fn read_source_tree(req: &str) -> Option<String> {
std::fs::read_to_string(dir.join(req)).ok()
}
/// FNV-1a (not cryptographic) over the manifest and each file it names, in load order, as eight
/// hex digits: a stamp that tells two runs whether they loaded the same interface.
/// FNV-1a (not cryptographic) over the core manifest and each file it names, then the layer's when
/// this run loads it, in load order, as eight hex digits: a stamp that tells two runs whether they
/// loaded the same interface.
pub(crate) fn digest() -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut eat = |bytes: &[u8]| {
@@ -30,15 +31,24 @@ pub(crate) fn digest() -> String {
h = h.wrapping_mul(0x1000_0000_01b3);
}
};
eat(super::manifest::MANIFEST.as_bytes());
if let Some(toc) = read(super::manifest::MANIFEST) {
eat(toc.as_bytes());
let mut loaded = vec![(super::manifest::MANIFEST, super::addons::Addon::core())];
if super::manifest::layer_enabled() {
loaded.push((
super::manifest::LAYER_MANIFEST,
super::addons::Addon::layer(),
));
}
for name in super::addons::Addon::builtin().toc.files {
// Only a chain entry's name counts: its bytes are the player's install.
eat(name.as_bytes());
if let Some(text) = read(&name) {
eat(text.as_bytes());
for (manifest, addon) in loaded {
eat(manifest.as_bytes());
if let Some(toc) = read(manifest) {
eat(toc.as_bytes());
}
for name in addon.toc.files {
// Only a chain entry's name counts: its bytes are the player's install.
eat(name.as_bytes());
if let Some(text) = read(&name) {
eat(text.as_bytes());
}
}
}
format!("{:08x}", h as u32)
@@ -57,7 +67,7 @@ mod tests {
for name in crate::ui_script::manifest::shipped_manifest_files() {
assert!(
super::read(&name).is_some(),
"benilla.toc names {name}, which is not in assets/ui"
"a manifest names {name}, which is not in assets/ui"
);
}
}
@@ -28,9 +28,9 @@ fn harness() -> UiScript {
load_xml(&s, f);
}
// Deviation: the seat refreshes on every alert recompute, because the stock one goes stale
// when a side glyph appears while shown and hangs the shield off the screen edge. The app
// installs this after every load.
super::manifest::install_durability_reseat(&s).unwrap();
// when a side glyph appears while shown and hangs the shield off the screen edge. The layer
// installs this after every stock file.
load_xml(&s, "FrameXMLFixes.xml");
s
}
@@ -374,7 +374,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
] {
load_xml(&s, f);
}
super::manifest::install_durability_reseat(&s).unwrap();
load_xml(&s, "FrameXMLFixes.xml");
let mut inv: InventorySlots = Default::default();
inv[18] = slot(2504, Some((0, 20)));
@@ -0,0 +1,258 @@
//! benilla's layer ([`super::manifest::LAYER_MANIFEST`]) loads as more of the stock load: after
//! every core file, before the first third-party addon, with no `ADDON_LOADED` and no AddOn API
//! row, as 1.12.1 loads FrameXML (`UI_Init 0x48fbf0`: `FrameXML.toc` at `0x48ffed`, the addons at
//! `0x4900a3`, only the latter through `AddOn_Load`); and a dev build boots without it.
use benilla_ui::script::UiScript;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// Records each global's first definition in order: `__newindex` fires only for a new key, so a
/// file that redefines a stock function is not logged, and one that loads stock code is.
const DEFINE_LOG: &str = r#"
DEFINE_LOG = {}
setmetatable(getfenv(0), { __newindex = function(t, k, v)
table.insert(DEFINE_LOG, k)
rawset(t, k, v)
end })
"#;
/// Records every `ADDON_LOADED` from before the load.
const ADDON_LOADED_LOG: &str = r#"
ADDON_LOADED_LOG = {}
local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:SetScript("OnEvent", function() table.insert(ADDON_LOADED_LOG, arg1) end)
"#;
/// A VM after the production in-game load, with one third-party addon, `ZZOrder`, in a hermetic
/// AddOns root; the layer on unless `stock_ui`. Returns the VM and the load's failures.
fn production_load(tag: &str, stock_ui: bool) -> (UiScript, Vec<String>) {
let tmp = std::env::temp_dir().join(format!("benilla-layer-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
let dir = home.join("AddOns").join("ZZOrder");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ZZOrder.toc"), "## Interface: 11200\nprobe.lua\n").unwrap();
std::fs::write(
dir.join("probe.lua"),
"ZZORDER_SAW_LAYER = BenillaOptionsFrame ~= nil\n",
)
.unwrap();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probesix".into()),
level: 60,
..Default::default()
}),
);
// A reply that hid nothing, so `GetNumAddOns` counts the registry.
s.note_addon_info_reply(&[]);
s.register_cvars(crate::cvars::registered_pairs());
let mut failures = super::load_font_registry(&s);
s.run(DEFINE_LOG).unwrap();
s.run(ADDON_LOADED_LOG).unwrap();
// The layer passed in, not set through `WOW_STOCK_UI`: every test in this process reads it.
failures.extend(super::manifest::load_ingame_ui_with(
&mut s,
None,
&[],
true,
!stock_ui,
));
failures.extend(s.errors());
let _ = std::fs::remove_dir_all(&tmp);
(s, failures)
}
/// Where `name` was first defined in [`DEFINE_LOG`].
fn defined_at(log: &[String], name: &str) -> usize {
log.iter()
.position(|n| n == name)
.unwrap_or_else(|| panic!("{name} was never defined in the load"))
}
/// The stock FrameXML's own globals, from the captured 1.12.1 `_G` (`reference/1.12-globals.tsv`):
/// the `framexml` rows a FrameXML file defines at load, LoadOnDemand ones aside.
fn stock_framexml_globals() -> std::collections::HashSet<String> {
let tsv = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
));
tsv.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| {
let mut cols = l.split('\t');
let (name, kind, origin) = (cols.next()?, cols.next()?, cols.next()?);
(origin == "framexml" && kind != "lod").then(|| name.to_string())
})
.collect()
}
/// The layer loads after every stock file and before the first third-party addon: the core's last
/// rows define before the layer's first, the layer's last before the addon, and nothing first
/// defined from the layer's start on is a stock FrameXML global.
#[test]
fn the_layer_loads_after_every_stock_file_and_before_the_addons() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("order", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
// The core's last stock row (TutorialFrame.lua), the core's own last row (GameMenuFrame.xml),
// the layer's first file (ScrollTemplates.xml) and last (FrameXMLFixes.xml is all
// redefinitions, so ScriptLogFrame.xml), then the addon.
let order = [
"TutorialFrame_OnHide",
"GameMenuButton_Pending",
"BenillaScrollBar_Step",
"BenillaScriptLog_Toggle",
"ZZORDER_SAW_LAYER",
];
let at: Vec<usize> = order.iter().map(|n| defined_at(&log, n)).collect();
for w in 0..order.len() - 1 {
assert!(
at[w] < at[w + 1],
"{} (#{}) must be defined before {} (#{})",
order[w],
at[w],
order[w + 1],
at[w + 1]
);
}
let stock = stock_framexml_globals();
assert!(stock.len() > 5000, "the reference's _G parse is broken");
// The engine sets `this`, `event` and `arg1`-`arg9` around each handler it runs.
let dispatch = |n: &str| {
n == "this"
|| n == "event"
|| n.strip_prefix("arg")
.is_some_and(|d| d.len() == 1 && d.as_bytes()[0].is_ascii_digit())
};
let late: Vec<&String> = log[at[2]..]
.iter()
.filter(|n| stock.contains(n.as_str()) && !dispatch(n))
.collect();
assert!(
late.is_empty(),
"stock FrameXML globals first defined after the layer began — a stock file loaded inside \
or after the layer: {late:?}"
);
assert!(
s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap(),
"the addon's file scope sees the layer"
);
}
/// The layer is no addon: no `ADDON_LOADED` of its own, no row in the AddOn API, and no row on the
/// character-select AddOns screen.
#[test]
fn the_layer_is_absent_from_the_addon_api() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("api", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let loaded: Vec<String> = s.eval("return ADDON_LOADED_LOG").unwrap();
assert_eq!(
loaded,
["ZZOrder"],
"only the third-party addon announces itself"
);
let names: Vec<String> = s
.eval(
"local out = {} \
for i = 1, GetNumAddOns() do table.insert(out, (GetAddOnInfo(i))) end \
return out",
)
.unwrap();
assert!(
names.iter().any(|n| n == "ZZOrder"),
"the probe sees the registry: {names:?}"
);
for name in ["benilla", "layer"] {
assert!(
!names.iter().any(|n| n.eq_ignore_ascii_case(name)),
"{name} is a row in the AddOn API: {names:?}"
);
assert!(
!s.eval::<bool>(&format!(
"return IsAddOnLoaded(\"{name}\") and true or false"
))
.unwrap(),
"IsAddOnLoaded(\"{name}\") answers"
);
}
assert!(
!super::addons::installed_rows()
.iter()
.any(|a| a.name.eq_ignore_ascii_case("benilla")),
"the AddOns screen lists benilla"
);
}
/// `WOW_STOCK_UI=1` drops the layer in a dev build alone; a player build always loads it.
#[test]
fn the_stock_ui_switch_is_a_dev_builds_alone() {
use super::manifest::layer_enabled_by;
assert!(
!layer_enabled_by(true, Some("1")),
"a dev build boots without it"
);
assert!(
layer_enabled_by(false, Some("1")),
"a player build ignores the switch"
);
assert!(layer_enabled_by(true, None) && layer_enabled_by(true, Some("0")));
}
/// Booted without the layer, as `WOW_STOCK_UI=1` does: no layer file loads, the stock files raise
/// nothing, and the host's calls into the layer are no-ops.
#[test]
fn the_stock_ui_switch_boots_the_core_without_the_layer() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("stock", true);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
// One global from each layer file that defines a new one.
for name in [
"BenillaScrollBar_Step",
"KeyBindings_OnHostKey",
"BenillaOptionsFrame_SelectCategory",
"BENILLA_BAG_WAS_OPEN",
"BenillaScriptLog_Toggle",
] {
assert!(
!log.iter().any(|n| n == name),
"{name} is defined: a layer file loaded under WOW_STOCK_UI=1"
);
}
assert!(!s.eval::<bool>("return BenillaOptionsFrame ~= nil").unwrap());
assert!(!s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap());
// The host's calls into the layer, and the game menu's Options rung.
for body in [
super::ERRORS_TOGGLE.to_string(),
super::ERRORS_CLEAR.to_string(),
super::host_key_capture("CTRL-J"),
"GameMenuButtonOptions:Click()".to_string(),
] {
s.run(&body).unwrap_or_else(|e| panic!("{body}: {e}"));
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+79 -75
View File
@@ -1,6 +1,7 @@
//! The in-game interface: its manifest, [`MANIFEST`], and the boot split that loads it in two
//! phases. An entry with a path is the reference's own file off the player's patch chain, a bare
//! filename is one we ship, and the manifest's order is the load order across both.
//! The in-game interface: the core manifest, [`MANIFEST`], loaded in two phases, then benilla's
//! layer, [`LAYER_MANIFEST`]. A core entry with a path is the reference's own file off the
//! player's patch chain, a bare filename one we ship, and the manifest's order is the load order
//! across both; every layer entry is ours and loads after the whole core.
use bevy::prelude::*;
@@ -9,13 +10,45 @@ use benilla_ui::script::UiScript;
use super::addons::Addon;
use super::reference_ui;
/// The built-in interface's manifest, relative to `assets/ui`.
/// The core interface's manifest, relative to `assets/ui`.
pub(super) const MANIFEST: &str = "benilla.toc";
/// The manifest's entries in load order, from both stores.
/// benilla's own interface, one layer on the stock one, relative to `assets/ui`.
pub(super) const LAYER_MANIFEST: &str = "layer.toc";
/// Whether this run loads the layer: always in a player build; a dev build boots the stock UI
/// alone under `WOW_STOCK_UI=1`.
pub(super) fn layer_enabled() -> bool {
layer_enabled_by(
crate::run_mode::dev_affordances(),
std::env::var("WOW_STOCK_UI").ok().as_deref(),
)
}
/// [`layer_enabled`] over its two facts: a dev build, and the switch's value.
pub(super) fn layer_enabled_by(dev_build: bool, stock_ui: Option<&str>) -> bool {
!(dev_build && stock_ui == Some("1"))
}
/// `/errors`: toggles the layer's script error log; a no-op when the run boots without the layer.
pub(crate) const ERRORS_TOGGLE: &str =
"if BenillaScriptLog_Toggle then BenillaScriptLog_Toggle() end";
/// `/errors clear`: empties the layer's script error log, or does nothing without the layer.
pub(crate) const ERRORS_CLEAR: &str = "if BenillaScriptLog_Clear then BenillaScriptLog_Clear() end";
/// Hands a chord the layer's Keybindings page is capturing (`BenillaBindCapture`), a bind string
/// such as `CTRL-J`, to its handler; a no-op without the layer.
pub(crate) fn host_key_capture(chord: &str) -> String {
format!("if KeyBindings_OnHostKey then KeyBindings_OnHostKey(\"{chord}\") end")
}
/// Every entry in load order: the core's, then the layer's.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
Addon::builtin().toc.files
let mut files = Addon::core().toc.files;
files.extend(Addon::layer().toc.files);
files
}
/// The manifest's entries that name a file we ship rather than one off the player's install.
@@ -27,48 +60,20 @@ pub(super) fn shipped_manifest_files() -> Vec<String> {
.collect()
}
/// Runs `UIParent_ManageFramePositions` after the manifest has loaded, then installs the load-time
/// repairs below. Never after `Fonts.xml` alone: `UIParent.lua` defines the pass.
/// Runs `UIParent_ManageFramePositions` after the manifest has loaded, then the buff pass below.
/// Never after `Fonts.xml` alone: `UIParent.lua` defines the pass.
fn bootstrap_positions(script: &UiScript) -> Vec<String> {
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
return vec![format!("managed-positions bootstrap: {e}")];
}
if let Err(e) = install_durability_reseat(script) {
error!("ui_script: durability re-seat hook: {e}");
return vec![format!("durability re-seat hook: {e}")];
}
if let Err(e) = apply_buff_durations(script) {
error!("ui_script: buff-duration layout: {e}");
return vec![format!("buff-duration layout: {e}")];
}
if let Err(e) = install_chat_plate_guard(script) {
error!("ui_script: chat plate guard: {e}");
return vec![format!("chat plate guard: {e}")];
}
Vec::new()
}
/// Deviation: the stock `FCF_ChatTabFadeFinished` (`FloatingChatFrame.lua:989-992`) nils `oldAlpha`
/// even under a live hover, so leaving and re-entering within `CHAT_FRAME_FADE_TIME` (a flick to
/// the scroll buttons, outside the hover box, does it) stops the plate fading in (l.873) for the
/// rest of the session; this keeps `oldAlpha` while `hover` is set. Redefined, not wrapped, so a
/// `ReloadUI` re-run cannot stack.
pub(super) fn install_chat_plate_guard(script: &UiScript) -> Result<(), String> {
script.run(CHAT_PLATE_GUARD).map_err(|e| e.to_string())
}
const CHAT_PLATE_GUARD: &str = r#"
if FCF_ChatTabFadeFinished then
function FCF_ChatTabFadeFinished(chatTab, chatFrame)
chatTab:Hide()
if not chatFrame.hover then
chatFrame.oldAlpha = nil
end
end
end
"#;
/// Runs `BuffButtons_UpdatePositions` at load, which `BuffFrame_OnLoad` never calls: until the
/// stock `UIOptionsFrame.lua`'s `VARIABLES_LOADED` arm runs it (l.206), the second buff row and
/// the debuff row keep `BuffFrame.xml`'s anchors whatever `SHOW_BUFF_DURATIONS` says.
@@ -83,7 +88,8 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
/// the blackout would stop short of the new screen.
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
pub(super) fn on_screen_resized(script: &UiScript) {
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
@@ -125,26 +131,6 @@ if UpperBlackBar and LowerBlackBar then
end
"#;
/// Deviation: re-runs `UIParent_ManageFramePositions` after `DurabilityFrame`'s own OnEvent. The
/// pass seats the frame 20 further in while a side glyph shows (`UIParent.lua:1759-1768`), but the
/// stock frame runs it only from OnShow/OnHide, so a glyph appearing while the frame is shown
/// leaves the shield's overhang off the screen edge. Latched, so a `ReloadUI` cannot stack it; the
/// stock handler runs first, unchanged, on the `this`/`event`/`arg1` already set for the dispatch.
pub(super) fn install_durability_reseat(script: &UiScript) -> Result<(), String> {
script.run(DURABILITY_RESEAT).map_err(|e| e.to_string())
}
const DURABILITY_RESEAT: &str = r#"
if DurabilityFrame and not DurabilityFrame.benillaReseat then
DurabilityFrame.benillaReseat = 1
local refOnEvent = DurabilityFrame:GetScript("OnEvent")
DurabilityFrame:SetScript("OnEvent", function()
if refOnEvent then refOnEvent() end
UIParent_ManageFramePositions()
end)
end
"#;
/// Runs a UI load in the counted sound-suppression scope, as `CGGameUI::Initialize` (`0x48fbf0`,
/// called at login `0x48f681` and `/reloadui` `0x495669`) runs its own: enter (`0x458f50`) at
/// `0x48fbfa`, leave (`0x458f60`) at `0x49016d`, and `PlaySoundByName` (`0x458030`) drops every
@@ -161,17 +147,20 @@ pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
out
}
/// Loads every [`MANIFEST`] entry at once, then the load-time repairs. Production splits this
/// load at boot ([`load_font_registry`], [`load_ingame_ui`]), so the callers are the tests and
/// [`crate::addon_harness`]. Returns every failure; a loader error is tagged
/// `"<Addon>/<file>: <error>"`.
/// Loads every [`MANIFEST`] entry at once, then the layer unless [`layer_enabled`] says no, then
/// the load-time repairs. Production splits this load at boot ([`load_font_registry`],
/// [`load_ingame_ui`]), so the callers are the tests and [`crate::addon_harness`]. Returns every
/// failure; a loader error is tagged `"<Addon>/<file>: <error>"`.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// The client's CVar table first: the stock `UIOptionsFrame.xml` reads CVars in its OnLoads,
// and a bare `UiScript::new()` has only `benilla-ui`'s own. A re-register only refreshes
// defaults.
script.register_cvars(crate::cvars::registered_pairs());
let mut failures = silenced_ui_load(&mut &*script, |script| {
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
let mut failures = load_manifest(script, &Addon::core().toc.files);
if layer_enabled() {
failures.extend(load_layer(script));
}
failures.extend(bootstrap_positions(script));
failures
});
@@ -189,7 +178,7 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// Loads a slice of [`MANIFEST`] entries, each from its own store and one at a time, so the
/// manifest's order is the load order across both.
fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
let builtin = Addon::builtin();
let core = Addon::core();
let reference = reference_ui::addon(
files
.iter()
@@ -202,26 +191,30 @@ fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
let from = if reference_ui::is_chain_entry(file) {
&reference
} else {
&builtin
&core
};
failures.extend(from.load_files(script, std::slice::from_ref(file)));
}
failures
}
/// Every [`LAYER_MANIFEST`] entry, in order, after the whole core: the layer loads as more of the
/// stock load, with no `ADDON_LOADED` and no registry row, as FrameXML does (`0x48ffed`).
fn load_layer(script: &UiScript) -> Vec<String> {
let layer = Addon::layer();
layer.load_files(script, &layer.toc.files)
}
/// The font-object registry alone, `Fonts.xml`, loaded at `Startup`.
/// Deviation: it loads before the login screen because benilla's glue screens share its glyph
/// atlas; the reference's glue has its own `GlueFonts.xml`. The font objects declared outside it
/// (FRIZQT 13, 14 and 20, un-outlined) repeat combinations it has, so the atlas plan loses nothing.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
load_manifest(
script,
Addon::builtin().toc.files.get(..1).unwrap_or_default(),
)
load_manifest(script, Addon::core().toc.files.get(..1).unwrap_or_default())
}
/// The in-game UI, every entry after `Fonts.xml`, then every third-party addon, on entering the
/// world. The reference loads both in `CGGameUI::Initialize` (`0x48fbf0`), reached only from world
/// The in-game UI, every core entry after `Fonts.xml`, then the layer unless [`layer_enabled`]
/// says no, then every third-party addon, on entering the world. The reference loads both in `CGGameUI::Initialize` (`0x48fbf0`), reached only from world
/// entry (`0x401570` from `0x46c236`), its addons at `0x4900a3` (`0x51f600`).
///
/// `identity`, `(realm, character)`, names the character's AddOn enable-state file, as the
@@ -233,14 +226,25 @@ pub(crate) fn load_ingame_ui(
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
) -> Vec<String> {
load_ingame_ui_with(script, identity, roster, version_check, layer_enabled())
}
/// [`load_ingame_ui`] with the layer decided by the caller.
pub(super) fn load_ingame_ui_with(
script: &mut UiScript,
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
layer: bool,
) -> Vec<String> {
// Bounded, addons included: a chunk that never returns fails as a load error instead of
// freezing the loading screen. The caller disarms the budget once the edge is done.
script.set_instruction_budget(super::addons::LOAD_INSTRUCTION_BUDGET);
let mut failures = load_manifest(
script,
Addon::builtin().toc.files.get(1..).unwrap_or_default(),
);
let mut failures = load_manifest(script, Addon::core().toc.files.get(1..).unwrap_or_default());
if layer {
failures.extend(load_layer(script));
}
failures.extend(bootstrap_positions(script));
// Each addon's `ADDON_LOADED` fires as that addon finishes, as the reference does (`0x51f5ad`).
failures.extend(super::addons::load_third_party(
@@ -260,7 +264,7 @@ mod tests {
/// [`load_ingame_ui`] the rest.
#[test]
fn the_manifest_is_a_toc_that_starts_with_the_font_registry() {
let toc = Addon::builtin().toc;
let toc = Addon::core().toc;
assert_eq!(toc.interface_versions(), vec![11200]);
assert_eq!(toc.directive("Title"), Some("benilla"));
assert_eq!(
+3
View File
@@ -16,6 +16,8 @@ pub(crate) mod addons;
mod content;
pub(crate) mod extract;
mod input;
#[cfg(test)]
mod layer_tests;
mod manifest;
/// The stock FrameXML this client runs off the player's own patch chain; its header is the rule.
@@ -31,6 +33,7 @@ pub(crate) use session::VmMemo;
// Not test-only: the addon harness loads the whole shipped interface under each addon.
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::{host_key_capture, ERRORS_CLEAR, ERRORS_TOGGLE};
pub(crate) use manifest::{load_font_registry, load_ingame_ui};
/// Whether the pointer is over any UI (the egui dev overlay or a player-UI frame), combined by
@@ -2,9 +2,9 @@
//! [`super::addons::Source`]; the parse, `<Include>` and `<Script file=>` resolution and chunk
//! naming are [`super::addons::Addon`]'s.
//!
//! `assets/ui/benilla.toc` is the one load order. An entry with a path separator comes off the
//! chain, a bare filename is a file we ship ([`is_chain_entry`]), and a name defined by both goes
//! to the later line. Without client data the chain files are absent, and the log says so once.
//! `assets/ui/benilla.toc` is the core's load order, and `layer.toc` follows it. An entry with a
//! path separator comes off the chain, a bare filename is a file we ship ([`is_chain_entry`]), and
//! a name defined by both goes to the later line. Without client data the chain files are absent, and the log says so once.
use std::sync::OnceLock;
@@ -349,7 +349,7 @@ mod tests {
.map(str::to_string)
.collect();
let migrated: Vec<String> = super::super::addons::Addon::builtin()
let migrated: Vec<String> = super::super::addons::Addon::core()
.toc
.files
.iter()
@@ -554,7 +554,7 @@ mod tests {
"the method probe is not working — it answered {got:?} for {control:?}"
);
let migrated: std::collections::HashSet<String> = super::super::addons::Addon::builtin()
let migrated: std::collections::HashSet<String> = super::super::addons::Addon::core()
.toc
.files
.iter()
@@ -870,7 +870,7 @@ mod tests {
};
// Every function each chain entry and its same-name `.lua` define.
let toc = &super::super::addons::Addon::builtin().toc.files;
let toc = &super::super::manifest::manifest_files();
// Load order: each manifest entry at its line, ours included (`ours_wins` reads both
// sides), then each reached addon's files after everything.
let chain = gated_chain_entries();
@@ -1185,6 +1185,13 @@ mod tests {
(the producer gate carries the event). Gated as the addon is a reached \
LoadOnDemand row.",
),
(
"StatsFrame.xml",
"GetDebugStats",
"the debug readout's one engine verb (`0x488af0`), not built. `StatsFrame` loads \
hidden and only its OnUpdate calls it; nothing stock shows it, and its binding, \
`TOGGLESTATS`, is a `debug=\"true\"` row the release loader skips (`0x4b70a4`).",
),
(
"StaticPopup.xml",
"ReplaceTradeEnchant",
@@ -1315,13 +1322,50 @@ mod tests {
);
}
/// The core's chain rows are the reference's own `FrameXML.toc` rows, every one, but the stock
/// `GameMenuFrame.xml`, which ours stands in for until the layer reshapes the stock menu. A
/// row the stock toc lacks runs a file the reference does not, or twice: `UIParent.xml:4`
/// sources `LocaleProperties.lua`, which has no row.
#[test]
fn the_cores_chain_rows_are_the_stock_tocs_rows() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = String::from_utf8_lossy(
&super::read("Interface\\FrameXML\\FrameXML.toc").expect("the reference's own toc"),
)
.into_owned();
let mut stock: Vec<String> = toc
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter(|l| !l.eq_ignore_ascii_case("GameMenuFrame.xml"))
.map(|l| format!("interface\\framexml\\{}", l.to_ascii_lowercase()))
.collect();
let mut core: Vec<String> = super::super::addons::Addon::core()
.toc
.files
.iter()
.filter(|f| super::is_chain_entry(f))
.map(|f| f.to_ascii_lowercase())
.collect();
stock.sort();
core.sort();
let missing: Vec<&String> = stock.iter().filter(|r| !core.contains(r)).collect();
let extra: Vec<&String> = core.iter().filter(|r| !stock.contains(r)).collect();
assert!(
missing.is_empty() && extra.is_empty(),
"benilla.toc against the stock FrameXML.toc — stock rows it does not load: \
{missing:?}; rows the stock toc does not have: {extra:?}"
);
assert_eq!(core, stock, "a stock row listed twice");
}
/// A chain `.xml` that does not source its own `.lua` needs the `.lua` as a manifest line too,
/// as the stock toc lists `MoneyInputFrame.lua` and `TextStatusBar.lua` (lines 11 and 32).
/// Without it every global the file should define reads nil, and nothing errors.
#[test]
fn every_chain_xml_brings_its_own_lua() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let toc = &super::super::manifest::manifest_files();
let listed: std::collections::HashSet<&str> = toc
.iter()
.map(|f| f.rsplit(['\\', '/']).next().unwrap_or(f))
@@ -1463,7 +1507,7 @@ mod tests {
#[test]
fn nothing_we_ship_is_shadowed_by_a_later_chain_entry() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let toc = &super::super::manifest::manifest_files();
let mut ours: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut shadowed: Vec<String> = Vec::new();
for entry in toc.iter() {
@@ -1493,7 +1537,7 @@ mod tests {
#[test]
fn every_template_the_manifest_inherits_is_declared_by_the_manifest() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let toc = &super::super::manifest::manifest_files();
let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut wanted: Vec<(String, String)> = Vec::new();
@@ -1566,7 +1610,7 @@ mod tests {
fn reached_addons() -> Vec<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut out: Vec<String> = Vec::new();
for entry in &super::super::addons::Addon::builtin().toc.files {
for entry in &super::super::manifest::manifest_files() {
let texts = if super::is_chain_entry(entry) {
entry_sources(entry)
} else {
@@ -1594,7 +1638,7 @@ mod tests {
/// Every chain file the shipped interface loads, in load order: the manifest's chain entries,
/// then each reached addon's files in its own toc order.
fn gated_chain_entries() -> Vec<String> {
let mut out: Vec<String> = super::super::addons::Addon::builtin()
let mut out: Vec<String> = super::super::addons::Addon::core()
.toc
.files
.iter()
@@ -823,7 +823,7 @@ fn every_shipped_font_object_is_published_as_a_lua_global() {
// The names come from the manifest's own entries, chain files included.
let mut names: Vec<String> = Vec::new();
for entry in &super::addons::Addon::builtin().toc.files {
for entry in &super::manifest::manifest_files() {
if !entry.to_ascii_lowercase().ends_with(".xml") {
continue;
}
@@ -1988,21 +1988,28 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
);
}
/// `assets/ui` does not grow: the interface is the stock 1.12 FrameXML off the player's own patch
/// chain, and a new file means a window was authored rather than migrated. Point `benilla.toc` at
/// `Interface\FrameXML\<Window>.xml`, delete ours, and build the engine verbs the stock file calls.
/// benilla's own interface is one layer, and it does not grow: `layer.toc` names exactly these
/// files, all ours (none off the chain), and `assets/ui` holds nothing else but the two manifests
/// and the core's one file. A window is migrated, not authored: point `benilla.toc` at
/// `Interface\FrameXML\<Window>.xml`, delete ours, and build the engine verbs the stock file
/// calls; what changes the stock UI goes in one of these files.
#[test]
fn assets_ui_does_not_grow() {
const SHIPPED: &[&str] = &[
"ContainerFrameAdapters.xml",
"GameMenuFrame.xml",
fn the_layer_does_not_grow() {
const LAYER: &[&str] = &[
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"ScriptLogFrame.xml",
"ScrollTemplates.xml",
"ContainerFrameAdapters.xml",
"SpellBookAdapters.xml",
"benilla.toc",
"ScriptLogFrame.xml",
"FrameXMLFixes.xml",
];
assert_eq!(
super::addons::Addon::layer().toc.files,
LAYER,
"layer.toc changed. The layer does not grow (docs/METHOD.md): a file that retired comes \
off this list; a new one needs a reason this list can name."
);
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut found: Vec<String> = std::fs::read_dir(&dir)
.expect("assets/ui")
@@ -2011,11 +2018,46 @@ fn assets_ui_does_not_grow() {
.filter(|n| !n.starts_with('.'))
.collect();
found.sort();
let mut shipped: Vec<String> = SHIPPED.iter().map(|s| s.to_string()).collect();
let mut shipped: Vec<String> = LAYER
.iter()
.chain(&["benilla.toc", "layer.toc", "GameMenuFrame.xml"])
.map(|s| s.to_string())
.collect();
shipped.sort();
assert_eq!(
found, shipped,
"assets/ui changed. It does not grow: a window is migrated, not authored (docs/METHOD.md). \
A file that retired comes off this list; a new one needs a reason this list can name."
"assets/ui holds the two manifests, the layer's files and the core's one file, nothing else"
);
}
/// The core is the stock UI: `benilla.toc` names the reference's own files off the chain and one
/// file of ours, `GameMenuFrame.xml`, which stands in for the stock one until the layer reshapes
/// the stock menu, and whose row says so.
#[test]
fn the_core_toc_names_no_file_of_ours_but_the_game_menu() {
let core = super::addons::Addon::core().toc.files;
let ours: Vec<&String> = core
.iter()
.filter(|f| !super::reference_ui::is_chain_entry(f))
.collect();
assert_eq!(
ours,
["GameMenuFrame.xml"],
"benilla.toc names a file of ours: benilla's own interface is the layer (layer.toc), \
which loads after every stock file"
);
let toc = super::content::read(super::manifest::MANIFEST).expect("benilla.toc");
let lines: Vec<&str> = toc.lines().collect();
let at = lines
.iter()
.position(|l| l.trim() == "GameMenuFrame.xml")
.expect("the row");
assert!(
lines[..at]
.iter()
.rev()
.take_while(|l| l.starts_with('#'))
.any(|l| l.contains("#553")),
"GameMenuFrame.xml's row names the issue that retires it"
);
}
+1 -1
View File
@@ -107,7 +107,7 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
.expect("the multibar stand-ins");
}
// Our options window's Graphics rows read stock `OptionsFrameSliders` (`OptionsFrame.lua`) at
// OnLoad; the manifest loads it one seat above ours, so a kit seats it before our file.
// OnLoad; the core loads it before the layer, so a kit seats it before our file.
if path
.rsplit('/')
.next()
+4 -3
View File
@@ -27,8 +27,8 @@ taken.
lives in its own crate on top of benilla, started through `benilla_app::run_with` as in
`crates/benilla-app/examples/extended_launcher.rs`. A deviation from the reference is the
maintainer's call and is recorded where it lives; a pull request is not the place to propose one.
- Anything from a WoW install: art, models, sounds, maps, data. The one exception is interface
code (FrameXML and GlueXML), and only through the migration recipe in `docs/METHOD.md`.
- Anything from a WoW install: art, models, sounds, maps, data, and interface code (FrameXML and
GlueXML), which runs off the player's own install (`docs/METHOD.md`).
- Big or mixed changes. One change per pull request, small enough to read in one sitting.
## How a change is judged
@@ -96,7 +96,8 @@ out of scope is closed with the reason.
`series` and `hotspot`.
- **The loop.** `cargo play` builds and runs the play profile. `scripts/check.sh` verifies a
round of work; `scripts/gates.sh` is the full chain, and it opens a window for the engine boot
checks, so it needs a display. Work on a branch.
checks, so it needs a display. `WOW_STOCK_UI=1` boots a dev build on the stock interface
alone, without benilla's layer. Work on a branch.
## Reporting a bug
+2 -1
View File
@@ -174,7 +174,7 @@
- `WOW_BUBBLE_TRACE` — benilla-app/src/chat_bubble.rs
- `WOW_CAM_CHANGED` — benilla-app/src/perf/mod.rs
- `WOW_CAM_DUMP` — benilla-app/src/player/camera.rs
- `WOW_CAPTURE` — benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/char_select/addons.rs, benilla-app/src/cvars.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/name_persist.rs, benilla-app/src/net.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs
- `WOW_CAPTURE` — benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/char_select/addons.rs, benilla-app/src/cvars.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/name_persist.rs, benilla-app/src/net.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/layer_tests.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs
- `WOW_CAPTURE_AGE` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_DEADLINE` — benilla-app/src/capture/mod.rs
- `WOW_CAPTURE_OUT` — benilla-app/src/capture/mod.rs
@@ -467,6 +467,7 @@
- `WOW_STATIC_GX_PROP` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_GX_WMO` — benilla-world/src/static_gx/mod.rs
- `WOW_STATIC_MERGE` — benilla-world/src/terrain_stream/merge.rs
- `WOW_STOCK_UI` — benilla-app/src/ui_script/manifest.rs
- `WOW_STREAM_TRACE` — benilla-app/src/perf/mod.rs
- `WOW_TAB_TRACE` — benilla-app/src/target/scan.rs
- `WOW_TABHOVER` — benilla-app/src/capture/fixtures.rs
+11 -8
View File
@@ -36,19 +36,22 @@ comment naming the reference fact and why we differ. Anything else is a fork.
## Hard rules
- **Never commit anything from the install.** Art, models, sounds, maps, data: the install is
read at runtime from a gitignored path, and everyone provides their own. The one exception is
interface code: FrameXML and GlueXML run off the player's own patch chain, and our own
counterparts under `assets/ui` stay until they retire.
read at runtime from a gitignored path, and everyone provides their own. Interface code is no
exception: FrameXML and GlueXML run off the player's own patch chain, and `assets/ui` holds
only benilla's own files.
- **The install is read-only.** benilla never writes into the WoW folder: no screenshot, log,
cache or scratch file. `scripts/smoke.sh` fails a run that leaves the install changed.
- **Local state lives in one folder**, `benilla-config/`, at the repo root in a dev build and
beside the binary in the player build; every path to it resolves through `crate::local_state`.
Player settings are CVars persisted as a diff in `benilla-config/config.toml`.
- **UI is stock-first: a window is migrated, not authored.** The end state is the stock 1.12
FrameXML executed off the player's own chain. `assets/ui` does not grow: a test names its
files and fails on a new one. A window is built by pointing `benilla.toc` at the stock file,
deleting ours and building the engine verbs it calls, never stubbing one to make the file
load.
- **The core UI is the stock UI; benilla's own is one layer on top.** The core loads the stock
1.12 FrameXML off the player's own chain, byte for byte, and builds the engine verbs those
files call, never stubbing one to make a file load. benilla's own interface (its options
window, its game-menu look, its fixes to the stock Lua) is one layer that loads after the
stock files and before third-party addons, the way an addon does: written against the 1.12 API
alone, listed in no addon list and always on, and offering nothing another addon can build on.
Anything that changes what the stock UI does or shows lives in the layer, and the layer does
not grow: a test names its files and fails on a new one.
- **A setting's default is the reference's default.** Every option boots at the stock 1.12
value. Shipping another value costs an explicit `Deviates` row with the reason, and a test
fails a row that drifts either way.
+1 -1
View File
@@ -66,7 +66,7 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
n_ref["engine"] + n_ref["framexml"] + n_ref["lua"]
printf " engine %5d functions, %5d other <- benilla implements these in Rust\n", \
n_ref_fn["engine"], n_ref["engine"] - n_ref_fn["engine"]
printf " framexml %5d functions, %5d other <- benilla transcribes these into assets/ui\n", \
printf " framexml %5d functions, %5d other <- the stock FrameXML defines these\n", \
n_ref_fn["framexml"], n_ref["framexml"] - n_ref_fn["framexml"]
printf " lua %5d functions, %5d other <- mlua provides these\n", \
n_ref_fn["lua"], n_ref["lua"] - n_ref_fn["lua"]