plates take the mouse, Auctioneer loads, and the damage font is yours

- hovering a nameplate lights its unit and clicking one selects it, pfUI's click-through works, a drag that starts on a plate still turns the camera, and turning plates off with V clears them (2159)
- Auctioneer's XML documents open (the reference has no XML namespaces), a .toc line reaching Blizzard_AuctionUI's templates resolves for Auctioneer, BeanCounter and SpecialTalentUI, and a missing include logs rather than raising the red error dialog (2155)
- a .lua reached through <Script file=> carries the same Interface\AddOns\ chunk name as a .toc-listed one, so Ace2's debugstack split works and AtlasLoot keys its database; 26 more corpus addons load, 176 of 219 (2155)
- the floating combat numbers draw in whatever DAMAGE_TEXT_FONT holds at world entry, so MikScrollingBattleText and pfUI pick the font (2156); a dodged swing prints no line and floats no word, and happiness leech lines print (2157)
- an unknown frameStrata in XML logs and keeps the default instead of raising, so EQL3's log window loads; FrameXML_Debug is a real switch, so ImprovedErrorFrame loads (2160)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:56:00 +07:00
co-authored by Claude Fable 5.1
parent 38e45badb9
commit 6af2bfcb73
32 changed files with 2036 additions and 346 deletions
+220 -35
View File
@@ -134,9 +134,12 @@ impl Addon {
/// source knows what a resolved path is allowed to reach.
///
/// **The sandbox is the AddOns folder, not one addon's subfolder.** `..` that walks above the
/// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it — so an addon can
/// reach a sibling library addon (which is how `Bagnon` reaches `BagBrother`, and what 1184's
/// per-addon guard wrongly blocked) but cannot reach the machine.
/// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it on the filesystem
/// leg — so an addon can reach a sibling library addon (which is how `Bagnon` reaches
/// `BagBrother`, and what 1184's per-addon guard wrongly blocked) but cannot reach the machine.
/// A `Dir` miss then falls through to the player's patch chain under the install's own name
/// for it ([`read_addon_file`], decision 2155), which is the single namespace the reference
/// opener has — and the reason `Auctioneer`'s reach into `Blizzard_AuctionUI` resolves at all.
///
/// The **builtin alone** keeps a basename fallback: its tree is flat, and a transcription that
/// writes a Blizzard-style directory path should still find the file. A `Dir` source must not
@@ -154,38 +157,53 @@ impl Addon {
.or_else(|| content::read(&base))
.map(String::into_bytes)
}
Source::Dir(root) => read_under(root, req),
Source::Dir(root) => read_addon_file(root, req),
Source::Chain => super::reference_ui::read(req),
}
}
/// This addon's own folder in its source's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`, and
/// `""` again for the chain, whose manifest entries are already full internal paths.
fn prefix(&self) -> &str {
/// This addon's own folder in the install's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, `""` for the chain (whose manifest entries
/// are already full internal paths), and `Interface/AddOns/<Folder>` for a `Dir`.
///
/// **`Interface/AddOns/`, not the bare folder name** (decision 2155). It used to be the folder
/// alone, which made the addon's path space the AddOns root rather than the install — a second
/// namespace the reference does not have. The visible cost was `<Script file=>`'s chunk NAME:
/// the loader names a file chunk after the path it resolved, so an XML-loaded Lua file came out
/// `@AtlasLoot\Core\AtlasLoot.lua` while the `.toc`-listed file beside it was
/// `@Interface\AddOns\AtlasLoot\…`. Every Ace2/FuBar-era library finds its own addon by
/// splitting a `debugstack` frame on `\AddOns\` (`AceDB-2.0.lua:742`,
/// `FuBarPlugin-2.0.lua:602`), so for those files the split found nothing and the library
/// silently keyed itself on a traceback — or, in `SSHonor_Fu`'s case, called
/// `IsAddOnLoadOnDemand(nil)`.
fn prefix(&self) -> String {
match &self.source {
Source::Builtin | Source::Chain => "",
Source::Dir(_) => &self.name,
Source::Builtin | Source::Chain => String::new(),
Source::Dir(_) => format!("{ADDONS_PREFIX}{}", self.name),
}
}
/// The name Lua gives a chunk from this source — **not cosmetic**, because addons parse it.
///
/// An addon's file is named the way the client names it, `Interface\AddOns\<Folder>\<File>`
/// ([`benilla_ui::script::addon_chunk_name`], whose header carries the FuBar `debugstack`
/// story). A **chain** file is named after its own chain path, `@Interface\FrameXML\…`, which
/// is both what the real client names it and what keeps FrameXML *out* of the `\AddOns\`
/// pattern those addons match against — FrameXML is not an addon.
/// `path` is the entry already resolved into the install's path space, which is the only thing
/// the client names a file chunk after: `"@%s"` (`0x8716e0`) over the resolved path, built by
/// `0x704bc0` for every `.lua` the loader touches (wow-re
/// `ui/scratch/include-lua-dispatch.md` §7). So an addon's file is `Interface\AddOns\<Folder>
/// \<File>` and a chain file is `Interface\FrameXML\…` — the second being both what the real
/// client names it and what keeps FrameXML *out* of the `\AddOns\` pattern the Ace2 family
/// matches against, because FrameXML is not an addon.
///
/// The loader gives an inline `<Script>` and a `<Script file=>` exactly this shape already
/// (`loader::Loader::run`), so a chain document's Lua is named identically whether it arrives
/// as a manifest entry or through its own `<Script file=>`.
fn chunk_name(&self, file: &str) -> String {
/// **One rule for both doors, which is the point** (decision 2155). `Loader::run` names a
/// `<Script file=>` chunk by exactly this rule over exactly this space, so a manifest entry and
/// an XML-referenced file now agree — they did not while a `Dir` addon's space was rooted at
/// the AddOns folder, and [`Addon::prefix`] carries what that cost.
///
/// The builtin keeps [`benilla_ui::script::addon_chunk_name`]: its tree is flat and internal,
/// so there is no resolved install path to name it after.
fn chunk_name(&self, file: &str, path: &str) -> String {
match &self.source {
Source::Builtin | Source::Dir(_) => {
benilla_ui::script::addon_chunk_name(&self.name, file)
}
Source::Chain => format!("@{}", file.replace('/', "\\")),
Source::Builtin => benilla_ui::script::addon_chunk_name(&self.name, file),
Source::Dir(_) | Source::Chain => format!("@{}", path.replace('/', "\\")),
}
}
@@ -216,7 +234,7 @@ impl Addon {
for file in files {
// A manifest entry is relative to the addon's own folder; `read` and the loader both
// work in the source's path space, so resolve once here and use it for both.
let path = benilla_ui::loader::join_ref(self.prefix(), file);
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 lied. For the builtin that is us — a client
@@ -244,7 +262,7 @@ impl Addon {
// The same execution `<Script file=>` gets (`loader::mod.rs`): one chunk, run in
// the one global state, in manifest order. The reference does exactly this —
// `AddOn_Load 0x51f240` hands each listed file to `0x6edb90` regardless of kind.
match script.run_chunk_named(&bytes, &self.chunk_name(file)) {
match script.run_chunk_named(&bytes, &self.chunk_name(file, &path)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
@@ -277,6 +295,15 @@ impl Addon {
// loader takes the path and does that itself, so it can also name the file a raise
// came from (1217).
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
// `FrameXML_Debug`'s trace lines, if the player turned it on (2160). Log-only, and
// never retained: the reference files them into the same per-document record as the
// errors, but at severity 0 and *where that record surfaces is an open question in
// wow-re* (`framexml-debug-trace-flag.md`, and `xml-template-name-lookup.md` §9 for
// severity 1). So they go where a trace can go without claiming a surface we have not
// derived — and the switch that produces them is off unless an addon asks.
for t in &report.traces {
info!("ui_script({}/{file}): {t}", self.name);
}
for w in &report.warnings {
warn!("ui_script({}/{file}): {w}", self.name);
// …and retained where a player can read it (2135). The `warn!` above is the
@@ -285,6 +312,22 @@ impl Addon {
// than inside the loader — only this caller knows which file the warning is from.
script.report_warning(&format!("{}/{file}: {w}", self.name));
}
// **A file the document named and the provider does not have — the same rule as a
// `.toc` line naming no file** (decision 2155, the rule 2107 unified for the walk and
// the demand load, at the two doors it did not reach). Severity by whose manifest
// lied, retained where a player can read it, and never a script error: the reference
// logs `Couldn't open %s` / `Error loading %s` and carries on with nothing raised.
// Still a `failures` entry, so our own boot tests fail on a `benilla.toc` document
// that names a file the chain does not hold.
for m in &report.missing_files {
let e = format!("{}/{file}: {m}", self.name);
match self.source {
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
script.report_load_failure(&e);
failures.push(e);
}
for e in &report.errors {
error!("ui_script({}/{file}): {e}", self.name);
// Same contract as the Lua arm above: a `<Script file=>` chunk that raised or an
@@ -337,6 +380,55 @@ fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(rel)).ok()
}
/// **THE file namespace — install-relative, one space for every source** (decision 2155).
///
/// `req` is a path the loader has already resolved and collapsed
/// ([`benilla_ui::loader::join_ref`]) in the client's own space: `Interface/AddOns/<Folder>/…` for
/// an addon's file, `Interface/FrameXML/…` for one that walked out of the AddOns tree. The
/// reference has exactly one such space — `0x647e60` answers a name from a hash index of the
/// install tree keyed by **install-root-relative path**, and then, attempt #4, from the MPQ chain
/// (wow-re `ui/scratch/include-lua-dispatch.md` §4.1, VERIFIED; loose *before* archive, which is
/// the order below). Two spaces is what this client had, and it cost three separate things:
///
/// - `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml`, a `.toc` line in `Auctioneer` and
/// in `BeanCounter`, collapses to `Interface/AddOns/Blizzard_AuctionUI/…` — a folder that is a
/// `.pub` decoy on disk with the real files inside `patch.MPQ`. Only the chain leg has it.
/// - `..\..\FrameXML\Fonts.xml`, a `.toc` line in `JIM_toolbox` and in `SpecialTalentUI`,
/// collapses out of the AddOns tree entirely, to `Interface/FrameXML/Fonts.xml`. Same leg.
/// (wow-re records both by name as **RESOLVING** on the real client —
/// `ui/scratch/xml-toc-path-resolution.md` §5 cases 1 and 2.)
/// - and the one that was invisible: with the addon space rooted at the AddOns folder, a
/// `<Script file=>` chunk was NAMED after that space — `@AtlasLoot\Core\AtlasLoot.lua` — while
/// a `.toc`-listed one was named `@Interface\AddOns\AtlasLoot\…`. See [`Addon::chunk_name`].
///
/// **The sandbox is stricter than it was, not looser.** Only a path under `Interface/AddOns/`
/// touches the filesystem at all, and [`read_under`] still refuses to escape the root once the
/// prefix is stripped; everything else can only reach the read-only archive chain, whose names
/// cannot leave `Interface\`'s own tree. An addon reaches a sibling and the client's own interface
/// files, and never the machine.
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
under_addons(req)
.and_then(|rel| read_under(root, rel))
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
}
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` if it does not carry one.
///
/// Case-insensitively, because the reference is a Windows client: a `.toc` writing
/// `..\..\Interface\Addons\Foo\bar.lua` names the same file there, and on a case-sensitive
/// filesystem an exact compare would silently send it to the chain-only leg.
fn under_addons(req: &str) -> Option<&str> {
let rest = req.get(ADDONS_PREFIX.len()..)?;
req.get(..ADDONS_PREFIX.len())
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
.map(|_| rest)
}
/// Where a `Source::Dir` addon's files live in the install's path space — the base every one of
/// its manifest entries resolves against, with its folder name appended
/// ([`Addon::prefix`]). `/`-separated, because that is [`benilla_ui::loader::join_ref`]'s space.
const ADDONS_PREFIX: &str = "Interface/AddOns/";
/// **The** addon folder — `<benilla-config>/AddOns/` — or `None` when there is none to read.
///
/// **One root, never two** (decision 1185, the director's call). 1184 searched the WoW install's
@@ -958,7 +1050,7 @@ impl Walk {
// own files (whose functions a binding body calls), before its saved variables. Read
// through the addon's own reader, so the AddOns-root sandbox (1186) covers it like every
// other file it loads; absent is the normal case and silent.
let bindings_xml = benilla_ui::loader::join_ref(addon.prefix(), "Bindings.xml");
let bindings_xml = benilla_ui::loader::join_ref(&addon.prefix(), "Bindings.xml");
if let Some(bytes) = addon.read(&bindings_xml) {
match benilla_ui::bindings_xml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(bindings) => script.register_addon_bindings(&addon.name, &bindings),
@@ -1062,7 +1154,8 @@ mod tests {
}
/// **The sandbox is the AddOns folder** (decision 1186): a sibling addon is reachable, the
/// machine is not.
/// machine is not — now expressed as *only a path under `Interface/AddOns/` touches the
/// filesystem at all* (2155), which is the same rule one level up and strictly tighter.
///
/// 1184 drew the line at each addon's own folder, which reads as the safer choice and breaks
/// the single most common structure in the ecosystem — a shared library addon exists precisely
@@ -1089,32 +1182,124 @@ mod tests {
source: Source::Dir(root),
};
// **Every path here is install-relative now** (2155) — the base is
// `Interface/AddOns/<Folder>`, which is what `prefix()` hands the loader.
assert_eq!(addon.prefix(), "Interface/AddOns/Probe");
let src = "Interface/AddOns/Probe/src";
// Its own file, and a sibling library addon reached the way a real addon reaches one.
assert_eq!(
addon.read(&join_ref("Probe/src", "own.txt")).as_deref(),
addon.read(&join_ref(src, "own.txt")).as_deref(),
Some(&b"yes"[..])
);
assert_eq!(
addon
.read(&join_ref("Probe/src", "..\\..\\ProbeLib\\core\\lib.xml"))
.read(&join_ref(src, "..\\..\\ProbeLib\\core\\lib.xml"))
.as_deref(),
Some(&b"sibling"[..]),
"a shared library addon must be reachable — this is what 1184 wrongly blocked"
);
// Above the AddOns root is refused: the escape survives `join_ref` as a leading `..` and
// `read_under` will not join it.
// Above the AddOns root touches no file at all: the collapsed path leaves
// `Interface/AddOns/`, so it never reaches `read_under` and can only ask the archive chain
// — which is stricter than the leading-`..` refusal this replaced, because a `..` that
// lands back INSIDE the install (`Interface/FrameXML/…`) is now a chain name rather than a
// filesystem join. `secret.txt` sits beside the AddOns root and is unreachable either way.
assert!(addon.read(&join_ref(src, "../../../secret.txt")).is_none());
assert!(addon
.read(&join_ref("Probe/src", "../../../secret.txt"))
.read(&join_ref("Interface/AddOns/Probe", "..\\secret.txt"))
.is_none());
assert!(addon.read(&join_ref("Probe", "..\\secret.txt")).is_none());
// A leading `/` is not "the filesystem root" — it re-roots at AddOns, which is the only
// root a FrameXML path has. So this looks for `<AddOns>/etc/hosts` and finds nothing.
assert!(
addon
.read(&join_ref(src, "../../../../../../../../etc/passwd"))
.is_none(),
"over-consuming `..` cannot walk out of the install either"
);
// A leading `/` is not "the filesystem root" — `join_ref` re-roots it at the base it is
// given, and with no base that is a bare relative name, which is not under
// `Interface/AddOns/` and so is a chain lookup that misses.
assert_eq!(join_ref("", "/etc/hosts"), "etc/hosts");
assert!(addon.read(&join_ref("", "/etc/hosts")).is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A `.toc`-listed `.lua` and an XML-referenced one get the SAME chunk name** — the defect
/// decision 2155 found, asserted from the outside because it is only visible from there.
///
/// Every Ace2-era library finds the addon it belongs to by splitting a `debugstack` frame on
/// `\AddOns\` — `AceDB-2.0.lua:742`'s `".-\n.-\\AddOns\\(.-)\\.*"`,
/// `FuBarPlugin-2.0.lua:602`'s `string.find(debugstack(6,1,0), "\\AddOns\\(.*)\\")`. While a
/// `Dir` addon's path space was the AddOns folder, a `<Script file=>` chunk was named
/// `@AtlasLoot\Core\AtlasLoot.lua` and that split found **nothing**: `SSHonor_Fu` called
/// `IsAddOnLoadOnDemand(nil)` and AtlasLoot's AceDB keyed itself on a raw traceback. 80 of the
/// 219-addon corpus ship at least one `<Script file=>`.
///
/// The reference names both the same way and the note says why: a `.toc` line resolves against
/// `Interface\AddOns\<Addon>\` and a bare `<Script file=X>` against `dirname(referrer)` —
/// the same directory — and the chunk name is `"@%s"` over the resolved path in both cases
/// (wow-re `ui/scratch/xml-toc-path-resolution.md` §1, `include-lua-dispatch.md` §7).
#[test]
fn an_xml_referenced_lua_is_named_like_a_toc_listed_one() {
let tmp =
std::env::temp_dir().join(format!("benilla-addon-chunkname-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("AddOns");
std::fs::create_dir_all(root.join("Probe/Core")).unwrap();
std::fs::write(
root.join("Probe/listed.lua"),
"LISTED = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/viaxml.lua"),
"VIAXML = debugstack(1, 1, 0)",
)
.unwrap();
std::fs::write(
root.join("Probe/Core/doc.xml"),
r#"<Ui><Script file="viaxml.lua"/></Ui>"#,
)
.unwrap();
let toc = "## Interface: 11200\nlisted.lua\nCore\\doc.xml\n";
let addon = Addon {
name: "Probe".into(),
toc: Toc::parse(toc),
source: Source::Dir(root),
};
let script = UiScript::new().unwrap();
assert!(addon.load(&script).is_empty());
// The exact pattern the libraries run, on each file's own traceback.
let folder = |global: &str| -> Option<String> {
script
.eval::<Option<String>>(&format!(
"local _,_,f = string.find({global} or \"\", \"\\\\AddOns\\\\(.-)\\\\\") return f"
))
.unwrap()
};
assert_eq!(
folder("LISTED").as_deref(),
Some("Probe"),
"a manifest-listed file has always been named right"
);
assert_eq!(
folder("VIAXML").as_deref(),
Some("Probe"),
"and an XML-referenced one must be named the same way — this was nil"
);
// …and literally, so this cannot pass for some other reason: both chunks carry the
// client's own full virtual path, which is the thing `\AddOns\` is being split out of.
for g in ["LISTED", "VIAXML"] {
let frame = script.eval::<String>(&format!("return {g}")).unwrap();
assert!(
frame.contains("Interface\\AddOns\\Probe\\"),
"{g} is named after the install path, not the AddOns root: {frame}"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
/// A missing hard dependency drops exactly the dependent, and an optional one drops nothing.
///
/// This is the behaviour a flat topological sort cannot express, and the reason the walk is
@@ -329,3 +329,41 @@ fn combat_text_master_toggle_unregisters() {
assert!(none, "disabled: nothing paints ({:?})", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`DAMAGE_TEXT_FONT` is a Lua global the engine reads, not a hardcoded face** (decision 2156).
///
/// `0x6c847c` is the only instruction in `WoW.exe` that reads it, through `FrameScript_GetText`'s
/// fast arm into a plain `lua_gettable(LUA_GLOBALSINDEX)` — so whatever is in the global when the
/// world-entry load edge closes is what the numbers draw in. Stock `Fonts.xml` puts Friz there;
/// MikScrollingBattleText and pfUI put their own face there at `ADDON_LOADED`, which the reference
/// reads because `0x6c8470` runs *after* the addon load, not at CRT init as three wow-re notes
/// had it.
///
/// Skips without client data.
#[test]
fn the_damage_text_font_is_read_from_the_lua_global() {
let data = benilla_formats::wow_data_or_skip!();
let _ = data;
let s = UiScript::new().unwrap();
// Before anything assigns it, there is nothing to bind — the reference's `0x704350` failure
// block leaves `0x703bf0`'s pre-seeded `""`, and an empty name is what the font factory's own
// guard rejects.
assert_eq!(crate::combat_text::read_damage_text_font(&s).0, None);
// Stock FrameXML, off the player's own chain: `Fonts.xml:6`.
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Fonts\\FRIZQT__.TTF"),
);
// An addon assigning it at ADDON_LOADED — MikScrollingBattleText's line 255, in shape.
s.run(r#"DAMAGE_TEXT_FONT = "Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf""#)
.expect("the assignment runs");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf"),
"the addon's face, not Friz"
);
}
@@ -490,6 +490,18 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
));
}
}
// **`DAMAGE_TEXT_FONT` binds HERE, at the end of the load edge, and once** (decision 2156):
// `0x6c8470` runs from `0x401570 + 0x1620`, *after* the UI load `0x401602` — so after
// FrameXML's `Fonts.xml` and after every non-LoadOnDemand addon's `ADDON_LOADED`, which is
// where MikScrollingBattleText and pfUI assign it. The reference reads the global's value
// eagerly, hands it to the font factory, and never looks again: one writer of `[0xce8820]`,
// no invalidation, and a `/reloadui` does not re-run it.
//
// One step later than the reference, deliberately: it reads after `PLAYER_ENTERING_WORLD`
// too, which here fires from [`crate::ui_unit`] when the self descriptor lands rather than
// inside this call. Nothing in the corpus assigns a font that late, and moving the seat would
// mean waiting on the wire for a value the whole load edge has already settled.
world.insert_resource(crate::combat_text::read_damage_text_font(&script));
// The load edge is over: disarm the instruction bound `load_ingame_ui` installed (decision
// 1306). From here every OnUpdate and event handler runs unhooked — a session must not kill
// a player's addon for being slow; only a load that never returns is fair game.