Files
benilla-octo/crates/benilla-ui/src/framexml.rs
T
SamandClaude Fable 5.1 6af2bfcb73 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>
2026-09-11 20:56:00 +07:00

1007 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The FrameXML **document** layer (decision 0068): turns a `.xml` file's text into an owned,
//! order-preserving tree, resolves `virtual`/`inherits` template expansion, and rewrites `$parent`
//! name tokens. This is XML-tree semantics only — no widget instantiation (frame factories, anchor
//! resolution, script compilation) and no Lua; those live in the layer above, which walks the tree
//! this module produces.
//!
//! Ground truth is wow-5875-re's verified transcription of the real client's loader
//! (`0x6edc00–0x6f3000`, closed VERIFIED ORCHESTRATION region):
//! - `system/ui/ui.md` §"The FrameXML XML loader — schema → frame-op interpretation (RF-0024)"
//! - `system/ui/scratch/rf24-framexml-loader.md` — the full top-level + per-element op map
//! - `system/ui/scratch/rf26-nested-frames.md` — `<Frames>` / load-order (widget-layer concern; cited
//! here only for the "spliced before the instance's own nodes" ordering that also governs template
//! `<Frames>` children, which this module's generic child-concat already gets right)
//! - `system/ui/scratch/rf27-parent-name-token.md` — the `$parent` substitution rule (RF-0027)
//!
//! XML *parsing* is DELEGATED plumbing per the RE spec ("XML parsing is DELEGATED; the schema→op map
//! is owned") — we use `roxmltree` rather than reproduce a tokenizer; only the schema→op
//! interpretation below is transcribed from the spec.
use std::collections::{HashMap, HashSet};
use std::fmt;
/// An owned XML element: tag, attributes (order preserved — Blizzard XML attribute order is
/// sometimes meaningful for authoring tools, and always useful for round-tripping/debugging), and
/// element children. Case-insensitive attribute lookup: "attrs via `GetAttribute 0x6f2cf0`" and
/// element-name compares are case-insensitive in the real loader (`0x64a4c0`,
/// rf24-framexml-loader.md, header), and Blizzard's own shipped XML is inconsistent about attribute
/// casing (e.g. both `relativeTo` and stray all-lowercase variants appear in the wild), so lookups
/// here fold case rather than assume a canonical spelling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Element {
pub tag: String,
attrs: Vec<(String, String)>,
pub children: Vec<Element>,
/// The node's own direct text content (the XML node struct's `body-text @ +0xc`, per
/// rf24-framexml-loader.md's node-struct note). Populated for text-bearing elements such as
/// `<Scripts>` handler bodies (`<OnLoad>lua…</OnLoad>`) and inline `<Script>` bodies. Empty for
/// purely structural elements.
pub body: String,
}
impl Element {
/// Case-insensitive attribute lookup (see struct docs).
pub fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
/// A boolean attribute (`hidden="true"`, `virtual="true"`, …): true iff present and equal to
/// the literal `"true"`, case-insensitively (`0x6f1b30`'s true-cmp, rf24-framexml-loader.md).
/// Absent or any other value is false — matching the client (there is no explicit `"false"`
/// branch in the spec; only a `true` match flips the flag).
pub fn attr_bool(&self, name: &str) -> bool {
self.attr(name)
.is_some_and(|v| v.eq_ignore_ascii_case("true"))
}
/// The same, but **presence-aware** — `None` when the attribute is absent.
///
/// [`attr_bool`](Self::attr_bool) folds "absent" and `="false"` together, which is fine for a
/// flag whose default is off and *silently wrong* for one whose default is on: it turns an
/// explicit opt-out into a no-op. `<EditBox autoFocus="false">` is exactly that flag, and the
/// shipped UI writes it ten times.
pub fn attr_bool_opt(&self, name: &str) -> Option<bool> {
self.attr(name).map(|v| v.eq_ignore_ascii_case("true"))
}
/// The `name` attribute, if any.
pub fn name(&self) -> Option<&str> {
self.attr("name")
}
/// All attributes, in document order, as written.
pub fn attrs(&self) -> &[(String, String)] {
&self.attrs
}
}
/// A `<Script>` reference: an external file (`<Script file="…"/>`) or an inline body
/// (`<Script>lua…</Script>`). Both run in document order relative to everything else at the top
/// level ("`<Script file=>`/inline `<Script>` → run Lua … **in document order** — this is how
/// XML-referenced FrameXML Lua loads", rf24-framexml-loader.md, top level).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptRef {
File(String),
Inline {
body: String,
/// The 1-based line **of this XML file** that `body`'s first character sits on.
///
/// Carried so the loader can pad the chunk out to that offset, making Lua's own error
/// lines the file's lines. Without it every inline block starts at line 1 and a raise
/// reports a number belonging to no file — worse than no number, because it looks like one
/// you could go and read (decision 1214).
line: u32,
},
}
/// One top-level item of a FrameXML document, in document order. Modeled as an order-preserving
/// `Vec<TopLevel>` rather than a split `{ scripts, fonts, templates, instances }` struct because the
/// real loader executes `<Include>`/`<Script>` interleaved with frame element definitions **in
/// document order** (rf24-framexml-loader.md, top level) — a split-by-kind struct would lose that
/// order and misrepresent Lua load sequencing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TopLevel {
/// `<Include file="…"/>` — recurse-load another file at this point in the sequence
/// (rf24-framexml-loader.md, top level: `0x8710c0`). File resolution/recursion is the loader
/// layer's job; this only preserves *where* the include happens relative to everything else.
Include(String),
Script(ScriptRef),
/// `<Font …>` — a named font definition (rf24-framexml-loader.md, top level: `0x87106c`).
Font(Element),
/// A frame/region element with `virtual="true"`: registered as a template keyed by `name`, not
/// instantiated (rf24-framexml-loader.md, top level: `0x6ee500`).
Template(Element),
/// A frame/region element without `virtual="true"`: instantiated in place.
Instance(Element),
}
/// The result of parsing one FrameXML document: its top-level items in order, plus any tolerable
/// issues found along the way.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedDocument {
pub items: Vec<TopLevel>,
/// Tolerable issues: unregistered (unnamed) virtual templates, missing `Include`/`Font`
/// attributes, a non-`<Ui>` root, and similar — the real client logs and continues rather than
/// aborting the whole file for these, so parsing does too (see [`parse`]'s docs).
pub warnings: Vec<String>,
}
impl ParsedDocument {
/// All registered templates, keyed by name, for use with [`expand`]. A [`TopLevel::Template`]
/// with no `name` (already flagged in [`ParsedDocument::warnings`] at parse time — an
/// "Unnamed virtual node" load error in the real client, rf24-framexml-loader.md) is simply
/// absent here, matching the fact that nothing can `inherits=` it.
pub fn templates(&self) -> HashMap<&str, &Element> {
self.items
.iter()
.filter_map(|item| match item {
TopLevel::Template(el) => el.name().map(|name| (name, el)),
_ => None,
})
.collect()
}
}
/// A document parse error: malformed XML. Tolerable issues (unknown tags, missing optional
/// attributes, an unregistrable template) are not errors — see [`ParsedDocument::warnings`].
#[derive(Debug)]
pub enum Error {
Xml(roxmltree::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Xml(e) => write!(f, "malformed FrameXML document: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Xml(e) => Some(e),
}
}
}
/// Parse FrameXML document text into an owned, order-preserving tree.
///
/// Top-level semantics (rf24-framexml-loader.md, "top level"): the root's children are walked in
/// order; `<Include file=>` and `<Script file=>`/inline `<Script>` are preserved as-is (their
/// execution is the loader layer's job — this only fixes their position); `<Font>` becomes a font
/// definition; any other element with `virtual="true"` registers as a template (by `name`); anything
/// else is an instance. Unknown top-level tags (not one of the 13 known frame-element types) are
/// kept as generic [`Element`]s and classified the same way (template if `virtual="true"`, else
/// instance) — the real client's loader tolerates them the same way (widget-type validation happens
/// later, at instantiation, which is out of this module's scope).
///
/// A root element that isn't (case-insensitively) `<Ui>` is tolerated with a warning, not a hard
/// error: the RE spec never states the loader checks the root's own tag name, only that it walks
/// its children (`0x6ede10`).
pub fn parse(text: &str) -> Result<ParsedDocument, Error> {
// **The reference has no XML Namespaces, and our stand-in parser does** (decision 2155).
//
// The client's document tree is `XMLTree.cpp`'s, built by embedded **expat 1.95.5** created
// through `XML_ParserCreate 0x7e6690` — *not* `XML_ParserCreateNS` — so namespace processing
// is off: a prefix is opaque bytes inside the attribute's name, an `xmlns:` declaration is an
// ordinary attribute, and an *undeclared* prefix cannot be an error because nothing is
// resolving one. The node struct the FrameXML loader walks is that same tree (name `@+0x8`,
// the `{char*, char*}` attribute array `@+0x14`, looked up by `GetAttribute 0x6f2cf0`'s linear
// case-insensitive scan — `scratch/rf24-framexml-loader.md`'s header and
// `scratch/simplehtml-markup-engine.md` §1.2).
//
// `roxmltree` is namespace-aware and has no switch for it, so it refuses
// `<Ui xsi:schemaLocation="…">` with `UnknownNamespace` when the file never declares
// `xmlns:xsi` — and takes the **whole document** with it. That is not a rare authoring slip:
// Auctioneer writes exactly that line at the top of all five of its UI documents and
// BeanCounter includes two of them, so the Auction House replacement lost its entire interface
// to a concept the client it targets does not implement.
//
// So a namespace error is answered by giving the parser the binding it wants and re-reading —
// driven by the prefix the error itself names, never by scanning the text for colons (which
// would fire inside comments, CDATA and attribute values). The document's own bytes are
// otherwise untouched, and [`element_from_node`] drops every prefixed attribute, so nothing
// downstream can see the difference between a repaired document and one that never had a
// prefix.
let repaired;
let doc = match roxmltree::Document::parse(text) {
Ok(doc) => doc,
Err(roxmltree::Error::UnknownNamespace(..)) => {
repaired = bind_undeclared_prefixes(text);
roxmltree::Document::parse(&repaired).map_err(Error::Xml)?
}
Err(e) => return Err(Error::Xml(e)),
};
let root = doc.root_element();
let mut warnings = Vec::new();
if !root.tag_name().name().eq_ignore_ascii_case("Ui") {
warnings.push(format!(
"root element is <{}>, not <Ui>; tolerated (rf24-framexml-loader.md's file loader walks \
the root's children regardless of the root's own tag)",
root.tag_name().name()
));
}
let mut items = Vec::new();
for child in root.children().filter(|n| n.is_element()) {
let tag = child.tag_name().name();
if tag.eq_ignore_ascii_case("Include") {
match attr_ci(child, "file") {
Some(file) => items.push(TopLevel::Include(file)),
None => warnings.push("<Include> without a file attribute; skipped".to_string()),
}
} else if tag.eq_ignore_ascii_case("Script") {
match attr_ci(child, "file") {
Some(file) => items.push(TopLevel::Script(ScriptRef::File(file))),
None => items.push(TopLevel::Script(ScriptRef::Inline {
body: direct_text(child),
line: inline_start_line(&doc, child),
})),
}
} else if tag.eq_ignore_ascii_case("Font") {
let el = element_from_node(child);
if el.name().is_none() {
warnings.push("<Font> without a name attribute".to_string());
}
items.push(TopLevel::Font(el));
} else {
let el = element_from_node(child);
if el.attr_bool("virtual") {
if el.name().is_none() {
warnings.push(format!(
"virtual <{}> without a name — a load error in the real client \
(\"Unnamed virtual node\", rf24-framexml-loader.md); kept but unregistered, \
so nothing can inherit it",
el.tag
));
}
items.push(TopLevel::Template(el));
} else {
items.push(TopLevel::Instance(el));
}
}
}
Ok(ParsedDocument { items, warnings })
}
/// Declare every prefix the document uses but never binds, so a namespace-aware parser stops
/// having an opinion the reference's parser cannot have (decision 2155; the *why* is in
/// [`parse`]).
///
/// **Driven by the parser's own error, not by a scan.** `roxmltree` names the offending prefix in
/// `UnknownNamespace`, so each round adds exactly that one binding to the ROOT element's start tag
/// — where a declaration is in scope for the whole document, including the root's own name — and
/// re-parses. A text scan for `name:` would fire inside comments, CDATA and attribute values
/// (`Interface\AddOns\…` and `$Id:` lines are everywhere in this corpus), and would have to
/// re-implement the tokenizer to avoid it.
///
/// Bounded, and honest when it gives up: a document that keeps naming new prefixes stops after
/// [`MAX_BOUND_PREFIXES`] rounds and the text is returned as it stands, so [`parse`] reports the
/// parser's real error rather than looping. A document whose error is something else on the retry
/// (a genuinely malformed file that also has a stray prefix) reports that error, which is the one
/// worth reading.
pub(crate) fn bind_undeclared_prefixes(text: &str) -> String {
let mut out = text.to_string();
for _ in 0..MAX_BOUND_PREFIXES {
let Err(roxmltree::Error::UnknownNamespace(prefix, _)) = roxmltree::Document::parse(&out)
else {
return out;
};
let Some(at) = root_attr_insertion_point(&out) else {
return out;
};
// The URI is inert and never read: nothing downstream resolves a namespace, and
// `element_from_node` drops prefixed attributes outright. It only has to be *a* URI, and
// distinct per prefix so two stray prefixes are not silently the same namespace.
out.insert_str(
at,
&format!(" xmlns:{prefix}=\"urn:benilla:undeclared:{prefix}\""),
);
}
out
}
/// How many distinct undeclared prefixes [`bind_undeclared_prefixes`] will bind before it stops.
///
/// Generous against the real cause (one file, one stray `xsi:`) and small enough that a
/// pathological document cannot turn a failed parse into a long loop.
const MAX_BOUND_PREFIXES: usize = 8;
/// The byte offset inside the ROOT element's start tag at which a new attribute may be inserted —
/// just before the `>` or `/>` that closes it.
///
/// Walks the prolog by hand rather than parsing, because the document by definition does not parse
/// at this point: `<?…?>`, `<!--…-->` and `<!…>` are skipped, and the first remaining `<` opens the
/// root. Inside that tag, `'` and `"` runs are stepped over whole, so a `>` living in an attribute
/// value (`hyperlinkFormat="|H%s|h[%s]|h"` is the shape that exists in this corpus) cannot be
/// mistaken for the tag's end.
fn root_attr_insertion_point(text: &str) -> Option<usize> {
let b = text.as_bytes();
let mut i = 0usize;
while i < b.len() {
if b[i] != b'<' {
i += 1;
continue;
}
let rest = &text[i..];
if rest.starts_with("<?") {
i += rest.find("?>").map(|n| n + 2)?;
} else if rest.starts_with("<!--") {
i += rest.find("-->").map(|n| n + 3)?;
} else if rest.starts_with("<!") {
i += rest.find('>').map(|n| n + 1)?;
} else {
// The root's start tag. Step over quoted runs so a `>` inside a value is not its end.
let mut j = i + 1;
while j < b.len() {
match b[j] {
q @ (b'\'' | b'"') => {
j += 1;
while j < b.len() && b[j] != q {
j += 1;
}
}
b'>' => {
// Insert before a `/>`'s slash, not between the slash and the `>`.
let at = if j > 0 && b[j - 1] == b'/' { j - 1 } else { j };
return Some(at);
}
_ => {}
}
j += 1;
}
return None;
}
}
None
}
fn attr_ci(node: roxmltree::Node, name: &str) -> Option<String> {
node.attributes()
.find(|a| a.name().eq_ignore_ascii_case(name))
.map(|a| a.value().to_string())
}
/// Concatenates a node's own direct text/CDATA children (not descending into child elements) —
/// the XML node struct's `body-text @ +0xc` (rf24-framexml-loader.md, header).
/// The 1-based line of the first text byte inside a `<Script>` element.
///
/// Taken from the first text child's own range rather than the element's, because a CDATA section
/// puts `<![CDATA[` between the two and every FrameXML script block in this project uses one — the
/// element's start line would be off by however much of the opening tag precedes the body.
/// Falls back to the element's line for a `<Script></Script>` with no text at all.
fn inline_start_line(doc: &roxmltree::Document, node: roxmltree::Node) -> u32 {
let at = node
.children()
.find(|n| n.is_text())
.unwrap_or(node)
.range()
.start;
doc.text_pos_at(at).row
}
fn direct_text(node: roxmltree::Node) -> String {
node.children()
.filter(|n| n.is_text())
.filter_map(|n| n.text())
.collect()
}
fn element_from_node(node: roxmltree::Node) -> Element {
Element {
tag: node.tag_name().name().to_string(),
attrs: node
.attributes()
// **A prefixed attribute is unreachable in the reference, so it is not an attribute
// here** (decision 2155). `GetAttribute 0x6f2cf0` compares a lookup key against the
// stored name whole, and no FrameXML attribute name contains a colon — so
// `xsi:schemaLocation` can never match any key the loader asks for. Dropping it is
// what keeps a *declared* prefix (stock FrameXML's own `<Ui xmlns:xsi=… xsi:schema
// Location=…>`) and an *undeclared* one (Auctioneer's) behave identically, and stops
// `roxmltree`'s local-name view from making `xsi:name` answer a lookup for `name`.
.filter(|a| a.namespace().is_none())
.map(|a| (a.name().to_string(), a.value().to_string()))
.collect(),
children: node
.children()
.filter(|n| n.is_element())
.map(element_from_node)
.collect(),
body: direct_text(node),
}
}
/// Resolve `inherits="A, B"` template references into a fully materialized [`Element`]: a
/// structural merge with inherited content first, the element's own content last.
///
/// **Splice order** (rf24-framexml-loader.md, "virtual / template inheritance": "the matched
/// entry is a template, its stored attribute/child nodes are spliced in first" [then the instance's
/// own nodes are read on top]; rf26-nested-frames.md confirms the same "inherited-first" ordering
/// for a template's own `<Frames>` children) — so:
/// - **children**: the (fully expanded) template's children first, in order, then the element's own
/// children appended after.
/// - **attributes**: the template's attributes, with the element's own attributes overriding any
/// attribute of the same name (case-insensitively) and adding any the template didn't have. This
/// is the literal reading of "spliced in first, then the instance's own" applied to attributes —
/// note this means an element that omits `name` entirely while inheriting a *named* template would
/// inherit that template's registration name, and `virtual="true"`/`inherits="…"` themselves are
/// ordinary attributes that splice through too (harmlessly inert post-expansion: nothing reads
/// them again after the one-time top-level virtual/instance routing decision, which already
/// happened on the *outer* node before expansion ever runs). Real FrameXML avoids ever hitting the
/// `name` case (instances either supply their own name or stay anonymous), and the RE notes don't
/// call out a `name`/`virtual` exception, so this implementation does not special-case them —
/// flagged as an unverified edge case, not a silent guess.
///
/// **Multiple `inherits`** (`"A, B"`, comma-separated, left-to-right): each named template is
/// resolved and merged in order, so `B`'s content lands on top of `A`'s where they overlap, and the
/// element's own content lands on top of both. The RE spec documents the single-template splice
/// precisely but not multi-template precedence beyond "the named template's[s'] … nodes"
/// (rf24-framexml-loader.md); left-to-right precedence (later name wins ties) is the natural reading
/// and is what this function implements — flagged, not guessed silently, since it isn't
/// byte-verified for the multi-name case specifically.
///
/// **Chains** (a template inheriting another template) recurse: each named template is itself fully
/// expanded (its own `inherits` resolved) before being merged in.
///
/// **Cycles** are guarded: if expanding a chain would re-enter a template already being expanded,
/// that reference is skipped and a warning is appended instead of infinite-recursing.
pub fn expand(
element: &Element,
templates: &HashMap<&str, &Element>,
warnings: &mut Vec<String>,
) -> Element {
expand_known(element, templates, &HashSet::new(), warnings)
}
/// [`expand`], plus the names that are known to be **font objects** rather than element templates.
///
/// A `<FontString inherits="GameFontNormalSmall">` names a font, not a template, and the font is
/// applied later by its own path (`apply_fontstring_font`) — so the name must be skipped here
/// silently rather than warned as unknown. `Loader::expand_region` already made that distinction
/// for an INSTANCE; it could not make it one level down, and stock `BuffFrame.xml` is exactly that
/// case: `BuffButtonDurationTemplate` is a virtual `<FontString>` that inherits a font object, so
/// every instance of it warned twenty-six times over (decision 1874).
pub fn expand_known(
element: &Element,
templates: &HashMap<&str, &Element>,
fonts: &HashSet<&str>,
warnings: &mut Vec<String>,
) -> Element {
let mut active = HashSet::new();
expand_inner(element, templates, fonts, warnings, &mut active)
}
fn expand_inner(
element: &Element,
templates: &HashMap<&str, &Element>,
fonts: &HashSet<&str>,
warnings: &mut Vec<String>,
active: &mut HashSet<String>,
) -> Element {
let Some(inherits) = element.attr("inherits") else {
return element.clone();
};
// ── ONE name, matched case-INSENSITIVELY (wow-re, template-name lookup) ────────────────────
//
// **No comma splitting.** 1.12's registry lookup `0x6ee6f0` has no splitter at all:
// `inherits="A, B"` is ONE literal name, misses, and applies neither template. Supporting the
// list is what LATER clients do, so splitting here made us a superset of 1.12 (1189). Nothing
// uses it — 0 comma lists in our own assets, 0 across the 218-addon corpus, and 0 in stock
// Blizzard XML's 249 distinct `inherits=` values.
//
// **Case-insensitive.** The compare is `SStrCmpI` → `_strnicmp` at `0x6ee747`, and the trap
// that decides it is the BUCKET HASH: `SStrHash 0x64b3f0` uppercases before mixing, so a
// mis-cased name lands in the same bucket and the stored-hash pre-check passes rather than
// short-circuiting. `Recap`'s `inherits="UIDropdownMenuTemplate"` (lowercase d) resolves on the
// real client. ASCII-only, like every other fold in this engine — bytes >= 0x80 are untouched.
//
// The fold is done as a scan on miss rather than by re-keying the view, because this same
// function resolves the FONT registry too and both are name→element maps of the same shape.
// Verbatim — not trimmed. `GetAttribute 0x6f2cf0` returns the parsed value as expat stored it
// (`SStrDup`, no trim, no fold), and the empty test is a single `cmp [esi],0` on the first
// byte: `inherits=""` is a silent skip, `inherits=" "` is a lookup that misses.
let mut base: Option<Element> = None;
for name in [inherits].into_iter().filter(|s| !s.is_empty()) {
if !active.insert(name.to_string()) {
warnings.push(format!(
"inheritance cycle detected at template '{name}'; skipping this reference"
));
continue;
}
let hit = templates.get(name).copied().or_else(|| {
templates
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| *v)
});
let Some(template) = hit else {
// A FONT OBJECT is not an unknown template: it is a different namespace, applied by
// `apply_fontstring_font` after this. Skip it without a word (1874).
if !fonts.contains(name) {
warnings.push(format!(
"unknown template '{name}' referenced by inherits; skipping"
));
}
active.remove(name);
continue;
};
let expanded_template = expand_inner(template, templates, fonts, warnings, active);
active.remove(name);
base = Some(match base {
None => expanded_template,
Some(acc) => merge(&acc, &expanded_template),
});
}
match base {
Some(base) => merge(&base, element),
None => element.clone(),
}
}
/// The structural merge documented on [`expand`]: `base`'s children first, `over`'s appended after;
/// `over`'s attributes override `base`'s of the same name (case-insensitive), and are added if
/// `base` didn't have them. `over`'s tag and body win (an instance's own tag/handler body, if any,
/// is what actually runs).
fn merge(base: &Element, over: &Element) -> Element {
let mut attrs = base.attrs.clone();
for (k, v) in &over.attrs {
if let Some(slot) = attrs.iter_mut().find(|(ek, _)| ek.eq_ignore_ascii_case(k)) {
slot.1 = v.clone();
} else {
attrs.push((k.clone(), v.clone()));
}
}
let mut children = base.children.clone();
children.extend(over.children.iter().cloned());
Element {
tag: over.tag.clone(),
attrs,
children,
body: if over.body.is_empty() {
base.body.clone()
} else {
over.body.clone()
},
}
}
/// The synthetic element a **runtime** `CreateFrame(kind, name, parent, "A, B")` expands: a bare
/// node of the caller's own `tag` carrying nothing but the `inherits=` list.
///
/// Handing this to [`expand`] is what makes the Lua path and the XML path *the same* path — one
/// comma-split, one chain resolution, one cycle guard, one unknown-template warning, one splice
/// order. Nothing about `inherits=` is written twice.
///
/// And because [`merge`] takes the **overriding** node's tag, the expanded result carries `tag` —
/// the kind `CreateFrame` was actually given — whatever the templates were declared as. That is
/// the honest answer to `CreateFrame("Frame", n, p, "SomeButtonTemplate")`: the frame already
/// exists as a Frame, so the template's `<Button>` tag cannot retype it, and the loader's per-kind
/// passes (which every one of them gate on the element tag) skip the parts that could not apply
/// anyway.
pub fn inherits_node(tag: &str, inherits: &str) -> Element {
Element {
tag: tag.to_string(),
attrs: vec![("inherits".to_string(), inherits.to_string())],
children: Vec::new(),
body: String::new(),
}
}
/// The literal fallback base name (rf27-parent-name-token.md, rule 5): when a `$parent`-prefixed
/// name has no named ancestor to substitute (e.g. a top-level element), the real client seeds the
/// result with the literal string `"Top"` (VA `0x8788ac`) rather than leaving the token literal,
/// erroring, or producing an empty name.
pub const DEFAULT_PARENT_NAME: &str = "Top";
/// `$parent` name-token substitution (RF-0027, rf27-parent-name-token.md).
///
/// A `name` attribute beginning, **case-insensitively**, with the literal `"$parent"` (rule 5: the
/// compare folds `A`–`Z`; `$Parent`/`$PARENT` all match — the *only* such token, there is no
/// `$parentKey`/`$parentN` variant, rf27 point 1) has that 7-character prefix replaced with
/// `parent_name`, and the remainder of `raw` appended verbatim (`SStrCat`). A `name` not starting
/// with the token is copied through unchanged.
///
/// `parent_name` must be the caller's **already-resolved** name for the nearest ancestor that has
/// one — i.e. the caller walks the ancestor chain (`this+0x9c`, rf27 rule 3) for the first ancestor
/// whose own (already-substituted) name is non-empty, and passes [`DEFAULT_PARENT_NAME`] (`"Top"`)
/// if there is none. Passing the already-resolved name (not the raw ancestor `name=` attribute
/// text) is what makes nested `$parent` chains compose (rf27 rule 4): a child of a
/// `$parent`-named parent inherits the parent's fully-resolved name, e.g. parent `PlayerFrame` +
/// `"$parentHealthBar"` → `"PlayerFrameHealthBar"`, and a grandchild's `"$parentBar"` resolves
/// against `"PlayerFrameHealthBar"`, not against the literal text `"$parentHealthBar"`.
///
/// Applies only to the `name` attribute (rf27 point 2) — `parent=` (which *resolves* the enclosing
/// frame) is never `$parent`-substituted, and no other attribute is either.
pub fn resolve_name(raw: &str, parent_name: &str) -> String {
const TOKEN: &str = "$parent";
match raw.get(..TOKEN.len()) {
Some(prefix) if prefix.eq_ignore_ascii_case(TOKEN) => {
format!("{parent_name}{}", &raw[TOKEN.len()..])
}
_ => raw.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **An undeclared namespace prefix is not an error, because the reference has no namespaces**
/// (decision 2155). The client's tree is `XMLTree.cpp`'s, built by expat 1.95.5 through
/// `XML_ParserCreate 0x7e6690` — never `XML_ParserCreateNS` — so nothing is resolving a prefix
/// and nothing can find one unbound.
///
/// The literal line is Auctioneer's, at the top of all five of its UI documents (and two of
/// them are `<Include>`d by BeanCounter): a `roxmltree` `UnknownNamespace` there cost the
/// Auction House replacement its entire interface, an element at a time, to a concept the
/// client it targets does not implement.
#[test]
fn an_undeclared_namespace_prefix_does_not_cost_the_document() {
let doc = parse(
r#"<Ui xsi:schemaLocation="http://www.blizzard.com/wow/ui/">
<Frame name="Survived"/>
</Ui>"#,
)
.expect("the reference parses this, so we must");
assert_eq!(doc.items.len(), 1);
let TopLevel::Instance(el) = &doc.items[0] else {
panic!("{:?}", doc.items[0])
};
assert_eq!(el.name(), Some("Survived"));
}
/// Two stray prefixes, and one of them on an ELEMENT rather than an attribute — the binding
/// goes on the root, where it is in scope for the whole document including the root's own
/// name, and the retry loop is driven by the parser naming each prefix in turn.
#[test]
fn several_undeclared_prefixes_are_all_bound() {
let doc = parse(
r#"<Ui xsi:schemaLocation="x" foo:bar="y">
<Frame name="A"/>
<bar:Frame name="B"/>
</Ui>"#,
)
.expect("every stray prefix is bound, not just the first");
assert_eq!(doc.items.len(), 2);
}
/// **A prefixed attribute is not an attribute** — `GetAttribute 0x6f2cf0` compares a lookup key
/// against the stored name whole, and no FrameXML key contains a colon, so `xsi:name` can never
/// answer a lookup for `name`. Asserted on a *declared* prefix as well, because stock FrameXML
/// writes one on every `<Ui>` and the two spellings must behave identically.
#[test]
fn a_prefixed_attribute_never_answers_an_unprefixed_lookup() {
for text in [
r#"<Ui><Frame xsi:name="Ghost" text="real"/></Ui>"#,
r#"<Ui xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Frame xsi:name="Ghost" text="real"/>
</Ui>"#,
] {
let doc = parse(text).unwrap();
let TopLevel::Instance(el) = &doc.items[0] else {
panic!()
};
assert_eq!(el.name(), None, "a prefixed name= is unreachable: {text}");
assert_eq!(el.attr("text"), Some("real"), "{text}");
}
}
/// A genuinely malformed document still fails — the repair answers `UnknownNamespace` and
/// nothing else, so a mismatched tag is still the error a reader gets.
#[test]
fn a_malformed_document_still_fails_with_its_own_error() {
assert!(parse(r#"<Ui xsi:a="b"><Frame></Ui>"#).is_err());
}
/// The insertion point is found in the ROOT's start tag, past a prolog and past a `>` that
/// lives inside an attribute value — the shape `hyperlinkFormat="|H%s|h[%s]|h"` has in this
/// corpus, and the reason this walks quotes rather than searching for the first `>`.
#[test]
fn the_root_tag_is_found_past_a_prolog_and_a_quoted_angle_bracket() {
let doc = parse(
"<?xml version=\"1.0\"?>\n<!-- a > in a comment -->\n\
<Ui xsi:a=\"b\" note=\"a &gt; b\"><Frame name=\"Ok\"/></Ui>",
)
.unwrap();
assert_eq!(doc.items.len(), 1);
// …and a self-closing root: the attribute goes before the slash, not between it and `>`.
assert!(parse(r#"<Ui xsi:a="b"/>"#).is_ok());
}
#[test]
fn top_level_order_preserves_script_and_element_interleave() {
let doc = parse(
r#"<Ui>
<Script file="A.lua"/>
<Frame name="First"/>
<Script>print("inline")</Script>
<Include file="Other.xml"/>
<Frame name="Second"/>
</Ui>"#,
)
.unwrap();
let kinds: Vec<&str> = doc
.items
.iter()
.map(|item| match item {
TopLevel::Include(_) => "include",
TopLevel::Script(ScriptRef::File(_)) => "script-file",
TopLevel::Script(ScriptRef::Inline { .. }) => "script-inline",
TopLevel::Font(_) => "font",
TopLevel::Template(_) => "template",
TopLevel::Instance(_) => "instance",
})
.collect();
assert_eq!(
kinds,
vec![
"script-file",
"instance",
"script-inline",
"include",
"instance"
],
"document order must be preserved, not grouped by kind"
);
match &doc.items[0] {
TopLevel::Script(ScriptRef::File(f)) => assert_eq!(f, "A.lua"),
other => panic!("expected file script, got {other:?}"),
}
assert!(doc.warnings.is_empty());
}
#[test]
fn inline_vs_file_script() {
let doc = parse(
r#"<Ui>
<Script file="Foo.lua"/>
<Script>local x = 1
print(x)</Script>
</Ui>"#,
)
.unwrap();
assert_eq!(
doc.items[0],
TopLevel::Script(ScriptRef::File("Foo.lua".to_string()))
);
match &doc.items[1] {
TopLevel::Script(ScriptRef::Inline { body, line }) => {
assert!(body.contains("local x = 1"));
assert!(body.contains("print(x)"));
// Line 3 of the literal above: `<Ui>`, `<Script file=…>`, then this one.
assert_eq!(*line, 3, "the inline body's own line in the file");
}
other => panic!("expected inline script, got {other:?}"),
}
}
#[test]
fn virtual_registers_as_template_non_virtual_as_instance() {
let doc = parse(
r#"<Ui>
<Frame name="MyTemplate" virtual="true"/>
<Frame name="MyInstance"/>
<Frame VIRTUAL="TRUE" name="CasedTemplate"/>
</Ui>"#,
)
.unwrap();
assert!(matches!(doc.items[0], TopLevel::Template(_)));
assert!(matches!(doc.items[1], TopLevel::Instance(_)));
assert!(
matches!(doc.items[2], TopLevel::Template(_)),
"virtual attribute name/value compare is case-insensitive"
);
let templates = doc.templates();
assert!(templates.contains_key("MyTemplate"));
assert!(templates.contains_key("CasedTemplate"));
assert_eq!(templates.len(), 2);
}
#[test]
fn unnamed_virtual_warns_but_does_not_fail() {
let doc = parse(r#"<Ui><Frame virtual="true"/></Ui>"#).unwrap();
assert!(matches!(doc.items[0], TopLevel::Template(_)));
assert_eq!(doc.warnings.len(), 1);
assert!(
doc.warnings[0].contains("Unnamed virtual node")
|| doc.warnings[0].contains("without a name")
);
assert!(
doc.templates().is_empty(),
"an unnamed template cannot be inherited"
);
}
#[test]
fn unknown_top_level_tag_is_tolerated_as_generic_element() {
let doc = parse(r#"<Ui><TotallyMadeUpTag name="Whatever" foo="bar"/></Ui>"#).unwrap();
match &doc.items[0] {
TopLevel::Instance(el) => {
assert_eq!(el.tag, "TotallyMadeUpTag");
assert_eq!(el.attr("foo"), Some("bar"));
}
other => panic!("expected a generic instance, got {other:?}"),
}
assert!(doc.warnings.is_empty());
}
/// `inherits="A, B"` is **one name**, not a list — and it misses.
///
/// This test used to assert the opposite (that both templates merged, children concatenated in
/// order). That was a superset of 1.12 borrowed from later clients: the carve found no splitter
/// anywhere in the loader — all 8 call sites hand `0x6ee6f0` the pointer `GetAttribute`
/// returned, one or two instructions later, and no comma is examined in `0x6ed000–0x6f6000` at
/// all. So the lookup runs **once**, for the literal name `"A, B"`, misses, warns at
/// severity 1, and the element loads with **neither** template applied.
///
/// Whitespace is never trimmed either, which is why the second half asserts `" A "` misses a
/// template that genuinely exists.
#[test]
fn a_comma_list_is_one_literal_name_that_misses_and_applies_neither_template() {
let doc = parse(
r#"<Ui>
<Frame name="A" virtual="true">
<Layers><Layer level="ARTWORK"><Texture name="FromA"/></Layer></Layers>
</Frame>
<Frame name="B" virtual="true" alpha="0.5">
<Layers><Layer level="ARTWORK"><Texture name="FromB"/></Layer></Layers>
</Frame>
<Frame name="Inst" inherits="A, B" alpha="1.0">
<Layers><Layer level="ARTWORK"><Texture name="FromInst"/></Layer></Layers>
</Frame>
<Frame name="Padded" inherits=" A "/>
</Ui>"#,
)
.unwrap();
let templates = doc.templates();
let TopLevel::Instance(inst) = &doc.items[2] else {
panic!("expected instance")
};
let mut warnings = Vec::new();
let expanded = expand(inst, &templates, &mut warnings);
// Warned, naming the whole unsplit string — not split, not silently ignored.
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("A, B"),
"the warning must name the literal it looked up: {warnings:?}"
);
// The element still loads, with all of its OWN content and none of the templates'.
assert_eq!(expanded.attr("alpha"), Some("1.0"));
assert_eq!(expanded.children.len(), 1);
fn texture_name(el: &Element) -> &str {
el.children[0].children[0].attr("name").unwrap()
}
assert_eq!(texture_name(&expanded.children[0]), "FromInst");
// And a padded name misses a template that exists — the value is used verbatim.
let TopLevel::Instance(padded) = &doc.items[3] else {
panic!("expected instance")
};
let mut w2 = Vec::new();
let expanded = expand(padded, &templates, &mut w2);
assert_eq!(w2.len(), 1, "{w2:?}");
assert!(
expanded.children.is_empty(),
"picked up A despite the spaces"
);
}
#[test]
fn inheritance_chain_recurses() {
let doc = parse(
r#"<Ui>
<Frame name="Root" virtual="true" frameStrata="LOW">
<Layers><Layer level="ARTWORK"><Texture name="RootTex"/></Layer></Layers>
</Frame>
<Frame name="Mid" virtual="true" inherits="Root" frameStrata="MEDIUM">
<Layers><Layer level="ARTWORK"><Texture name="MidTex"/></Layer></Layers>
</Frame>
<Frame name="Leaf" inherits="Mid"/>
</Ui>"#,
)
.unwrap();
let templates = doc.templates();
let TopLevel::Instance(leaf) = &doc.items[2] else {
panic!("expected instance")
};
let mut warnings = Vec::new();
let expanded = expand(leaf, &templates, &mut warnings);
assert!(warnings.is_empty());
// Mid's own frameStrata (it overrode Root's) wins, since chain-expanding Mid resolves Root
// first and Mid's own attrs on top, before Leaf (which sets nothing) merges on top of that.
assert_eq!(expanded.attr("frameStrata"), Some("MEDIUM"));
assert_eq!(
expanded.children.len(),
2,
"Root's then Mid's <Layers>, in order"
);
}
#[test]
fn inheritance_cycle_warns_and_terminates() {
let doc = parse(
r#"<Ui>
<Frame name="A" virtual="true" inherits="B"/>
<Frame name="B" virtual="true" inherits="A"/>
<Frame name="Inst" inherits="A"/>
</Ui>"#,
)
.unwrap();
let templates = doc.templates();
let TopLevel::Instance(inst) = &doc.items[2] else {
panic!("expected instance")
};
let mut warnings = Vec::new();
// Must terminate (not infinitely recurse) and report the cycle.
let _expanded = expand(inst, &templates, &mut warnings);
assert!(
warnings.iter().any(|w| w.contains("cycle")),
"expected a cycle warning, got {warnings:?}"
);
}
#[test]
fn parent_name_substitution_basic_and_case_insensitive() {
assert_eq!(
resolve_name("$parentHealthBar", "PlayerFrame"),
"PlayerFrameHealthBar"
);
assert_eq!(
resolve_name("$PARENThealthbar", "PlayerFrame"),
"PlayerFramehealthbar"
);
assert_eq!(resolve_name("$Parent", "PlayerFrame"), "PlayerFrame");
assert_eq!(
resolve_name("NotAParentName", "PlayerFrame"),
"NotAParentName"
);
// No named ancestor: caller passes the literal "Top" fallback.
assert_eq!(resolve_name("$parentFoo", DEFAULT_PARENT_NAME), "TopFoo");
}
#[test]
fn parent_name_substitution_composes_through_nested_children() {
// A named parent, a child using $parent, and a grandchild using $parent against the
// child's *resolved* name — not the child's raw literal "$parent..." text (rf27 rule 4).
let parent_name = "PlayerFrame";
let child_name = resolve_name("$parentHealthBar", parent_name);
assert_eq!(child_name, "PlayerFrameHealthBar");
let grandchild_name = resolve_name("$parentText", &child_name);
assert_eq!(grandchild_name, "PlayerFrameHealthBarText");
}
/// Parses a REAL shipped FrameXML file when `BENILLA_FRAMEXML` points at one (never committed —
/// extract e.g. `Interface\FrameXML\PlayerFrame.xml` with benilla-extract). Skips silently
/// otherwise, matching toc.rs's `real_manifest_when_available` pattern so CI/gates don't depend
/// on client data.
#[test]
fn real_framexml_when_available() {
let Ok(path) = std::env::var("BENILLA_FRAMEXML") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML");
let doc = parse(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
assert!(
!doc.items.is_empty(),
"{path}: a real FrameXML file has at least one top-level item"
);
for w in &doc.warnings {
eprintln!("{path}: warning: {w}");
}
}
}