`benilla.toc` enumerated every interface file, so a row the realm adds in a client update was *silently* absent: no "not found", no warning, just a global nobody defined. That is what `/xpboost` was, and the only way to find it was to diff two MPQ archives by hand. The manifest now decides order and substitution, and the realm's own `FrameXML.toc` decides membership: every row it carries that the manifest does not name loads after it, in the chain's order, and the boot log names them. `DECLINED` is the short list of rows this client must not run (`StatsFrame.xml`, whose OnUpdate is the only reader of a `GetDebugStats` we do not have), each with its reason; a row we merely have no verb for still loads and raises where it is used, which beats silence. `WOW_FRAMEXML_TAIL=0` opts out and says what it is leaving out. Finding that out turned up the reason the row was missing in the first place, which is worse and is now fixed too. `Chain::open` mounts `patch-?.MPQ` in the reference's order — case-folded, last wins — so `patch-A.mpq` mounts **above** `patch-5.mpq`. The realm ships its interface in `patch-5`; this update added six rows to that toc; and the 140-row copy inside an older `patch-A.mpq` kept winning. The interface was loading August's list of files. So `Chain::read_all` returns every archive's copy of a name in ascending priority, and `realm_toc_rows` reads a shadowed copy when it holds every row the winner does *plus* more. That case is unambiguous — nothing was removed, so nothing can be lost — and it is a packaging accident with no intent behind it either way. A copy that disagrees by removal is a real change of mind we cannot arbitrate: the winner stands and the log says what was passed over. Both paths say which archive, so the clean fix on disk is one line of log away. Verified against grom's own install and his addon corpus: with the `OctoXpBoost` row deleted from the manifest — tomorrow's update, simulated — the tail finds it, resolves it to a readable chain path, and `/xpboost` is a live slash command with a function behind it. The tail is empty with the manifest as it stands, which is the point: the mechanism is armed and changes nothing today. Tests: the merge takes both lists as arguments so it runs where there is no client data, since that is the machine where a regression in it would otherwise skip unseen; the letter-above-digit mount order is pinned on its own, because that fact is what cost the six rows; `read_all`'s ordering contract is checked against a real chain; and a `DECLINED` row that the realm stops shipping now fails as stale. The 269 data-gated failures are the same 269, name for name, as at HEAD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
579 lines
25 KiB
Rust
579 lines
25 KiB
Rust
//! 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.
|
|
//!
|
|
//! [`interface_files`], not the manifest alone, is that list: the realm's own `FrameXML.toc` has
|
|
//! the last word on *membership*, so a row it gains in a client update loads without anyone
|
|
//! editing this crate (`reference_ui::tail`).
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::UiScript;
|
|
|
|
use super::addons::Addon;
|
|
use super::reference_ui;
|
|
|
|
/// The built-in interface's manifest, relative to `assets/ui`.
|
|
pub(super) const MANIFEST: &str = "benilla.toc";
|
|
|
|
/// The manifest's entries in load order, from both stores.
|
|
#[cfg(test)]
|
|
pub(super) fn manifest_files() -> Vec<String> {
|
|
Addon::builtin().toc.files
|
|
}
|
|
|
|
/// The whole interface in load order: [`MANIFEST`], then every row the realm's own `FrameXML.toc`
|
|
/// carries that the manifest does not name (`reference_ui::tail`).
|
|
///
|
|
/// The tail goes last for two reasons: the manifest's order deviates from the chain's deliberately
|
|
/// in places, so there is no unambiguous position to interleave an unknown row into; and the
|
|
/// chain's toc puts the realm's own additions late itself. Their order *relative to each other* is
|
|
/// the chain's, which is what a pair like `Turtle_GroupUI.xml` and the fix that patches it needs.
|
|
fn interface_files() -> Vec<String> {
|
|
let mut files = Addon::builtin().toc.files;
|
|
files.extend(reference_ui::tail().iter().cloned());
|
|
files
|
|
}
|
|
|
|
/// The manifest's entries that name a file we ship rather than one off the player's install.
|
|
#[cfg(test)]
|
|
pub(super) fn shipped_manifest_files() -> Vec<String> {
|
|
manifest_files()
|
|
.into_iter()
|
|
.filter(|f| !reference_ui::is_chain_entry(f))
|
|
.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.
|
|
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}")];
|
|
}
|
|
if let Err(e) = install_macro_letters_patch(script) {
|
|
error!("ui_script: macro letters patch: {e}");
|
|
return vec![format!("macro letters patch: {e}")];
|
|
}
|
|
if let Err(e) = install_focus_slash_commands(script) {
|
|
error!("ui_script: focus slash commands: {e}");
|
|
return vec![format!("focus slash commands: {e}")];
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
/// ClassicAPI (`docs/ADDON_COMPAT.md`): `/focus` and `/clearfocus`, the aliases over
|
|
/// `FocusUnit`/`ClearFocus` that pfUI's `modules/focus.lua` names as living "in ClassicAPI's
|
|
/// SlashCommandsRegistry". They go in here, not beside the verbs in `benilla-ui`'s
|
|
/// `compat/classic_api/focus.rs`, for a mechanical reason: the stock `ChatFrame.lua:680` *assigns*
|
|
/// `SlashCmdList = { }`, and the compat layer installs before FrameXML loads, so an entry made
|
|
/// there would be dropped on the floor. Same position and same reason as
|
|
/// [`install_macro_letters_patch`].
|
|
///
|
|
/// Inferred, not confirmed: upstream's own argument handling for `/focus` is not published (its
|
|
/// `docs/API.md` lists the two functions and no slash grammar). The argument is read as a unit
|
|
/// token — the only thing this client can resolve, and the reason pfUI ships its own `/focusname`
|
|
/// for names — defaulting to `"target"`, which is the later clients' `/focus`. The no-target case
|
|
/// clearing the focus is `FocusUnit`'s own documented behaviour, not this handler's.
|
|
pub(super) fn install_focus_slash_commands(script: &UiScript) -> Result<(), String> {
|
|
script.run(FOCUS_SLASH_COMMANDS).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `msg` arrives untrimmed (`ChatEdit_ParseText` hands over `strsub(text, strlen(command) + 2)`),
|
|
/// and `"target "` is a recognised token naming nothing, which would clear the focus instead of
|
|
/// setting it — hence the `gsub`, parenthesised to drop `gsub`'s second return. 1.12 has neither
|
|
/// `strtrim` nor `trim`.
|
|
const FOCUS_SLASH_COMMANDS: &str = r#"
|
|
if SlashCmdList then
|
|
SLASH_FOCUS1 = "/focus"
|
|
SlashCmdList["FOCUS"] = function(msg)
|
|
local unit = (gsub(msg or "", "^%s*(.-)%s*$", "%1"))
|
|
FocusUnit(unit ~= "" and unit or "target")
|
|
end
|
|
SLASH_CLEARFOCUS1 = "/clearfocus"
|
|
SlashCmdList["CLEARFOCUS"] = function()
|
|
ClearFocus()
|
|
end
|
|
end
|
|
"#;
|
|
|
|
/// SuperWoW (`docs/ADDON_COMPAT.md`): the macro body limit is 255 letters in 1.12 and 510 under
|
|
/// SuperWoW. [`benilla_ui::script::MAX_MACRO_BODY`] already stores the raised cap; two things in
|
|
/// the stock UI still carry the old number from the player's own install and need resetting at
|
|
/// boot, same as [`install_chat_plate_guard`]: `MacroFrameText`'s FrameXML `letters="255"`
|
|
/// (`SetMaxLetters`, the method `letters=` itself calls at creation), and the counter's own
|
|
/// `MACROFRAME_CHAR_LIMIT` global string (`"%d/255 Characters Used"`), which would otherwise keep
|
|
/// reading 255 even once typing past it works.
|
|
pub(super) fn install_macro_letters_patch(script: &UiScript) -> Result<(), String> {
|
|
script.run(MACRO_LETTERS_PATCH).map_err(|e| e.to_string())
|
|
}
|
|
|
|
const MACRO_LETTERS_PATCH: &str = r#"
|
|
if MacroFrameText then
|
|
MacroFrameText:SetMaxLetters(510)
|
|
end
|
|
if MACROFRAME_CHAR_LIMIT then
|
|
MACROFRAME_CHAR_LIMIT = "%d/510 Characters Used"
|
|
end
|
|
"#;
|
|
|
|
/// 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.
|
|
pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
|
|
script
|
|
.run("if BuffButtons_UpdatePositions then BuffButtons_UpdatePositions() end")
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
|
|
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
|
|
/// 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.
|
|
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) {
|
|
error!("ui_script: full-screen quad re-seat: {e}");
|
|
}
|
|
}
|
|
|
|
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
|
|
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
|
|
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
|
|
/// the stock OnLoad leaves them.
|
|
const FULLSCREEN_QUADS_RESEAT: &str = r#"
|
|
if BlackoutWorld then
|
|
local width = GetScreenWidth()
|
|
local height = GetScreenHeight()
|
|
if ( width / height < 4 / 3 ) then
|
|
width = width * 1.25
|
|
height = height * 1.25
|
|
end
|
|
BlackoutWorld:SetWidth( width )
|
|
BlackoutWorld:SetHeight( height )
|
|
end
|
|
if UpperBlackBar and LowerBlackBar then
|
|
local width = GetScreenWidth()
|
|
local height = GetScreenHeight()
|
|
local barWidth, blackBarHeight = 1024, 128
|
|
if ( width / height > 4 / 3 ) then
|
|
local desiredHeight = width / 2
|
|
if ( desiredHeight > height ) then
|
|
desiredHeight = height
|
|
end
|
|
barWidth = width
|
|
blackBarHeight = ( height - desiredHeight ) / 2
|
|
end
|
|
UpperBlackBar:SetHeight( blackBarHeight )
|
|
UpperBlackBar:SetWidth( barWidth )
|
|
LowerBlackBar:SetHeight( blackBarHeight )
|
|
LowerBlackBar:SetWidth( barWidth )
|
|
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
|
|
/// call in between. `PLAYER_LOGIN` fires inside it only on `/reloadui` (`0x490168`); a fresh login
|
|
/// fires it from the player's create (`0x5deb60` to `0x4908c0`), a cascade with its own scope
|
|
/// (`0x4908d5` to `0x490a56`). The stock `TargetFrame_OnHide` at load is one sound it swallows.
|
|
pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
|
|
script: &mut S,
|
|
body: impl FnOnce(&mut S) -> R,
|
|
) -> R {
|
|
script.borrow().push_sound_suppression();
|
|
let out = body(script);
|
|
script.borrow().pop_sound_suppression();
|
|
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>"`.
|
|
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());
|
|
// A player, because the realm's own FrameXML reads one **at file scope**: both
|
|
// `Turtle_ShopUI.lua` (:28) and `Turtle_TransmogUI.lua` (:17) run
|
|
// `strlower((select(2, UnitRace("player"))))` in their main chunk, which raises on nil and
|
|
// takes the whole file with it — so every `*_OnLoad` those files define goes undefined and
|
|
// each of their frames then fails its own OnLoad in turn. Six load errors from one missing
|
|
// unit.
|
|
//
|
|
// Seating one is not a fiction for this function's callers: it is only ever reached offline
|
|
// (the tests and `addon_harness`), and in the live client FrameXML loads **on entering the
|
|
// world**, where a player always exists. Without this the offline load is the one that
|
|
// diverges from the running client, not the other way round — which is exactly what made
|
|
// these six look like a product fault when they were a harness gap.
|
|
//
|
|
// Deliberately minimal: a name (the setter ignores an empty-named record), a race and a
|
|
// class. Nothing reads the values, only their presence.
|
|
script.set_player_record(benilla_ui::script::PlayerRecord {
|
|
name: "Loader".into(),
|
|
race: Some(("Human".into(), "Human".into())),
|
|
class: Some(("Warrior".into(), "WARRIOR".into())),
|
|
sex: 2,
|
|
});
|
|
let mut failures = silenced_ui_load(&mut &*script, |script| {
|
|
let mut failures = load_manifest(script, &interface_files());
|
|
failures.extend(bootstrap_positions(script));
|
|
failures
|
|
});
|
|
// A raise one call below the loader's own dispatch (an OnLoad that shows a frame whose OnShow
|
|
// raises) lands in the VM's error list, not the loader's report; it is a load failure too.
|
|
failures.extend(
|
|
script
|
|
.errors()
|
|
.into_iter()
|
|
.map(|e| format!("a handler raised during the load walk: {e}")),
|
|
);
|
|
failures
|
|
}
|
|
|
|
/// 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 reference = reference_ui::addon(
|
|
files
|
|
.iter()
|
|
.filter(|f| reference_ui::is_chain_entry(f))
|
|
.cloned()
|
|
.collect(),
|
|
);
|
|
let mut failures = Vec::new();
|
|
for file in files {
|
|
let from = if reference_ui::is_chain_entry(file) {
|
|
&reference
|
|
} else {
|
|
&builtin
|
|
};
|
|
failures.extend(from.load_files(script, std::slice::from_ref(file)));
|
|
}
|
|
failures
|
|
}
|
|
|
|
/// 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, interface_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
|
|
/// entry (`0x401570` from `0x46c236`), its addons at `0x4900a3` (`0x51f600`).
|
|
///
|
|
/// `identity`, `(realm, character)`, names the character's AddOn enable-state file, as the
|
|
/// reference keys `AddOns.txt` per character; `roster`, the realm's characters, decides an addon
|
|
/// this one has no row for. `version_check` is the persisted `checkAddonVersion`, since this VM
|
|
/// has no CVar table yet at load.
|
|
pub(crate) fn load_ingame_ui(
|
|
script: &mut UiScript,
|
|
identity: Option<&(String, String)>,
|
|
roster: &[String],
|
|
version_check: 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, interface_files().get(1..).unwrap_or_default());
|
|
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(
|
|
script,
|
|
identity,
|
|
roster,
|
|
version_check,
|
|
));
|
|
failures
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// ClassicAPI's `/focus` and `/clearfocus` (`docs/ADDON_COMPAT.md`): registered into the
|
|
/// table the stock `ChatFrame.lua` declares, and reachable by the same walk every typed or
|
|
/// macro line takes ([`UiScript::run_slash_command`]).
|
|
#[test]
|
|
fn the_focus_slash_commands_dispatch_through_the_stock_command_table() {
|
|
use benilla_ui::script::UnitState;
|
|
|
|
let mut s = UiScript::new().unwrap();
|
|
// A bare VM has no `ChatFrame.lua`; the patch guards on the table, so this is the boot
|
|
// where only `Fonts.xml` has loaded, and it must not raise.
|
|
install_focus_slash_commands(&s).unwrap();
|
|
assert!(
|
|
!s.has_slash_command("focus"),
|
|
"no command table yet, so no command"
|
|
);
|
|
|
|
s.run("SlashCmdList = {}").unwrap();
|
|
install_focus_slash_commands(&s).unwrap();
|
|
s.set_unit(
|
|
"target",
|
|
Some(UnitState {
|
|
exists: true,
|
|
guid: 0x1234,
|
|
name: Some("Hogger".into()),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
|
|
assert!(s.run_slash_command("focus", ""), "/focus is claimed");
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return UnitName("focus")"#).unwrap(),
|
|
"Hogger",
|
|
"a bare /focus focuses the current target"
|
|
);
|
|
// The argument arrives untrimmed from `ChatEdit_ParseText`; a stray space must not read as
|
|
// a token naming nothing, which would clear the focus instead of re-setting it.
|
|
assert!(s.run_slash_command("FOCUS", " target "));
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return UnitName("focus")"#).unwrap(),
|
|
"Hogger"
|
|
);
|
|
|
|
assert!(s.run_slash_command("clearfocus", ""));
|
|
assert_eq!(
|
|
s.eval::<Option<String>>(r#"return UnitName("focus")"#)
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// SuperWoW's 510-letter macro body (`docs/ADDON_COMPAT.md`): the patch resets the stock
|
|
/// `MacroFrameText` edit box's letter cap and the counter's own format string, in isolation
|
|
/// (no shipped FrameXML, no install data) rather than through the full boot sequence.
|
|
#[test]
|
|
fn install_macro_letters_patch_raises_the_cap_and_the_counter_text() {
|
|
let s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"
|
|
MacroFrameText = CreateFrame("EditBox", "MacroFrameText")
|
|
MacroFrameText:SetMaxLetters(255)
|
|
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
install_macro_letters_patch(&s).unwrap();
|
|
|
|
assert_eq!(
|
|
s.eval::<i64>("return MacroFrameText:GetMaxLetters()")
|
|
.unwrap(),
|
|
510
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return string.format(MACROFRAME_CHAR_LIMIT, 21)"#)
|
|
.unwrap(),
|
|
"21/510 Characters Used"
|
|
);
|
|
}
|
|
|
|
/// `Fonts.xml` must stay entry 0: [`load_font_registry`] loads entry 0 at startup and
|
|
/// [`load_ingame_ui`] the rest.
|
|
#[test]
|
|
fn the_manifest_is_a_toc_that_starts_with_the_font_registry() {
|
|
let toc = Addon::builtin().toc;
|
|
assert_eq!(toc.interface_versions(), vec![11200]);
|
|
assert_eq!(toc.directive("Title"), Some("benilla"));
|
|
assert_eq!(
|
|
toc.files.first().map(String::as_str),
|
|
Some("Interface\\FrameXML\\Fonts.xml"),
|
|
"the font registry is the manifest's first entry — the loader splits there"
|
|
);
|
|
}
|
|
|
|
/// A shipped source file the manifest does not list never loads, with no error to say so.
|
|
/// Both spellings count: the manifest carries `.lua` as well as `.xml` (`AIOHandlers.lua`).
|
|
#[test]
|
|
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
|
|
let mut listed = shipped_manifest_files();
|
|
let mut shipped: Vec<String> = super::super::content::shipped_files()
|
|
.filter(|f| f.ends_with(".xml") || f.ends_with(".lua"))
|
|
.map(str::to_owned)
|
|
.collect();
|
|
listed.sort();
|
|
shipped.sort();
|
|
assert_eq!(listed, shipped);
|
|
}
|
|
|
|
/// [`reference_ui::is_chain_entry`] reads a path separator as a chain entry, so a shipped file
|
|
/// in a subdirectory would be looked for on the player's install instead.
|
|
#[test]
|
|
fn every_file_we_ship_is_a_bare_name_so_a_path_can_only_mean_the_chain() {
|
|
for name in super::super::content::shipped_files() {
|
|
assert!(
|
|
!reference_ui::is_chain_entry(name),
|
|
"assets/ui is flat, but ships {name}: a separator makes it a file off the install"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every chain entry the manifest names is in the player's 1.12 patch chain.
|
|
#[test]
|
|
fn every_chain_entry_resolves_off_the_players_install() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let chain: Vec<String> = manifest_files()
|
|
.into_iter()
|
|
.filter(|f| reference_ui::is_chain_entry(f))
|
|
.collect();
|
|
for entry in &chain {
|
|
assert!(
|
|
reference_ui::read(entry).is_some(),
|
|
"benilla.toc sources {entry} off the patch chain, which does not hold it"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The stock pass (`UIParent.lua:1592-1775`) indexes these frames unguarded, so a file that
|
|
/// runs it at load must follow every file it reads, as the reference's toc orders them.
|
|
#[test]
|
|
fn every_load_time_runner_of_the_managed_pass_follows_what_it_reads() {
|
|
let files = manifest_files();
|
|
let at = |leaf: &str| {
|
|
files
|
|
.iter()
|
|
.position(|f| f.ends_with(&format!("\\{leaf}")))
|
|
.unwrap_or_else(|| panic!("the manifest lists {leaf}"))
|
|
};
|
|
// What the pass reads by name (`UIParent.lua:1598-1775`), and the file that declares it.
|
|
const READS: &[(&str, &str)] = &[
|
|
(
|
|
"MultiBarLeft / MultiBarRight / MultiBarBottomLeft",
|
|
"MultiActionBars.xml",
|
|
),
|
|
(
|
|
"PetActionBarFrame + SlidingActionBarTexture0/1",
|
|
"PetActionBarFrame.xml",
|
|
),
|
|
("ReputationWatchBar", "ReputationFrame.xml"),
|
|
(
|
|
"MainMenuExpBar / MainMenuBarMaxLevelBar / MainMenuBar",
|
|
"MainMenuBar.xml",
|
|
),
|
|
("CastingBarFrame", "CastingBarFrame.xml"),
|
|
("QuestTimerFrame", "QuestTimerFrame.xml"),
|
|
("QuestWatchFrame", "QuestLogFrame.xml"),
|
|
("DurabilityFrame + its three glyphs", "DurabilityFrame.xml"),
|
|
("MinimapCluster", "Minimap.xml"),
|
|
(
|
|
"ChatFrame1 / ChatFrame2 (+ FCF_DockUpdate)",
|
|
"FloatingChatFrame.xml",
|
|
),
|
|
// The shapeshift arm (l.1705-1732): the stance bar's file declares these and also runs
|
|
// the pass, so it must precede the other runner.
|
|
(
|
|
"ShapeshiftBarLeft / Middle / Right",
|
|
"BonusActionBarFrame.xml",
|
|
),
|
|
];
|
|
// Who runs it at load: an OnLoad, or the OnShow of a frame its OnLoad shows.
|
|
const RUNNERS: &[(&str, &str)] = &[
|
|
(
|
|
"ShapeshiftBar_OnLoad → Show → OnShow",
|
|
"BonusActionBarFrame.xml",
|
|
),
|
|
("WorldStateAlwaysUpFrame OnLoad", "WorldStateFrame.xml"),
|
|
];
|
|
for (what, runner) in RUNNERS {
|
|
for (name, read) in READS {
|
|
if read == runner {
|
|
continue; // its own declarations precede its own OnLoad
|
|
}
|
|
assert!(
|
|
at(read) < at(runner),
|
|
"{runner} ({what}) loads at {} but reads {name}, declared by {read} at {} — \
|
|
the pass raises at load and never seats the frame it was run for. Move the \
|
|
runner below the read, as the reference's toc has it.",
|
|
at(runner),
|
|
at(read)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_declares_a_uiparent_child_before_uiparent_itself_loads() {
|
|
let files = manifest_files();
|
|
let at = files
|
|
.iter()
|
|
.position(|f| f == r"Interface\FrameXML\UIParent.xml")
|
|
.expect("the manifest lists UIParent.xml");
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
|
for early in &files[..at] {
|
|
// A chain entry is a stock file off the player's install, not one on disk here.
|
|
if reference_ui::is_chain_entry(early) {
|
|
continue;
|
|
}
|
|
let text = std::fs::read_to_string(dir.join(early)).unwrap();
|
|
assert!(
|
|
!text.contains(r#"parent="UIParent""#),
|
|
"{early} loads before UIParent.xml (position {at}) but declares a \
|
|
UIParent child — the loader would warn and silently drop it. Move \
|
|
UIParent.xml up, or the declaration down."
|
|
);
|
|
}
|
|
}
|
|
}
|