Files
benilla-octo/crates/benilla-ui/src/script/region.rs
T
SamandClaude Fable 5 7146bc91f0 the login scene frames its art, char select remembers, DXT stays DXT
- the glue scene widens only as far as its art goes: none of the seven login dioramas is drawn wider than about 3:2 — measured per texel by `benilla-extract glueextent`, which rasterises every batch into the camera's own tan-space, painting where the reference's alpha-key test passes — so the frame zooms down to the reference's own 16:9 opening (never tighter) and pillarboxes past the aspect the art cannot fill, instead of showing the clear colour past the backdrop's slanted edges; 4:3 stays byte-identical
- the character screen opens on whoever you last logged in as: the reference's `lastCharacterIndex` CVar, written at Enter World and only there — clicks and arrow keys never touch it, which is what makes the value mean "last logged in" rather than "last clicked" — restored on every character-list rebuild and clamped into range
- spam-hovering tooltips stops costing 2 ms: two write sites gave up naming their node and re-derived the whole layout graph (1,812 µs against the ledger's 44, on 919 of 1,262 resolves) — the wrap pin now touches the one region it wrote, and re-pointing a tooltip at the button under the cursor retargets the node's edges in place instead of throwing the cached graph away
- two corrections off the project's first GPU-bound report (a Steam Deck at 94% GPU busy on 8.4 W of a 15 W budget, the memory-stall signature): `farclip` ships the reference's registered 350 instead of the clamp's 777 — the value also sizes terrain residency, so 777 streamed, held and drew 4.8× the area — and BLP textures upload WoW's own DXT blocks verbatim the way the reference does, instead of decoding to RGBA8 at 8× the bytes of DXT1; the terrain slider still reaches 777, and the lanes that read texels on the CPU (terrain arrays, the character-skin compositor) stay decoded and say why

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:11:59 +07:00

800 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The **region** side of the object model (RF-0023's distinct-tag leaves): the `Texture`/
//! `FontString` wrapper cache, their shared metatable, and the region method surface. Split from
//! [`super::object`] (which keeps the frame side + `CreateFrame`) so each grows along its own
//! axis — frames grow per-kind method tables ([`super::statusbar`], [`super::button`]), regions
//! grow paint/coords methods here.
use mlua::{Lua, Table, Value};
use super::object::{
anchor_bits_eq, anchor_retarget_is_structural, as_f32, decode_id, id_to_lud, point_from_str,
};
use super::{
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
REG_TEXTURE_META, REG_TEXTURE_METHODS, REG_TITLE_META, REG_TITLE_METHODS, REG_WRAPPERS, SCREEN,
};
use crate::layout::Anchor;
use crate::widget::{RegionHandle, RegionKind};
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result<RegionHandle> {
let id = decode_id(this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.id_to_region
.get(&id)
.copied()
.ok_or_else(|| mlua::Error::runtime("stale or invalid region handle"))
}
/// Apply the font parts an **XML element** supplies — `font=`, `<FontHeight>`, `outline=` — any
/// subset of which may be absent.
///
/// **This is deliberately not the `SetFont` binding, and that separation is the point.** The
/// reference applies XML font attributes in C++ (`LoadXML`), never through the Lua method, and its
/// `SetFont` therefore *requires* both a path and a height, raising
/// `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`) without them. Our loader used to
/// call the binding with three `Option`s — `SetFont(nil, nil, "OUTLINE")` for an outline-only
/// `<FontString>` — which is why that binding could not be made faithful: one name was doing two
/// jobs, and the more lenient job won. Splitting them lets `SetFont` be the reference's `SetFont`
/// and lets the loader keep the partial application XML actually needs.
///
/// Each part supplied is an **explicit** set (`FontExplicit`), so it survives a later mutation of
/// the font object this region inherits. An empty `font=` is treated as absent — it keeps the
/// inherited face, rather than being the binding's load-failure edge.
pub(crate) fn apply_font_parts(
lua: &Lua,
this: &Table,
path: Option<String>,
height: Option<f32>,
flags: Option<String>,
) -> mlua::Result<()> {
let rh = region_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
if let Some(p) = path.filter(|p| !p.is_empty()) {
d.font_path = Some(p);
d.font_explicit.face = true;
}
if let Some(h) = height {
d.font_height = Some(h);
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = super::Outline::flags(&f);
d.font_explicit.outline = true;
}
model.touch_measure(rh);
Ok(())
}
/// Get-or-create the wrapper table for a region id (distinct metatable — the region "tag").
pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
let wrappers: Table = lua.named_registry_value(REG_WRAPPERS)?;
if let Value::Table(t) = wrappers.get::<Value>(id)? {
return Ok(t);
}
let t = lua.create_table()?;
t.raw_set(0, Value::LightUserData(id_to_lud(id)))?;
// **A title region gets a NARROWER metatable, chosen here rather than per lookup.** wow-re Q6
// carves the object as answering *exactly* the 19 Region methods — no Show/Hide, no textures,
// no text — and this cache is created once per region, so picking the table at construction
// costs nothing on the call path (dispatching inside `__index` would put a model borrow and a
// kind lookup in front of EVERY region method call in the UI).
let kind = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.id_to_region
.get(&id)
.and_then(|rh| model.arena.region(*rh))
.map(|r| r.kind)
};
let meta: Table = lua.named_registry_value(match kind {
Some(RegionKind::Texture) => REG_TEXTURE_META,
Some(RegionKind::FontString) => REG_FONTSTRING_META,
Some(RegionKind::Title) => REG_TITLE_META,
// A wrapper for an id with no live region: the full table, which is what this cache did for
// every region before the leaves were split. Nothing can call through it — every method
// resolves the handle first and raises on a dead one.
None => REG_REGION_META,
})?;
t.set_metatable(Some(meta))?;
wrappers.set(id, t.clone())?;
Ok(t)
}
/// Install the region method table + the shared region metatable.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_region_methods(lua)?;
// SetPortraitTexture(textureRegion, unit) — the live API's **global** (ref-UnitFrame.lua:
// `SetPortraitTexture(this.portrait, this.unit)`), distinct from the region-method
// `SetPortraitToTexture(path)` icon crop. It binds a Texture region to a unit token; the app
// renders that unit's model off-screen and feeds the bake back through the region's
// [`super::QuadContent::Texture::portrait_unit`], with `circular` marking the round stencil
// (the bake is square with an opaque backdrop; the frame-ring portraits cut the inscribed
// circle, exactly what the app's quad shader does with the flag). `texture`/`color` drop —
// the bake replaces them. A later `SetTexture`/`SetPortraitToTexture` clears the binding.
lua.globals().set(
"SetPortraitTexture",
lua.create_function(|lua, (region, unit): (Table, String)| {
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
data.portrait_unit = Some(unit);
data.texture = None;
data.fill = None;
data.circular = true;
Ok(())
})?,
)?;
// SetPortraitToTexture(textureName, path) — the ENGINE GLOBAL, which is what 1.12 has.
//
// `reference/1.12-globals.tsv` marks it `engine`, and the reference's own two call sites both
// pass a texture NAME: `ContainerFrame.lua:419` writes
// `SetPortraitToTexture(frame:GetName().."Portrait", "…KeyRing-Bag-Icon")` and
// `MailFrame.lua:174` writes `SetPortraitToTexture("OpenMailFrameIcon", stationeryIcon)`.
// **That first one matters here: we SOURCE `ContainerFrame.lua` off the patch chain, so the
// client's own file calls this global inside our VM.**
//
// A NAME, not a region handle — strictly what the reference's callers are attested to pass.
// Whether the real binding also accepts a texture object is not carved, so it is not accepted
// here: inventing the wider signature is how a superset starts (1189), and nothing needs it —
// both of our own callers already hold the name.
//
// The behaviour is the crop it was always: set the texture AND mark the region a portrait, so
// it draws masked to its inscribed circle. The client's portraits are circular and the frame
// ring is a thin band whose transparent corners would otherwise show the square texture's
// edges. Distinct from `SetPortraitTexture(region, unit)`'s live model bake, so it drops any
// live-unit binding; a later `SetTexture` clears the flag again.
lua.globals().set(
"SetPortraitToTexture",
lua.create_function(|lua, (name, path): (String, String)| {
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some(id) = model.region_names.get(&name).copied() else {
return Ok(());
};
let Some(rh) = model.id_to_region.get(&id).copied() else {
return Ok(());
};
let data = model.region_data.entry(rh).or_default();
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
Ok(())
})?,
)?;
// BenillaSetBoothTexture(textureRegion, slotToken) — the **square** twin of
// SetPortraitTexture (decision 0208 §5): the same `portrait_unit` booth-image binding
// WITHOUT the circular mask, for the paper doll's rectangular model pane (its texture region
// samples the booth's body bake edge to edge — no frame ring to mask for). Benilla-named:
// the real client's pane is a live 3D `<PlayerModel>`; ours is the doctrine-consistent
// still (0105/0118), so the binding is ours, not the live API's.
lua.globals().set(
"BenillaSetBoothTexture",
lua.create_function(|lua, (region, token): (Table, String)| {
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
data.portrait_unit = Some(token);
data.texture = None;
data.fill = None;
data.circular = false;
Ok(())
})?,
)?;
let region_meta = lua.create_table()?;
let region_index = lua.create_function(|lua, (_this, key): (Table, Value)| {
let methods: Table = lua.named_registry_value(REG_REGION_METHODS)?;
methods.get::<Value>(key)
})?;
region_meta.set("__index", region_index)?;
lua.set_named_registry_value(REG_REGION_META, region_meta)?;
Ok(())
}
mod layout;
mod paint;
mod text;
fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// GetName() → this region's global name, or nil when it was declared anonymously — the pair
// the frame side already answers. Real FrameXML round-trips a region through its name wherever
// it can't hold a reference to itself: `ComboFrame.lua`'s shine chain hands `frame:GetName()`
// to a fade `finishedFunc` and `getglobal`s it back ("hack since a frame can't have a
// reference to itself in it" — its own comment).
//
// Resolution is [`region_name_of`], shared with `IsObjectType`'s `Usage:` text so the two can
// never disagree about what this region is called.
m.set(
"GetName",
lua.create_function(|lua, this: Table| {
let id = decode_id(&this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
match region_name_of(&model, id) {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── GetObjectType / IsObjectType: the last two of the Region map (1244 §4 closed) ───────────
//
// 1244 shipped four of the six missing Region-map members and deliberately left these two
// DISPATCHED rather than guessed, because every interesting detail is one a plausible
// implementation gets wrong. wow-re answered (§5 trio + byte cross-check,
// `system/ui/scratch/widget-type-identity.md`), and every one of those details is below.
//
// `GetObjectType` is a per-class `.data` `const char*` read through `vtable[+0x1c]` — Texture
// `0x773480` → `"Texture"`, FontString `0x7735d0` → `"FontString"` — pushed with
// `lua_pushstring`, exactly one value, extra arguments ignored with no arity check.
m.set(
"GetObjectType",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(region_type_name(&model, rh))
})?,
)?;
// `IsObjectType(name)` — binding `0x7a1290`. Four traps, all verified, all here:
//
// · **Case-INSENSITIVE, whole-string.** `vtable[+0x18]` → `SStrCmpI 0x64a4c0` → `_strnicmp`,
// which folds both operands before comparing and breaks at the first NUL on either side —
// so no prefix or substring match either. (The *method-name* lookup `0x702000` uses the
// case-SENSITIVE sibling; the two are easy to conflate and behave differently.)
// · **A short, hardcoded chain — and there is no root type.** Texture answers `"Texture"` and
// `"Region"`; FontString answers `"FontString"` and `"Region"`. **1.12.1 has no
// `"LayoutFrame"`, `"ScriptObject"` or `"Object"` type at all** — those strings exist only
// inside `__FILE__` paths and allocator tags — so `tex:IsObjectType("LayoutFrame")` is nil.
// That is the single most likely thing to invent from knowing later clients.
// · **A hit is the NUMBER 1 and a miss is nil — never a boolean**, read off the pushed tags
// (`lua_pushnumber` tag 3 vs `lua_pushnil` tag 0; tag 1 is never written), and exactly one
// value on both paths.
// · **A bad argument RAISES.** The gate is `lua_isstring`, which accepts strings and NUMBERS
// only; anything else (missing, nil, boolean, table, function, userdata) hits
// `luaL_error(L, "Usage: %s:IsObjectType(\"TYPE\")")` with `%s` the region's name or
// `<unnamed>`, and that longjmps rather than returning. A number is ACCEPTED and stringified
// in place, so `tex:IsObjectType(5)` compares against `"5"` and quietly answers nil — we
// format it and compare for real rather than short-circuiting, though no type name is
// numeric so the answer is nil either way.
m.set(
"IsObjectType",
lua.create_function(|lua, (this, want): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let want = match &want {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(n) => n.to_string(),
Value::Integer(i) => i.to_string(),
_ => {
let who = region_name_of(&model, decode_id(&this)?)
.unwrap_or_else(|| "<unnamed>".to_string());
return Err(mlua::Error::runtime(format!(
"Usage: {who}:IsObjectType(\"TYPE\")"
)));
}
};
let leaf = region_type_name(&model, rh);
let hit = want.eq_ignore_ascii_case(leaf) || want.eq_ignore_ascii_case("Region");
Ok(if hit { Value::Number(1.0) } else { Value::Nil })
})?,
)?;
// SetParent(frame) — **a Texture/FontString really does have this**, and we were the ones
// missing it. `SetParent` lives in the REGION method table (`0x7a1550`); Texture's class lookup
// falls back to Region's at `0x79c650` and FontString's at `0x79ee50`, so both reach it (wow-re
// `system/ui/scratch/widget-api-batch-benilla.md` Q7, §5-verified). `FuBar_FuXPFu.lua:210`'s
// `self.Spark:SetParent(self.XPBar)` is not a broken addon.
//
// Four contract traps, each spelled out because each is a plausible implementation's silent
// divergence:
//
// · **The argument must be a FRAME.** `0x7a16ea` runs `IsA(FrameTag)` on it, and a Texture or
// Region argument raises `"…Wrong parent object type, expected frame"` (`0x87cb78`). Ours
// raises too — a re-parent that quietly did nothing is the silent-drop class of 1203/1205.
// · **A missing argument is NOT the nil form.** TNONE fails the `== LUA_TNIL` test and falls
// through to `"…Couldn't find region named '%s'"` (`0x87cb48`), so `tex:SetParent()` raises
// while `tex:SetParent(nil)` detaches. That is why this reads a `MultiValue`: mlua cannot
// tell an absent argument from an explicit nil any other way.
// · **Anchors are untouched.** The re-link moves draw-layer/region-list membership only; a
// re-parented Texture still anchors to whatever `SetPoint` named — which is why FuXPFu
// re-points its spark afterwards, and why our anchors (which store the resolved target id)
// need no fixing up at all.
// · **Zero return values**, on every path.
//
// The mechanism half — full re-link, layer and sub-level preserved, `nil` = orphaned but not
// destroyed — is [`crate::widget::WidgetArena::set_region_owner`]'s doc.
m.set(
"SetParent",
lua.create_function(|lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let Some(Value::Table(this)) = it.next() else {
return Err(mlua::Error::runtime("SetParent: expected a region"));
};
let rh = region_handle_of(lua, &this)?;
let Some(parent) = it.next() else {
return Err(mlua::Error::runtime(
"SetParent(): Couldn't find region named '' (no argument)",
));
};
let wrong_type =
|| mlua::Error::runtime("SetParent(): Wrong parent object type, expected frame");
let mut model = lua.app_data_mut::<Model>().expect("model");
let new_owner = match &parent {
Value::Nil => None,
// A region/font-object wrapper decodes to an id that is not a frame's (or to no id
// at all) — both land on the same "expected frame" the reference raises.
Value::Table(t) => Some(
decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied())
.ok_or_else(wrong_type)?,
),
// A name resolves through the frame registry, as every other frame-target argument
// does (`SetPoint`'s relativeTo, `SetParent` on the frame side).
Value::String(s) => {
let name = s.to_str()?;
Some(model.arena.lookup(name.as_ref()).ok_or_else(|| {
mlua::Error::runtime(format!(
"SetParent(): Couldn't find region named '{}'",
name.as_ref()
))
})?)
}
_ => return Err(wrong_type()),
};
if model.arena.set_region_owner(rh, new_owner) {
// An un-anchored region draws relative to its owner, and an anchored one resolves
// against the owner's rect and effective scale (`layout.rs`'s region sweep) — the
// owner is a layout input, so a re-link is a layout change.
model.touch_layout();
}
Ok(())
})?,
)?;
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
// ref kit hides tab slices, cooldown swipes, money coins…). A hidden region draws nothing;
// IsVisible additionally requires the owner frame's effective visibility, mirroring the
// frame-side pair.
m.set(
"Show",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().hidden = false;
Ok(())
})?,
)?;
m.set(
"Hide",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().hidden = true;
Ok(())
})?,
)?;
m.set(
"IsShown",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
Ok(if shown { Value::Integer(1) } else { Value::Nil })
})?,
)?;
m.set(
"IsVisible",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
let owner_visible = model
.arena
.region(rh)
.and_then(|r| model.arena.frame(r.owner))
.is_some_and(|f| f.effective_visible);
Ok(if shown && owner_visible {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// The three clusters this file was split into (0716's budget). Order is immaterial —
// every one of them only writes into the same method table.
paint::install(lua, &m)?;
text::install(lua, &m)?;
layout::install(lua, &m)?;
// ── The title region's own, narrower table ──────────────────────────────────────────────────
//
// 1250 §5 recorded this as a named divergence: our region metatable was shared, so a title
// region answered `SetTexture`/`Show`/`Hide` where the reference raises `attempt to call
// method`. Inert — the kind never draws — but a **superset in PRESENCE**, and 1189 is the
// record of what a superset costs when an addon feature-detects.
//
// Closed by copying exactly the Region map (the 19 names 1244/1245 landed and assert as a set)
// out of the full table. It is a copy rather than a second install because the two must never
// disagree about what, say, `GetPoint` does — one implementation, two visibilities.
//
// **The Texture/FontString superset is NOT closed here, deliberately.** They still share one
// table, so a Texture answers `SetText` and a FontString answers `SetTexture`. Splitting those
// needs the per-table membership facts, and the naive partition is WRONG: `paint.rs` installs
// `SetDrawLayer`/`SetVertexColor`/`SetAlpha`/`SetAlphaGradient`, all of which the font carve
// says a FontString legitimately has. A wrong split REMOVES verbs addons use, which is worse
// than the superset it fixes — so that half waits for the membership read (1238's shape).
let title = lua.create_table()?;
for name in super::REGION_MAP_METHODS {
let f: Value = m.get(name)?;
title.set(name, f)?;
}
// ── The two LEAF tables (wow-re `texture-fontstring-method-split.md`) ───────────────────────
//
// Texture's map is `0x87c128` (22 entries, lookup `0x79c620`), FontString's is `0xcf5400` (32,
// lookup `0x79ee20`); both tail-call the Region map and stop there — no third table. Until now
// ours was ONE table for both, so a Texture answered `SetText` and a FontString answered
// `SetTexture`: a superset in both directions.
//
// **Partitioned, not pruned.** Every name we install keeps a home; what changes is which leaf
// can see it. Removing the five names that are in NEITHER client map (`SetPortraitToTexture`,
// `SetRotation`, `SetSize`, `SetFormattedText`, `GetStringHeight`) is a separate question per
// name — and getting a split wrong REMOVES verbs addons use, which is worse than the superset
// it fixes.
//
// Copied out of the full table rather than installed twice, so one implementation stands behind
// both visibilities — and note the carve's warning that the shared names use the IDENTICAL
// `const char*` in the client's two tables, so de-duplicating by name would drop one side.
for (key, extra) in [
(REG_TEXTURE_METHODS, &super::TEXTURE_ONLY_METHODS[..]),
(REG_FONTSTRING_METHODS, &super::FONTSTRING_ONLY_METHODS[..]),
] {
let leaf = lua.create_table()?;
for name in super::REGION_MAP_METHODS
.iter()
.chain(super::REGION_LEAF_SHARED.iter())
.chain(extra.iter())
{
let f: Value = m.get(*name)?;
leaf.set(*name, f)?;
}
lua.set_named_registry_value(key, leaf)?;
}
for (meta_key, methods_key) in [
(REG_TEXTURE_META, REG_TEXTURE_METHODS),
(REG_FONTSTRING_META, REG_FONTSTRING_METHODS),
] {
let meta = lua.create_table()?;
let index = lua.create_function(move |lua, (_this, key): (Table, Value)| {
let methods: Table = lua.named_registry_value(methods_key)?;
methods.get::<Value>(key)
})?;
meta.set("__index", index)?;
lua.set_named_registry_value(meta_key, meta)?;
}
let title_meta = lua.create_table()?;
let title_index = lua.create_function(|lua, (_this, key): (Table, Value)| {
let methods: Table = lua.named_registry_value(REG_TITLE_METHODS)?;
methods.get::<Value>(key)
})?;
title_meta.set("__index", title_index)?;
lua.set_named_registry_value(REG_TITLE_METHODS, title)?;
lua.set_named_registry_value(REG_TITLE_META, title_meta)?;
lua.set_named_registry_value(REG_REGION_METHODS, m)?;
Ok(())
}
/// The layout [`super::layout::Handle`] a region anchors to by default: its **owner frame**'s id
/// (minted if needed), or [`SCREEN`] if the region has somehow lost its owner.
/// This region's global name, or `None` when it was declared anonymously.
///
/// Scans the region-name registry rather than mirroring the name onto the region: that registry is
/// the single authority (the widget arena deliberately holds none), and a second copy is one more
/// thing to drift. Linear in NAMED regions, and every caller is human-rate.
pub(super) fn region_name_of(model: &Model, id: u32) -> Option<String> {
model
.region_names
.iter()
.find(|&(_, &v)| v == id)
.map(|(k, _)| k.clone())
}
/// Publish a region's global name into the **region-name registry** — the table a sibling's
/// `SetPoint`/`SetAllPoints` resolves a named `relativeTo` through
/// ([`super::object::layout_methods`]). First-wins, the same rule frames follow.
///
/// `CreateTexture(name)` does this for itself; the paths that *don't* are the typed widgets' own
/// sub-textures, which are created by a setter (`SetThumbTexture`, `SetColorWheelTexture`, …) and
/// named afterwards by the XML loader. Without this they land in `_G` and nowhere else, and a
/// `relativeTo="ColorPickerWheel"` on a sibling silently falls back to the parent frame — which is
/// exactly how the colour picker's value strip first ended up 224 px to the right of where the
/// reference puts it, anchored to the window instead of to the wheel.
pub(crate) fn publish_region_name(lua: &Lua, name: &str, region: &Table) {
let Ok(id) = super::object::decode_id(region) else {
return;
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_names.entry(name.to_string()).or_insert(id);
}
/// The string `GetObjectType()` answers for this region, and the leaf `IsObjectType` matches.
///
/// The reference reads a per-class `.data` `const char*` through `vtable[+0x1c]`; ours reads the
/// arena's [`RegionKind`], which is the same fact stored once. A handle whose region has been
/// destroyed answers `"Region"` — the base every leaf also matches, so an identity question about
/// a dead handle degrades to the truthful half rather than naming a leaf it no longer is.
pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str {
match model.arena.region(rh).map(|r| r.kind) {
Some(RegionKind::Texture) => "Texture",
Some(RegionKind::FontString) => "FontString",
// The title region's own type name — it is a Region and nothing more (Q6).
Some(RegionKind::Title) => "Region",
None => "Region",
}
}
/// Free one region and **every trace of its identity** — the arena slot, its paint, its resolved
/// rect, its name registration, and the stable id both directions.
///
/// Anything that had fetched its wrapper now resolves to a stale-handle error, which is the honest
/// answer for an object the widget destroyed. That matters because the client really does destroy:
/// `CSimpleButton::SetFontString 0x778d20` runs the *scalar deleting* destructor on the label it
/// replaces (`old->vtable[0](1)`, returning the storage to the FontString pool), so a caller
/// holding the old string is holding a freed object — orphaning it instead would leave a live,
/// parentless label that still answers every getter, which is a different and quieter bug.
///
/// One law, two callers ([`super::simplehtml`]'s block free and the button's label swap); it lives
/// here because the region side owns region lifetime, and because two hand-written copies of a
/// five-map teardown is how one of them ends up forgetting a map.
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
// A NAMED region also holds a slot in the name registry — anonymous ones (every SimpleHTML
// block, most labels) do not, which is why the first caller of this law never needed it.
model.region_names.retain(|_, v| *v != id);
}
model.arena.destroy_region(rh);
// A death is the archetypal STRUCTURAL change — it takes a node out of the layout roster and
// its reverse edges with it, which is exactly what a per-node ledger cannot describe
// (decision 1388).
model.touch_layout();
}
pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
match model.arena.region(rh).map(|r| r.owner) {
Some(owner) => model.frame_id(owner),
None => SCREEN,
}
}
/// The client's **creation-path implicit anchor** (wow-re `system/ui/scratch/`
/// `region-implicit-anchor.md`, §5 VERIFIED; decision 1310): a per-region-type post-step the real
/// engine runs immediately after a region's LoadXML returns (`0x7701c0` texture / `0x771480`
/// fontstring — the same two fire from the Button state-texture and ButtonText paths, and from Lua
/// `CreateTexture`/`CreateFontString` only on a template-registry hit). Condition: the region has a
/// parent AND every one of its nine anchor slots is empty — any anchor from any source suppresses
/// it. Then:
///
/// - a **Texture** gets `SetAllPoints(parent)` — two corner anchors, TOPLEFT→TOPLEFT and
/// BOTTOMRIGHT→BOTTOMRIGHT at (0,0). Two opposing corners pin all four edges, so an authored
/// `<Size>` is **structurally unread** (the resolver law) — which is why the reference's
/// stack-split plate authors a vestigial 256×32 and renders 172×96 (B180).
/// - a **FontString** gets ONE middle-row `SetPoint` chosen by its live justify word
/// (`[this+0x120] & 7`: 1 → LEFT→LEFT, 4 → RIGHT→RIGHT, else CENTER→CENTER, offsets (0,0)) —
/// and its `<Size>` stays live (single anchor + W/H sizes the opposite edges).
/// [`RegionData::justify`] *is* that word — `SetJustifyH` writes it and a font-object link
/// merges into it behind the explicit mask — so reading it here is reading `+0x120`.
/// - a **Title region** gets nothing (verified negative), and a templateless Lua region is never
/// routed here at all: it stays rect-less and does not render.
///
/// These are ordinary anchors once installed: a later same-point `SetPoint` replaces only its own
/// slot and the other implicit corner survives (verified) — callers that re-apply authored anchors
/// over a materialized region must `ClearAllPoints` first, exactly as the reference's XML path
/// avoids the mix by running this step *after* `<Anchors>` load.
pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) {
let Some((kind, owner)) = model.arena.region(rh).map(|r| (r.kind, r.owner)) else {
return;
};
let owner_id = model.frame_id(owner);
let data = model.region_data.entry(rh).or_default();
if !data.anchors.is_empty() {
return;
}
use crate::layout::Point;
match kind {
RegionKind::Texture => {
data.anchors = vec![
Anchor::new(Point::TopLeft, owner_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, owner_id, Point::BottomRight, 0.0, 0.0),
];
}
RegionKind::FontString => {
// The exact byte compare chain: `& 7` then equality against LEFT (1) and RIGHT (4) —
// every other value, the CENTER bit and the cleared axis included, falls to CENTER.
let point = match data.justify.0 & crate::justify::H_MASK {
0x01 => Point::Left,
0x04 => Point::Right,
_ => Point::Center,
};
data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)];
}
RegionKind::Title => return,
}
model.touch_layout();
}
/// [`implicit_creation_anchor`] behind a wrapper table — the loader-facing form (the loader holds
/// region wrappers, not handles), same seam as [`apply_font_parts`].
pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::Result<()> {
let rh = region_handle_of(lua, wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
implicit_creation_anchor(&mut model, rh);
Ok(())
}
/// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a frame
/// name, or nil) to a layout id, defaulting to `owner` when absent/unresolved.
pub(super) fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 {
match target {
Value::Table(t) => decode_id(t)
.ok()
.filter(|id| model.id_to_frame.contains_key(id) || model.id_to_region.contains_key(id))
.unwrap_or(owner),
Value::String(s) => s
.to_str()
.ok()
.and_then(|n| {
// Frames first (the client's global namespace is one; frames publish before their
// regions build), then the region-name registry — the real XML anchors regions to
// sibling regions by name (merchant label plate → `$parentSlot`).
model
.arena
.lookup(n.as_ref())
.map(|h| model.frame_id(h))
.or_else(|| model.region_names.get(n.as_ref()).copied())
})
.unwrap_or_else(|| {
// The owner fallback matches the frame path, but a *named* target that doesn't
// resolve is almost always a bug — a typo, or an XML forward reference (anchors
// resolve at SetPoint time, so a target must be declared before its dependents;
// ItemTextFrame's scrollbar track landed on the parchment this way). Warn
// instead of silently misdirecting the anchor.
let who = model
.id_to_frame
.get(&owner)
.and_then(|&h| model.arena.frame(h))
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<anonymous>".into());
model.warnings.push(format!(
"SetPoint(region of {who}): relativeTo '{}' does not resolve — anchored to the owner",
s.to_str().ok().as_deref().unwrap_or("<non-utf8>")
));
owner
}),
_ => owner,
}
}
/// Bit-exact equality for a region's explicit size — the layout gate's own lens
/// (`InputFingerprint::input` feeds `f32::to_bits`), so a setter's no-op detection and the gate
/// can never disagree; see [`anchor_bits_eq`].
pub(super) fn size_bits_eq(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> bool {
match (a, b) {
(None, None) => true,
(Some((aw, ah)), Some((bw, bh))) => {
aw.to_bits() == bw.to_bits() && ah.to_bits() == bh.to_bits()
}
_ => false,
}
}
/// `Region:SetPoint(point [, relativeTo [, relativePoint]] [, x, y])` — the region twin of
/// [`super::object`]'s frame `SetPoint`, writing [`super::RegionData::anchors`]. The overload is
/// disambiguated by argument *type* exactly as the frame version.
pub(super) fn region_set_point(
lua: &Lua,
this: &Table,
point: &str,
rest: [Value; 4],
) -> mlua::Result<()> {
let point = point_from_str(point)
.ok_or_else(|| mlua::Error::runtime(format!("SetPoint: unknown point '{point}'")))?;
let rh = region_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let owner = region_owner_id(&mut model, rh);
let mut cursor = 0usize;
let rel_to_id: u32 = match rest.first() {
Some(Value::Table(_) | Value::String(_) | Value::Nil) => {
cursor = 1;
resolve_target(&mut model, &rest[0], owner)
}
// A leading number is the `SetPoint(point, x, y)` overload — cursor stays at 0.
_ => owner,
};
let mut rel_point = point;
if let Some(Value::String(s)) = rest.get(cursor) {
if let Some(p) = s.to_str().ok().and_then(|n| point_from_str(n.as_ref())) {
rel_point = p;
cursor += 1;
}
}
let x = rest.get(cursor).map(as_f32).unwrap_or(0.0);
let y = rest.get(cursor + 1).map(as_f32).unwrap_or(0.0);
let data = model.region_data.entry(rh).or_default();
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
// Same no-op law as the frame twin (`layout_methods::set_point`): idempotent only when the
// bit-identical anchor already holds the tail and no earlier entry carries this point.
let same_at_tail = data.anchors.last().is_some_and(|a| anchor_bits_eq(a, &new))
&& !data.anchors[..data.anchors.len() - 1]
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// The frame twin's law (decision 1388): re-pointing the same anchor at the same target is
// a VALUE change and names its node; anything that moves the target set is structural.
// This is the castbar spark's and every combat-text string's per-frame write.
// …and a retarget names its node too (decision 1625) — see the frame twin.
let structural = anchor_retarget_is_structural(&data.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| data.anchors.iter().map(|a| a.relative_to).collect());
data.anchors.retain(|a| a.point != point);
data.anchors.push(new);
match old_targets {
None => model.touch_layout_region(rh),
Some(old) => {
let new_targets: Vec<u32> = model.region_data[&rh]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_region(rh, &old, &new_targets);
}
}
}
Ok(())
}
/// The measured extent a FontString reports, falling back to an explicit `SetSize`.
///
/// Hoisted out of the text cluster when this file split (0716): `GetStringWidth`/`GetStringHeight`
/// live in `region::text` and `GetWidth`/`GetHeight` in `region::layout`, and both read it.
pub(super) fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> {
let rh = region_handle_of(lua, this)?;
// Same-tick measure when a host font engine is installed — see `region::text`'s `natural_w`.
// A no-op for a Texture (not a FontString) and for an already-current measure.
super::measure::ensure_measured(lua, rh);
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
// The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same
// recipe the request loop stamps, or every read under a SetScale'd owner reports stale.
let scale = model
.arena
.region(rh)
.and_then(|r| model.arena.frame(r.owner))
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key(scale)));
let size = d.and_then(|d| d.size);
let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0);
let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0);
Ok((w, h))
}