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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?)),
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user