Texture and FontString stop answering each other's methods

- the one shared region table splits into the client's own maps: a Texture no longer answers SetText, a FontString no longer answers SetTexture, and feature-detecting addons see the true surface
- CreateTitleRegion lands whole — object and drag — so an options window that is its own drag handle actually moves
- font-object names resolve whatever their case, and the CursorUpdate/CursorOnUpdate pair arrives for the container-item templates
- GetStringHeight is pruned (1.12 has no such method) and SetPortraitToTexture moves to the global it really is

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:03:34 +07:00
co-authored by Claude Fable 5
parent 56d515a374
commit 84f93c47de
32 changed files with 1109 additions and 80 deletions
+5 -5
View File
@@ -306,10 +306,10 @@
-- Fixed art, again because there is no bag item: the keyring's own portrait
-- (ref ContainerFrame.lua l.419) and its own background variant, snug-fitted to the
-- level-gated slot count exactly like an equipped bag.
local portrait = getglobal(frame:GetName() .. "Portrait")
if portrait then
portrait:SetPortraitToTexture("Interface\\ContainerFrame\\KeyRing-Bag-Icon")
end
-- The GLOBAL form, which is the reference's own (ContainerFrame.lua l.419
-- writes exactly this call) — 1.12 has no such Texture method.
SetPortraitToTexture(frame:GetName() .. "Portrait",
"Interface\\ContainerFrame\\KeyRing-Bag-Icon")
local size = GetContainerNumSlots(bagId)
if size > 0 then BenillaBagWindow_FitBackground(frame, size) end
else
@@ -331,7 +331,7 @@
-- The circular crop (the real SetBagPortaitTexture is C-side and masks the
-- icon to the ring's inscribed circle): a raw square icon pokes its corners
-- past the round rim — the director's bank-bag popout artifact.
portrait:SetPortraitToTexture(icon)
SetPortraitToTexture(frame:GetName() .. "Portrait", icon)
else
-- The backpack fallback art is authored round — no crop to apply.
portrait:SetTexture("Interface\\Buttons\\Button-Backpack-Up")
@@ -24,7 +24,8 @@
slots, so it has to detour out of the container API to describe one. benilla's keyring is an
ordinary container (id -2, decision 0765): its slots come through `GetContainerItemInfo` and
`GameTooltip:SetBagItem` like every other bag's. So the detour is not merely unavailable here
(neither `KeyRingButtonIDToInvSlotID` nor `CursorUpdate` is a binding) — it is *wrong* here,
(`KeyRingButtonIDToInvSlotID` is not a binding here; `CursorUpdate` now is, transcribed from
UIParent.lua into UiPanels.xml, so only half the detour is missing) — it is *wrong* here,
and supplying the two verbs would buy an empty tooltip, because our keyring items were never
fed into the inventory-slot space it reads.
+1 -1
View File
@@ -115,7 +115,7 @@
-- has measured THIS text. A zero keeps the current height (never collapses the row) and
-- the frame's OnUpdate below re-runs the fit until it settles.
function BenillaGossipRow_Resize(button)
local h = button.label:GetStringHeight()
local h = button.label:GetHeight()
if h <= 0 or button.benillaFitHeight == h then return end
button.benillaFitHeight = h
button:SetHeight(h + 2)
@@ -56,6 +56,29 @@
- The keyring button (ref KeyRingButton, `hidden="true"` by default) stays out with the
keyring container itself.
-->
<!-- ── MainMenuBarMicroButton (ref MainMenuBarMicroButtons.xml l.4-18, VERBATIM) ─────────────
The micro-button base template. Ours declares its buttons directly, so nothing here needed
it — but an ADDON that adds a micro button inherits this name, and an unresolved
`inherits=` is silent: no error, just a button with no size, no hit-rect inset and no
tooltip scripts.
Verbatim, and it costs nothing: `GameTooltip_AddNewbieTip` is already ours (GameTooltip.xml)
and the rest is geometry. The HitRectInsets top=18 is the reference's own — the micro
buttons overlap the bar art above them, and without it the top third of every addon micro
button would eat clicks meant for the bar. -->
<Button name="MainMenuBarMicroButton" virtual="true">
<Size><AbsDimension x="29" y="58"/></Size>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
<Scripts>
<OnEnter>
GameTooltip_AddNewbieTip(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- ref GlobalStrings.lua — the eight labels (enUS), in the `X = X or` FALLBACK form: the
-- app runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
+1 -1
View File
@@ -252,7 +252,7 @@
-- the SetText (the measure round-trip), hence the guard + the OnUpdate settle above.
function BenillaQuestTitleButton_Resize(button)
local text = button:GetFontString()
local h = text and text:GetStringHeight() or 0
local h = text and text:GetHeight() or 0
if h <= 0 or button.benillaFitHeight == h then return end
button.benillaFitHeight = h
button:SetHeight(h + 2)
+30
View File
@@ -715,6 +715,36 @@
end
end
-- ── The inspect-cursor pair (ref UIParent.lua l.1805-1817, VERBATIM) ──────────────────
-- `CursorUpdate` is the ctrl-hover "can I inspect this?" cursor swap, and `CursorOnUpdate`
-- is the OnUpdate wrapper that only re-runs it while the tooltip is still ours. Both are
-- **FrameXML, not engine** (`reference/1.12-globals.tsv` marks them `framexml`, while
-- `ResetCursor` and `ShowInspectCursor` beside them are `engine`) — so they are ours to
-- transcribe, and every piece they reach for already resolves here: `IsControlKeyDown`,
-- `ShowInspectCursor`, `ResetCursor` and `GameTooltip:IsOwned` are all present.
--
-- Two corpus addons call `CursorUpdate` (AdvancedTradeSkillWindow, WhoHas) and one calls
-- `CursorOnUpdate`. It is also a blocker — one of several — for two reference templates
-- addons inherit (`GroupLootFrameTemplate`, `ContainerFrameItemButtonTemplate`), and it is
-- called by the SOURCED `ContainerFrame.lua`'s keyring fork.
--
-- `this.hasItem` is read, not required: the reference tests it as a plain truthy field the
-- hovering frame sets for itself, so a frame that never sets it simply takes the
-- `ResetCursor` arm — which is why this is safe to ship before anything sets it.
function CursorUpdate()
if ( IsControlKeyDown() and this.hasItem ) then
ShowInspectCursor()
else
ResetCursor()
end
end
function CursorOnUpdate()
if ( GameTooltip:IsOwned(this) ) then
CursorUpdate()
end
end
-- SetDesaturation(texture, desaturation) — ref UIParent.lua l.1499-1510, verbatim. The
-- reference itself has a no-shader fallback, and OURS IS ALWAYS THAT PATH: this engine has
-- no SetDesaturated, so `shaderSupported` is false exactly as on a 1.12 card without the
@@ -93,6 +93,35 @@
server slot order, not insertion order, exactly like the reference. The target-of-target
branches take the not-shown leg (wrap 6, buffs under Debuff7) until a ToT frame exists.
-->
<!-- ── TargetBuffButtonTemplate (ref TargetFrame.xml l.9-27, VERBATIM) ──────────────────────
A reference template addons INHERIT and we did not declare. `CT_UnitFrames` builds its own
`CT_UnitFrames_BuffButtonTemplate` on top of it (CT_TargetFrame.xml:4), and an unresolved
`inherits=` is SILENT — no load error, no session error, nothing in any column. The addon
simply gets a button with no size, no icon region and no tooltip scripts, and nothing
anywhere says so. That invisibility is why this row only ever appeared in the report's
missing-TEMPLATES ranking.
Transcribed verbatim because every piece of it already resolves here: `GameTooltip:SetOwner`
and `GameTooltip:SetUnitBuff` are both ours, and the icon is a plain `setAllPoints` texture.
The `$parentIcon` name matters — that is what a consumer getglobals to set the texture. -->
<Button name="TargetBuffButtonTemplate" virtual="true">
<Size><AbsDimension x="21" y="21"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT", 15, -25);
GameTooltip:SetUnitBuff("target", this.id);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- The right-click-menu explanations the detailed-tooltip fork shows in place of the unit
-- lines (BenillaUnitFrame_OnEnter below) — ref GlobalStrings.lua l.2993/3081/2754/2755
@@ -408,7 +408,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"return GossipRow{i}:GetTop(), GossipRow{i}:GetBottom(), \
GossipRow{i}Label:GetStringHeight()"
GossipRow{i}Label:GetHeight()"
))
.unwrap()
};
@@ -627,7 +627,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"local b = getglobal('QuestTitleButton{i}')\n\
return b:GetTop(), b:GetBottom(), b:GetFontString():GetStringHeight()"
return b:GetTop(), b:GetBottom(), b:GetFontString():GetHeight()"
))
.unwrap()
};
@@ -503,3 +503,106 @@ fn every_shipped_font_object_is_published_as_a_lua_global() {
assert!(height > 0.0, "{name}: height {height}");
}
}
/// **Two reference templates addons INHERIT, and the silence that hid them.**
///
/// `TargetBuffButtonTemplate` (ref `TargetFrame.xml`) and `MainMenuBarMicroButton` (ref
/// `MainMenuBarMicroButtons.xml`) are declared by the reference and were not by us. An unresolved
/// `inherits=` is **silent** — no load error, no session error, nothing in any harness column — so
/// a consumer just gets a button with no size, no hit-rect and no scripts, and nothing anywhere
/// says so. They surfaced only in the report's missing-TEMPLATES ranking.
///
/// Asserted through what a consumer actually gets: inherit the template, then read back the
/// geometry and the script the reference confers.
#[test]
fn the_inheritable_reference_templates_confer_their_shape() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
assert!(super::load_default_ui(&s).is_empty());
// `CT_UnitFrames/CT_TargetFrame.xml:4` builds its own virtual template on top of this one, so
// the inherit has to work through a second hop as well as directly.
s.run(
r#"
BuffProbe = CreateFrame("Button", "BuffProbe", UIParent, "TargetBuffButtonTemplate")
MicroProbe = CreateFrame("Button", "MicroProbe", UIParent, "MainMenuBarMicroButton")
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<(f64, f64)>("return BuffProbe:GetWidth(), BuffProbe:GetHeight()")
.unwrap(),
(21.0, 21.0),
"the buff button carries the reference's 21x21"
);
assert!(
s.eval::<bool>("return BuffProbeIcon ~= nil").unwrap(),
"$parentIcon is the region a consumer getglobals to set the texture"
);
assert!(
s.eval::<bool>(r#"return BuffProbe:GetScript("OnEnter") ~= nil"#)
.unwrap(),
"the tooltip script comes with the template"
);
assert_eq!(
s.eval::<(f64, f64)>("return MicroProbe:GetWidth(), MicroProbe:GetHeight()")
.unwrap(),
(29.0, 58.0)
);
assert!(s
.eval::<bool>(r#"return MicroProbe:GetScript("OnEnter") ~= nil"#)
.unwrap());
assert!(s.errors().is_empty(), "no script errors: {:?}", s.errors());
}
/// **`CursorUpdate` / `CursorOnUpdate` — the inspect-cursor pair, transcribed from FrameXML.**
///
/// Both are `framexml` origin in `reference/1.12-globals.tsv` (while `ResetCursor` and
/// `ShowInspectCursor` beside them are `engine`), so they are ours to write rather than to bind.
/// Two corpus addons call `CursorUpdate` and one calls `CursorOnUpdate`; it is also one of the
/// blockers on two reference templates addons inherit, and the sourced `ContainerFrame.lua` calls
/// it from its keyring fork.
#[test]
fn the_inspect_cursor_pair_takes_both_arms() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
assert!(super::load_default_ui(&s).is_empty());
// Both exist as functions — the shape a caller checks before hooking.
assert_eq!(
s.eval::<(String, String)>("return type(CursorUpdate), type(CursorOnUpdate)")
.unwrap(),
("function".into(), "function".into())
);
// **The no-ctrl arm, which is the one that runs in practice.** `this.hasItem` is read as a
// plain truthy field, so a frame that never sets it simply takes `ResetCursor` — this is why
// the pair is safe to ship before anything sets that field.
s.run(
r#"
CursorProbe = CreateFrame("Frame", "CursorProbe", UIParent)
this = CursorProbe
CursorUpdate()
this = nil
"#,
)
.unwrap();
assert!(
s.errors().is_empty(),
"the ResetCursor arm runs clean: {:?}",
s.errors()
);
// `CursorOnUpdate` gates on tooltip ownership, so with the tooltip unowned it must do nothing
// at all rather than reach through to the cursor.
s.run("this = CursorProbe CursorOnUpdate() this = nil")
.unwrap();
assert!(
s.errors().is_empty(),
"the unowned-tooltip gate short-circuits: {:?}",
s.errors()
);
}
+3 -1
View File
@@ -614,7 +614,9 @@ impl Loader<'_> {
// One model borrow, then the insert — `font_objects` and `framexml_fonts` are both on it.
{
let mut model = self.model();
model.font_objects.insert(name.clone(), font);
model
.font_objects_by_lower
.insert(name.to_ascii_lowercase(), font);
// Store the merged (flattened) node so a chain rooted here reads resolved values.
model
.framexml_fonts
+9
View File
@@ -257,6 +257,15 @@ impl Loader<'_> {
/// exist. Depth is bounded: a template registry can contain a cycle, and a loader that hangs on
/// a malformed addon is worse than one that gives up on it.
fn font_object_through_templates(&self, inherits: &str) -> Option<String> {
// **The probes here stay case-EXACT, and the outcome is still folded.** Font names match
// case-insensitively in 1.12 (`0x783870`, `SStrCmpI`), and that fold lives in
// `Model::font_object` — so a miss here falls through to the caller's
// `unwrap_or_else(|| name.to_string())` and `SetFontObject` resolves it anyway.
//
// Not folded HERE because the `framexml_fonts` element registry's other reader hands its
// keys to `framexml::expand`, which is the SAME expansion templates go through — and
// whether the client matches TEMPLATE names case-insensitively is not carved. Folding this
// registry would quietly extend a verified font fact to unverified template behaviour.
const MAX_HOPS: usize = 8;
let model = self.model();
let fonts = model.framexml_fonts.borrow();
+40
View File
@@ -1327,6 +1327,46 @@ mod region_template_tests {
);
}
/// **A font-object name resolves case-INSENSITIVELY**, because the client's font registry
/// compares its keys with `SStrCmpI` (`0x783870`/`0x7838c7`, wow-re
/// `font-object-lua-surface.md`: *"Font names are matched case-insensitively"*).
///
/// `Recap/RecapOptions.xml:32` inherits `GameFontHighLightSmall`; the shipped font is
/// `GameFontHighlightSmall`, one letter's case apart. On the real client that resolves, and it
/// used to be a warn-once gap here — a NARROWING fix, like the unit-token fold (1247).
#[test]
fn a_font_object_inherits_resolves_whatever_its_case() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Font name="GameFontHighlightSmall" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
</Font>
<Frame name="CaseHost">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="CaseLabel" inherits="GameFontHighLightSmall" text="hi"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
report.warnings.is_empty(),
"a differently-cased font name must resolve, not warn: {:?}",
report.warnings
);
assert_eq!(
s.eval::<(String, f32)>("local f, h = CaseLabel:GetFont() return f, h")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 10.0),
"the mis-cased inherit found the shipped font"
);
}
/// **EQL3's actual shape: FontString -> virtual FontString TEMPLATE -> Font object.** One hop
/// works (above); this is the two-hop chain every "define a line template once, stamp it N
/// times" addon writes, and `EQL3_Tracker.xml:6/32` is the corpus instance —
+6 -3
View File
@@ -115,7 +115,7 @@ fn link_label_to_font_object(lua: &Lua, text: Option<RegionHandle>, name: Option
};
// An unregistered name is not an error here: `SetTextFontObject` already accepted it, and the
// loader's own log-and-continue rule (0068) owns the reporting.
let Some(fo) = model.font_objects.get(name).cloned() else {
let Some(fo) = model.font_object(name).cloned() else {
return;
};
let d = model.region_data.entry(rh).or_default();
@@ -311,7 +311,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// so this answers the harmless number rather than raising on a guess.
for (name, region_getter) in [
("GetTextWidth", "GetStringWidth"),
("GetTextHeight", "GetStringHeight"),
// `GetHeight`, not a `GetStringHeight` — 1.12 has no such method (byte-verified absent
// in every encoding), and the reference's own `Button:GetTextHeight 0x782390` is this
// same call on the embedded FontString rather than a separate API.
("GetTextHeight", "GetHeight"),
] {
m.set(
name,
@@ -435,7 +438,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Some(f) => (Some(f.path), Some(f.height), f.flags),
None => {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let fo = inherits.and_then(|n| model.font_objects.get(&n));
let fo = inherits.and_then(|n| model.font_object(&n));
(
fo.and_then(|f| f.font.clone()),
fo.and_then(|f| f.height),
+10 -1
View File
@@ -199,7 +199,7 @@ impl UiScript {
} else {
bs.normal_font.as_ref()
};
state_font = name.and_then(|n| model.font_objects.get(n));
state_font = name.and_then(|n| model.font_object(n));
button_font = bs.font.as_ref();
// The per-state color override (Button:Set*TextColor) — hover falls
// back to the normal color, mirroring the font fallback above.
@@ -212,6 +212,15 @@ impl UiScript {
};
}
}
// A TITLE REGION NEVER DRAWS. It is a hit rectangle, not a visual: wow-re
// carves it as a plain Region with no textures at all
// (`widget-api-batch-benilla.md` Q6). Falling through here would emit the
// texture quad the `else` branch below builds — invisible on screen, but the
// render report counts quads, so every addon that makes one would read as
// "drew something" (1246's lesson about what an instrument is told).
if region.map(|r| r.kind) == Some(crate::widget::RegionKind::Title) {
continue;
}
let mut data = model.region_data.get(&rh).cloned().unwrap_or_default();
// Region-level Hide (the VisibleRegion bit): no quad at all.
if data.hidden {
+11 -5
View File
@@ -229,7 +229,7 @@ pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
/// Per-state **button** label fonts need no equivalent: they are stored as names and re-resolved at
/// every `extract`, so a mutated font object reaches them on the next frame by construction.
pub(crate) fn propagate(model: &mut Model, name: &str) {
let Some(fo) = model.font_objects.get(name).cloned() else {
let Some(fo) = model.font_object(name).cloned() else {
return;
};
for d in model.region_data.values_mut() {
@@ -244,7 +244,10 @@ pub(crate) fn propagate(model: &mut Model, name: &str) {
fn edit<R>(lua: &Lua, this: &Table, f: impl FnOnce(&mut FontObject) -> R) -> mlua::Result<R> {
let name = name_of(this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let out = f(model.font_objects.entry(name.clone()).or_default());
let out = f(model
.font_objects_by_lower
.entry(name.to_ascii_lowercase())
.or_default());
propagate(&mut model, &name);
Ok(out)
}
@@ -253,7 +256,7 @@ fn edit<R>(lua: &Lua, this: &Table, f: impl FnOnce(&mut FontObject) -> R) -> mlu
fn read(lua: &Lua, this: &Table) -> mlua::Result<FontObject> {
let name = name_of(this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.font_objects.get(&name).cloned().unwrap_or_default())
Ok(model.font_object(&name).cloned().unwrap_or_default())
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -529,7 +532,7 @@ fn copy_from(lua: &Lua, this: &Table, other: &Value, verb: &str) -> mlua::Result
let src = resolve_required(verb, other)?;
let paint = {
let model = lua.app_data_ref::<Model>().expect("model");
model.font_objects.get(&src).cloned().ok_or_else(|| {
model.font_object(&src).cloned().ok_or_else(|| {
mlua::Error::runtime(format!(
"{verb}: no font object named '{src}' is registered"
))
@@ -558,7 +561,10 @@ fn create_font(lua: &Lua, name: Option<String>) -> mlua::Result<Table> {
})?;
{
let mut model = lua.app_data_mut::<Model>().expect("model");
model.font_objects.entry(name.clone()).or_default();
model
.font_objects_by_lower
.entry(name.to_ascii_lowercase())
.or_default();
}
let t = wrapper(lua, &name)?;
publish_global(lua, &name, &t)?;
+2 -2
View File
@@ -188,7 +188,7 @@ pub(super) fn install(
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_objects.get(&name).cloned() else {
let Some(fo) = model.font_object(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
@@ -215,7 +215,7 @@ pub(super) fn install(
.region_data
.get(&rh)
.and_then(|d| d.font_object.clone())
.filter(|n| model.font_objects.contains_key(n))
.filter(|n| model.font_object(n).is_some())
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
+124 -4
View File
@@ -192,6 +192,117 @@ const REG_FRAME_META: &str = "__benilla_frame_meta";
const REG_REGION_META: &str = "__benilla_region_meta";
const REG_FRAME_METHODS: &str = "__benilla_frame_methods";
const REG_REGION_METHODS: &str = "__benilla_region_methods";
/// The full table's registry key, exposed for the exhaustiveness gate in `tests::reference_surface`
/// — the split's correctness is a property of what the VM HOLDS, not of the source that builds it.
#[cfg(test)]
pub(crate) const REG_REGION_METHODS_FOR_TEST: &str = REG_REGION_METHODS;
/// The **title region's** method table + metatable — the 19 Region methods and nothing else.
const REG_TITLE_METHODS: &str = "__benilla_title_methods";
const REG_TITLE_META: &str = "__benilla_title_meta";
/// The two region LEAF tables + metatables — Texture and FontString each answer their own map.
const REG_TEXTURE_METHODS: &str = "__benilla_texture_methods";
const REG_TEXTURE_META: &str = "__benilla_texture_meta";
const REG_FONTSTRING_METHODS: &str = "__benilla_fontstring_methods";
const REG_FONTSTRING_META: &str = "__benilla_fontstring_meta";
/// The **Region method map** (`0xcf54b4`) — the 19 names every region leaf reaches through its own
/// lookup's fallback, carved in wow-re `system/ui/scratch/font-object-lua-surface.md` and asserted
/// as a SET by `tests::reference_surface`. Named here because two things need the same list: that
/// test, and the title region's narrower method table (1250 §5).
/// Names on **both** region leaves — and each leaf registers its own copy, so these are NOT on the
/// Region map and must not be hoisted into it (wow-re
/// `system/ui/scratch/texture-fontstring-method-split.md`, stated there as a trap in as many words).
/// `GetDrawLayer` is in the client's pair and absent here; absent is absent.
pub(crate) const REGION_LEAF_SHARED: [&str; 8] = [
"SetDrawLayer",
"SetVertexColor",
"SetAlpha",
"GetAlpha",
"Show",
"Hide",
"IsVisible",
"IsShown",
];
/// **Texture-only.** Note `GetVertexColor` sits here while `SetVertexColor` is shared — an asymmetry
/// no reasonable partition invents, and the carve calls it out. Also note the client's Texture map
/// has `SetGradientAlpha` where FontString has `SetAlphaGradient`: a near-miss pair, and we install
/// only the FontString one.
///
/// The tail three are OURS, not 1.12's, and are parked here rather than pruned: `SetPortraitToTexture`
/// and `SetRotation` are texture verbs the carve's 22 does not list, and `SetSize` is an Era
/// geometry verb absent from the Region map. Removing a superset is a separate question per name —
/// this landing partitions, it does not prune.
pub(crate) const TEXTURE_ONLY_METHODS: [&str; 11] = [
"SetGradient",
"SetGradientAlpha",
"GetTexture",
"SetTexture",
"GetTexCoord",
"SetTexCoord",
"SetBlendMode",
"SetDesaturated",
"GetVertexColor",
"SetRotation",
"SetSize",
];
/// **FontString-only** — the font/text/justify/shadow block plus the string metrics.
///
/// The tail two are OURS: `SetFormattedText` is not in the client's 32, and `SetSize` is the same
/// Era geometry verb the Texture list carries. Parked, not pruned, pending their own checks.
///
/// `GetStringHeight` was here and is GONE (1251's first prune): byte-verified absent from 1.12 in
/// every encoding, ours was a byte-identical duplicate of `GetHeight`, and every call site — two of
/// our own XML files, two tests, and `Button:GetTextHeight`'s delegate — now goes through the
/// Region method the reference itself uses.
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [
"SetFont",
"GetFont",
"SetFontObject",
"GetFontObject",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
"SetText",
"GetText",
"SetTextHeight",
"GetStringWidth",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetAlphaGradient",
"SetFormattedText",
"SetSize",
];
pub(crate) const REGION_MAP_METHODS: [&str; 19] = [
"GetObjectType",
"IsObjectType",
"GetName",
"GetParent",
"SetParent",
"GetCenter",
"GetLeft",
"GetRight",
"GetTop",
"GetBottom",
"GetWidth",
"SetWidth",
"GetHeight",
"SetHeight",
"GetNumPoints",
"GetPoint",
"SetPoint",
"SetAllPoints",
"ClearAllPoints",
];
const REG_WRAPPERS: &str = "__benilla_wrappers";
const REG_SCRIPTS: &str = "__benilla_scripts";
@@ -893,7 +1004,7 @@ impl UiScript {
/// where that fork lives). A census that asks only the template registry reports every font in
/// the corpus as a missing template.
pub fn has_font_object(&self, name: &str) -> bool {
self.model_ref().font_objects.contains_key(name)
self.model_ref().font_object(name).is_some()
}
/// The **widget kind of a published name** — `"MessageFrame"`, `"Button"`, `"Texture"`, … — or
@@ -944,6 +1055,9 @@ impl UiScript {
model.arena.region(h).map(|r| match r.kind {
crate::widget::RegionKind::Texture => "Texture",
crate::widget::RegionKind::FontString => "FontString",
// A title region is a plain Region and says so (Q6) — and it is unreachable by name
// anyway: `CreateTitleRegion` takes no name argument at all.
crate::widget::RegionKind::Title => "Region",
})
}
@@ -974,7 +1088,9 @@ impl UiScript {
/// like one declared in XML (`fs:SetFontObject(Name)`, `Name:GetFont()`). One registration act,
/// one outcome: the two paths cannot drift.
pub fn register_font_object(&self, name: &str, font: FontObject) {
self.model_mut().font_objects.insert(name.to_string(), font);
self.model_mut()
.font_objects_by_lower
.insert(name.to_ascii_lowercase(), font);
// Publishing cannot fail for a fresh table + a string key; a registry hiccup is not worth
// an unwrap in a host-facing setter, and the record is already in place either way.
let _ = font::publish(&self.lua, name);
@@ -983,7 +1099,7 @@ impl UiScript {
/// Look up a registered [`FontObject`] by name (the resolved paint), if any. Used by tests and by
/// the `SetFontObject` binding.
pub fn font_object(&self, name: &str) -> Option<FontObject> {
self.model_ref().font_objects.get(name).cloned()
self.model_ref().font_object(name).cloned()
}
/// Every registered [`FontObject`] (the resolved paints of all loaded `<Font>` nodes) — the
@@ -991,7 +1107,11 @@ impl UiScript {
/// off this to know which outlined cell variants the shipped UI can actually request
/// (the outlined-glyph bake, the fade-composite fold-back record).
pub fn font_objects(&self) -> Vec<FontObject> {
self.model_ref().font_objects.values().cloned().collect()
self.model_ref()
.font_objects_by_lower
.values()
.cloned()
.collect()
}
// ── internals ────────────────────────────────────────────────────────────────────────────
+23 -2
View File
@@ -104,7 +104,17 @@ pub(crate) struct Model {
/// The named virtual **Font object** registry (`<Font name=…>` → resolved paint), keyed by name.
/// Populated by the loader as it walks top-level `<Font>` nodes; read by `SetFontObject` and by
/// FontString `inherits=` resolution. Data only — no Lua handles (MAXCSTACK discipline).
pub(crate) font_objects: HashMap<String, FontObject>,
/// The named `<Font>` objects, **keyed by the ASCII-LOWERCASED name**.
///
/// 1.12's font registry hashes the name and compares keys with `SStrCmpI` — case-INSENSITIVE
/// (`0x783870`/`0x7838c7`, wow-re `system/ui/scratch/font-object-lua-surface.md`: *"Font names
/// are matched case-insensitively"*), and a name string handed to `SetFontObject` folds the
/// same way. `Recap/RecapOptions.xml:32` inherits `GameFontHighLightSmall` — the shipped font
/// is `GameFontHighlightSmall`, one letter's case apart — and on the real client that resolves.
///
/// Read it through [`Model::font_object`], never directly: that is where the fold lives, and
/// the name says so because nothing else guards the invariant (1247's shape).
pub(crate) font_objects_by_lower: HashMap<String, FontObject>,
/// The last [`UiScript::resolve`] result for **anchored** regions (those with a non-empty
/// [`RegionData::anchors`]): the region's own resolved rect, owner-relative (see [`extract`]).
/// Regions with no anchors are absent here — they fall to the size-centered / fill-owner path.
@@ -959,6 +969,17 @@ impl Model {
/// The uppercase scan is a fast path, not an optimisation for its own sake: every internal
/// caller passes a lowercase literal (`"player"`, `"target"`), so the common case allocates
/// nothing and only an addon's `"Player"` pays for a `String`.
/// A named font object, **case-folded the way the client folds it** — `SStrCmpI` over the font
/// registry's keys. ASCII only, and the uppercase scan is a fast path: every internal caller
/// passes an exact shipped name, so only an addon's odd spelling pays for a `String`.
pub(crate) fn font_object(&self, name: &str) -> Option<&FontObject> {
if name.bytes().any(|b| b.is_ascii_uppercase()) {
self.font_objects_by_lower.get(&name.to_ascii_lowercase())
} else {
self.font_objects_by_lower.get(name)
}
}
pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> {
if token.bytes().any(|b| b.is_ascii_uppercase()) {
self.units_by_lower.get(&token.to_ascii_lowercase())
@@ -988,7 +1009,7 @@ impl Model {
chat_tab: false,
region_data: HashMap::new(),
backdrops: HashMap::new(),
font_objects: HashMap::new(),
font_objects_by_lower: HashMap::new(),
region_resolved: HashMap::new(),
next_id: 1,
id_to_frame: HashMap::new(),
+1 -1
View File
@@ -28,7 +28,7 @@ use crate::widget::{FrameHandle, FrameKind};
mod events_regions;
mod frame_state;
mod layout_methods;
mod movable;
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use layout_methods::anchor_bits_eq;
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
@@ -142,6 +142,86 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
},
)?,
)?;
// ── The title region: Frame:CreateTitleRegion() / GetTitleRegion() ──────────────────────────
//
// wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q6 (`0x773910` / `0x773820`). Four
// details, each one a coin-flip a reimplementation loses:
//
// · **It reads NO argument at all** — nothing in `0x773910`-`0x773a1f` touches Lua index 2. So
// `CustomNameplates/options.lua:73`'s `CreateTitleRegion(optionsFrame)` is harmless and
// identical to the no-arg call, rather than an error or a differently-parented region.
// · **It is IDEMPOTENT, destructively.** One region per frame (`CSimpleFrame+0xA8`): a second
// call runs ClearAllPoints on the existing one and returns THE SAME OBJECT — so calling it
// on an XML-declared `<TitleRegion>` silently wipes that region's anchors. Returning a
// fresh region instead would leave the first one hit-testing forever.
// · **A fresh one has NO anchors** and does nothing until `SetPoint`/`SetAllPoints`. Both
// corpus consumers immediately call `SetAllPoints`, which is the whole-window drag idiom.
// · **`GetTitleRegion` answers 1 value (nil)** when there is none — note the asymmetry with
// `GetBackdrop`, which answers 0 values. Q6 flags it explicitly; both converged
// independently there, so both are reproduced here.
// **ONE NAMED DIVERGENCE, because it is a superset and 1189 is what a superset costs.** Q6 says
// the object answers *exactly* the 19 Region methods — no Show/Hide, no scripts, no textures.
// Ours answers the whole shared region table, because Texture/FontString/Title use one
// metatable here, so `titleRegion:SetTexture(…)` is accepted where the reference would raise
// `attempt to call method`. It is inert rather than wrong — a title region never draws (the
// extract skips its kind outright), so a texture or a Hide set on one changes nothing — but an
// addon that FEATURE-DETECTS would see a method the real client does not have. Splitting the
// metatable is the fix; it is not free, and nothing in the corpus asks, so this is recorded
// rather than hidden.
m.set(
"CreateTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let existing = {
let model = lua.app_data_ref::<Model>().expect("model");
model.arena.frame(owner).and_then(|f| f.title_region)
};
if let Some(rh) = existing {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
}
model.region_id(rh)
};
return region_wrapper(lua, id);
}
let wrapper = create_region(lua, &this, RegionKind::Title, None, None)?;
let id = decode_id(&wrapper)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let rh = *model
.id_to_region
.get(&id)
.expect("the region we just created is registered");
if let Some(f) = model.arena.frame_mut(owner) {
f.title_region = Some(rh);
}
Ok(wrapper)
})?,
)?;
m.set(
"GetTitleRegion",
lua.create_function(|lua, this: Table| {
let owner = frame_handle_of(lua, &this)?;
let found = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.arena
.frame(owner)
.and_then(|f| f.title_region)
.map(|rh| model.region_id(rh))
};
match found {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
// ONE value, and it is nil — not zero values. See the `GetBackdrop` asymmetry above.
None => Ok(Value::Nil),
}
})?,
)?;
m.set(
"CreateFontString",
lua.create_function(
@@ -101,6 +101,15 @@ pub(crate) struct FrameMove {
frame: FrameHandle,
/// The cursor position at the last pump (`root+0xd08`/`+0xd0c`), UI px, y-up.
sample: (f32, f32),
/// Whether releasing the mouse ends this move on its own.
///
/// **The reference's drag MODE, reduced to the one bit that is observable from Lua.** The
/// mouse-up handler `0x766420` auto-cancels modes 1 (modifier-drag) and 2 (title region) and
/// leaves mode 3 (`StartMoving`) running until `StopMovingOrSizing`
/// (wow-re `widget-api-batch-benilla.md` Q6). So a title drag ends on release while a scripted
/// one outlives the button — which is exactly the distinction this module's own doc already
/// records for the drag/move split.
pub(crate) auto_stop: bool,
}
/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump.
@@ -337,10 +346,41 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
// Lua `StartMoving` is mode 3: it survives the button and ends only at
// `StopMovingOrSizing`.
auto_stop: false,
});
Ok(())
}
/// Begin a **title-region** move — mode 2, the drag a mouse-down inside `frame:GetTitleRegion()`
/// starts (wow-re Q6, `0x7662c0` → `0x765320(frame, mode=2, …)` → `0x7652b0`).
///
/// [`start_moving`]'s body **minus the movable gate**, and that omission is the carved part rather
/// than a shortcut: the movable bit is `frame+0xb4 & 0x100`, tested by `StartMoving` (`0x77678b`,
/// else `"Frame %s is not movable"`) and by the modifier-drag path — and **not read anywhere** on
/// `0x7662c0`→`0x765320`→`0x7652b0`→`0x768430`. Q6 marks the no-gate reading VERIFIED and the
/// observable claim INFERRED, because both FrameXML users happen to be `movable="true"`; a title
/// region on a non-movable frame is the case that would tell them apart, and it drags here.
///
/// Returns whether a move actually started — `false` when one was already in flight, which is
/// `0x7767e8`'s `root+0xcfc != 0` guard and the same refusal [`start_moving`] makes.
pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
if model.moving.is_some() {
return false;
}
super::toplevel::raise(model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
auto_stop: true,
});
true
}
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
+51
View File
@@ -267,6 +267,22 @@ impl UiScript {
new_id
}
/// Is `(x, y)` inside this frame's title region?
///
/// **False whenever the region has no resolved rect**, which is the common case and the correct
/// one: a freshly created title region has NO anchors at all and does nothing until
/// `SetPoint`/`SetAllPoints` (Q6). Both corpus consumers call `SetAllPoints` immediately, so
/// theirs covers the whole window — the whole-window-as-drag-handle idiom.
fn title_region_hit(&self, h: crate::widget::FrameHandle, x: f32, y: f32) -> bool {
let model = self.model_ref();
model
.arena
.frame(h)
.and_then(|f| f.title_region)
.and_then(|rh| model.region_resolved.get(&rh))
.is_some_and(|r| point_in_rect(*r, x, y))
}
/// A mouse button transition at `(x, y)`: `button` is the WoW name (`"LeftButton"`,
/// `"RightButton"`, …), `down` is press vs release. Fires `OnMouseDown(self, button)` on press or
/// `OnMouseUp(self, button)` on release, on the captured frame.
@@ -301,6 +317,41 @@ impl UiScript {
/// statement-position calls (`script.mouse_button(...);`) are unaffected.
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
let hit_id = self.hit_test(x, y);
// ── The title region, and it SWALLOWS the press ─────────────────────────────────────────
//
// A mouse-down inside `frame:GetTitleRegion()` starts a mode-2 move and never reaches the
// ordinary path: the reference's `0x7662c0` tests `titleRegion+0x24` point-in-rect FIRST
// and only a MISS falls through to `0x7663e6`, which sets the captured frame and dispatches
// `vtable[0x68]` — the OnMouseDown script. So a hit takes `OnMouseDown`/`OnMouseUp` and
// `RegisterForDrag`'s `OnDragStart` with it (wow-re `widget-api-batch-benilla.md` Q6).
//
// Returning early is what implements that swallow: no OnMouseDown, no drag arm, no OnClick,
// no double-click bookkeeping. It reports the event CONSUMED, because it was.
if down {
if let Some(h) = self.hit_test_frame(x, y) {
if self.title_region_hit(h, x, y) {
self.model_mut().cursor_pos = (x, y);
crate::script::object::movable::start_title_move(&mut self.model_mut(), h);
return true;
}
}
}
// ── …and the release ends it, where a scripted move would survive ───────────────────────
//
// `0x766420` auto-cancels modes 1 (modifier-drag) and 2 (title region) and leaves mode 3
// (`StartMoving`) alone — so a window grabbed by its title bar stops when the button comes
// up, while `OnDragStart → StartMoving` keeps moving until the addon's
// `StopMovingOrSizing`. Reduced to [`FrameMove::auto_stop`], because that one bit is all
// of the mode that is observable from Lua.
if !down
&& self
.model_ref()
.moving
.as_ref()
.is_some_and(|m| m.auto_stop)
{
self.model_mut().moving = None;
}
// The session clock, read before the borrow below — the double-click detector's only input
// beyond the hit ([`Model::last_click`]). Same `GetTime()` seconds every script sees.
let now = self.now();
+140 -2
View File
@@ -7,7 +7,10 @@
use mlua::{Lua, Table, Value};
use super::object::{anchor_bits_eq, as_f32, decode_id, id_to_lud, point_from_str};
use super::{Model, REG_REGION_META, REG_REGION_METHODS, REG_WRAPPERS, SCREEN};
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};
@@ -70,7 +73,28 @@ pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
}
let t = lua.create_table()?;
t.raw_set(0, Value::LightUserData(id_to_lud(id)))?;
let meta: Table = lua.named_registry_value(REG_REGION_META)?;
// **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)
@@ -102,6 +126,43 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// 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
@@ -354,6 +415,81 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
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(())
}
@@ -383,6 +519,8 @@ 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",
}
}
@@ -259,26 +259,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to
// its inscribed circle). The client's portraits are circular (model or icon fallback); the frame
// ring is a thin band whose transparent corners would otherwise show the square texture's edges.
// The live API's `SetPortraitToTexture(texture, path)` is a global crop helper; ours is the
// region-method face of the same intent. This is the *icon/path* portrait — distinct from the
// `SetPortraitTexture(region, unit)` live model bake, so it drops any live-unit binding.
// `SetTexture` on the same region clears the flag (a plain texture again).
m.set(
"SetPortraitToTexture",
lua.create_function(|lua, (this, path): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
Ok(())
})?,
)?;
// SetAlphaGradient(start, length) — the per-character write-on reveal (CSimpleFontString;
// the quest-description machinery, ref QuestFrame.lua:548/558). Returns whether `start` is
// still inside the text (chars) — the ref's OnUpdate loop advances until this goes false.
+9 -7
View File
@@ -21,7 +21,7 @@ use crate::justify;
use crate::script::Model;
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
use super::{measured_wh, region_handle_of};
use super::region_handle_of;
/// Populate `m`'s text methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
@@ -72,7 +72,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure
// GetStringWidth (FontString): the host-measured text extent from the measure
//
// **There is no `GetStringHeight` beside it, and that asymmetry is the client's.** 1.12 has no
// such method on any table — byte-verified absent in every encoding, with `GetStringWidth`
// itself as the positive control — and Blizzard's own FrameXML calls it zero times. Ours was a
// byte-identical duplicate of `GetHeight`, which is what the reference uses for this
// (`0x7a2030` falls through to the same cached measurement), so it was pruned rather than
// reimplemented (1251).
// round-trip ([`super::UiScript::set_measured_text`]) — the client asks its font engine for the
// laid-out string's metrics exactly here (`fontstring.md`), and the tooltip's auto-size sums
// these to fit its lines. `0` until the string has been measured (a frame's latency; converges).
@@ -113,11 +120,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| natural_w(lua, &this))?,
)?;
m.set(
"GetStringHeight",
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
)?;
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML
// `justifyH`). The token table, the whole-string match, the raise on a miss and the cross-axis
// clear are all [`crate::justify`]'s, shared with the `<Font>` object's identical pair
@@ -412,3 +412,179 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
"StartSizing must refuse a frame that is not resizable"
);
}
/// **`CreateTitleRegion` / `GetTitleRegion` — the object half** (wow-re
/// `widget-api-batch-benilla.md` Q6, `0x773910` / `0x773820`).
///
/// Four details, each one a coin-flip a reimplementation loses, and each one asserted.
#[test]
fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
TFrame = CreateFrame("Frame", "TFrame")
TFrame:SetPoint("BOTTOMLEFT", 100, 100)
TFrame:SetSize(200, 80)
"#,
)
.unwrap();
// GetTitleRegion answers ONE value and it is nil — not zero values, which is the asymmetry
// Q6 flags against `GetBackdrop`.
assert_eq!(
s.eval::<i64>("return select('#', TFrame:GetTitleRegion())")
.unwrap(),
1
);
assert!(s
.eval::<Option<bool>>("return TFrame:GetTitleRegion() ~= nil and true or nil")
.unwrap()
.is_none());
// It is a plain REGION — not a Texture wearing a hat.
s.run("TR = TFrame:CreateTitleRegion()").unwrap();
assert_eq!(
s.eval::<String>("return TR:GetObjectType()").unwrap(),
"Region"
);
assert_eq!(
s.eval::<i64>("return TR:IsObjectType('Region')").unwrap(),
1
);
assert!(s
.eval::<Option<i64>>("return TR:IsObjectType('Texture')")
.unwrap()
.is_none());
assert!(s
.eval::<bool>("return TFrame:GetTitleRegion() == TR")
.unwrap());
// **It takes NO argument**: `CreateTitleRegion(frame)` is the no-arg call, which is exactly
// what `CustomNameplates/options.lua:73` writes.
assert!(s
.eval::<bool>("return TFrame:CreateTitleRegion(TFrame) == TR")
.unwrap());
// **Idempotent, and DESTRUCTIVELY so.** A second call returns the same object after clearing
// its anchors — so it silently wipes the points a caller already set.
s.run("TR:SetAllPoints(TFrame)").unwrap();
s.resolve();
assert_eq!(s.eval::<i64>("return TR:GetNumPoints()").unwrap(), 2);
assert!(s
.eval::<bool>("return TFrame:CreateTitleRegion() == TR")
.unwrap());
assert_eq!(
s.eval::<i64>("return TR:GetNumPoints()").unwrap(),
0,
"the second call ran ClearAllPoints on the region it returned"
);
// **It answers the 19 Region methods and NOTHING else** — 1250 §5's named divergence, closed.
// Q6: no Show/Hide, no scripts, no textures. Ours used to share one metatable with Texture and
// FontString, so a title region answered `SetTexture` where the reference raises.
for name in crate::script::REGION_MAP_METHODS {
assert_eq!(
s.eval::<String>(&format!("return type(TR.{name})"))
.unwrap(),
"function",
"a title region answers {name}"
);
}
for absent in [
"SetTexture",
"GetTexture",
"SetTexCoord",
"SetVertexColor",
"SetDrawLayer",
"Show",
"Hide",
"IsShown",
"IsVisible",
"SetText",
"GetText",
"SetAlpha",
] {
assert_eq!(
s.eval::<String>(&format!("return type(TR.{absent})"))
.unwrap(),
"nil",
"a title region must NOT answer {absent} — the reference raises there"
);
}
// It never draws: a title region is a hit rectangle, not a visual.
s.run("TR:SetAllPoints(TFrame)").unwrap();
s.resolve();
let region_quads: Vec<_> = s
.extract()
.into_iter()
.filter(|q| matches!(q.target, crate::script::ZTarget::Region(_)))
.collect();
assert!(
region_quads.is_empty(),
"a title region emits no quad of its own (the frame's own quad is not one): {region_quads:?}"
);
// The full region kinds are untouched by the split — asserted LAST, because creating a real
// texture would otherwise put a legitimate quad into the check above.
s.run("TTex = TFrame:CreateTexture(nil, 'ARTWORK')")
.unwrap();
assert_eq!(
s.eval::<String>("return type(TTex.SetTexture)").unwrap(),
"function",
"a Texture still answers its own verbs"
);
}
/// **The drag half — and the three things that make it not just `StartMoving`.**
///
/// A mouse-down inside the title region starts a move that (1) SWALLOWS `OnMouseDown`, (2) ends on
/// release where a scripted move persists, and (3) does not consult `SetMovable`.
#[test]
fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
downs = 0
TP = CreateFrame("Frame", "TP")
TP:SetPoint("BOTTOMLEFT", 100, 100)
TP:SetSize(200, 80)
TP:EnableMouse(true)
TP:SetScript("OnMouseDown", function() downs = downs + 1 end)
TP:CreateTitleRegion():SetAllPoints(TP)
"#,
)
.unwrap();
s.resolve();
let corner = |s: &mut UiScript| {
s.resolve();
s.eval::<f64>("return TP:GetLeft()").unwrap()
};
assert_eq!(corner(&mut s), 100.0);
// Press inside the title region: the move starts and OnMouseDown never runs.
s.mouse_button(150.0, 150.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return downs").unwrap(),
0,
"a title-region hit swallows OnMouseDown — a miss would fall through to it"
);
s.mouse_move(250.0, 150.0);
assert_eq!(corner(&mut s), 200.0, "the frame followed the cursor");
// **Release ends it** — mode 2 auto-cancels. Moving the cursor afterwards must not drag it.
s.mouse_button(250.0, 150.0, "LeftButton", false);
s.mouse_move(400.0, 150.0);
assert_eq!(
corner(&mut s),
200.0,
"the move ended at the release; a scripted StartMoving would still be running"
);
// **No `SetMovable` gate on this path** — TP was never made movable, and it dragged. Q6 marks
// the no-gate reading VERIFIED (the movable bit is simply not read on
// 0x7662c0->0x765320->0x7652b0->0x768430); a title region on a non-movable frame is the case
// that tells it apart from `StartMoving`, which raises "Frame %s is not movable".
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
}
@@ -1145,27 +1145,9 @@ fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() {
/// All 19. `GetObjectType`/`IsObjectType` were held out of this list when 1244 landed the other
/// four — dispatched rather than guessed — and joined it when wow-re answered
/// (`system/ui/scratch/widget-type-identity.md`). The list is the whole map again.
const REGION_MAP: &[&str] = &[
"GetObjectType",
"IsObjectType",
"GetName",
"GetParent",
"SetParent",
"GetCenter",
"GetLeft",
"GetRight",
"GetTop",
"GetBottom",
"GetWidth",
"SetWidth",
"GetHeight",
"SetHeight",
"GetNumPoints",
"GetPoint",
"SetPoint",
"SetAllPoints",
"ClearAllPoints",
];
// The one list, shared with the title region's narrower table (`script::REGION_MAP_METHODS`)
// so the two can never disagree about what "the Region map" is.
const REGION_MAP: [&str; 19] = crate::script::REGION_MAP_METHODS;
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
@@ -1514,3 +1496,44 @@ fn a_frames_type_chain_matches_the_roster() {
"the frame's Usage text names the frame: {err}"
);
}
/// **Every region method belongs to a leaf — no name may be installed and invisible to both.**
///
/// The split (`script::region`) copies names out of the full table into a Texture leaf and a
/// FontString leaf. A name in neither list is still installed, still costs a closure, and is
/// reachable from NOTHING — the silent half of a wrong partition.
///
/// This is not hypothetical: the partition was first built from a grep, and that grep missed
/// `SetGradient`/`SetGradientAlpha` because they are installed from a LOOP rather than a literal
/// `m.set("…")`. One test caught one of them; this gate catches the whole class, and it reads the
/// table the VM actually holds rather than the source that builds it.
#[test]
fn every_installed_region_method_lands_in_a_leaf() {
use crate::script::{
FONTSTRING_ONLY_METHODS, REGION_LEAF_SHARED, REGION_MAP_METHODS, TEXTURE_ONLY_METHODS,
};
let s = crate::script::UiScript::new().unwrap();
let full: mlua::Table = s
.lua()
.named_registry_value(crate::script::REG_REGION_METHODS_FOR_TEST)
.expect("the full region method table");
let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new();
known.extend(REGION_MAP_METHODS);
known.extend(REGION_LEAF_SHARED);
known.extend(TEXTURE_ONLY_METHODS);
known.extend(FONTSTRING_ONLY_METHODS);
let mut orphans: Vec<String> = Vec::new();
for pair in full.pairs::<String, mlua::Value>() {
let (name, _) = pair.expect("region method entry");
if !known.contains(name.as_str()) {
orphans.push(name);
}
}
orphans.sort();
assert!(
orphans.is_empty(),
"installed on the region table but in NO leaf list, so no region can call them: {orphans:?}"
);
}
@@ -662,3 +662,130 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
"SetGradient's stops are opaque, so the midpoint alpha is 1"
);
}
/// **The split itself: a Texture answers texture verbs and NOT text ones, and vice versa.**
///
/// Until this landed, one shared table meant a Texture answered `SetText` and a FontString answered
/// `SetTexture` — a superset in both directions (wow-re
/// `system/ui/scratch/texture-fontstring-method-split.md`: Texture's map `0x87c128` is 22 entries,
/// FontString's `0xcf5400` is 32, both tail-calling the Region map and stopping there).
#[test]
fn the_two_region_leaves_answer_their_own_maps() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
LeafOwner = CreateFrame("Frame", "LeafOwner")
Tex = LeafOwner:CreateTexture("Tex", "ARTWORK")
Str = LeafOwner:CreateFontString("Str", "ARTWORK")
"#,
)
.unwrap();
let has = |s: &crate::script::UiScript, obj: &str, m: &str| {
s.eval::<String>(&format!("return type({obj}.{m})"))
.unwrap()
== "function"
};
// Texture-only, and the asymmetry the carve calls out: `SetVertexColor` is on BOTH leaves while
// `GetVertexColor` is Texture-only. No reasonable partition invents that.
for m in [
"SetTexture",
"GetTexture",
"SetTexCoord",
"SetBlendMode",
"GetVertexColor",
] {
assert!(has(&s, "Tex", m), "a Texture answers {m}");
assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}");
}
// FontString-only.
for m in [
"SetText",
"GetText",
"GetStringWidth",
"SetJustifyH",
"SetAlphaGradient",
] {
assert!(has(&s, "Str", m), "a FontString answers {m}");
assert!(!has(&s, "Tex", m), "a Texture must NOT answer {m}");
}
// On BOTH — and each leaf registers its own copy in the client, so these are NOT on the Region
// map and must not be hoisted into it.
for m in [
"SetVertexColor",
"SetAlpha",
"Show",
"Hide",
"IsShown",
"SetDrawLayer",
] {
assert!(
has(&s, "Tex", m) && has(&s, "Str", m),
"{m} is on both leaves"
);
}
// The Region map reaches both, through each leaf's own fallback.
for m in crate::script::REGION_MAP_METHODS {
assert!(
has(&s, "Tex", m) && has(&s, "Str", m),
"{m} is the Region map"
);
}
// **`GetStringHeight` is GONE and must stay gone.** 1.12 has no such method on any table (0
// hits in every encoding; the control `GetStringWidth` has 1), Blizzard's own FrameXML calls it
// 0 times, and ours was a byte-identical duplicate of `GetHeight` — which is the method the
// reference itself uses for this, falling through to the same cached measurement
// `GetStringWidth` reads. Keeping the width and dropping the height is not an oversight: the
// client really is asymmetric here.
assert!(
!has(&s, "Str", "GetStringHeight"),
"1.12 has no GetStringHeight"
);
assert!(
has(&s, "Str", "GetStringWidth"),
"…but it does have GetStringWidth"
);
assert!(
has(&s, "Str", "GetHeight"),
"GetHeight is the replacement, via the Region map"
);
// The near-miss pair: Texture has SetGradientAlpha, FontString has SetAlphaGradient.
assert!(has(&s, "Tex", "SetGradientAlpha") && !has(&s, "Str", "SetGradientAlpha"));
assert!(has(&s, "Str", "SetAlphaGradient") && !has(&s, "Tex", "SetAlphaGradient"));
}
/// **`SetPortraitToTexture` is a GLOBAL in 1.12, not a Texture method.**
///
/// `reference/1.12-globals.tsv` marks it `engine`, and both of the reference's own call sites pass a
/// texture NAME: `ContainerFrame.lua:419` and `MailFrame.lua:174`. The first is the one that binds
/// us — we SOURCE `ContainerFrame.lua` off the patch chain, so the client's own file calls this
/// global inside our VM.
#[test]
fn set_portrait_to_texture_is_a_global_taking_a_name() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
PortHost = CreateFrame("Frame", "PortHost")
Port = PortHost:CreateTexture("PortHostPortrait", "ARTWORK")
SetPortraitToTexture("PortHostPortrait", "Interface\\ContainerFrame\\KeyRing-Bag-Icon")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Port:GetTexture()").unwrap(),
"Interface\\ContainerFrame\\KeyRing-Bag-Icon"
);
// The Texture METHOD is gone — 1.12's Texture map has no such entry.
assert_eq!(
s.eval::<String>("return type(Port.SetPortraitToTexture)")
.unwrap(),
"nil",
"1.12 has no Texture:SetPortraitToTexture — it is a global"
);
// An unknown name is not an error: the reference's callers compose names that may not exist
// yet, and nothing here may raise on the sourced file's behalf.
s.run(r#"SetPortraitToTexture("NoSuchPortrait", "Interface\\X")"#)
.unwrap();
assert!(s.errors().is_empty());
}
+1 -1
View File
@@ -152,7 +152,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
} else {
"GameTooltipText"
};
let fo = model.font_objects.get(font).cloned();
let fo = model.font_object(font).cloned();
apply_font(d, font, fo.as_ref());
}
}
@@ -70,6 +70,14 @@ pub enum RegionKind {
Texture,
/// A `FontString` (text run).
FontString,
/// A frame's **title region** — the drag handle `Frame:CreateTitleRegion()` makes.
///
/// A third leaf rather than a texture that happens to draw nothing, because the difference is
/// OBSERVABLE: wow-re carves the object as a plain Region (`widget-api-batch-benilla.md` Q6,
/// `0x773910`) whose `GetObjectType()` answers `"Region"` and which exposes exactly the 19
/// Region methods — **no Show/Hide, no scripts, no textures**. A `Texture` in disguise would
/// answer `"Texture"` to any addon that asked, and would emit a quad.
Title,
}
/// Per-kind widget state — the "later layer" over the kind tag, for the kinds whose behavior is
+8
View File
@@ -208,6 +208,13 @@ pub struct Frame {
/// `SetParent(nil)` stays in this list so [`WidgetArena::destroy`] still frees it — see
/// [`Region::detached`], which is what actually takes it out of the draw.
pub regions: Vec<RegionHandle>,
/// This frame's **title region** — the drag handle at `CSimpleFrame+0xA8`, or `None`.
///
/// **One per frame**, which is what makes `CreateTitleRegion` idempotent: a second call returns
/// the SAME object after clearing its anchors, so calling it on an XML-declared title region
/// silently wipes them (wow-re `widget-api-batch-benilla.md` Q6). It is also in
/// [`Frame::regions`], so the arena still frees it.
pub title_region: Option<RegionHandle>,
/// The draw stratum (`frameStrata +0xc0`, default MEDIUM).
pub strata: Strata,
/// The level within the stratum (`frameLevel +0xc4`, default 0).
@@ -496,6 +503,7 @@ impl WidgetArena {
parent,
children: Vec::new(),
regions: Vec::new(),
title_region: None,
strata: Strata::default(),
level: 0,
alpha,