The comment and string passes' last leftovers, and one lookup made to match the reference. **Comments that stated a wrong reference fact**, now stating the right one: - A cast failure clears the GCD, and a failed cooldown-on-event spell also drops its parked record (`0x6e73cc`). - `SetUnit` is `0x505d70`; `0x476cb0` is the component clone it reaches. - Interior sound follows the character, where the listener sits, not the camera. - Template inheritance re-runs `LoadXML` on the one object against each template (`0x76985c`), and reads `name` and `id` from the instance alone (`0x769770`). - On the ghost-to-alive edge the reference clears its corpse cache and sends nothing (`0x5ee990`, `0x491f50`). - The name cache has no high-bit miss. An empty-name answer evicts the record in the reference (`0x55f6f0`). - The combat-log deferred queue (`0xc4e208`) waits only on an item name. A unit name never waits. - A flying spline's segments are paced by curve length (`0x454320`, `0x453760`), not by chord length. - A stop within 3 yd of its stop point keeps the arrow nocked (`0x6019e4`). - A missile's sound id is at `CMissile+0x40`. - A nil or unresolved `AssistUnit` token is a gap against `0xb8` ERR_GENERIC_NO_TARGET, not a deviation. **Strings:** - Test, log and tool messages lose leftover history wording and placeholder labels. - The extractor's scan summaries describe the current tint and sequence-clock paths. - The smoke script's failure text carries no private numbers. **Line continuations:** twelve log, panic and assert strings get back the `\` continuations they had lost. Each printed a run of spaces mid-message. Fixes #106. **One lookup:** a region's `inherits=` is looked up as one verbatim name, matched case-insensitively (`0x6ee6f0`, `_strnicmp` at `0x6ee747`), as `framexml::expand` already did for frames. The gate before it split on commas and matched case-sensitively, so a texture or font string whose `inherits=` spelled its template in another case never received the template. `a_region_takes_a_mis_cased_template` fails before this change and passes after. **Tests:** the quest tests stop loading three FrameXML files that no quest file uses. **In game:** nothing changes for the stock UI. Log lines read as one sentence, and an addon texture that names its template in another case now gets it. Verified with `scripts/check.sh` on the changed crates, plus the loader and quest tests with the install present. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
770 lines
28 KiB
Rust
770 lines
28 KiB
Rust
//! The FrameXML document layer: a `.xml` file's text as an owned, order-preserving tree, with
|
||
//! `inherits` template expansion and `$parent` name substitution; no widgets and no Lua. It
|
||
//! follows the reference's loader (`0x6edc00`–`0x6f3000`, the file loader `0x6ede10`), whose XML
|
||
//! tokenizer is an embedded expat and ours `roxmltree`.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::fmt;
|
||
|
||
/// An owned XML element, attributes in document order. Lookups fold case, as the reference's
|
||
/// `GetAttribute 0x6f2cf0` and element-name compares (`0x64a4c0`) do.
|
||
#[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 (the reference node's body text at `+0xc`): a handler or
|
||
/// `<Script>` body.
|
||
pub body: String,
|
||
}
|
||
|
||
impl Element {
|
||
/// Case-insensitive attribute lookup.
|
||
pub fn attr(&self, name: &str) -> Option<&str> {
|
||
self.attrs
|
||
.iter()
|
||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||
.map(|(_, v)| v.as_str())
|
||
}
|
||
|
||
/// True only for a case-insensitive `"true"` (`0x6f1b30`); there is no `"false"` branch.
|
||
pub fn attr_bool(&self, name: &str) -> bool {
|
||
self.attr(name)
|
||
.is_some_and(|v| v.eq_ignore_ascii_case("true"))
|
||
}
|
||
|
||
/// [`attr_bool`](Self::attr_bool), but `None` when absent, for a flag whose default is on
|
||
/// (`<EditBox autoFocus="false">`).
|
||
pub fn attr_bool_opt(&self, name: &str) -> Option<bool> {
|
||
self.attr(name).map(|v| v.eq_ignore_ascii_case("true"))
|
||
}
|
||
|
||
pub fn name(&self) -> Option<&str> {
|
||
self.attr("name")
|
||
}
|
||
|
||
pub fn attrs(&self) -> &[(String, String)] {
|
||
&self.attrs
|
||
}
|
||
}
|
||
|
||
/// A `<Script file=…>` or an inline `<Script>` body; both run in document order with the rest of
|
||
/// the top level (`0x704bc0`/`0x704cd0`, from `0x6ede10`'s walk).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum ScriptRef {
|
||
File(String),
|
||
Inline {
|
||
body: String,
|
||
/// The 1-based line of the file that `body` starts on, so the loader can pad the chunk and
|
||
/// Lua's error lines are the file's.
|
||
line: u32,
|
||
},
|
||
}
|
||
|
||
/// One top-level item, kept in document order: the reference runs `<Include>` and `<Script>`
|
||
/// interleaved with frame definitions (`0x6ede10`).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum TopLevel {
|
||
/// `<Include file=…>`: load another file at this point (`0x8710c0`).
|
||
Include(String),
|
||
Script(ScriptRef),
|
||
/// `<Font …>`, a named font definition (`0x87106c`).
|
||
Font(Element),
|
||
/// An element with `virtual="true"`: a template keyed by `name`, not instantiated
|
||
/// (`0x6ee500`).
|
||
Template(Element),
|
||
/// An element without `virtual="true"`: instantiated in place.
|
||
Instance(Element),
|
||
}
|
||
|
||
/// One parsed document: its top-level items in order, and the issues it loaded past.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct ParsedDocument {
|
||
pub items: Vec<TopLevel>,
|
||
/// An unnamed virtual, an `<Include>` or `<Font>` missing its attribute, a root that is not
|
||
/// `<Ui>`: logged, and the file goes on.
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
impl ParsedDocument {
|
||
/// The named templates, for [`expand`]; an unnamed one (the reference's "Unnamed virtual
|
||
/// node" error, `0x6ee500`) is absent.
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// Malformed XML; anything softer is a [`ParsedDocument::warnings`] entry.
|
||
#[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 text. As in `0x6ede10`, the root's children are walked in order: `<Include>`
|
||
/// and `<Script>` keep their place, `<Font>` is a font, and any other element, known tag or not,
|
||
/// is a template when `virtual="true"` and an instance otherwise; widget types are checked at
|
||
/// instantiation. A root that is not `<Ui>` only warns: the reference's walk is not known to check
|
||
/// the root's tag.
|
||
pub fn parse(text: &str) -> Result<ParsedDocument, Error> {
|
||
// The reference's expat is created by `XML_ParserCreate 0x7e6690`, not the NS variant, so a
|
||
// prefix is opaque and an undeclared one (`xsi:` with no `xmlns:xsi`) is no error, where
|
||
// `roxmltree` rejects the whole document. Bind the prefix the error names and re-read; the
|
||
// bytes are otherwise untouched, and `element_from_node` drops prefixed attributes.
|
||
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 (the reference'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\"); 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 each prefix the document uses but never binds, one per round on the root's start tag
|
||
/// (in scope everywhere, the root's own name included), taking the prefix each
|
||
/// `UnknownNamespace` error names, never a text scan, which would match inside comments and
|
||
/// values. Any other error, or [`MAX_BOUND_PREFIXES`] rounds, returns the text as it stands.
|
||
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;
|
||
};
|
||
// An inert URI, distinct per prefix so two stray prefixes stay two namespaces.
|
||
out.insert_str(
|
||
at,
|
||
&format!(" xmlns:{prefix}=\"urn:benilla:undeclared:{prefix}\""),
|
||
);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The most undeclared prefixes [`bind_undeclared_prefixes`] binds before giving up.
|
||
const MAX_BOUND_PREFIXES: usize = 8;
|
||
|
||
/// The byte offset just before the `>` or `/>` closing the root's start tag, found by hand since
|
||
/// the document does not parse yet: the prolog is skipped and quoted values are stepped over, so
|
||
/// a `>` inside one (`hyperlinkFormat="|H%s|h[%s]|h"`) is not 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.
|
||
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())
|
||
}
|
||
|
||
/// The 1-based line of the first text byte inside a `<Script>`: the first text child's, since
|
||
/// `<![CDATA[` can sit between the tag and the body, else the element's own.
|
||
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()
|
||
// `GetAttribute 0x6f2cf0` matches the whole stored name and no FrameXML key has a
|
||
// colon, so a prefixed attribute is unreachable; dropping it also stops `roxmltree`'s
|
||
// local names from letting `xsi:name` answer 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` into a materialized [`Element`]: the named template, itself expanded first,
|
||
/// then the element on top. Children are the template's then the element's; the element's
|
||
/// attributes override or extend the template's (case-insensitively). The reference merges
|
||
/// nothing: `LoadXML` re-enters itself on the one object against each template, the most distant
|
||
/// first, then the instance (`0x76985c`; `<Frames>` repeat the walk, `0x76a060`), and reads `name`
|
||
/// and `id` from the instance alone (`PreLoadXML 0x769770`). Here `name` and `virtual` merge like
|
||
/// any attribute. A cycle is skipped with a warning.
|
||
pub fn expand(
|
||
element: &Element,
|
||
templates: &HashMap<&str, &Element>,
|
||
warnings: &mut Vec<String>,
|
||
) -> Element {
|
||
expand_known(element, templates, &HashSet::new(), warnings)
|
||
}
|
||
|
||
/// [`expand`], skipping without a warning an `inherits` that names a font object: a
|
||
/// `<FontString inherits="GameFontNormalSmall">` takes a font, applied later, not a template.
|
||
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();
|
||
};
|
||
|
||
// `inherits` is one name, not a list: `0x6ee6f0` has no splitter, so `"A, B"` misses (later
|
||
// clients split it). It is used verbatim (`GetAttribute 0x6f2cf0` does not trim; `""` is
|
||
// skipped, `" "` misses) and matched case-insensitively, ASCII only: `_strnicmp` at
|
||
// `0x6ee747`, reached despite a mis-cased name because `SStrHash 0x64b3f0` uppercases.
|
||
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 applied after this.
|
||
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(),
|
||
}
|
||
}
|
||
|
||
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one.
|
||
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 bare node a runtime `CreateFrame(kind, name, parent, inherits)` expands through
|
||
/// [`expand`], so Lua and XML resolve `inherits` alike. The result keeps `tag`, the kind
|
||
/// `CreateFrame` was given: a template's `<Button>` cannot retype a `"Frame"`.
|
||
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(),
|
||
}
|
||
}
|
||
|
||
/// What `$parent` expands to with no named ancestor: the literal `"Top"` (`0x8788ac`).
|
||
pub const DEFAULT_PARENT_NAME: &str = "Top";
|
||
|
||
/// `$parent` substitution (`0x76c5b0`): a leading `$parent`, case-insensitive and the only such
|
||
/// token, becomes `parent_name`, the rest appended verbatim. `parent_name` is the already-resolved
|
||
/// name of the nearest named ancestor (the `+0x9c` walk), else [`DEFAULT_PARENT_NAME`], so nested
|
||
/// `$parent` names compose. The reference calls it from two sites only, in `SetName` (`0x76c691`)
|
||
/// and in the layout resolver `0x76c700` (`0x76c71c`): a name and an anchor's `relativeTo` (XML,
|
||
/// `SetPoint` or `SetAllPoints`) expand, and `parent=` never does.
|
||
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::*;
|
||
|
||
#[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"));
|
||
}
|
||
|
||
/// One stray prefix is on an element, not an attribute.
|
||
#[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);
|
||
}
|
||
|
||
/// Both spellings, since stock FrameXML declares `xmlns:xsi` on every `<Ui>`.
|
||
#[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}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_malformed_document_still_fails_with_its_own_error() {
|
||
assert!(parse(r#"<Ui xsi:a="b"><Frame></Ui>"#).is_err());
|
||
}
|
||
|
||
#[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 > b\"><Frame name=\"Ok\"/></Ui>",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(doc.items.len(), 1);
|
||
// A self-closing root: the binding goes before the slash.
|
||
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());
|
||
}
|
||
|
||
/// All eight callers of `0x6ee6f0` pass `GetAttribute`'s pointer as is, and nothing in
|
||
/// `0x6ed000`–`0x6f6000` examines a comma; the value is not trimmed either.
|
||
#[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);
|
||
|
||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||
assert!(
|
||
warnings[0].contains("A, B"),
|
||
"the warning must name the literal it looked up: {warnings:?}"
|
||
);
|
||
|
||
// The element loads with its own content only.
|
||
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");
|
||
|
||
// A padded name misses: 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 frameStrata overrides Root's, and Leaf sets none.
|
||
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();
|
||
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: the caller passes the literal "Top".
|
||
assert_eq!(resolve_name("$parentFoo", DEFAULT_PARENT_NAME), "TopFoo");
|
||
}
|
||
|
||
#[test]
|
||
fn parent_name_substitution_composes_through_nested_children() {
|
||
// The grandchild resolves against the child's resolved name, not its raw "$parent…" text.
|
||
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");
|
||
}
|
||
}
|