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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, ®ion, 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, ®ion, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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, ®ion_wrapper, parent_name, dbg);
|
||||
} else {
|
||||
let attrs = FontAttrs::Own;
|
||||
self.load_fontstring_level(node, ®ion_wrapper, parent_name, attrs, true, dbg);
|
||||
}
|
||||
}
|
||||
if let Some(a) = region
|
||||
.attr("alpha")
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
{
|
||||
self.call_region(®ion_wrapper, "SetAlpha", a, dbg);
|
||||
}
|
||||
self.apply_region_layout(region, ®ion_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, ®ion_wrapper, dbg);
|
||||
}
|
||||
let loads = if is_texture {
|
||||
self.texture_file_loads(raw)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.apply_region_visual(region, ®ion_wrapper, is_texture, &loads, dbg);
|
||||
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion_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, ®ion_wrapper, parent_name, dbg, FontAttrs::Own);
|
||||
self.apply_fontstring_font(region, ®ion_wrapper, dbg);
|
||||
self.apply_region_visual(region, ®ion_wrapper, false, &[], dbg);
|
||||
for node in &levels {
|
||||
let attrs = FontAttrs::Own;
|
||||
self.load_fontstring_level(node, ®ion_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, ®ion_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>"),
|
||||
®ion.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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(®ion, "ClearAllPoints", (), dbg);
|
||||
self.apply_region_layout(bt, ®ion, 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, ®ion, dbg);
|
||||
for level in &label_levels {
|
||||
self.load_fontstring_level(level, ®ion, self_name, font_attrs, false, dbg);
|
||||
}
|
||||
self.apply_wrap_attrs(bt, ®ion, dbg);
|
||||
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion) {
|
||||
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()) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user