An XML state texture installs a fresh region, as in 1.12.1 (#940)

An XML state texture on an instance over a template went through the Lua path leg, which reuses the populated region, so the template's TexCoords, size, tint, blend and anchors stayed on the new art.

In 1.12.1 `Button::LoadXML` (`0x7788c0`) builds a fresh region for each state tag through the `<Layers>` adder (`0x6f26f0`) and hands it to the slot store (`0x778fd0`, `0x779110`; the checked pair `0x785330`, `0x785440`), which destroys the region it held. A fresh region has TexCoords 0..1 and BLEND (`0x76fc40`, `0x76fc64`), and an XML highlight keeps its own blend (`0x7789a4`).

The XML leg now builds the region as a `<Layers>` texture and installs it, freeing the old one; the Lua leg is unchanged. An XML highlight with no `alphaMode` now draws BLEND.

Tests: all six tags on a CheckButton template, the name kept, the Lua leg's region kept.

Fixes #875

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 03:59:14 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3bfec89085
commit 0fc40d3f87
4 changed files with 287 additions and 59 deletions
+217
View File
@@ -2091,6 +2091,223 @@ mod loader_tests {
"blank, not painted"
);
}
/// `LoadXML` re-enters itself for the template before the instance (`0x76985c`), and each
/// state-texture element builds a new region through the `<Layers>` adder (`0x6f26f0`) that the
/// slot store installs, destroying the one it displaces (`0x778fd0` via `0x77900a`, the
/// highlight's `0x779110`, the checked pair's `0x785330`/`0x785440`). So an instance's element
/// keeps nothing of the template's: the fresh region's TexCoords, alpha and BLEND (`0x76fc40`;
/// the highlight keeps its own, `0x7789a4`), and `SetAllPoints` with no anchors (`0x7701c0`).
#[test]
fn an_instance_state_texture_replaces_the_templates() {
const TAGS: [(&str, &str); 6] = [
("NormalTexture", "GetNormalTexture"),
("PushedTexture", "GetPushedTexture"),
("DisabledTexture", "GetDisabledTexture"),
("HighlightTexture", "GetHighlightTexture"),
("CheckedTexture", "GetCheckedTexture"),
("DisabledCheckedTexture", "GetDisabledCheckedTexture"),
];
let template: String = TAGS
.iter()
.map(|(tag, _)| {
format!(
r#"<{tag} name="$parentTmpl{tag}" file="Interface\Tmpl{tag}" alpha="0.5" alphaMode="MOD">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="3" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</{tag}>"#
)
})
.collect();
let instance: String = TAGS
.iter()
.map(|(tag, _)| format!(r#"<{tag} file="Interface\Inst{tag}"/>"#))
.collect();
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(&format!(
r#"<Ui>
<CheckButton name="StateTmpl" virtual="true">
<Size><AbsDimension x="40" y="20"/></Size>
{template}
</CheckButton>
<CheckButton name="StateInst" inherits="StateTmpl">
<Anchors><Anchor point="CENTER"/></Anchors>
{instance}
</CheckButton>
<CheckButton name="LaidOutInst" inherits="StateTmpl">
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<NormalTexture file="Interface\Laid" alpha="0.25">
<Size><AbsDimension x="20" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</NormalTexture>
</CheckButton>
</Ui>"#
));
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
for (tag, getter) in TAGS {
let got: (String, Vec<f32>, String, f32, i64, String, bool, f32, f32) = s
.eval(&format!(
r#"local t = StateInst:{getter}()
local p, rel, rp, x, y = t:GetPoint(1)
return t:GetTexture(), {{ t:GetTexCoord() }}, t:GetBlendMode(), t:GetAlpha(),
t:GetNumPoints(), p .. ">" .. rp, rel == StateInst, x, y"#
))
.unwrap();
assert_eq!(
got,
(
format!("Interface\\Inst{tag}"),
vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
"BLEND".to_string(),
1.0,
2,
"TOPLEFT>TOPLEFT".to_string(),
true,
0.0,
0.0,
),
"<{tag}>: a fresh region, filling the button"
);
// The template's region was destroyed: its global is a dead handle, as the
// reference's unregister leaves it (`0x701cd0`).
assert!(
!s.eval::<bool>(&format!(
"return (pcall(StateInstTmpl{tag}.GetTexture, StateInstTmpl{tag}))"
))
.unwrap(),
"<{tag}>: the template's region is gone"
);
}
assert_eq!(
s.eval::<i64>("return table.getn({ StateInst:GetRegions() })")
.unwrap(),
TAGS.len() as i64,
"one region per slot, none of the template's left over"
);
// An instance element's own size, anchors and alpha lay the fresh region out.
let laid: (i64, String, f32, f32, f32, Vec<f32>) = s
.eval(
r#"local t = LaidOutInst:GetNormalTexture()
return t:GetNumPoints(), (t:GetPoint(1)), t:GetWidth(), t:GetHeight(),
t:GetAlpha(), { t:GetTexCoord() }"#,
)
.unwrap();
assert_eq!(
laid,
(
1,
"CENTER".to_string(),
20.0,
16.0,
0.25,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]
)
);
s.resolve();
let drawn: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect();
assert!(
!drawn.iter().any(|p| p.contains("Tmpl")),
"no template art left drawing: {drawn:?}"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An instance's state texture named as its template's carries the name: `GetName()` reads the
/// region's own (`SetName 0x76c650`, from `0x76fdf0`). The global stays the first holder's, as
/// `0x701bd0` publishes only into an empty one (`0x701c8a`), so it is the destroyed template
/// region's dead handle.
#[test]
fn a_same_named_instance_state_texture_keeps_its_name() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="NamedTmpl" virtual="true">
<Size><AbsDimension x="40" y="20"/></Size>
<NormalTexture name="$parentNormal" file="Interface\Tmpl"/>
</Button>
<Button name="NamedInst" inherits="NamedTmpl">
<Anchors><Anchor point="CENTER"/></Anchors>
<NormalTexture name="$parentNormal" file="Interface\Inst"/>
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let got: (String, String, bool) = s
.eval(
r#"local t = NamedInst:GetNormalTexture()
return tostring(t:GetName()), t:GetTexture(),
(pcall(NamedInstNormal.GetTexture, NamedInstNormal))"#,
)
.unwrap();
assert_eq!(
got,
(
"NamedInstNormal".to_string(),
"Interface\\Inst".to_string(),
false
)
);
}
/// The Lua path leg (`0x781970` → `0x781b3f` → `0x778f10`) only swaps the file of a slot that
/// holds a region (`0x770200`): the region, its TexCoords, blend, alpha and anchors stay.
#[test]
fn a_lua_path_on_a_loaded_state_texture_keeps_the_region() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="PathBtn">
<Size><AbsDimension x="40" y="20"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<NormalTexture file="Interface\Before" alphaMode="MOD">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</NormalTexture>
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let got: (bool, String, Vec<f32>, String, f32, i64, String) = s
.eval(
r#"local held = PathBtn:GetNormalTexture()
held:SetAlpha(0.5)
PathBtn:SetNormalTexture("Interface\\After")
local t = PathBtn:GetNormalTexture()
return t == held, t:GetTexture(), { t:GetTexCoord() }, t:GetBlendMode(),
t:GetAlpha(), t:GetNumPoints(), (t:GetPoint(1))"#,
)
.unwrap();
assert_eq!(
got,
(
true,
"Interface\\After".to_string(),
vec![0.25, 0.25, 0.25, 0.75, 0.75, 0.25, 0.75, 0.75],
"MOD".to_string(),
0.5,
1,
"TOPLEFT".to_string(),
)
);
}
}
mod layer_blend_tests {
+25 -54
View File
@@ -4,9 +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, tex_coords_of, Loader,
};
use super::{abs_dim, abs_value, children_named, children_named_any, color_of, Loader};
impl Loader<'_> {
/// `<ScrollFrame>`'s `<ScrollChild>` (`LoadXML 0x786bc0`): after the base load (`0x769820`) it
@@ -106,52 +104,25 @@ impl Loader<'_> {
{
return;
}
let tex = |tag: &str, method: &str, this: &mut Self| {
// Each state-texture element is a new region from the `<Layers>` texture adder (`0x6f26f0`,
// `0x778903`): named, laid out, painted and given its implicit all-points as any layer
// texture is, then handed to the slot store, which destroys the region it displaces. An
// instance's element over its template's therefore starts fresh: `LoadXML` runs the
// template's level first (`0x76985c`), and nothing of that region survives the instance's.
// The Lua path leg differs: it repaints the slot's region and keeps its state (`0x778f10`).
let tex = |tag: &str, this: &mut Self| {
for raw in children_named(el, tag) {
// A state texture may inherit a virtual `<Texture>`, the stock button kit's only
// form (`UIPanelButtonUpTexture`); it expands like a `<Layers>` region.
let expanded = this.expand_region(raw);
let t = &expanded;
// The reference sends all four state textures through the `<Layers>` texture adder
// (`0x6f26f0`, from `0x778903`), so a `<Color>` is the region's own colour fill; the
// Lua setter has no colour form (`0x781970`). `""` creates the slot unpainted.
let color = children_named(t, "Color").next().map(color_of);
match t.attr("file") {
Some(file) => this.call(wrapper, method, file.to_string(), dbg),
None => this.call(wrapper, method, String::new(), dbg),
}
let getter = method.replacen("Set", "Get", 1);
if let Ok(region) = wrapper.call_method::<Table>(getter.as_str(), ()) {
if let (None, Some(c)) = (t.attr("file"), color) {
this.call_region(&region, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
}
if let Some(mode) = t.attr("alphaMode") {
this.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
// The setter gave the region the runtime path's implicit anchors, but the XML
// path places the authored ones first and the implicit step after (`0x778903`):
// clear, lay out, then re-run it, or a leftover implicit corner would pin it.
this.call_region(&region, "ClearAllPoints", (), dbg);
this.apply_region_layout(t, &region, self_name, dbg, FontAttrs::Own);
if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, &region) {
this.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
}
if let Some(tc) = tex_coords_of(t) {
this.call_region(&region, "SetTexCoord", tc, dbg);
}
// A named state texture is published both ways: the region registry for its
// `GetName()`, `_G` for `getglobal` and a sibling's `relativeTo`.
if let Some(rname) = t.name().map(|raw| framexml::resolve_name(raw, self_name))
{
crate::script::region::publish_region_name(this.lua(), &rname, &region);
if let Err(e) = this.lua().globals().set(rname.clone(), region) {
this.report
.warnings
.push(format!("{dbg}: state-texture global '{rname}': {e}"));
}
}
let Some(region) =
this.build_texture_child(raw, wrapper, self_name, "ARTWORK", dbg)
else {
continue; // build_texture_child already reported why
};
this.install_texture(wrapper, tag, &region, None, dbg);
// Freeing the displaced region took its name out of the registry `GetName()`
// reads, which a replacement of the same name takes back: the reference keeps the
// name on the region (`0x76c650`). `_G` keeps its first holder (`0x701c8a`).
if let Some(rname) = raw.name().map(|n| framexml::resolve_name(n, self_name)) {
crate::script::region::publish_region_name(this.lua(), &rname, &region);
}
}
};
@@ -207,13 +178,13 @@ impl Loader<'_> {
}
}
}
tex("NormalTexture", "SetNormalTexture", self);
tex("PushedTexture", "SetPushedTexture", self);
tex("DisabledTexture", "SetDisabledTexture", self);
tex("HighlightTexture", "SetHighlightTexture", self);
tex("NormalTexture", self);
tex("PushedTexture", self);
tex("DisabledTexture", self);
tex("HighlightTexture", self);
if is_check {
tex("CheckedTexture", "SetCheckedTexture", self);
tex("DisabledCheckedTexture", "SetDisabledCheckedTexture", self);
tex("CheckedTexture", self);
tex("DisabledCheckedTexture", self);
if let Some(c) = el.attr("checked") {
let checked = c.eq_ignore_ascii_case("true") || c == "1";
self.call(wrapper, "SetChecked", checked, dbg);
+36 -2
View File
@@ -360,6 +360,40 @@ fn install_slot(
) -> mlua::Result<bool> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_slot_model(&mut model, h, slot, incoming)
}
/// The loader's install of the texture a state-texture element built through the `<Layers>`
/// adder (`0x6f26f0`), never through a Lua setter: `Button::LoadXML` hands each new region to the
/// slot store (`0x77890e`, `0x778940`, `0x778972`; the highlight's `0x7789a9`; the checked pair's
/// `0x7851dd`, `0x78520e`), which destroys whatever the slot held, an inherited template's
/// included, so a handle still held on it goes dead (`0x701cd0` clears its `[0]`). The highlight's
/// store sets the region's own blend (`0x7789a4`), so it keeps it.
pub(super) fn install_loaded(
model: &mut Model,
h: FrameHandle,
tag: &str,
rh: RegionHandle,
) -> mlua::Result<()> {
let slot = match tag {
"NormalTexture" => Slot::Normal,
"PushedTexture" => Slot::Pushed,
"DisabledTexture" => Slot::Disabled,
"HighlightTexture" => Slot::Highlight,
"CheckedTexture" => Slot::Checked,
"DisabledCheckedTexture" => Slot::DisabledChecked,
_ => return Err(mlua::Error::runtime(format!("not a Button texture: {tag}"))),
};
install_slot_model(model, h, slot, Some(rh)).map(|_| ())
}
/// [`install_slot`] on the model.
fn install_slot_model(
model: &mut Model,
h: FrameHandle,
slot: Slot,
incoming: Option<RegionHandle>,
) -> mlua::Result<bool> {
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Button(_))
@@ -368,7 +402,7 @@ fn install_slot(
}
let writes = std::cell::RefCell::new(crate::widget::ShownWrites::default());
let changed = super::region::install_into_slot(
&mut model,
model,
h,
incoming,
slot.shape().1,
@@ -384,7 +418,7 @@ fn install_slot(
}
},
)?;
super::region::apply_shown_writes(&mut model, writes.into_inner());
super::region::apply_shown_writes(model, writes.into_inner());
Ok(changed)
}
+9 -3
View File
@@ -210,9 +210,9 @@ pub(super) fn set_texture_path(model: &mut Model, rh: RegionHandle, path: &str)
model.touch_layout_art(rh);
}
/// The loader's install of the texture a `<BarTexture>`, `<ThumbTexture>` or ColorSelect texture
/// element (`tag`) built, as the widget's object, never through a Lua setter. `layer` is the
/// element's `drawLayer` for the bar and the thumb.
/// The loader's install of the texture a `<BarTexture>`, `<ThumbTexture>`, Button state-texture or
/// ColorSelect texture element (`tag`) built, as the widget's object, never through a Lua setter.
/// `layer` is the element's `drawLayer` for the bar and the thumb.
pub(crate) fn install_loaded_texture(
lua: &Lua,
this: &Table,
@@ -227,6 +227,12 @@ pub(crate) fn install_loaded_texture(
match tag {
"BarTexture" => super::statusbar::install_loaded(&mut model, h, rh, layer),
"ThumbTexture" => super::slider::install_loaded(&mut model, h, rh, layer),
"NormalTexture"
| "PushedTexture"
| "DisabledTexture"
| "HighlightTexture"
| "CheckedTexture"
| "DisabledCheckedTexture" => super::button::install_loaded(&mut model, h, tag, rh),
_ => super::colorselect::install_loaded(&mut model, h, tag, rh),
}
}