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
@@ -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());
}