From 0fc40d3f878fa4de2783189ab50f7b34c7043bac Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:59:14 +0700 Subject: [PATCH] An XML state texture installs a fresh region, as in 1.12.1 (#940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 `` 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 --- crates/benilla-ui/src/loader/tests.rs | 217 ++++++++++++++++++++++++ crates/benilla-ui/src/loader/widgets.rs | 79 +++------ crates/benilla-ui/src/script/button.rs | 38 ++++- crates/benilla-ui/src/script/region.rs | 12 +- 4 files changed, 287 insertions(+), 59 deletions(-) diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index ab2b4f72..f1ff1575 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -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 `` 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"> + + + + "# + ) + }) + .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#" + + + {template} + + + + {instance} + + + + + + + + + "# + )); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + + for (tag, getter) in TAGS { + let got: (String, Vec, 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::(&format!( + "return (pcall(StateInstTmpl{tag}.GetTexture, StateInstTmpl{tag}))" + )) + .unwrap(), + "<{tag}>: the template's region is gone" + ); + } + assert_eq!( + s.eval::("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) = 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 = 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#" + + + "#, + ); + 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#" + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + let got: (bool, String, Vec, 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 { diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 96d4baf1..50456b61 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -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<'_> { /// ``'s `` (`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 `` 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 ``, the stock button kit's only - // form (`UIPanelButtonUpTexture`); it expands like a `` region. - let expanded = this.expand_region(raw); - let t = &expanded; - // The reference sends all four state textures through the `` texture adder - // (`0x6f26f0`, from `0x778903`), so a `` 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::(getter.as_str(), ()) { - if let (None, Some(c)) = (t.attr("file"), color) { - this.call_region(®ion, "SetTexture", (c[0], c[1], c[2], c[3]), dbg); - } - if let Some(mode) = t.attr("alphaMode") { - this.call_region(®ion, "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(®ion, "ClearAllPoints", (), dbg); - this.apply_region_layout(t, ®ion, self_name, dbg, FontAttrs::Own); - if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, ®ion) { - this.report - .errors - .push(format!("{dbg}: implicit anchor: {e}")); - } - if let Some(tc) = tex_coords_of(t) { - this.call_region(®ion, "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, ®ion); - 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, ®ion, 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, ®ion); } } }; @@ -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); diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 7c2a7566..1ac16921 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -360,6 +360,40 @@ fn install_slot( ) -> mlua::Result { let h = frame_handle_of(lua, this)?; let mut model = lua.app_data_mut::().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 `` +/// 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, +) -> mlua::Result { 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) } diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index b9303dee..c5f2a40d 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -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 ``, `` 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 ``, ``, 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), } }