The XML loader reads each template level, as 1.12.1 does (#1892)

The XML loader merged a template chain into one element, losing each attribute's level: `setAllPoints` lost to a template's `<Anchors>`, an empty `text=` wiped a template's text, the template's `<TexCoords>` won.

1.12.1 never merges: each loader re-enters on `inherits` (`0x76fe36`, `0x770f9e`, `0x76985c`), then reads its own node's attributes and first children (`0x6f2c50`).

Now regions load per level in `0x770f40`'s order; frames read geometry, `frameLevel`, StatusBar attributes and button text per level. A failed texture fills green (`0x878d24`). Level tests cover each rule; the stock load is unchanged but for the AddOns window's crops.

Fixes #1534
Fixes #1548
Fixes #1547
Fixes #1866
Fixes #1423
Fixes #1858
Fixes #1424
Fixes #1472
Fixes #1861
Fixes #1859
Fixes #1171
Fixes #1860
Fixes #1817

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-18 12:35:06 +07:00
committed by GitHub
parent 20abc74643
commit 31ff8ec00c
18 changed files with 1223 additions and 525 deletions
@@ -6,8 +6,10 @@
character-select AddOns screen is the only addon UI.
-->
<!-- The era's header font, which 1.12's Fonts.xml lacks. -->
<Font name="BenillaGameFontNormalMed1" inherits="GameFontNormal" virtual="true">
<!-- The era's header font, which 1.12's Fonts.xml lacks. Its own font= carries the height:
1.12 reads a <FontHeight> only beside one. -->
<Font name="BenillaGameFontNormalMed1" inherits="GameFontNormal" font="Fonts\FRIZQT__.TTF"
virtual="true">
<FontHeight><AbsValue val="13"/></FontHeight>
</Font>
@@ -2075,6 +2075,40 @@ mod tests {
/// Seats a player before a probe loads the manifest, as the live client always has one by
/// then: the stock macro window formats `UnitName("player")` into a tab label in its `OnLoad`,
/// and without a player that raises like a load failure.
/// The reference fills an XML texture whose file does not load opaque green and logs it
/// (`0x770136`-`0x770171`). No stock texture the core or the glue load draws fails on an
/// install, so a line here is benilla's resolver missing a file the reference opens, which
/// would paint part of the stock interface green.
#[test]
fn every_texture_the_stock_interface_loads_opens() {
let data = benilla_formats::wow_data_or_skip!();
let probe = |s: &mut UiScript| {
let chain = std::sync::Mutex::new(benilla_formats::open_chain(&data).expect("chain"));
let loose = benilla_assets::LooseFiles::install();
s.set_texture_probe(Box::new(move |path| {
benilla_assets::sprite_found(&chain, &loose, path)
}));
};
let failed = |s: &mut UiScript| -> Vec<String> {
s.finish_ui_load_log();
let mut lines: Vec<String> = s.diagnostics().into_iter().map(|d| d.message).collect();
lines.extend(s.take_load_log_writes().iter().map(|w| format!("{w:?}")));
lines.retain(|l| l.contains("Unable to load texture file"));
lines
};
let mut core = UiScript::new().expect("VM");
core.set_screen_size(1024.0, 768.0);
probe(&mut core);
super::super::manifest::load_default_ui(&core);
assert_eq!(failed(&mut core), Vec::<String>::new(), "the core");
let mut glue = UiScript::new_glue().expect("VM");
glue.set_screen_size(1024.0, 768.0);
probe(&mut glue);
super::super::glue::load_glue(&mut glue);
assert_eq!(failed(&mut glue), Vec::<String>::new(), "the glue");
}
fn seat_a_player(s: &mut UiScript) {
s.set_unit(
"player",
+63 -32
View File
@@ -141,7 +141,8 @@ pub struct Templates {
#[derive(Debug, Clone)]
struct Template {
element: Element,
/// Shared, so a load's per-level reads hold the node without copying it.
element: std::rc::Rc<Element>,
/// The context word at registration (`[entry+0x1c]`, `0x6ee6be`); `None` is the reference's 0.
context: Option<String>,
}
@@ -169,7 +170,7 @@ impl Templates {
context: Option<&str>,
) -> Registration {
let template = Template {
element,
element: std::rc::Rc::new(element),
context: context.map(str::to_owned),
};
match self.entries.entry(name.to_ascii_lowercase()) {
@@ -192,7 +193,32 @@ impl Templates {
pub fn get(&self, name: &str) -> Option<&Element> {
self.entries
.get(&name.to_ascii_lowercase())
.map(|t| &t.element)
.map(|t| &*t.element)
}
/// The templates `element`'s load runs before its own body, in the order the reference runs
/// them: each level's `inherits` template is loaded through its own recursive load first
/// (`0x76fe5b`), the most distant first. [`expand`] flattens the same chain into one element,
/// losing the boundaries that per-level state (a texture's wrap flag, `0x76fec6`) follows. A
/// cycle stops at the repeated name.
pub fn inherited_levels(&self, element: &Element) -> Vec<std::rc::Rc<Element>> {
let mut out: Vec<std::rc::Rc<Element>> = Vec::new();
let mut active: Vec<String> = Vec::new();
let mut name = element.attr("inherits").map(str::to_string);
while let Some(n) = name.filter(|n| !n.is_empty()) {
let key = n.to_ascii_lowercase();
if active.contains(&key) {
break;
}
let Some(t) = self.entries.get(&key) else {
break;
};
active.push(key);
name = t.element.attr("inherits").map(str::to_string);
out.push(t.element.clone());
}
out.reverse();
out
}
pub fn contains(&self, name: &str) -> bool {
@@ -493,34 +519,6 @@ fn expand_inner(
}
}
/// The elements `element`'s load runs, in the order the reference runs them: each level's
/// `inherits` template is loaded through its own recursive load first (`0x76fe5b`), then the
/// level's own body. [`expand`] flattens the same chain into one element, losing the boundaries
/// that per-level state (a texture's wrap flag, `0x76fec6`) follows.
pub fn inherit_chain<'a>(element: &'a Element, templates: &'a dyn Inheritable) -> Vec<&'a Element> {
fn walk<'a>(
el: &'a Element,
templates: &'a dyn Inheritable,
active: &mut Vec<String>,
out: &mut Vec<&'a Element>,
) {
if let Some(name) = el.attr("inherits").filter(|n| !n.is_empty()) {
let key = name.to_ascii_lowercase();
if !active.contains(&key) {
if let Some(template) = templates.find(name) {
active.push(key);
walk(template, templates, active, out);
active.pop();
}
}
}
out.push(el);
}
let mut out = Vec::new();
walk(element, templates, &mut Vec::new(), &mut out);
out
}
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id`
/// are dropped: they belong to the node they are written on.
fn merge(base: &Element, over: &Element) -> Element {
@@ -584,6 +582,27 @@ pub fn resolve_name(raw: &str, parent_name: &str) -> String {
}
}
/// The client's integer parse `0x64ac60`, which the frame loader reads `frameLevel=` with
/// (`0x7699c5`): an optional `-`, then the leading digits, wrapping in 32 bits; no whitespace skip
/// and no `+`, so `" 5"` and `"+5"` are 0 and `"5a"` is 5.
pub(crate) fn atoi(s: &str) -> i32 {
let (negative, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s),
};
let n = digits
.bytes()
.take_while(u8::is_ascii_digit)
.fold(0i32, |n, b| {
n.wrapping_mul(10).wrapping_add(i32::from(b - b'0'))
});
if negative {
n.wrapping_neg()
} else {
n
}
}
/// C's `atof` (`0x64aaa0`), which the reference reads numeric attributes with (SimpleHTML's
/// `width=` and `height=`, `spacing=`): the longest numeric prefix after leading whitespace, 0 when
/// there is none, so `"12px"` is 12.
@@ -970,7 +989,19 @@ print(x)</Script>
#[cfg(test)]
mod atof_tests {
use super::atof;
use super::{atof, atoi};
#[test]
fn atoi_takes_a_minus_and_the_leading_digits() {
assert_eq!(atoi("5"), 5);
assert_eq!(atoi("5a"), 5);
assert_eq!(atoi("-3"), -3);
assert_eq!(atoi("+5"), 0);
assert_eq!(atoi(" 5"), 0);
assert_eq!(atoi(""), 0);
assert_eq!(atoi("-"), 0);
assert_eq!(atoi("4294967297"), 1);
}
#[test]
fn atof_takes_the_numeric_prefix() {
+85 -60
View File
@@ -2,13 +2,18 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use super::regions::FontAttrs;
use super::{abs_dim, children_named, Loader};
use super::{abs_dim, children_named, Levels, Loader};
impl Loader<'_> {
/// A frame element's LoadXML attributes and simple children (`0x769820`), and a model pane's
/// own (`0x76cac0`).
pub(super) fn apply_attrs(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
/// own (`0x76cac0`): from the merged `el`, and `frameLevel` from each of `levels`.
pub(super) fn apply_attrs(
&mut self,
el: &Element,
levels: &Levels,
wrapper: &Table,
dbg: &str,
) {
if el.attr_bool("hidden") {
self.call(wrapper, "Hide", (), dbg);
}
@@ -24,9 +29,19 @@ impl Loader<'_> {
.push(format!("{dbg}: Unknown frame strata: {strata}"));
}
}
if let Some(level) = el.attr("frameLevel") {
if let Ok(n) = level.parse::<i64>() {
// `frameLevel=` per level, read with `atoi` (`0x7699c5`): above 0 it is set (`0x76a4f0`,
// the setter `SetFrameLevel` calls), else logged (`0x878628`) and the level kept, so an
// instance's `"0"` leaves its template's level.
for level in levels {
let Some(value) = level.attr("frameLevel").filter(|v| !v.is_empty()) else {
continue;
};
let n = framexml::atoi(value);
if n > 0 {
self.call(wrapper, "SetFrameLevel", n, dbg);
} else {
let name = self.object_name(wrapper);
self.status_warning(format!("Frame {name}: Unknown frame level: {value}"));
}
}
if let Some(alpha) = el.attr("alpha") {
@@ -150,68 +165,78 @@ impl Loader<'_> {
return;
}
};
self.apply_region_layout(tr, &region, self_name, dbg, FontAttrs::Own);
// The title region's layout load is `0x767800` itself (`0x81c528[2]`, called at
// `0x769b8a`), which never re-enters `inherits`: the node alone.
self.apply_geometry(tr, &region, true, self_name, dbg);
}
/// `<Size>` (`0x767800`). Every one applies in document order, and expansion puts an
/// instance's children after its template's, so the instance's own `<Size>` wins.
pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
for size in children_named(el, "Size") {
let (x, y) = abs_dim(size);
if let Some(w) = x {
self.call(wrapper, "SetWidth", w, dbg);
}
if let Some(h) = y {
self.call(wrapper, "SetHeight", h, dbg);
}
}
}
/// `<Anchors>` (`0x767800`), after the `setAllPoints` shorthand: `relativePoint` defaults to
/// `point`, and `relativeTo` expands `$parent` to the nearest named ancestor's name, else
/// `Top` (`0x76c5b0`).
pub(super) fn apply_anchors(
/// One node's geometry, the CLayoutFrame part of every frame and region `LoadXML` (`0x767800`),
/// run once per level ([`Loader::levels`]): the node's first `<Size>`, then its first
/// `<Anchors>`, whose points are added, or, with none, `setAllPoints="true"` (compared whole,
/// `0x767879`) as `SetAllPoints`, which clears the points first (`0x767db0`). `setAllPoints`
/// beside the node's own `<Anchors>` is logged (`0x8784ac`) and ignored, so a template's
/// all-points and an instance's anchor both stand (`ActionButtonTemplate.xml`'s
/// `$parentCooldown` over `CooldownFrameTemplate`). `relativePoint` defaults to `point`, and
/// `relativeTo` expands `$parent` to `parent_name`, the nearest named ancestor's, else `Top`
/// (`0x76c5b0`).
pub(super) fn apply_geometry(
&mut self,
el: &Element,
node: &Element,
wrapper: &Table,
region: bool,
parent_name: &str,
dbg: &str,
) {
if el.attr_is_true("setAllPoints") {
self.call(wrapper, "SetAllPoints", (), dbg);
}
for anchors in children_named(el, "Anchors") {
for anchor in children_named(anchors, "Anchor") {
let Some(point) = anchor.attr("point") else {
self.report
.warnings
.push(format!("{dbg}: <Anchor> without a point; skipped"));
continue;
};
let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string();
let rel_to: Option<String> = anchor
.attr("relativeTo")
.map(|r| framexml::resolve_name(r, parent_name));
let (x, y) = children_named(anchor, "Offset")
.next()
.map(abs_dim)
.unwrap_or((None, None));
let args = (
point.to_string(),
rel_to,
rel_point,
x.unwrap_or(0.0),
y.unwrap_or(0.0),
);
let d = super::DeferredAnchor {
wrapper: wrapper.clone(),
region: false,
args,
dbg: dbg.to_string(),
};
// Deferred while the enclosing subtree has not built its target yet.
self.apply_anchor(d, true);
if let Some(size) = children_named(node, "Size").next() {
let (x, y) = abs_dim(size);
if let Some(w) = x {
self.call_on(wrapper, region, "SetWidth", w, dbg);
}
if let Some(h) = y {
self.call_on(wrapper, region, "SetHeight", h, dbg);
}
}
let set_all_points = node.attr_is_true("setAllPoints");
let Some(anchors) = children_named(node, "Anchors").next() else {
if set_all_points {
self.call_on(wrapper, region, "SetAllPoints", (), dbg);
}
return;
};
if set_all_points {
self.status_warning("SETALLPOINTS set to true in frame with anchors (ignored)".into());
}
for anchor in children_named(anchors, "Anchor") {
let Some(point) = anchor.attr("point") else {
let kind = if region { "region " } else { "" };
self.report
.warnings
.push(format!("{dbg}: {kind}<Anchor> without a point; skipped"));
continue;
};
let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string();
let rel_to: Option<String> = anchor
.attr("relativeTo")
.map(|r| framexml::resolve_name(r, parent_name));
let (x, y) = children_named(anchor, "Offset")
.next()
.map(abs_dim)
.unwrap_or((None, None));
let args = (
point.to_string(),
rel_to,
rel_point,
x.unwrap_or(0.0),
y.unwrap_or(0.0),
);
let d = super::DeferredAnchor {
wrapper: wrapper.clone(),
region,
args,
dbg: dbg.to_string(),
};
// Deferred while the enclosing subtree has not built its target yet.
self.apply_anchor(d, true);
}
}
}
+484
View File
@@ -0,0 +1,484 @@
//! The loader's per-level reads: what the reference reads on each `inherits` node in turn
//! ([`Loader::levels`]) rather than on the merged element.
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn ui() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s
}
fn load_str(s: &UiScript, xml: &str) -> LoadReport {
let report = load(s, &framexml::parse(xml).expect("valid FrameXML"), &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
report
}
fn has_line(report: &LoadReport, line: &str) -> bool {
report.status.lines().iter().any(|l| l == line) && report.warnings.iter().any(|w| w == line)
}
/// `0x767800`: `setAllPoints` beside the same node's `<Anchors>` is logged and ignored; at another
/// level it stands, and `SetAllPoints` clears the points an earlier level added.
#[test]
fn set_all_points_yields_to_its_own_nodes_anchors_only() {
let mut s = ui();
let report = load_str(
&s,
r#"<Ui>
<Frame name="Host">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="HostBoth" setAllPoints="true">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
<Frame name="Both" parent="Host" setAllPoints="true">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
<Frame name="AllPointsTemplate" setAllPoints="true" virtual="true"/>
<Frame name="CenterTemplate" virtual="true">
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
<Frame name="TemplateAll" parent="Host" inherits="AllPointsTemplate">
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="InstanceAll" parent="Host" inherits="CenterTemplate" setAllPoints="true"/>
</Ui>"#,
);
s.resolve();
assert!(has_line(
&report,
"SETALLPOINTS set to true in frame with anchors (ignored)"
));
let points = |name: &str| {
s.eval::<i64>(&format!("return {name}:GetNumPoints()"))
.unwrap()
};
assert_eq!(points("Both"), 1, "the frame's own anchors alone");
assert_eq!(points("HostBoth"), 1, "the region's own anchors alone");
// The cooldown shape: the template's all-points, then the instance's anchor on top.
assert_eq!(points("TemplateAll"), 3);
assert_eq!(
s.eval::<(f32, f32)>("return TemplateAll:GetWidth(), TemplateAll:GetHeight()")
.unwrap(),
(100.0, 50.0)
);
// The instance's all-points clears the template's CENTER.
assert_eq!(points("InstanceAll"), 2);
}
/// `0x7699ad`: `frameLevel` is read with `atoi` per level and set only above 0; otherwise the line
/// `0x878628` is logged and the level stays.
#[test]
fn a_frame_level_of_zero_or_less_is_logged_and_kept_per_level() {
let s = ui();
let report = load_str(
&s,
r#"<Ui>
<Frame name="Host" frameLevel="3"/>
<Frame name="Plain" parent="Host"/>
<Frame name="Zero" parent="Host" frameLevel="0"/>
<Frame name="Negative" parent="Host" frameLevel="-3"/>
<Frame name="Trailing" parent="Host" frameLevel="5a"/>
<Frame name="Plus" parent="Host" frameLevel="+5"/>
<Frame name="LevelSeven" frameLevel="7" virtual="true"/>
<Frame name="OverSeven" parent="Host" inherits="LevelSeven" frameLevel="0"/>
</Ui>"#,
);
let level = |name: &str| {
s.eval::<i64>(&format!("return {name}:GetFrameLevel()"))
.unwrap()
};
let plain = level("Plain");
assert_eq!(plain, 4, "a child sits one above its parent");
assert_eq!(level("Zero"), plain);
assert_eq!(level("Negative"), plain);
assert_eq!(level("Plus"), plain);
assert_eq!(level("Trailing"), 5);
assert_eq!(level("OverSeven"), 7, "the template's level stands");
for (name, value) in [
("Zero", "0"),
("Negative", "-3"),
("Plus", "+5"),
("OverSeven", "0"),
] {
assert!(
has_line(
&report,
&format!("Frame {name}: Unknown frame level: {value}")
),
"{name}: {:?}",
report.warnings
);
}
}
/// `0x782ef0`: per level, the range needs both bounds (read with `atof`) and `defaultValue` follows
/// it; `orientation` is skipped when empty and logged, never raised, when unknown.
#[test]
fn a_status_bar_reads_its_range_and_orientation_per_level() {
let s = ui();
let report = load_str(
&s,
r#"<Ui>
<StatusBar name="Fresh"/>
<StatusBar name="MaxOnly" maxValue="100" defaultValue="50"/>
<StatusBar name="Both" minValue="0" maxValue="100x" defaultValue="40"/>
<StatusBar name="RangeTemplate" minValue="0" maxValue="10" defaultValue="3" virtual="true"/>
<StatusBar name="OverRange" inherits="RangeTemplate" maxValue="100"/>
<StatusBar name="Diagonal" orientation="DIAGONAL"/>
<StatusBar name="Blank" orientation=""/>
<StatusBar name="Upright" orientation="vertical"/>
</Ui>"#,
);
let state = |name: &str| {
s.eval::<(f32, f32, f32)>(&format!(
"local lo, hi = {name}:GetMinMaxValues() return lo, hi, {name}:GetValue()"
))
.unwrap()
};
assert_eq!(state("MaxOnly"), state("Fresh"), "one bound sets nothing");
assert_eq!(state("Both"), (0.0, 100.0, 40.0));
assert_eq!(
state("OverRange"),
(0.0, 10.0, 3.0),
"the template's pair stands"
);
let orientation = |name: &str| {
s.eval::<String>(&format!("return {name}:GetOrientation()"))
.unwrap()
};
assert_eq!(orientation("Diagonal"), "HORIZONTAL");
assert_eq!(orientation("Blank"), "HORIZONTAL");
assert_eq!(orientation("Upright"), "VERTICAL");
assert!(has_line(
&report,
"Frame Diagonal: Unknown orientation DIAGONAL in element StatusBar"
));
}
/// `0x76fe20`: every `<TexCoords>` child applies in order, each over 0, 1, 0, 1 with its edges read
/// by `atof`, and the instance's level comes after its template's, so the last one wins whole.
#[test]
fn every_tex_coords_applies_in_order_over_the_default_edges() {
let s = ui();
load_str(
&s,
r#"<Ui>
<Texture name="CropTemplate" file="Interface\A" virtual="true">
<TexCoords left="0" right="0.578125" top="0" bottom="0.75"/>
</Texture>
<Frame name="Host">
<Layers><Layer>
<Texture name="Partial" file="Interface\A"><TexCoords left="0.5"/></Texture>
<Texture name="Twice" file="Interface\A">
<TexCoords left="0.1" right="0.2" top="0.3" bottom="0.4"/>
<TexCoords left="0.5"/>
</Texture>
<Texture name="Trailing" file="Interface\A">
<TexCoords left="0.25x" right="0.75" top="" bottom="1"/>
</Texture>
<Texture name="Cropped" inherits="CropTemplate">
<TexCoords left="0.025" right="0.535" top="0" bottom="0.75"/>
</Texture>
</Layer></Layers>
</Frame>
</Ui>"#,
);
let coords = |name: &str| {
s.eval::<Vec<f32>>(&format!("return {{{name}:GetTexCoord()}}"))
.unwrap()
};
// UL, LL, UR, LR: (l, t), (l, b), (r, t), (r, b).
let rect = |l: f32, r: f32, t: f32, b: f32| vec![l, t, l, b, r, t, r, b];
assert_eq!(coords("Partial"), rect(0.5, 1.0, 0.0, 1.0));
assert_eq!(
coords("Twice"),
rect(0.5, 1.0, 0.0, 1.0),
"the second, whole"
);
assert_eq!(coords("Trailing"), rect(0.25, 0.75, 0.0, 1.0));
assert_eq!(
coords("Cropped"),
rect(0.025, 0.535, 0.0, 0.75),
"the instance's crop"
);
}
/// `0x770102`-`0x770171`: a level's `file=` that fails to load logs `0x878d24` and fills the
/// texture opaque green, even over a template level whose file loaded; with no probe installed the
/// path is stored as before.
#[test]
fn an_xml_texture_whose_file_fails_to_load_fills_green() {
let xml = r#"<Ui>
<Texture name="GoodTemplate" file="Interface\Good" virtual="true"/>
<Frame name="Host">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer>
<Texture name="Bad" file="Interface\Nope" setAllPoints="true"/>
<Texture name="OverGood" inherits="GoodTemplate" file="Interface\NopeToo"/>
<Texture file="Interface\Nope"/>
<Texture name="Fine" file="Interface\Good"/>
</Layer></Layers>
</Frame>
</Ui>"#;
let mut s = ui();
s.set_texture_probe(Box::new(|path: &str| !path.contains("Nope")));
let report = load_str(&s, xml);
let texture = |s: &UiScript, name: &str| {
s.eval::<Option<String>>(&format!("return {name}:GetTexture()"))
.unwrap()
};
assert_eq!(texture(&s, "Bad").as_deref(), Some("Solid Texture"));
assert_eq!(texture(&s, "OverGood").as_deref(), Some("Solid Texture"));
assert_eq!(texture(&s, "Fine").as_deref(), Some("Interface\\Good"));
for line in [
r"Texture Bad: Unable to load texture file Interface\Nope",
r"Texture OverGood: Unable to load texture file Interface\NopeToo",
r"Texture <unnamed>: Unable to load texture file Interface\Nope",
] {
assert!(has_line(&report, line), "{line}: {:?}", report.warnings);
}
s.resolve();
let green = s.extract().iter().any(|q| {
matches!(
&q.content,
crate::script::QuadContent::Texture { color: Some(c), .. } if *c == [0.0, 1.0, 0.0, 1.0]
)
});
assert!(green, "the failed texture draws opaque green");
let unprobed = ui();
let report = load_str(&unprobed, xml);
assert_eq!(
texture(&unprobed, "Bad").as_deref(),
Some("Interface\\Nope")
);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
const TEST_FONT: &str = r#"<Font name="LevelTestFont" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>"#;
/// `0x770f40`: a `font=` file needs the node's own `<FontHeight>`, and a refused one returns before
/// `spacing`, the justifies and the colour (`0x771191`); an empty `font=` is absent (`0x7710f7`).
#[test]
fn a_fontstring_font_file_needs_its_own_nodes_height_and_a_refusal_stops_the_level() {
let s = ui();
let report = load_str(
&s,
&format!(
r#"<Ui>{TEST_FONT}
<FontString name="HeightTemplate" font="Fonts\ARIALN.TTF" virtual="true">
<FontHeight><AbsValue val="20"/></FontHeight>
</FontString>
<Frame name="Host">
<Layers><Layer>
<FontString name="Control" inherits="LevelTestFont"/>
<FontString name="Refused" inherits="LevelTestFont" font="Fonts\MORPHEUS.TTF"
justifyH="LEFT" justifyV="TOP" spacing="3">
<Color r="1" g="0" b="0"/>
</FontString>
<FontString name="LeansOnTemplate" inherits="HeightTemplate"
font="Fonts\MORPHEUS.TTF"/>
<FontString name="OwnWins" inherits="HeightTemplate" font="Fonts\MORPHEUS.TTF">
<FontHeight><AbsValue val="16"/></FontHeight>
</FontString>
<FontString name="EmptyFont" inherits="LevelTestFont" font="">
<FontHeight><AbsValue val="30"/></FontHeight>
</FontString>
</Layer></Layers>
</Frame>
</Ui>"#
),
);
let paint = |name: &str| {
s.eval::<String>(&format!(
"local r, g, b = {name}:GetTextColor() \
return table.concat({{{name}:GetJustifyH(), {name}:GetJustifyV(), \
{name}:GetSpacing(), r, g, b}}, ' ')"
))
.unwrap()
};
assert_eq!(paint("Refused"), paint("Control"), "nothing after the font");
let font = |name: &str| {
s.eval::<(String, f32)>(&format!("local f, h = {name}:GetFont() return f, h"))
.unwrap()
};
assert_eq!(font("LeansOnTemplate"), ("Fonts\\ARIALN.TTF".into(), 20.0));
assert_eq!(font("OwnWins"), ("Fonts\\MORPHEUS.TTF".into(), 16.0));
assert_eq!(font("EmptyFont"), font("Control"));
for name in ["Refused", "LeansOnTemplate"] {
let line = format!("FontString {name}: Missing font height in FontString element");
assert!(has_line(&report, &line), "{line}: {:?}", report.warnings);
}
assert_eq!(
report.warnings.len(),
2,
"no other line: {:?}",
report.warnings
);
}
/// `0x7713ca`-`0x771454`: a FontString's own `<Shadow>` takes its first `<Color>` over opaque black
/// and its first `<Offset>`, whose missing coordinate is 0 (`0x6f1ebe`), else (0.001, -0.001).
#[test]
fn a_fontstrings_own_shadow_element_is_read() {
let s = ui();
load_str(
&s,
&format!(
r#"<Ui>{TEST_FONT}
<Frame name="Host">
<Layers><Layer>
<FontString name="Shadowed" inherits="LevelTestFont">
<Shadow>
<Offset><AbsDimension x="2" y="-3"/></Offset>
<Color r="1" g="0" b="0" a="0.5"/>
</Shadow>
</FontString>
<FontString name="BareShadow" inherits="LevelTestFont"><Shadow/></FontString>
<FontString name="HalfOffset" inherits="LevelTestFont">
<Shadow><Offset><AbsDimension x="2"/></Offset></Shadow>
</FontString>
</Layer></Layers>
</Frame>
</Ui>"#
),
);
let shadow = |name: &str| {
s.eval::<Vec<f32>>(&format!(
"local x, y = {name}:GetShadowOffset() \
local r, g, b, a = {name}:GetShadowColor() return {{x, y, r, g, b, a}}"
))
.unwrap()
};
assert_eq!(shadow("Shadowed"), vec![2.0, -3.0, 1.0, 0.0, 0.0, 0.5]);
assert_eq!(
shadow("BareShadow"),
vec![0.001, -0.001, 0.0, 0.0, 0.0, 1.0]
);
assert_eq!(
shadow("HalfOffset"),
vec![2.0, 0.0, 0.0, 0.0, 0.0, 1.0],
"an <Offset> zeroes the coordinate it leaves out"
);
}
/// `0x771006`-`0x771032`, `0x778c07`-`0x778c31`: an empty `text=` is skipped, so a template's text
/// stands, and a global holding `""` shows its key.
#[test]
fn an_empty_text_is_skipped_and_an_empty_global_shows_its_key() {
let s = ui();
s.eval::<()>("LEVEL_TEST_EMPTY = \"\"").unwrap();
load_str(
&s,
&format!(
r#"<Ui>{TEST_FONT}
<FontString name="TextTemplate" inherits="LevelTestFont" text="A" virtual="true"/>
<Button name="ButtonTemplateA" text="A" virtual="true">
<ButtonText name="$parentText" inherits="TextTemplate"/>
</Button>
<Frame name="Host">
<Layers><Layer>
<FontString name="KeyShown" inherits="LevelTestFont" text="LEVEL_TEST_EMPTY"/>
<FontString name="Kept" inherits="TextTemplate" text=""/>
</Layer></Layers>
</Frame>
<Button name="KeyButton" text="LEVEL_TEST_EMPTY">
<ButtonText name="$parentText" inherits="LevelTestFont"/>
</Button>
<Button name="KeptButton" inherits="ButtonTemplateA" text=""/>
<Button name="LabelKept">
<ButtonText name="$parentText" inherits="TextTemplate" text=""/>
</Button>
</Ui>"#
),
);
let text = |expr: &str| s.eval::<Option<String>>(&format!("return {expr}")).unwrap();
assert_eq!(
text("KeyShown:GetText()").as_deref(),
Some("LEVEL_TEST_EMPTY")
);
assert_eq!(text("Kept:GetText()").as_deref(), Some("A"));
assert_eq!(
text("KeyButton:GetText()").as_deref(),
Some("LEVEL_TEST_EMPTY")
);
assert_eq!(text("KeptButton:GetText()").as_deref(), Some("A"));
assert_eq!(text("LabelKept:GetText()").as_deref(), Some("A"));
}
/// `0x783c30`: a `<Font>` copies the object `inherits=` names, then reads `outline=` and
/// `monochrome=` only beside its own `font=` file, whose own `<FontHeight>` it needs; a refused
/// height skips the rest of the node.
#[test]
fn a_font_element_reads_its_flags_only_beside_its_own_font_file() {
let s = ui();
let report = load_str(
&s,
r#"<Ui>
<Font name="LevelParentFont" font="Fonts\FRIZQT__.TTF" outline="NORMAL" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0" b="0"/>
</Font>
<Font name="LevelOutlineOnly" inherits="LevelParentFont" outline="THICK"
monochrome="1" virtual="true"/>
<Font name="LevelOwnFile" inherits="LevelParentFont" font="Fonts\ARIALN.TTF"
virtual="true">
<FontHeight><AbsValue val="14"/></FontHeight>
</Font>
<Font name="LevelNoHeight" inherits="LevelParentFont" font="Fonts\ARIALN.TTF"
justifyH="LEFT" virtual="true">
<Color r="0" g="1" b="0"/>
</Font>
<Font name="LevelNoParent" inherits="LevelNoSuchFont" virtual="true"/>
</Ui>"#,
);
let font = |name: &str| {
s.eval::<String>(&format!(
"local f, h, flags = {name}:GetFont() local r, g, b = {name}:GetTextColor() \
return table.concat({{f, h, flags or '', {name}:GetJustifyH(), r, g, b}}, ' ')"
))
.unwrap()
};
let parent = font("LevelParentFont");
assert_eq!(
font("LevelOutlineOnly"),
parent,
"no file of its own, no flags of its own"
);
assert_eq!(
font("LevelOwnFile"),
parent.replacen("Fonts\\FRIZQT__.TTF 12 OUTLINE", "Fonts\\ARIALN.TTF 14 ", 1),
"its own file sets the whole flags word"
);
assert_eq!(
font("LevelNoHeight"),
parent,
"a refused height skips the node"
);
assert!(has_line(
&report,
"Font LevelNoHeight: Missing font height in Font element"
));
assert!(has_line(
&report,
"Couldn't find inherited font: LevelNoSuchFont"
));
}
+204 -121
View File
@@ -8,7 +8,7 @@
//! Lua handles are never accumulated breadth-wise (MAXCSTACK): a frame's wrapper and `OnLoad`
//! live only across its own subtree build, and everything durable lives Lua-side.
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use mlua::{Function, ObjectLike, Table, Value};
@@ -199,13 +199,15 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s
loader.report.warnings.extend(notes);
// The XML `inherits=` expansion; its warnings get the `CreateFrame` call as a prefix.
let node = framexml::inherits_node(kind, template);
let first = loader.report.warnings.len();
let expanded = loader.expand(&framexml::inherits_node(kind, template));
let expanded = loader.expand(&node);
for w in &mut loader.report.warnings[first..] {
*w = format!("{dbg}: {w}");
}
let levels = loader.levels(&node);
loader.decorate(&expanded, wrapper, &self_name, &parent_name, &dbg);
loader.decorate(&expanded, &levels, wrapper, &self_name, &parent_name, &dbg);
loader.report
}
@@ -273,7 +275,7 @@ struct Loader<'a> {
pub(super) status_root: &'a str,
// The template and font registries live on the `Model` and persist across loads: the
// reference's template table is global (`0x6ee500`), so `MerchantFrame.xml` inherits a template
// `CharacterFrameTemplates.xml` registered. Fonts are their own namespace, stored flattened.
// `CharacterFrameTemplates.xml` registered. Fonts are their own namespace.
pub(super) report: LoadReport,
/// Warn-once keys.
pub(super) warned: HashSet<String>,
@@ -284,6 +286,45 @@ struct Loader<'a> {
pub(super) deferred_anchors: Vec<DeferredAnchor>,
}
/// The nodes an element's `LoadXML` reads, in the order it reads them: the reference merges no
/// template into an instance, but re-enters `LoadXML` on each `inherits` node first (`0x76985c`
/// for a frame, `0x76fe5b` for a texture, `0x770f9e` for a FontString), then reads the node's own
/// attributes and its own first child of each kind (`0x6f2c50`). So an attribute whose rule is
/// local to a node (`setAllPoints` beside the node's own `<Anchors>`, a `font=` with its own
/// `<FontHeight>`) is read level by level, the template first and the element last;
/// [`Loader::expand`]'s merged element serves the rest. The templates are shared with the
/// registry, not copied.
pub(super) struct Levels<'a> {
inherited: Vec<std::rc::Rc<Element>>,
own: &'a Element,
}
type LevelIter<'l> = std::iter::Chain<
std::iter::Map<
std::slice::Iter<'l, std::rc::Rc<Element>>,
fn(&std::rc::Rc<Element>) -> &Element,
>,
std::iter::Once<&'l Element>,
>;
impl Levels<'_> {
pub(super) fn iter(&self) -> LevelIter<'_> {
let shared: fn(&std::rc::Rc<Element>) -> &Element = |e| e;
self.inherited
.iter()
.map(shared)
.chain(std::iter::once(self.own))
}
}
impl<'l> IntoIterator for &'l Levels<'_> {
type Item = &'l Element;
type IntoIter = LevelIter<'l>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
/// One `SetPoint` held until its target exists.
pub(super) struct DeferredAnchor {
pub(super) wrapper: Table,
@@ -336,6 +377,23 @@ impl Loader<'_> {
crate::script::event::invoke_with_globals(self.lua, wrapper.clone(), func, Vec::new())
}
/// An object's name as the reference's load lines print it: `GetName`, else `<unnamed>`
/// (`0x84c7f0`).
pub(super) fn object_name(&self, wrapper: &Table) -> String {
wrapper
.call_method::<Option<String>>("GetName", ())
.ok()
.flatten()
.unwrap_or_else(|| "<unnamed>".to_string())
}
/// A severity-1 line into the document's record, the status object's `+0xc` slot every
/// `LoadXML` logs through, retained as a warning too.
pub(super) fn status_warning(&mut self, line: String) {
self.report.status.report(status::WARNING, line.clone());
self.report.warnings.push(line);
}
pub(super) fn warn_once(&mut self, key: &str, msg: impl Into<String>) {
if self.warned.insert(key.to_string()) {
self.report.warnings.push(msg.into());
@@ -343,12 +401,13 @@ impl Loader<'_> {
}
/// Resolve `text=` as a global-string key (`FrameScript_GetText 0x703bf0`), so
/// `GlobalStrings.lua` must run before any XML. A miss comes back empty (`0x882748`) and all
/// three `text=` readers then show the raw value (`0x778c07`, `0x771006`, `0x7292a6`). The
/// warning on a key-shaped miss is benilla's own.
/// `GlobalStrings.lua` must run before any XML. A miss comes back empty (`0x882748`), and all
/// three `text=` readers show the raw value for an empty answer (`0x771032`, `0x778c31`,
/// `0x7292a6`), so a global holding `""` shows its key. The warning on a key-shaped miss is
/// benilla's own.
pub(super) fn resolve_text(&mut self, raw: &str, dbg: &str) -> String {
if let Ok(s) = self.lua().globals().get::<String>(raw) {
return s;
return if s.is_empty() { raw.to_string() } else { s };
}
if is_global_string_key(raw) {
self.warn_once(
@@ -408,8 +467,7 @@ impl Loader<'_> {
TopLevel::Font(el) => self.do_font(el),
TopLevel::Template(el) => self.register_template(el),
TopLevel::Instance(el) => {
let expanded = self.expand(el);
self.materialize(&expanded, None, framexml::DEFAULT_PARENT_NAME);
self.materialize(el, None, framexml::DEFAULT_PARENT_NAME);
}
}
}
@@ -516,20 +574,17 @@ impl Loader<'_> {
out
}
/// The `file=` loads of a texture region, in the order the reference runs them
/// (`0x76fe20`): each `inherits` level's own file with the wrap flag of that level's own
/// `<TexCoords>` (the flag is a local of the level's load, zeroed before its children,
/// `0x76fec6`), the template first. A level without a `file=` loads nothing.
pub(super) fn texture_file_loads(&self, raw: &Element) -> Vec<(String, bool)> {
let model = self.model();
let templates = model.framexml_templates.borrow();
framexml::inherit_chain(raw, &*templates)
.into_iter()
.filter_map(|level| {
let file = level.attr("file").filter(|f| !f.is_empty())?;
Some((file.to_string(), tex_coords_wrap(level)))
})
.collect()
/// The nodes `raw`'s `LoadXML` reads ([`Levels`]).
pub(super) fn levels<'a>(&self, raw: &'a Element) -> Levels<'a> {
let inherited = self
.model()
.framexml_templates
.borrow()
.inherited_levels(raw);
Levels {
inherited,
own: raw,
}
}
/// [`Loader::expand`] for a region, only when `inherits=` names a registered element template:
@@ -546,27 +601,34 @@ impl Loader<'_> {
}
}
/// A top-level `<Font name=…>`: flatten its `inherits=` chain (fonts inherit only fonts),
/// register the [`FontObject`] and store the flattened element for later fonts to inherit.
/// A top-level `<Font name=…>` (`CSimpleFont::LoadXML 0x783c30`): the font object `inherits=`
/// names is copied (`0x783ce6`; a miss is logged, `0x87a51c`), then the node's own attributes
/// apply over it ([`font_object_leg`]). The object is registered and published whether or not
/// its own font was refused.
pub(super) fn do_font(&mut self, el: &Element) {
let Some(name) = el.name().map(str::to_string) else {
return;
};
let merged = {
let model = self.model();
let fonts = model.framexml_fonts.borrow();
let view: HashMap<&str, &Element> =
fonts.iter().map(|(k, v)| (k.as_str(), v)).collect();
let mut warns = Vec::new();
let merged = framexml::expand(el, &view, &mut warns);
drop(view);
drop(fonts);
drop(model);
self.report.warnings.extend(warns);
merged
let lookup = |this: &Self, key: &str| {
this.model()
.font_objects_by_lower
.get(&key.to_ascii_lowercase())
.cloned()
};
let font = font_object_from_element(&merged);
let mut font = FontObject::default();
if let Some(parent) = el.attr("inherits").filter(|n| !n.is_empty()) {
match lookup(self, parent) {
Some(f) => font = f,
None => self.status_warning(format!("Couldn't find inherited font: {parent}")),
}
}
let named = el
.attr("font")
.filter(|f| !f.is_empty())
.and_then(|f| lookup(self, f));
if !font_object_leg(el, &mut font, named) {
self.report_missing_font_height("Font", &name, &el.tag);
}
{
let mut model = self.model();
model
@@ -575,7 +637,7 @@ impl Loader<'_> {
model
.framexml_fonts
.borrow_mut()
.insert(name.clone(), merged);
.insert(name.clone(), el.clone());
}
// In 1.12 a named `<Font>` is also a Lua global, the object `SetFontObject` takes.
if let Err(e) = crate::script::font::publish(self.lua(), &name) {
@@ -585,15 +647,17 @@ impl Loader<'_> {
}
}
/// Build one template-expanded frame element, its nested `<Frames>`, then its `OnLoad`
/// Build one frame element, template-expanded, its nested `<Frames>`, then its `OnLoad`
/// (bottom-up, `0x76a060`). `parent` is the lexically enclosing frame, which `parent=`
/// overrides; `None` when the frame type is unknown, which skips the subtree.
pub(super) fn materialize(
&mut self,
el: &Element,
raw: &Element,
parent: Option<&Table>,
parent_name: &str,
) -> Option<Table> {
let expanded = self.expand(raw);
let el = &expanded;
// Only creation lives here: everything after `CreateFrame` is `decorate`, which
// `apply_template` enters for a frame that already exists.
@@ -695,17 +759,21 @@ impl Loader<'_> {
// `$parent` for its contents; a nameless frame passes its nearest named ancestor on.
let self_name = resolved_name.as_deref().unwrap_or(parent_name).to_string();
self.decorate(el, &wrapper, &self_name, parent_name, &dbg_name);
let levels = self.levels(raw);
self.decorate(el, &levels, &wrapper, &self_name, parent_name, &dbg_name);
Some(wrapper)
}
/// Everything a frame element does to an existing frame, from `LoadXML` attributes to its
/// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and
/// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in
/// [`apply_template`]. `el` is the template-expanded element and `levels` the nodes it merges
/// ([`Loader::levels`]). `$parent` is `self_name` in this frame's contents and `parent_name` in
/// its own anchors (`0x76c5b0`).
#[allow(clippy::too_many_arguments)]
pub(super) fn decorate(
&mut self,
el: &Element,
levels: &Levels,
wrapper: &Table,
self_name: &str,
parent_name: &str,
@@ -717,10 +785,12 @@ impl Loader<'_> {
// hook, its own OnShow and OnHide fire nothing.
crate::script::visibility::set_load_window(self.lua(), wrapper, true);
// 2 · LoadXML attributes (`0x769820`).
self.apply_attrs(el, wrapper, dbg_name);
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
self.apply_size(el, wrapper, dbg_name);
self.apply_anchors(el, wrapper, parent_name, dbg_name);
self.apply_attrs(el, levels, wrapper, dbg_name);
// 3 · <Size> and 4 · <Anchors>, each level's own (the CLayoutFrame geometry base,
// `0x767800`).
for level in levels {
self.apply_geometry(level, wrapper, false, parent_name, dbg_name);
}
// 5 · <Layers> regions (`0x769d70`) and the <Backdrop> plate (`0x77e6c0`), whose nine
// pieces are regions too, in document order: both are arms of the one child loop
// (`0x769b1c`), so the order they link into `GetRegions` is the file's. A template's
@@ -739,10 +809,10 @@ impl Loader<'_> {
self.apply_title_region(el, wrapper, self_name, dbg_name);
// 5b · per-kind LoadXML extras (EditBox `0x779fb0`), each gated on the element's own tag,
// so a Frame wearing a <Button> template skips the Button steps.
self.apply_statusbar(el, wrapper, self_name, dbg_name);
self.apply_statusbar(el, levels, wrapper, self_name, dbg_name);
self.apply_slider(el, wrapper, self_name, dbg_name);
self.apply_colorselect(el, wrapper, self_name, dbg_name);
self.apply_button(el, wrapper, self_name, dbg_name);
self.apply_button(el, levels, wrapper, self_name, dbg_name);
self.apply_editbox(el, wrapper, dbg_name);
self.apply_messageframe(el, wrapper, dbg_name);
self.apply_simplehtml(el, wrapper, dbg_name);
@@ -759,8 +829,7 @@ impl Loader<'_> {
crate::script::tooltip_post_load(self.lua(), wrapper);
for frames_el in children_named(el, "Frames") {
for child in &frames_el.children {
let expanded = self.expand(child);
self.materialize(&expanded, Some(wrapper), self_name);
self.materialize(child, Some(wrapper), self_name);
}
}
@@ -898,32 +967,20 @@ pub(super) fn color_of(el: &Element) -> [f32; 4] {
[c("r", 0.0), c("g", 0.0), c("b", 0.0), c("a", 1.0)]
}
/// A `<TexCoords>` child's UV rect `(l, r, t, b)`; one missing an edge is ignored (full UVs).
pub(super) fn tex_coords_of(el: &Element) -> Option<(f32, f32, f32, f32)> {
let tc = children_named(el, "TexCoords").next()?;
let edge = |k: &str| tc.attr(k).and_then(|v| v.trim().parse::<f32>().ok());
Some((edge("left")?, edge("right")?, edge("top")?, edge("bottom")?))
}
/// Whether an XML `<Texture>` loads its file with the wrap flag on: some `<TexCoords>` child has
/// an edge below 0 or above 1 (`0x76fff0`-`0x77006b` set the flag at `0x770071`, which `0x77012d`
/// passes to `0x770200`). An edge left out or empty keeps its default 0, 1, 0, 1 (`0x76fef5`),
/// and a NaN fails both tests.
pub(super) fn tex_coords_wrap(el: &Element) -> bool {
children_named(el, "TexCoords").any(|tc| {
[("left", 0.0), ("right", 1.0), ("top", 0.0), ("bottom", 1.0)]
.into_iter()
.any(|(k, default)| {
// Not `contains`: a NaN edge is in range here.
#[allow(clippy::manual_range_contains)]
let out_of_range = |edge: f32| edge < 0.0 || edge > 1.0;
let edge = tc
.attr(k)
.filter(|v| !v.is_empty())
.map_or(default, crate::framexml::atof);
out_of_range(edge)
})
})
/// A `<TexCoords>` element's edges `(l, r, t, b)`: each starts at 0, 1, 0, 1 and is read with
/// `atof` only when present and non-empty (`0x76fef5`-`0x76ff70`).
pub(super) fn tex_coords(tc: &Element) -> (f32, f32, f32, f32) {
let edge = |k: &str, default: f32| {
tc.attr(k)
.filter(|v| !v.is_empty())
.map_or(default, crate::framexml::atof)
};
(
edge("left", 0.0),
edge("right", 1.0),
edge("top", 0.0),
edge("bottom", 1.0),
)
}
/// A `<FontHeight>`-style scalar: its `<AbsValue val=>` child, else its own `val=`.
@@ -932,56 +989,82 @@ pub(super) fn abs_value(el: &Element) -> Option<f32> {
src.attr("val").and_then(|v| v.trim().parse::<f32>().ok())
}
/// A [`FontObject`] from a flattened `<Font>`. The merge appends inherited nodes first, so the last
/// `<FontHeight>`, `<Color>` and `<Shadow>` win.
pub(super) fn font_object_from_element(el: &Element) -> FontObject {
let justify_h = el
.attr("justifyH")
.map(|j| match j.to_ascii_uppercase().as_str() {
"LEFT" => JustifyH::Left,
"RIGHT" => JustifyH::Right,
_ => JustifyH::Center,
/// A `<Shadow>` element (`0x7713ca`, `0x784058`): its first `<Color>` over opaque black
/// (`0xff000000`), and its first `<Offset>`, else (0.001, -0.001) (`0x3a83126f`, `0xba83126f`).
/// The offset reader zeroes both coordinates first (`0x6f1ebe`, `0x6f1ece`), so a coordinate an
/// `<Offset>` leaves out is 0.
pub(super) fn shadow_of(sh: &Element) -> crate::script::FontShadow {
let offset = children_named(sh, "Offset")
.next()
.map_or([0.001, -0.001], |o| {
let (x, y) = abs_dim(o);
[x.unwrap_or(0.0), y.unwrap_or(0.0)]
});
let justify_v = el
.attr("justifyV")
.map(|j| match j.to_ascii_uppercase().as_str() {
let color = children_named(sh, "Color")
.next()
.map_or([0.0, 0.0, 0.0, 1.0], color_of);
crate::script::FontShadow { offset, color }
}
/// One `<Font>` node's own attributes over `font`, what it inherited (`0x783cee`-`0x784111`):
/// `font=` naming a font object (`named`) copies that object (`0x783d22`); any other `font=` is a
/// file that needs the node's own first `<FontHeight>` above 0 and sets the flags word whole from
/// the node's `outline=` and `monochrome=` (`0x783db1`-`0x783ea3`), so an `outline=` without its
/// own file is never read. Then `spacing=` with `atof` (`0x783ee0`), `justifyV`, `justifyH` and
/// each `<Color>` and `<Shadow>` child, the last of each winning. `false` when the height was
/// refused, which returns before all of that (`0x783dae`).
pub(super) fn font_object_leg(
el: &Element,
font: &mut FontObject,
named: Option<FontObject>,
) -> bool {
match (el.attr("font").filter(|f| !f.is_empty()), named) {
(Some(_), Some(object)) => *font = object,
(Some(path), None) => {
let height = children_named(el, "FontHeight")
.next()
.and_then(abs_value)
.filter(|h| *h > 0.0);
let Some(height) = height else {
return false;
};
font.font = Some(path.to_string());
font.height = Some(height);
font.outline = el.attr("outline").map(Outline::parse).unwrap_or_default();
// Any non-empty `monochrome=` sets the flag (`0x783e01`-`0x783e14`), even "false".
font.monochrome = el.attr("monochrome").is_some_and(|v| !v.is_empty());
}
(None, _) => {}
}
if let Some(sp) = el.attr("spacing").filter(|v| !v.is_empty()) {
font.spacing = Some(crate::framexml::atof(sp));
}
if let Some(j) = el.attr("justifyV").filter(|v| !v.is_empty()) {
font.justify_v = Some(match j.to_ascii_uppercase().as_str() {
"TOP" => JustifyV::Top,
"BOTTOM" => JustifyV::Bottom,
_ => JustifyV::Middle,
});
// `<Shadow>` inherits like any child: `MasterFont`'s (1,-1) black reaches every `GameFont*`
// (`Fonts.xml:55`).
let shadow = children_named(el, "Shadow").last().map(|sh| {
let offset = children_named(sh, "Offset")
.next()
.map(abs_dim)
.map(|(x, y)| [x.unwrap_or(0.0), y.unwrap_or(0.0)])
.unwrap_or([0.0, 0.0]);
let color = children_named(sh, "Color")
.next()
.map(color_of)
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
crate::script::FontShadow { offset, color }
});
FontObject {
font: el.attr("font").map(str::to_string),
height: children_named(el, "FontHeight").last().and_then(abs_value),
color: children_named(el, "Color").last().map(color_of),
outline: el.attr("outline").map(Outline::parse).unwrap_or_default(),
// Any non-empty `monochrome=` sets the flag (`0x783e01`-`0x783e14`), even "false".
monochrome: el.attr("monochrome").is_some_and(|v| !v.is_empty()),
justify_h,
justify_v,
shadow,
// `spacing=` (`0x783ee0`, read with `atof` when not empty), inherited down the flattened
// chain like the other attributes.
spacing: el
.attr("spacing")
.filter(|v| !v.is_empty())
.map(crate::framexml::atof),
}
if let Some(j) = el.attr("justifyH").filter(|v| !v.is_empty()) {
font.justify_h = Some(match j.to_ascii_uppercase().as_str() {
"LEFT" => JustifyH::Left,
"RIGHT" => JustifyH::Right,
_ => JustifyH::Center,
});
}
for child in &el.children {
if child.tag.eq_ignore_ascii_case("Color") {
font.color = Some(color_of(child));
} else if child.tag.eq_ignore_ascii_case("Shadow") {
font.shadow = Some(shadow_of(child));
}
}
true
}
#[cfg(test)]
mod level_tests;
#[cfg(test)]
mod template_name_tests;
#[cfg(test)]
+233 -258
View File
@@ -3,12 +3,12 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use crate::status;
use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader};
use super::{abs_value, children_named, color_of, Loader};
/// Whether a region reads its own font attributes, the reference's `FONTSTRING+0x12c`: set by the
/// ctor (`0x770de7`) and cleared only on a Button's `<NormalText>` (`0x778b7b`), whose string then
/// skips them (`0x7710e1`-`0x771467`) for the button's Normal font to take (`0x783c30`). Here it
/// gates justify and spacing, the font attributes the button's label pass applies.
/// skips them (`0x7710db` → `0x771468`) for the button's Normal font to take (`0x783c30`): an
/// `inherits` font object, `font=`, `spacing`, the justifies, `<Color>` and `<Shadow>`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum FontAttrs {
/// Every other region, a Button's `<ButtonText>` included.
@@ -29,10 +29,6 @@ impl Loader<'_> {
for layer in children_named(layers, "Layer") {
let level = layer.attr("level").unwrap_or("ARTWORK").to_string();
for region in &layer.children {
// `inherits=` may name a region template, in the frames' one registry
// (`0x6ee500`); a font object's name passes on to `apply_fontstring_font`.
let raw = region;
let region = &self.expand_region(raw);
let is_texture = region.tag.eq_ignore_ascii_case("Texture");
let is_fontstring = region.tag.eq_ignore_ascii_case("FontString");
if !is_texture && !is_fontstring {
@@ -42,15 +38,15 @@ impl Loader<'_> {
));
continue;
}
self.build_region(raw, region, wrapper, parent_name, &level, is_texture, dbg);
self.build_region(region, wrapper, parent_name, &level, is_texture, dbg);
}
}
}
/// The texture a widget element's child (`<BarTexture>`, `<ThumbTexture>`, a ColorSelect
/// texture) names, built as the shared texture adder does (`0x6f26f0`): `inherits=` expanded,
/// then the same region a `<Layers>` texture is, named, published, laid out, filled and given
/// its implicit all-points. The widget then installs the object it returns.
/// texture) names, built as the shared texture adder does (`0x6f26f0`): the same region a
/// `<Layers>` texture is, named, published, loaded level by level and given its implicit
/// all-points. The widget then installs the object it returns.
pub(super) fn build_texture_child(
&mut self,
raw: &Element,
@@ -59,25 +55,26 @@ impl Loader<'_> {
level: &str,
dbg: &str,
) -> Option<Table> {
let expanded = self.expand_region(raw);
self.build_region(raw, &expanded, wrapper, parent_name, level, true, dbg)
self.build_region(raw, wrapper, parent_name, level, true, dbg)
}
/// One `<Layer>` region on `wrapper`, created on `level` and loaded: its name (`$parent`
/// expanded, published as a global), `hidden`, `alpha`, layout, font and visual, then the
/// implicit creation anchor, run after the region's LoadXML as the reference does (`0x7701c0`,
/// `0x771480`); an anchored region is left alone. `None` when the creation raised.
/// expanded, published as a global, read from `raw` alone, `0x6f2751`), then its `LoadXML`
/// level by level ([`Loader::levels`]; `inherits=` may name a region template, in the frames'
/// one registry, `0x6ee500`), then the implicit creation anchor, run after the region's
/// LoadXML as the reference does (`0x7701c0`, `0x771480`); an anchored region is left alone.
/// `None` when the creation raised.
fn build_region(
&mut self,
raw: &Element,
region: &Element,
wrapper: &Table,
parent_name: &str,
level: &str,
is_texture: bool,
dbg: &str,
) -> Option<Table> {
let rname: Option<String> = region
let levels = self.levels(raw);
let rname: Option<String> = raw
.name()
.map(|raw| framexml::resolve_name(raw, parent_name));
let method = if is_texture {
@@ -93,28 +90,14 @@ impl Loader<'_> {
return None;
}
};
if region.attr_bool("hidden") {
if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) {
self.report.fault(format!("{dbg}: region Hide: {e}"));
for node in &levels {
if is_texture {
self.load_texture_level(node, &region_wrapper, parent_name, dbg);
} else {
let attrs = FontAttrs::Own;
self.load_fontstring_level(node, &region_wrapper, parent_name, attrs, true, dbg);
}
}
if let Some(a) = region
.attr("alpha")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call_region(&region_wrapper, "SetAlpha", a, dbg);
}
self.apply_region_layout(region, &region_wrapper, parent_name, dbg, FontAttrs::Own);
if !is_texture {
// Before the visual pass, so an explicit `<Color>` beats the font object's.
self.apply_fontstring_font(region, &region_wrapper, dbg);
}
let loads = if is_texture {
self.texture_file_loads(raw)
} else {
Vec::new()
};
self.apply_region_visual(region, &region_wrapper, is_texture, &loads, dbg);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper) {
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
@@ -139,7 +122,7 @@ impl Loader<'_> {
if !region.tag.eq_ignore_ascii_case("FontString") {
continue;
}
let region = &self.expand_region(region);
let levels = self.levels(region);
let rname: Option<String> = region
.name()
.map(|raw| framexml::resolve_name(raw, parent_name));
@@ -173,9 +156,10 @@ impl Loader<'_> {
}
},
};
self.apply_region_layout(region, &region_wrapper, parent_name, dbg, FontAttrs::Own);
self.apply_fontstring_font(region, &region_wrapper, dbg);
self.apply_region_visual(region, &region_wrapper, false, &[], dbg);
for node in &levels {
let attrs = FontAttrs::Own;
self.load_fontstring_level(node, &region_wrapper, parent_name, attrs, true, dbg);
}
// The implicit creation anchor, as in `apply_layers`; an EditBox's adopt replaces it.
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region_wrapper) {
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
@@ -189,7 +173,7 @@ impl Loader<'_> {
self.report
.fault(format!("{dbg}: adopt special FontString: {e}"));
}
if let Some(bytes) = region.attr("bytes") {
if let Some(bytes) = levels.iter().rev().find_map(|l| l.attr("bytes")) {
if let Err(e) = crate::script::apply_xml_bytes(self.lua(), wrapper, bytes) {
self.report.fault(format!("{dbg}: FontString bytes: {e}"));
}
@@ -227,169 +211,233 @@ impl Loader<'_> {
}
}
/// A region's geometry: `<Size>`, a FontString's justify when [`FontAttrs::Own`],
/// `setAllPoints` and `<Anchors>`, whose `$parent` is the owner's name, or a nameless owner's
/// nearest named ancestor's.
pub(super) fn apply_region_layout(
/// One level of a `<FontString>`'s `LoadXML` (`0x770f40`), after its `inherits` level: an
/// `inherits` font object, the geometry, `hidden`, `text`, the wrap attributes, then, when the
/// string owns its font attributes ([`FontAttrs`], `0x7710db`), `font=`, `spacing`,
/// `justifyV`, `justifyH` and the `<Color>` and `<Shadow>` children, in that order. A `font=`
/// file needs this node's own first `<FontHeight>` above 0 (`0x77112f`-`0x771154`); without
/// one the level reports `0x878e10` and returns (`0x771191`), so nothing after the font is
/// read on it, whatever height a template gave. `with_text` is false for a Button's label,
/// whose text the button sets.
pub(super) fn load_fontstring_level(
&mut self,
region: &Element,
node: &Element,
wrapper: &Table,
parent_name: &str,
attrs: FontAttrs,
with_text: bool,
dbg: &str,
font_attrs: FontAttrs,
) {
// Every `<Size>` in document order, as in `apply_size`.
for size in children_named(region, "Size") {
let (x, y) = abs_dim(size);
if let Some(w) = x {
self.call_region(wrapper, "SetWidth", w, dbg);
}
if let Some(h) = y {
self.call_region(wrapper, "SetHeight", h, dbg);
let own = attrs == FontAttrs::Own;
// `inherits=` names a font object first (`0x770f69`), linked only when the string owns
// its font attributes (`0x770f72`), else a template, loaded as a level of its own. Any
// other name passes through and is reported: `SetFontObject("")` would clear the font
// object where a bad name warns.
if let Some(name) = node.attr("inherits").filter(|n| !n.is_empty()) {
let template = self.model().framexml_templates.borrow().contains(name);
if own && (self.is_font_object(name) || !template) {
self.set_font_object(wrapper, "inherits", name, dbg);
}
}
if font_attrs == FontAttrs::Own {
if let Some(j) = region.attr("justifyH") {
self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg);
}
if let Some(j) = region.attr("justifyV") {
self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg);
}
// `spacing=` (`0x771257`) goes through the setter and clears inherit bit `0x10`
// (`0x771298`), so it outlasts the font object's.
if let Some(sp) = region.attr("spacing").filter(|v| !v.is_empty()) {
self.call_region(wrapper, "SetSpacing", crate::framexml::atof(sp), dbg);
self.apply_geometry(node, wrapper, true, parent_name, dbg);
match node.attr_bool_opt("hidden") {
Some(true) => self.call_region(wrapper, "Hide", (), dbg),
Some(false) => self.call_region(wrapper, "Show", (), dbg),
None => {}
}
if let Some(a) = node
.attr("alpha")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call_region(wrapper, "SetAlpha", a, dbg);
}
// An empty `text` is skipped (`0x77101b`), so a template's text stands. The string's own
// `SetText` (`0x77103a` → `0x771d80`), not Lua's, which raises on a string with no font
// (`0x79d7e5`); `LoadXML` sets the text before the font.
if let Some(raw) = node.attr("text").filter(|t| with_text && !t.is_empty()) {
let text = self.resolve_text(raw, dbg);
if let Err(e) = crate::script::region::store_text_of(self.lua, wrapper, Some(text)) {
self.report.fault(format!("{dbg}: region SetText: {e}"));
}
}
if region.attr_is_true("setAllPoints") {
self.call_region(wrapper, "SetAllPoints", (), dbg);
self.apply_wrap_attrs(node, wrapper, dbg);
if !own {
return;
}
for anchors in children_named(region, "Anchors") {
for anchor in children_named(anchors, "Anchor") {
let Some(point) = anchor.attr("point") else {
self.report
.warnings
.push(format!("{dbg}: region <Anchor> without a point; skipped"));
continue;
};
let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string();
let rel_to: Option<String> = anchor
.attr("relativeTo")
.map(|r| framexml::resolve_name(r, parent_name));
let (x, y) = children_named(anchor, "Offset")
// `font=`, empty as absent (`0x7710f7`): a font object's name links it (`0x771114`), any
// other value is a file its own `<FontHeight>` and `outline=` qualify (`0x77110b`), so
// `ZoneText.xml`'s `AutoFollowStatusText` draws at GameFontNormal's 12, not its own 20.
match node.attr("font").filter(|f| !f.is_empty()) {
Some(name) if self.is_font_object(name) => {
self.set_font_object(wrapper, "font", name, dbg);
}
Some(path) => {
let height = children_named(node, "FontHeight")
.next()
.map(abs_dim)
.unwrap_or((None, None));
let args = (
point.to_string(),
rel_to,
rel_point,
x.unwrap_or(0.0),
y.unwrap_or(0.0),
);
let d = super::DeferredAnchor {
wrapper: wrapper.clone(),
region: true,
args,
dbg: dbg.to_string(),
.and_then(abs_value)
.filter(|h| *h > 0.0);
let Some(height) = height else {
let name = self.object_name(wrapper);
self.report_missing_font_height("FontString", &name, &node.tag);
return;
};
// Deferred while the enclosing subtree has not built its target yet.
self.apply_anchor(d, true);
// The flags word, set whole on this leg (`0x771194`-`0x771214`): `outline=` is
// NORMAL or THICK, and any non-empty `monochrome=` adds the flag, even "false".
let flags = (
node.attr("outline")
.map(crate::script::Outline::parse)
.unwrap_or_default(),
node.attr("monochrome").is_some_and(|v| !v.is_empty()),
);
if let Err(e) = crate::script::apply_font_parts(
self.lua,
wrapper,
Some(path.to_string()),
Some(height),
Some(flags),
) {
self.report.fault(format!("{dbg}: region font attrs: {e}"));
}
}
None => {}
}
// `spacing=` (`0x771257`) goes through the setter and clears inherit bit `0x10`
// (`0x771298`), so it outlasts the font object's.
if let Some(sp) = node.attr("spacing").filter(|v| !v.is_empty()) {
self.call_region(wrapper, "SetSpacing", crate::framexml::atof(sp), dbg);
}
if let Some(j) = node.attr("justifyV").filter(|v| !v.is_empty()) {
self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg);
}
if let Some(j) = node.attr("justifyH").filter(|v| !v.is_empty()) {
self.call_region(wrapper, "SetJustifyH", j.to_string(), dbg);
}
// Each `<Color>` is the text colour (`0x77f750`) and each `<Shadow>` the shadow
// (`0x7722a0`), both local to the string, so they outlast the font object's.
for child in &node.children {
if child.tag.eq_ignore_ascii_case("Color") {
let c = color_of(child);
self.call_region(wrapper, "SetVertexColor", (c[0], c[1], c[2], c[3]), dbg);
} else if child.tag.eq_ignore_ascii_case("Shadow") {
let sh = super::shadow_of(child);
let c = sh.color;
self.call_region(wrapper, "SetShadowColor", (c[0], c[1], c[2], c[3]), dbg);
self.call_region(
wrapper,
"SetShadowOffset",
(sh.offset[0], sh.offset[1]),
dbg,
);
}
}
}
pub(super) fn apply_region_visual(
/// `SetFontObject` for an XML `<attr>=` naming `name`, a failure reported once.
fn set_font_object(&mut self, wrapper: &Table, attr: &str, name: &str, dbg: &str) {
if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) {
self.warn_once(
&format!("fontobj:{name}"),
format!("{dbg}: <FontString {attr}=\"{name}\">: {e}"),
);
}
}
/// One level of a `<Texture>`'s `LoadXML` (`0x76fe20`), after its `inherits` level: the
/// geometry, `hidden`, each child in order, then `file=` and `alphaMode`. A `<TexCoords>` sets
/// all eight coordinates from its own edges over 0, 1, 0, 1, so the last one applied wins whole
/// (`0x76fef5`, `0x76ffe8`), and any edge outside [0, 1] sets the level's wrap flag, which the
/// file load takes (`0x770071`, `0x77012d`); a later `SetTexCoord` leaves it, and `SetTexture`
/// loads with it off. A `<Color>` is a solid fill (`0x770360`), an 8×8 texture in the
/// reference, so its alpha multiplies with `SetVertexColor` rather than being replaced by it;
/// the level's `file=`, read after its children (`0x770102`), overwrites the same slot.
fn load_texture_level(
&mut self,
region: &Element,
region_wrapper: &Table,
is_texture: bool,
file_loads: &[(String, bool)],
node: &Element,
wrapper: &Table,
parent_name: &str,
dbg: &str,
) {
let color = children_named(region, "Color").next().map(color_of);
if is_texture {
if !file_loads.is_empty() {
// A `<Color>` beside `file=` is discarded, not a tint: `0x76fe20` applies it in its
// child loop, then the file, read after (`0x770102`), overwrites the same slot.
// Each `inherits` level loads its own file with the wrap flag of its own
// `<TexCoords>`, fixed here: a later `SetTexCoord` leaves it, and `SetTexture`
// loads with it off (`0x77012d`).
for (file, wrap) in file_loads {
if let Err(e) =
crate::script::load_texture_file(self.lua, region_wrapper, file, *wrap)
{
self.report.fault(format!("{dbg}: region SetTexture: {e}"));
}
}
} else if let Some(c) = color {
// No file: a solid fill, an 8×8 texture in the reference, so its alpha multiplies
// with `SetVertexColor` rather than being replaced by it.
self.call_region(region_wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
}
// `<Gradient>` writes the vertex colours (`+0xb8`, `0x7700f2` → `0x77f910`), which
// `file=` leaves alone, so unlike `<Color>` it tints the art. `0x6f2630` stages both
// stops as 0 (`0x7700d8`), so a missing one is transparent black, and reads a missing
// orientation as HORIZONTAL; an unknown one stays HORIZONTAL and is reported.
if let Some(g) = children_named(region, "Gradient").next() {
let stop = |tag: &str| children_named(g, tag).next().map_or([0.0; 4], color_of);
let (min, max) = (stop("MinColor"), stop("MaxColor"));
let token = g.attr("orientation").unwrap_or_default();
let vertical = match crate::script::region::orientation(token) {
Some(v) => v,
None if token.is_empty() => false,
None => {
let line = format!("Unknown orientation {token} in element {}", g.tag);
self.report.status.report(status::WARNING, line.clone());
self.report.warnings.push(line);
false
}
};
self.call_region(
region_wrapper,
"SetGradientAlpha",
(
if vertical { "VERTICAL" } else { "HORIZONTAL" },
min[0],
min[1],
min[2],
min[3],
max[0],
max[1],
max[2],
max[3],
),
dbg,
);
}
if let Some(tc) = tex_coords_of(region) {
self.call_region(region_wrapper, "SetTexCoord", tc, dbg);
}
if let Some(mode) = region.attr("alphaMode") {
self.call_region(region_wrapper, "SetBlendMode", mode.to_string(), dbg);
}
} else {
if let Some(text) = region.attr("text") {
// A global-string key, not a literal (`0x703bf0`).
let text = self.resolve_text(text, dbg);
// The string's own `SetText` (`0x77103a` → `0x771d80`), not Lua's, which raises on
// a string with no font (`0x79d7e5`); `LoadXML` sets the text before the font.
if let Err(e) =
crate::script::region::store_text_of(self.lua, region_wrapper, Some(text))
{
self.report.fault(format!("{dbg}: region SetText: {e}"));
}
}
self.apply_wrap_attrs(region, region_wrapper, dbg);
if let Some(c) = color {
self.call_region(
region_wrapper,
"SetVertexColor",
(c[0], c[1], c[2], c[3]),
dbg,
);
self.apply_geometry(node, wrapper, true, parent_name, dbg);
match node.attr_bool_opt("hidden") {
Some(true) => self.call_region(wrapper, "Hide", (), dbg),
Some(false) => self.call_region(wrapper, "Show", (), dbg),
None => {}
}
if let Some(a) = node
.attr("alpha")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call_region(wrapper, "SetAlpha", a, dbg);
}
let mut wrap = false;
for child in &node.children {
if child.tag.eq_ignore_ascii_case("TexCoords") {
let (l, r, t, b) = super::tex_coords(child);
// Not `contains`: a NaN edge is in range here.
#[allow(clippy::manual_range_contains)]
let out_of_range = |edge: f32| edge < 0.0 || edge > 1.0;
wrap |= [l, r, t, b].into_iter().any(out_of_range);
self.call_region(wrapper, "SetTexCoord", (l, r, t, b), dbg);
} else if child.tag.eq_ignore_ascii_case("Color") {
let c = color_of(child);
self.call_region(wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
} else if child.tag.eq_ignore_ascii_case("Gradient") {
self.apply_gradient(child, wrapper, dbg);
}
}
// A file that fails to load is logged (`0x878d24`) and the texture filled opaque green
// (`0xff00ff00` through `0x770360`, `0x77016a`), the reference's tell for a bad path.
if let Some(file) = node.attr("file").filter(|f| !f.is_empty()) {
match crate::script::load_texture_file(self.lua, wrapper, file, wrap) {
Ok(crate::script::FileLoad::Failed) => {
let name = self.object_name(wrapper);
self.status_warning(format!(
"Texture {name}: Unable to load texture file {file}"
));
self.call_region(wrapper, "SetTexture", (0.0, 1.0, 0.0, 1.0), dbg);
}
Ok(_) => {}
Err(e) => self.report.fault(format!("{dbg}: region SetTexture: {e}")),
}
}
if let Some(mode) = node.attr("alphaMode") {
self.call_region(wrapper, "SetBlendMode", mode.to_string(), dbg);
}
}
/// A texture's `<Gradient>`: it writes the vertex colours (`+0xb8`, `0x7700f2` → `0x77f910`),
/// which `file=` leaves alone, so unlike `<Color>` it tints the art. `0x6f2630` stages both
/// stops as 0 (`0x7700d8`), so a missing one is transparent black, and reads a missing
/// orientation as HORIZONTAL; an unknown one stays HORIZONTAL and is reported.
fn apply_gradient(&mut self, g: &Element, wrapper: &Table, dbg: &str) {
let stop = |tag: &str| children_named(g, tag).next().map_or([0.0; 4], color_of);
let (min, max) = (stop("MinColor"), stop("MaxColor"));
let token = g.attr("orientation").unwrap_or_default();
let vertical = match crate::script::region::orientation(token) {
Some(v) => v,
None if token.is_empty() => false,
None => {
let line = format!("Unknown orientation {token} in element {}", g.tag);
self.status_warning(line);
false
}
};
self.call_region(
wrapper,
"SetGradientAlpha",
(
if vertical { "VERTICAL" } else { "HORIZONTAL" },
min[0],
min[1],
min[2],
min[3],
max[0],
max[1],
max[2],
max[3],
),
dbg,
);
}
/// The font object a FontString's `inherits=` names, directly or through virtual FontString
@@ -427,79 +475,6 @@ impl Loader<'_> {
let fonts = model.framexml_fonts.borrow();
fonts.contains_key(name) || fonts.keys().any(|k| k.eq_ignore_ascii_case(name))
}
/// A `<FontString>`'s font: `inherits=` through [`Self::font_object_through_templates`], then
/// `font=`, a font object's name first and a file only on a miss (`0x770f40`, `0x771104`).
pub(super) fn apply_fontstring_font(&mut self, region: &Element, wrapper: &Table, dbg: &str) {
if let Some(name) = region.attr("inherits") {
// The expanded element keeps the instance's `inherits=`, a template's name, so the
// chain is walked to its font object. An unresolved name passes through unchanged:
// `SetFontObject("")` would clear the font object where a bad name warns.
let resolved = self
.font_object_through_templates(name)
.unwrap_or_else(|| name.to_string());
if let Err(e) = wrapper.call_method::<()>("SetFontObject", resolved) {
self.warn_once(
&format!("fontobj:{name}"),
format!("{dbg}: <FontString inherits=\"{name}\">: {e}"),
);
}
}
// `font=` gates `<FontHeight>` and `outline=` (`0x7710e1`-`0x771254`): a font object's
// name links it and skips them (`0x771114`, `0x771119`), any other value is a file they
// qualify (`0x77110b`), and with no `font=` they are never read (`0x7710fa`), so
// `ZoneText.xml`'s `AutoFollowStatusText` draws at GameFontNormal's 12, not its own 20.
match region.attr("font") {
Some(name) if self.is_font_object(name) => {
if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) {
self.warn_once(
&format!("fontobj:{name}"),
format!("{dbg}: <FontString font=\"{name}\">: {e}"),
);
}
}
Some(path) => {
// A file needs a first `<FontHeight>` above 0 (`0x77112f`-`0x771154`); else the
// load reports `0x878e10` (`0x77117d`) and returns (`0x771191`), before `outline=`
// and with the string's font unset.
let height = children_named(region, "FontHeight")
.next()
.and_then(abs_value)
.filter(|h| *h > 0.0);
let Some(height) = height else {
let name = wrapper
.call_method::<Option<String>>("GetName", ())
.ok()
.flatten();
self.report_missing_font_height(
"FontString",
name.as_deref().unwrap_or("<unnamed>"),
&region.tag,
);
return;
};
// The flags word, set whole on this leg (`0x771194`-`0x771214`): `outline=` is
// NORMAL or THICK, and any non-empty `monochrome=` adds the flag, even "false".
let flags = (
region
.attr("outline")
.map(crate::script::Outline::parse)
.unwrap_or_default(),
region.attr("monochrome").is_some_and(|v| !v.is_empty()),
);
if let Err(e) = crate::script::apply_font_parts(
self.lua,
wrapper,
Some(path.to_string()),
Some(height),
Some(flags),
) {
self.report.fault(format!("{dbg}: region font attrs: {e}"));
}
}
None => {}
}
}
}
/// The client's decimal parse `0x64ade0`: the leading digits of `s`, wrapping in 32 bits, and 0
+16
View File
@@ -151,6 +151,22 @@ impl Loader<'_> {
}
}
/// [`Self::call_region`] on a region wrapper, else [`Self::call`].
pub(super) fn call_on(
&mut self,
wrapper: &Table,
region: bool,
method: &str,
args: impl mlua::IntoLuaMulti,
dbg: &str,
) {
if region {
self.call_region(wrapper, method, args, dbg);
} else {
self.call(wrapper, method, args, dbg);
}
}
/// Call a region-wrapper method, recording (not propagating) any error.
pub(super) fn call_region(
&mut self,
+3 -2
View File
@@ -1555,10 +1555,11 @@ mod loader_tests {
assert_eq!(base.height, Some(12.0));
assert_eq!(base.color, Some([1.0, 0.82, 0.0, 1.0]));
// Leaf: Base's face through Mid, Mid's white, its own height of 10.
// Leaf: Base's face and height through Mid, and Mid's white; its own `<FontHeight>` has
// no `font=` beside it, so it is never read (`0x783cf1`).
let leaf = s.font_object("Leaf").expect("Leaf registered");
assert_eq!(leaf.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
assert_eq!(leaf.height, Some(10.0));
assert_eq!(leaf.height, Some(12.0));
assert_eq!(leaf.color, Some([1.0, 1.0, 1.0, 1.0]));
}
+61 -36
View File
@@ -4,7 +4,7 @@ use crate::framexml::{self, Element};
use crate::script::LabelFont;
use super::regions::FontAttrs;
use super::{abs_dim, abs_value, children_named, children_named_any, color_of, Loader};
use super::{abs_dim, abs_value, children_named, children_named_any, color_of, Levels, Loader};
impl Loader<'_> {
/// `<ScrollFrame>`'s `<ScrollChild>` (`LoadXML 0x786bc0`): after the base load (`0x769820`) it
@@ -32,8 +32,7 @@ impl Loader<'_> {
));
continue;
};
let expanded = self.expand(child);
let Some(child_wrapper) = self.materialize(&expanded, Some(wrapper), self_name) else {
let Some(child_wrapper) = self.materialize(child, Some(wrapper), self_name) else {
continue; // materialize already reported why
};
if let Err(e) = wrapper.call_method::<()>("SetScrollChild", child_wrapper) {
@@ -42,11 +41,13 @@ impl Loader<'_> {
}
}
/// `<StatusBar>` (`0x782ef0`): a reversed `minValue`/`maxValue` pair is swapped, as
/// `SetMinMaxValues` also does; `drawLayer` is the bar texture's layer, ARTWORK by default.
/// `<StatusBar>` (`0x782ef0`): the `<BarTexture>`, built on `drawLayer` (ARTWORK by default),
/// and `<BarColor>`, then for each level ([`Loader::levels`]) its range, its value and its
/// orientation.
pub(super) fn apply_statusbar(
&mut self,
el: &Element,
levels: &Levels,
wrapper: &Table,
self_name: &str,
dbg: &str,
@@ -54,19 +55,6 @@ impl Loader<'_> {
if !el.tag.eq_ignore_ascii_case("StatusBar") {
return;
}
let parse = |attr: &str| el.attr(attr).and_then(|v| v.trim().parse::<f32>().ok());
let (min, max) = (parse("minValue"), parse("maxValue"));
if min.is_some() || max.is_some() {
self.call(
wrapper,
"SetMinMaxValues",
(min.unwrap_or(0.0), max.unwrap_or(0.0)),
dbg,
);
}
if let Some(o) = el.attr("orientation") {
self.call(wrapper, "SetOrientation", o.to_string(), dbg);
}
let layer = el.attr("drawLayer");
for bar in children_named(el, "BarTexture") {
// Built as any texture element is, and installed as the bar on `drawLayer`, ARTWORK
@@ -80,8 +68,39 @@ impl Loader<'_> {
let c = color_of(bc);
self.call(wrapper, "SetStatusBarColor", (c[0], c[1], c[2], c[3]), dbg);
}
if let Some(v) = parse("defaultValue") {
self.call(wrapper, "SetValue", v, dbg);
for level in levels {
// Each value with `atof`, present and non-empty: the range needs both bounds on the
// node, and `defaultValue` is read only after it (`0x782fb0`-`0x783021`). The setter
// `0x783270` clamps a min above the max down to it.
let value = |attr: &str| {
level
.attr(attr)
.filter(|v| !v.is_empty())
.map(framexml::atof)
};
if let (Some(min), Some(max)) = (value("minValue"), value("maxValue")) {
self.call(wrapper, "SetMinMaxValues", (min, max), dbg);
if let Some(v) = value("defaultValue") {
self.call(wrapper, "SetValue", v, dbg);
}
}
// `orientation` through the token parse `0x6f1a20`: empty is skipped, a miss logged
// (`0x87a3c4`) and never raised (`0x783064`), where the Lua setter raises.
if let Some(token) = level.attr("orientation").filter(|v| !v.is_empty()) {
match crate::script::region::orientation(token) {
Some(vertical) => {
let o = if vertical { "VERTICAL" } else { "HORIZONTAL" };
self.call(wrapper, "SetOrientation", o, dbg);
}
None => {
let name = self.object_name(wrapper);
self.status_warning(format!(
"Frame {name}: Unknown orientation {token} in element {}",
level.tag
));
}
}
}
}
}
@@ -91,6 +110,7 @@ impl Loader<'_> {
pub(super) fn apply_button(
&mut self,
el: &Element,
levels: &Levels,
wrapper: &Table,
self_name: &str,
dbg: &str,
@@ -137,11 +157,15 @@ impl Loader<'_> {
FontAttrs::Own
};
// `SetText` creates the label even with no text, so its layout lands on a real region;
// `text=` is a global-string key (`0x703bf0`).
let label = match bt.attr("text") {
Some(raw) => self.resolve_text(raw, dbg),
None => String::new(),
};
// `text=` is each level's, a global-string key (`0x703bf0`), an empty one skipped
// (`0x77101b`).
let label_levels = self.levels(bt);
let mut label = String::new();
for level in &label_levels {
if let Some(raw) = level.attr("text").filter(|t| !t.is_empty()) {
label = self.resolve_text(raw, dbg);
}
}
// A named label is created as a named region, not aliased after `SetText`, so its
// `GetName()` answers.
let bt_name = bt.name().map(|raw| framexml::resolve_name(raw, self_name));
@@ -162,14 +186,12 @@ impl Loader<'_> {
// `0x778b96`); the button's own adopter anchor never fires on them (`0x778d5f`).
// On `<NormalText>` the justify is the Normal font's, so the implicit anchor reads
// the ctor's CENTER (`0x212`); the justify still reaches the paint (`0x784111`).
// Both load as any FontString does (`0x6f2780` → `0x770f40`, `0x778b8f`), each
// level in turn; `<NormalText>` skips its font attributes (`0x778b7b`).
self.call_region(&region, "ClearAllPoints", (), dbg);
self.apply_region_layout(bt, &region, self_name, dbg, font_attrs);
// `<ButtonText>` reads its own `inherits=` and `font=` as any FontString does
// (`0x6f2780` → `0x770f40`); `<NormalText>` skips them (`0x778b7b`).
if font_attrs == FontAttrs::Own {
self.apply_fontstring_font(bt, &region, dbg);
for level in &label_levels {
self.load_fontstring_level(level, &region, self_name, font_attrs, false, dbg);
}
self.apply_wrap_attrs(bt, &region, dbg);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region) {
self.report.fault(format!("{dbg}: implicit anchor: {e}"));
}
@@ -196,10 +218,13 @@ impl Loader<'_> {
self.call(wrapper, "SetChecked", checked, dbg);
}
}
if let Some(text) = el.attr("text") {
// A global-string key (`0x703bf0`): `text="DELETE"` reads "Delete".
let text = self.resolve_text(text, dbg);
self.call(wrapper, "SetText", text, dbg);
// Each level's `text=`, a global-string key (`0x703bf0`, `text="DELETE"` reads "Delete"),
// an empty one skipped (`0x778c1a`).
for level in levels {
if let Some(text) = level.attr("text").filter(|t| !t.is_empty()) {
let text = self.resolve_text(text, dbg);
self.call(wrapper, "SetText", text, dbg);
}
}
// The per-state fonts, two spellings each; every occurrence applies, in document order.
for (children, method, which) in [
@@ -479,7 +504,7 @@ impl Loader<'_> {
}
/// One `<SimpleHTML>` font child's element font, with the same `font=` gate as
/// [`Self::apply_fontstring_font`].
/// [`Self::load_fontstring_level`].
fn apply_element_font(&mut self, el: &Element, wrapper: &Table, elem: usize, dbg: &str) {
let name = ["P", "H1", "H2", "H3"][elem];
if let Some(inherits) = el.attr("inherits").filter(|n| !n.is_empty()) {
+1 -1
View File
@@ -15,7 +15,7 @@
//! that mask. `+0x38` is not it; its high bits mark a held value, which the inheriting merge also
//! sets (`0x7709d3`).
//!
//! Not built: a `<Font inherits=…>` chain is flattened at load (`Loader::do_font`), so mutating
//! Not built: a `<Font inherits=…>` parent is copied at load (`Loader::do_font`), so mutating
//! `MasterFont` does not reach `GameFontNormal`, where the reference links it live: `LoadXML
//! 0x783c30` calls the `SetFontObject` link `0x770c60` for `inherits=` (`0x783ce6`) and `font=`
//! (`0x783d22`). No corpus addon mutates a declared font object.
+1 -1
View File
@@ -300,7 +300,7 @@ pub use quest::{
pub use quest_log::{QuestObjectiveView, QuestRecordView, QuestSlotView, QUEST_LOG_SLOTS};
pub(crate) use region::{
apply_font_parts, apply_wrap_attrs, implicit_creation_anchor_lua, install_loaded_texture,
load_texture_file,
load_texture_file, FileLoad,
};
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
pub use session::SessionRequest;
+1 -1
View File
@@ -440,7 +440,7 @@ mod layout;
mod paint;
mod text;
pub(crate) use paint::{load_texture_file, orientation};
pub(crate) use paint::{load_texture_file, orientation, FileLoad};
pub(crate) use text::store_text_of;
fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
+24 -4
View File
@@ -454,6 +454,17 @@ impl crate::script::UiScript {
}
}
/// What an XML `file=` load came to ([`load_texture_file`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FileLoad {
/// The host's texture probe found the file.
Loaded,
/// The probe did not: `0x770200` returned 0 and the texture kept what it had (`0x7702ac`).
Failed,
/// No probe is installed (a test harness): the path is stored, with no verdict.
Unprobed,
}
/// The XML `<Texture file=>` load of a region (`0x76fe20` → `0x770200`): the path form of
/// [`set_texture`] with the wrap flag the element's `<TexCoords>` decide.
pub(crate) fn load_texture_file(
@@ -461,9 +472,14 @@ pub(crate) fn load_texture_file(
this: &Table,
file: &str,
wrap: bool,
) -> mlua::Result<()> {
) -> mlua::Result<FileLoad> {
let probed = lua
.app_data_ref::<Model>()
.expect("model")
.texture_probe
.is_some();
let path = Value::String(lua.create_string(file)?);
set_texture(
let answer = set_texture(
lua,
this.clone(),
path,
@@ -471,8 +487,12 @@ pub(crate) fn load_texture_file(
Value::Nil,
Value::Nil,
wrap,
)
.map(drop)
)?;
Ok(match (probed, answer) {
(false, _) => FileLoad::Unprobed,
(true, Value::Nil) => FileLoad::Failed,
(true, _) => FileLoad::Loaded,
})
}
/// `Texture:SetTexture` (`0x79bb40`) and the XML `<Texture file=>` load (`0x76fe20`), the two
@@ -100,7 +100,7 @@ fn tablet_reads_the_tooltip_header_size_off_the_global() {
<Font name="GameTooltipText" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Font name="GameTooltipHeaderText" inherits="GameTooltipText">
<Font name="GameTooltipHeaderText" inherits="GameTooltipText" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="14"/></FontHeight>
</Font>
</Ui>"#,
@@ -126,7 +126,7 @@ fn tablet_reads_the_tooltip_header_size_off_the_global() {
assert_eq!((header, normal), (14.0, 12.0));
}
/// `inherits=` is flattened once, at load, where the reference links it live (`0x770c60`), and
/// `inherits=` is copied once, at load, where the reference links it live (`0x770c60`), and
/// the published object carries the flattened values.
#[test]
fn an_inheriting_font_still_flattens_to_the_same_values() {
@@ -139,7 +139,7 @@ fn an_inheriting_font_still_flattens_to_the_same_values() {
<Color r="1" g="1" b="1"/>
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
<Font name="DerivedFont" inherits="MasterFont" outline="NORMAL">
<Font name="DerivedFont" inherits="MasterFont" font="Fonts\FRIZQT__.TTF" outline="NORMAL">
<FontHeight><AbsValue val="18"/></FontHeight>
</Font>
</Ui>"#,
@@ -513,7 +513,9 @@ fn the_spacing_attribute_is_read_with_atof() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Font name="AtofFont" font="Fonts\FRIZQT__.TTF" spacing=" 3px"/>
<Font name="AtofFont" font="Fonts\FRIZQT__.TTF" spacing=" 3px">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Frame name="AtofHost">
<Layers><Layer level="ARTWORK">
<FontString name="AtofOwn" spacing="2.5em"/>
+1 -1
View File
@@ -303,7 +303,7 @@ pub struct FontShadow {
pub color: [f32; 4],
}
/// A named `<Font>` object's paint, flattened through its `inherits=` chain; a field the chain
/// A named `<Font>` object's paint, its own over what its `inherits=` parent had; a field the chain
/// never sets is `None`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FontObject {
+2 -2
View File
@@ -634,8 +634,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### loader
- A `<Script file=>` value with a separator joins the including document's directory, like a bare name. ([`src/loader/mod.rs:370`](../crates/benilla-ui/src/loader/mod.rs#L370))
- Padded to the block's line, so a raise reports a line a reader can find in the file; the reference counts from the body's first line. ([`src/loader/mod.rs:396`](../crates/benilla-ui/src/loader/mod.rs#L396))
- A `<Script file=>` value with a separator joins the including document's directory, like a bare name. ([`src/loader/mod.rs:429`](../crates/benilla-ui/src/loader/mod.rs#L429))
- Padded to the block's line, so a raise reports a line a reader can find in the file; the reference counts from the body's first line. ([`src/loader/mod.rs:455`](../crates/benilla-ui/src/loader/mod.rs#L455))
#### markup