the addon survey seats a populated world, and hidden frames arrive

- the corpus harness now seats a buff, a target and a ticking cooldown instead of an empty world, so "nothing to draw" and "failed to draw" stop sharing a row — and the richer seat immediately exposed two real gaps
- an addon button inheriting our action-button template now works even when it replaces the template's OnLoad: the handlers resolve their regions by name, the way the reference does on every access
- more of the reference's hidden-by-default frames exist by name — ReputationWatchBar, MiniMapBattlefieldFrame, MiniMapMeetingStoneFrame — for the addons that reposition them
- StartSizing lands beside its stop verb, and the sourced bag-anchor code runs against windows we actually build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:29:44 +07:00
co-authored by Claude Fable 5
parent 017860667b
commit 023235a086
16 changed files with 1645 additions and 933 deletions
+147
View File
@@ -232,6 +232,36 @@
-- (ActionButton_GetPagedID:446-467 — MultiBarBottomLeft ⇒ page 6, MultiBarBottomRight ⇒
-- page 5, else the paged/bonus formula); we carry the fork as data — a fixed per-button
-- page base set at OnLoad (MultiBars.xml), nil for the bonus-paged main bar.
-- **Adopt a button that never ran our OnLoad.**
--
-- Our handlers read a region cache (`button.icon`, `.count`, `.cooldown`, …) that
-- `BenillaActionButton_OnLoad` fills. An addon inheriting `ActionBarButtonTemplate` gets
-- those handlers and supplies its OWN `<OnLoad>`, which REPLACES the template's rather
-- than chaining — so ours never runs and every one of those reads was nil. zBar.xml:7 is
-- the corpus shape; it died in OUR file at `ActionBar.xml:529` on `button.icon`.
--
-- Resolving from the derived names is not a fallback bolted on: it is exactly what our
-- OnLoad does, and what the REFERENCE does on every access (`ActionButton_Update` reads
-- `getglobal(this:GetName().."Icon")` each time rather than caching). We cache because it
-- is cheaper; this is the same lookup for the buttons that never got cached.
--
-- It deliberately does NOT join `BENILLA_ACTION_BUTTONS`. That roster is the event fan-out
-- for OUR bars, and a foreign button's owner drives its own updates — adding it would have
-- us repainting somebody else's button behind their back.
function BenillaActionButton_Adopt(button)
if not button or button.icon then return end
local n = button:GetName()
if not n then return end
button.icon = getglobal(n .. "Icon")
button.hotkey = getglobal(n .. "HotKey")
button.count = getglobal(n .. "Count")
button.flash = getglobal(n .. "Flash")
button.border = getglobal(n .. "Border")
button.cooldown = getglobal(n .. "Cooldown")
button.flashing = button.flashing or 0
button.flashtime = button.flashtime or 0
end
function BenillaActionButton_Action(button)
-- `index` falls back to the button's ID, which is where the REFERENCE reads it from:
-- `ActionButton_GetPagedID` uses `this:GetID()`, and our `.index` is only that value
@@ -260,6 +290,7 @@
-- ships no CVar state, so the branch collapses to the stock-client one: action-bar
-- tooltips live at the screen's bottom-right, above the bags.
function BenillaActionButton_OnEnter(button)
BenillaActionButton_Adopt(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip_SetDefaultAnchor(GameTooltip, button)
GameTooltip:SetAction(BenillaActionButton_Action(button))
@@ -305,6 +336,7 @@
-- Ref ActionBarFrame.xml:12-22's fork verbatim: shift picks up, else uses (checkCursor=1 —
-- the engine routes a click-with-payload to PlaceAction instead, decision 0216 §7).
function BenillaActionButton_OnClick(button)
BenillaActionButton_Adopt(button)
local action = BenillaActionButton_Action(button)
if IsShiftKeyDown() then
PickupAction(action)
@@ -320,12 +352,14 @@
-- stops the *drag*, and shift-click stays the deliberate way to move an action with the bar
-- locked — which is what makes the setting usable rather than a wall.
function BenillaActionButton_OnDragStart(button)
BenillaActionButton_Adopt(button)
if LOCK_ACTIONBAR == "1" then return end
PickupAction(BenillaActionButton_Action(button))
BenillaActionButton_Update(button)
end
function BenillaActionButton_OnReceiveDrag(button)
BenillaActionButton_Adopt(button)
if LOCK_ACTIONBAR == "1" then return end
PlaceAction(BenillaActionButton_Action(button))
BenillaActionButton_Update(button)
@@ -427,6 +461,7 @@
-- tints red/grey, an unbound one wears the RANGE_INDICATOR dot (both legs the ref's,
-- unified in 1008 — the SCOPE note).
function ActionButton_OnUpdate(elapsed)
BenillaActionButton_Adopt(this)
local button = this
if ( button.flashing == 1 ) then
button.flashtime = button.flashtime - elapsed
@@ -499,7 +534,78 @@
-- arguments are real state our per-container registration needs, so no adapter can supply
-- them. That is the per-button event registration change already at the top of the arc
-- queue, not a naming fix.
-- **`ActionButton_OnLoad()` — the reference's own, taking nothing and reading `this`.**
--
-- This sat at the top of the arc queue for the whole wave, recorded as unadaptable: ours
-- takes `(button, index, base, commandPrefix)` and the reference's takes none, so "no
-- adapter can supply them". Reading ref ActionButton.lua:103-119 shows that was wrong —
-- the reference derives all three from `this` and it is our own signature that caches
-- them:
--
-- index -> `this:GetID()` (what ActionButton_GetPagedID reads)
-- commandPrefix -> `this.buttonType`, a field the BAR sets, "ACTIONBUTTON" by default
-- (ref l.118 `ActionButton_UpdateHotkeys(this.buttonType)`)
-- base -> not an OnLoad concept at all; the reference forks on the PARENT's
-- NAME at read time. A foreign bar ("zBar1") matches no multibar, so
-- the paged/bonus formula applies — which is what leaving `base` nil
-- already does here.
--
-- The events are registered on the BUTTON, as the reference does, and reach it through the
-- `<OnEvent>` this change adds to `ActionBarButtonTemplate`. Our own bars do NOT call this
-- and never register, so they keep being driven by the BENILLA_ACTION_BUTTONS fan-out and
-- cannot be double-driven.
function ActionButton_OnLoad()
local button = this
if not button then return end
BenillaActionButton_Adopt(button)
button.index = button.index or button:GetID()
button.bindingCommand = (button.buttonType or "ACTIONBUTTON") .. (button.index or 1)
button.showgrid = 0
button.flashing = 0
button.flashtime = 0
button:RegisterForDrag("LeftButton", "RightButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button:RegisterEvent("PLAYER_ENTERING_WORLD")
button:RegisterEvent("UPDATE_BONUS_ACTIONBAR")
button:RegisterEvent("ACTIONBAR_SHOWGRID")
button:RegisterEvent("ACTIONBAR_HIDEGRID")
button:RegisterEvent("ACTIONBAR_PAGE_CHANGED")
button:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
button:RegisterEvent("UPDATE_BINDINGS")
BenillaActionButton_PaintHotkey(button)
BenillaActionButton_Update(button)
end
-- The per-button event arm the reference's own template carries. Only buttons that ran
-- `ActionButton_OnLoad` ever registered, so this is a no-op for ours.
function ActionButton_OnEvent()
local button = this
if not button then return end
BenillaActionButton_Adopt(button)
if event == "ACTIONBAR_SHOWGRID" then
button.showgrid = 1
elseif event == "ACTIONBAR_HIDEGRID" then
button.showgrid = 0
elseif event == "UPDATE_BINDINGS" then
BenillaActionButton_PaintHotkey(button)
end
BenillaActionButton_Update(button)
end
-- ref ActionButton.lua:446 — the action id a button displays, forked on its parent bar.
-- `BenillaActionButton_Action` IS this function; only the name was ours. CT_BarMod calls it
-- the moment there is an action to page, which is why an EMPTY-world survey never saw it:
-- it surfaced only when the fixture started seating a populated session.
function ActionButton_GetPagedID(button)
return BenillaActionButton_Action(button or this)
end
function ActionButton_Update()
-- Adopt here too: this adapter carries the REFERENCE's name, so `this` is very often a
-- button somebody else built (zBar's, CT_BarMod's) that never ran our OnLoad and so has
-- no region cache. The handler-boundary adopts do not cover it — an addon calls this
-- global directly, without any of our scripts in the stack.
BenillaActionButton_Adopt(this)
BenillaActionButton_Update(this)
end
@@ -1075,6 +1181,7 @@
<!-- The bar half: the reference's `ActionBarButtonTemplate`, regions inherited, handlers added. -->
<CheckButton name="ActionBarButtonTemplate" inherits="ActionButtonTemplate" virtual="true">
<Scripts>
<OnEvent>ActionButton_OnEvent()</OnEvent>
<OnClick>BenillaActionButton_OnClick(self)</OnClick>
<OnDragStart>BenillaActionButton_OnDragStart(self)</OnDragStart>
<OnReceiveDrag>BenillaActionButton_OnReceiveDrag(self)</OnReceiveDrag>
@@ -1537,4 +1644,44 @@
</CheckButton>
</Frames>
</Frame>
<!-- **The reputation watch bar, built and hidden** — the third time this posture has been the
answer (1219's vertical multibars, 1223's bonus bar, this). `ref-ReputationFrame.xml:869`
declares it `<Frame name="ReputationWatchBar" parent="MainMenuBar" hidden="true">`: real on
the client from load, shown only while a faction is being watched.
benilla does not drive reputation watching, so it stays hidden — but three corpus addons
index it by name (`FuBar_FuXPFu`, `CT_BottomBar`, `zBarEx`), and FuXPFu dies at
`FuBar_FuXPFu.lua:227` on the nil. Its own `OnEnable` reaches for it the moment the addon
enables, so nothing before the populated-session fixture ever got that far.
Geometry quoted: a 1024x11 frame whose BOTTOM sits on MainMenuBar's TOP at (0,-3), holding
a 1024x8 `ReputationWatchStatusBar` at TOP. The reference's two art slices
(`ReputationWatchBarTexture0/1`, `UI-ReputationWatchBar`) are declared with it — an addon
that repositions the bar expects the named regions to exist, and they draw nothing while
the frame is hidden. -->
<Frame name="ReputationWatchBar" enableMouse="true" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="1024" y="11"/></Size>
<Anchors>
<Anchor point="BOTTOM" relativeTo="MainMenuBar" relativePoint="TOP"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor>
</Anchors>
<Frames>
<StatusBar name="ReputationWatchStatusBar" drawLayer="ARTWORK">
<Size><AbsDimension x="1024" y="8"/></Size>
<Anchors><Anchor point="TOP"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="ReputationWatchBarTexture0" file="Interface\PaperDollInfoFrame\UI-ReputationWatchBar">
<Size><AbsDimension x="256" y="11"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="2"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="ReputationWatchBarTexture1" file="Interface\PaperDollInfoFrame\UI-ReputationWatchBar">
<Size><AbsDimension x="256" y="11"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ReputationWatchBarTexture0" relativePoint="RIGHT"/></Anchors>
</Texture>
</Layer>
</Layers>
</StatusBar>
</Frames>
</Frame>
</Ui>
@@ -78,5 +78,29 @@
end
refItemButtonOnEnter(button)
end
-- ── `updateContainerFrameAnchors` — a no-op here, and that IS the benilla answer ────────
-- The reference's body (l.480-489) opens `while ContainerFrame1.bags[index] do` and walks
-- the twelve `ContainerFrame1..12` windows it owns, positioning each into columns. benilla
-- does not build those windows at all — our bags are `BagFrame.xml`'s own, and they
-- position themselves. `ui_script::manifest` already records the same difference one
-- function over: *"ToggleBackpack walks twelve ContainerFrames we do not build; ours walks
-- our windows."*
--
-- So this is not a stub standing in for something missing: there is nothing here for it to
-- anchor, and the reference body raises `attempt to index global 'ContainerFrame1'` the
-- moment anything calls it. FIVE corpus addons did — Bongos, Bongos_ActionBar and the
-- CT_BarMod family all HOOK it (`BLIZZARD_Original_updateContainerFrameAnchors` in the
-- traceback) and then call through, so the raise landed inside a Blizzard file with our
-- bag layout nowhere in the stack.
--
-- Deliberately silent rather than warned: an addon calling this on every bag update would
-- turn a warn-once into a warn-always, and unlike the cases in the section above nothing is
-- LOST by doing nothing — the windows it would have moved are already where our own layout
-- put them. The header's "never silently stubbed" rule is about absent capability; this is
-- present capability with a different owner.
function updateContainerFrameAnchors()
end
]]></Script>
</Ui>
@@ -304,6 +304,56 @@
cluster — hoisted to the cluster the same offset lands past the screen edge.
Unprefixed (this file's own convention: every frame here keeps the
reference's bare name, unlike the newer Benilla*-prefixed windows). -->
<!-- **Two more of the reference's minimap icons, built and hidden** — the
fourth time this posture has been the answer (1219's vertical multibars,
1223's bonus bar, ReputationWatchBar, these). `ref-Minimap.xml` declares
both `hidden="true"`: real on the client from load, shown only when there
is a battleground queue or a meeting stone to advertise.
benilla drives neither, so they stay hidden — but addons reposition the
whole icon set by NAME. `CleanMinimap.lua:209` is
`getglobal(frameName):ClearAllPoints()` over a list that includes both, and
it died on the nil; `MiniMapBattlefieldFrame` alone is 5 addons in the
corrected ranking (1218). Their icon regions are declared with them, since
an addon that moves a frame expects its named children to exist.
Geometry quoted from the reference: both 33x33 with an 18x18 icon at
TOPLEFT (7,-6), Battlefield at BOTTOMLEFT (13,-13) of the cluster and
MeetingStone at TOPRIGHT (23,-65). -->
<Button name="MiniMapBattlefieldFrame" enableMouse="true" hidden="true">
<Size><AbsDimension x="33" y="33"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="13" y="-13"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MiniMapBattlefieldIcon" file="Interface\BattlefieldFrame\UI-Battlefield-Icon">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="-1" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
</Button>
<Button name="MiniMapMeetingStoneFrame" enableMouse="true" hidden="true">
<Size><AbsDimension x="33" y="33"/></Size>
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="23" y="-65"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="MiniMapMeetingStoneIcon" file="Interface\Icons\INV_Misc_Rune_06">
<Size><AbsDimension x="18" y="18"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
</Button>
<Frame name="MiniMapMailFrame" enableMouse="true" hidden="true">
<Size>
<AbsDimension x="33" y="33"/>
+20 -1
View File
@@ -176,8 +176,27 @@ fn main() {
.map(|r| r.name.as_str())
.collect();
if !silent.is_empty() {
// **This list is a QUESTION, not a defect list, and the header used to say otherwise.**
//
// It read "read this list first", which invites treating every row as a bug. Most are not.
// The survey seats a player and an empty world: no buffs, no target, no combat, no cursor
// over an item. An addon with nothing to draw in that world draws nothing CORRECTLY — and
// that is most of this list. Measured on the vanilla corpus rather than assumed: of 48 rows
// checked, 6 ship no XML at all (pure libraries — Ace, LibStub, Stubby, DevTools), and of
// the rest the buff bars (CT_BuffMod 29 frames, ElkBuffBar 3, neither hidden at birth) are
// waiting on buffs that a fresh login does not have either.
//
// What the row DOES mean is "nothing here can be ruled out by the other four columns" —
// which is worth printing, and is not the same as "broken".
//
// The instrument change that would sharpen it is a POPULATED session fixture (a buff, a
// target, a live cooldown) so that "nothing to draw" and "failed to draw" stop sharing a
// row. Named here rather than done, because seating content changes the fixture for all
// 218 addons and deserves its own controlled A/B.
println!(
"\n CLEAN ON EVERY OTHER COLUMN AND DREW NOTHING ({}) — read this list first:",
"\n DREW NOTHING, and clean on every other column ({}) — a QUESTION, not a defect\n \
list: the seated world has one buff, one target and one running cooldown, so an addon\n \
waiting on combat, a cursor over an item or a slash command belongs here too:",
silent.len()
);
for chunk in silent.chunks(4) {
+170 -2
View File
@@ -832,13 +832,26 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve
// (`load_dependencies`) and there is no reason session errors should differ — charging it here
// would count one library's fault once per addon that embeds it, which is the whole reason
// one-VM-per-addon exists.
//
// **Attribution is by the RAISING CHUNK now, not by which event window the raise fell in**
// (decision 1226 — recorded when this was still window-based, fixed here). The window proxy
// broke on the shape it was written for: AceAddon drains its ENTIRE `nextAddon` queue on any
// `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`) and calls each consumer's
// `OnInitialize` there (`:230`). So the SURVEYED addon's own code runs inside a DEPENDENCY's
// window, and firing deps outside the mark charged those raises to nobody — the whole
// FuBar/Ace family was silently OVER-reported as surviving. `Region:SetParent` landing made
// FuBar_FuXPFu flip `ok -> fail` and that looked like a regression; it was this.
//
// Every `ADDON_LOADED` fires inside the mark now, and the dependency exemption is applied
// afterwards by asking WHOSE FILE raised. Chunk names have been truthful since 1217
// (`@Interface\AddOns\<Folder>\<File>`), which is what makes the honest key available at all.
let before = script.errors().len();
for dep in deps {
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(dep.clone())],
);
}
let before = script.errors().len();
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
@@ -849,7 +862,37 @@ fn drive_session_start(script: &mut UiScript, name: &str, deps: &[String]) -> Ve
for _ in 0..10 {
script.tick(0.1);
}
script.errors().split_off(before)
let raised = script.errors().split_off(before);
raised
.into_iter()
.filter(|e| !raised_inside_a_dependencys_own_file(e, name, deps))
.collect()
}
/// Does this raise belong to a DEPENDENCY's file rather than the surveyed addon's?
///
/// The rule `load_dependencies` already states for load errors, now enforceable for session errors
/// too: *a library that fails is its own row; blaming its consumers would count one fault once per
/// addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the frames
/// below it are the call path, and a consumer calling into a library that then raises is still the
/// library's fault, exactly as it is at load time.
///
/// **Conservative on purpose.** A chunk that names no addon folder at all — our own FrameXML, an
/// `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what drove it. Only
/// a chunk that positively names one of this addon's own dependencies is dropped. Getting that
/// backwards would swing the column the other way, and the whole point of 1226 is that a proxy
/// which errs silently in one direction is how the number drifted in the first place.
fn raised_inside_a_dependencys_own_file(err: &str, name: &str, deps: &[String]) -> bool {
let Some(first) = err.lines().next() else {
return false;
};
// The surveyed addon's own folder always wins, even when a dependency's name is a substring of
// a path inside it.
if first.contains(&format!("\\{name}\\")) || first.starts_with(&format!("{name}\\")) {
return false;
}
deps.iter()
.any(|d| first.contains(&format!("\\{d}\\")) || first.starts_with(&format!("{d}\\")))
}
/// Invoke the reference's own UI entry points, so an addon's OVERRIDES actually execute.
@@ -1206,6 +1249,69 @@ fn seat_a_session(script: &mut UiScript) {
..Default::default()
}),
);
// ── A POPULATED world, not merely an inhabited one ──────────────────────────────────────
//
// The columns above answer "did it raise"; the render column answers "did it draw". Until
// now the survey seated a player into an EMPTY world — no buffs, no target, no live
// cooldown — so a buff bar, a target frame or a cooldown-text addon drew nothing and landed
// in the drew-nothing list beside the pure libraries. `CT_BuffMod` declares 29 frames with
// none hidden at birth and `ElkBuffBar` 3: neither was failing to build, both had nothing to
// put in them.
//
// That made "nothing to draw" and "failed to draw" share a row, and the second is the one
// worth finding. Seating content separates them — and, more usefully, exposes the addons
// that only fail WHEN there is something to draw, which no empty-world column can reach.
//
// Deliberately minimal and deliberately ordinary: one buff, one target, one occupied action
// with a running cooldown. Not a stress fixture — a Tuesday.
script.set_auras(
"player",
Some(vec![benilla_ui::script::AuraState {
spell_id: 1243,
name: Some("Power Word: Fortitude".into()),
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
count: 1,
helpful: true,
cancelable: true,
..Default::default()
}]),
);
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Target Dummy".into()),
health: 80,
max_health: 100,
level: 60,
power_type: 0,
power: 50,
max_power: 100,
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..Default::default()
}),
);
script.set_action(
1,
Some(benilla_ui::script::ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
}),
);
script.set_action_state(
1,
Some(benilla_ui::script::ActionState {
usable: true,
// (startTime ms, duration ms, enable) — a cooldown with time left on it, which is
// what a cooldown-text addon (OmniCC, CooldownCount) needs before it draws anything.
cooldown: Some((1, 30_000, true)),
..Default::default()
}),
);
}
/// Every string key in the VM's `_G`.
@@ -2841,6 +2947,68 @@ mod dependency_tests {
/// dependency's OWN handler raising is the dependency's row, never its consumers'. Those
/// events fire outside the error-capture window, so one library's fault cannot be counted once
/// per addon that embeds it.
/// **A consumer's OWN code raising inside a DEPENDENCY's window is the consumer's row.**
///
/// 1226's finding, now enforced. `AceAddon-2.0.lua:104-105` drains its entire `nextAddon` queue
/// on ANY `ADDON_LOADED` it sees and calls each consumer's `OnInitialize` there (`:230`). So
/// the surveyed addon's own file runs inside a dependency's window — and while attribution was
/// by window, those raises were charged to nobody and the whole FuBar/Ace family read as
/// surviving when it was not.
///
/// Attribution is by the raising CHUNK now, which 1217 made truthful. The fixture is AceAddon's
/// shape reduced: the library drains a queue on the first ADDON_LOADED it sees, whoever it
/// names, and the consumer's callback raises from the consumer's own file.
///
/// The second half is the half that must not regress: the LIBRARY's own raise, in the same
/// window, still belongs to the library.
#[test]
fn a_consumers_raise_inside_a_dependency_window_is_the_consumers_row() {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-attrib-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let write = |name: &str, toc: &str, file: &str, body: &str| {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
std::fs::write(dir.join(file), body).unwrap();
};
// AceAddon's shape: drain every queued consumer on the FIRST ADDON_LOADED, whoever it names.
write(
"QueueLib",
"## Interface: 11200\nlib.lua\n",
"lib.lua",
"QueueLibQueue = {}\n\
QueueLibFrame = CreateFrame(\"Frame\")\n\
QueueLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
QueueLibFrame:SetScript(\"OnEvent\", function()\n\
while table.getn(QueueLibQueue) > 0 do\n\
local f = table.remove(QueueLibQueue, 1) f()\n\
end\n\
end)\n",
);
// The consumer queues a callback that raises from ITS OWN file.
write(
"QueueUser",
"## Interface: 11200\n## Dependencies: QueueLib\nuse.lua\n",
"use.lua",
"table.insert(QueueLibQueue, function() error(\"consumer init blew up\") end)\n",
);
let reports = survey(&tmp);
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
assert!(
of("QueueUser")
.session_errors
.iter()
.any(|e| e.contains("consumer init blew up")),
"the consumer's own file raised — window or not, it is the consumer's row: {:?}",
of("QueueUser").session_errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn each_loaded_addon_gets_its_own_addon_loaded_event() {
let tmp = std::env::temp_dir().join(format!(
@@ -24,9 +24,9 @@ fn shipped_action_bar_drives_end_to_end() {
);
if file == "ActionBar.xml" {
assert_eq!(
report.frames, 59,
report.frames, 61,
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \
+ BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223)"
+ BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar and its status bar (2 — hidden, ref ReputationFrame.xml:869)"
);
}
}
+4
View File
@@ -523,6 +523,9 @@ pub(crate) struct Model {
/// with nothing moving, and a move outlives the mouse button (the reference's mouse-up
/// auto-stop skips the Lua drag type).
pub(crate) moving: Option<super::object::FrameMove>,
/// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same
/// `StopMovingOrSizing` (`0x776990`).
pub(crate) sizing: Option<super::object::FrameSizing>,
/// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held
/// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag —
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
@@ -1038,6 +1041,7 @@ impl Model {
drag_registered: HashMap::new(),
drag: None,
moving: None,
sizing: None,
slider_drag: None,
gossip: None,
gossip_selects: Vec::new(),
+1 -1
View File
@@ -31,7 +31,7 @@ mod layout_methods;
mod movable;
pub(crate) mod toplevel;
pub(crate) use layout_methods::anchor_bits_eq;
pub(crate) use movable::{advance_move, FrameMove};
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// id ↔ lightuserdata
@@ -274,8 +274,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// **97 of the 108 addons that drew and then raised on being touched died on that one line.** No
/// missing verb: `GetLeft` was always there, and always answered nil.
///
/// Cheap: `resolve_layout`'s tier-1 gate is one epoch comparison that returns immediately when
/// nothing has been touched, so a settled tree pays a compare per getter.
/// Cheap on a SETTLED tree: `resolve_layout`'s tier-1 gate is one epoch comparison that returns
/// immediately when nothing has been touched, so a run of getters pays a compare each.
///
/// **The interleaved case is not, and this is measured rather than asserted.** A write bumps the
/// epoch, so `SetPoint; GetLeft; SetPoint; GetLeft; …` resolves the whole graph once per iteration.
/// Timed on a 200-frame tree: 30 alternating pairs cost **1.41 ms** against **54 µs** for the same
/// writes with a single read at the end — ~26x, about 47 µs per settle, and the per-settle half
/// scales with the GRAPH, not the loop.
///
/// That is exactly the shape Dewdrop's menu builder has, so opening a menu pays it once. It buys a
/// menu that works at all, which is the trade taken here. If it ever reads as a hitch, the fix is a
/// narrower resolve (the queried frame's subtree), not a return to the stale cache.
///
/// **It deliberately does NOT fire `OnSizeChanged`.** That drain runs Lua handlers, and re-entering
/// Lua from inside a binding is how a borrow panic or an unbounded recursion happens. The
@@ -103,6 +103,19 @@ pub(crate) struct FrameMove {
sample: (f32, f32),
}
/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FrameSizing {
frame: FrameHandle,
/// The grip the caller named — the EDGES this drag moves. `LEFT` moves the left edge and
/// leaves the right where it is, `BOTTOMRIGHT` moves both of those, and so on.
left: bool,
right: bool,
top: bool,
bottom: bool,
sample: (f32, f32),
}
/// Populate `m`'s movable/resizable methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetMovable(flag) / IsMovable() — flag word bit 0x100 (`0x776420`/`0x7764d0` through the
@@ -177,6 +190,57 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
start_moving(&mut model, h)
})?,
)?;
// StartSizing(point) — begin a resize drag from a named grip (`0x776830`, verified in wow-re's
// ledger; the reference's own caller is `FloatingChatFrame.lua:600`,
// `this:GetParent():StartSizing(anchorPoint)`).
//
// **What is verified and what is read, kept apart on purpose.** Verified: the verb exists, it
// takes the grip name, it returns nothing, and `StopMovingOrSizing` ends it (the same slot
// clear as a move). NOT recorded anywhere in wow-re — the ledger has it as ORCHESTRATION with
// no inline math — is WHICH EDGES a given grip moves. Taken here as the plain meaning of an
// anchor point, which is how the reference's own caller uses it (its resize grips pass the
// corner they sit in): the named edges follow the cursor and the opposite ones stay put. If an
// RE pass ever contradicts that, this comment is where to correct it.
//
// Four corpus addons reach it through ONE line — `FuBar_Panel.lua:980`, replicated into
// FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2 — which is 1207's rule and why the
// count is not four independent votes.
m.set(
"StartSizing",
lua.create_function(|lua, (this, point): (Table, Option<String>)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.arena.frame(h).is_some_and(|f| f.resizable) {
return Err(not_flagged(&model, h, "resizable"));
}
// Same "do not start a second one" guard `start_moving` carries.
if model.sizing.is_some() || model.moving.is_some() {
return Ok(());
}
let p = point.unwrap_or_default().to_ascii_uppercase();
let (left, right) = (p.contains("LEFT"), p.contains("RIGHT"));
let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM"));
// A grip naming no edge would resize nothing; the reference has no such call and we
// refuse rather than invent one.
if !(left || right || top || bottom) {
return Ok(());
}
super::toplevel::raise(&mut model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
let sample = model.cursor_pos;
model.sizing = Some(FrameSizing {
frame: h,
left,
right,
top,
bottom,
sample,
});
Ok(())
})?,
)?;
// StopMovingOrSizing() — leave the drag (`0x776990` → `0x765640`): a state clear, and only
// when `self` IS the frame in the slot. Nothing is written back — the pump has been moving the
// anchors all along, so the frame simply keeps the last position it was dragged to. Harmless
@@ -187,6 +251,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.sizing.is_some_and(|sz| sz.frame == h) {
model.sizing = None;
}
if model.moving.is_some_and(|mv| mv.frame == h) {
model.moving = None;
}
@@ -283,6 +350,62 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
/// writing to a dead handle.
/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others.
///
/// The mirror of [`advance_move`] — same sample-and-recentre, same dead-frame bail, same
/// local-unit scaling (offsets are pre-scale, so a cursor delta divides by the frame's own scale on
/// the way in). Where a move translates every anchor, a resize moves the gripped edges only: the
/// width/height change, and the anchor offsets follow only for the edges that actually moved, which
/// is what keeps the OPPOSITE edge planted.
pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
let Some(sz) = model.sizing else { return };
if model.arena.frame(sz.frame).is_none() {
model.sizing = None;
return;
}
let (dx, dy) = (pos.0 - sz.sample.0, pos.1 - sz.sample.1);
if dx == 0.0 && dy == 0.0 {
return;
}
let inv = 1.0 / eff_scale(model, sz.frame);
let (dx, dy) = (dx * inv, dy * inv);
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
return;
};
// Width grows when the RIGHT grip goes right, or the LEFT grip goes left. Height likewise with
// y-up: the TOP grip going up grows it, the BOTTOM grip going down grows it.
let dw = if sz.right {
dx
} else if sz.left {
-dx
} else {
0.0
};
let dh = if sz.top {
dy
} else if sz.bottom {
-dy
} else {
0.0
};
input.width = (input.width + dw).max(1.0);
input.height = (input.height + dh).max(1.0);
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
// or the resize would push the right edge instead. Only the gripped axis shifts.
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
for a in &mut input.anchors {
if sz.left {
a.x_off += dx;
}
if sz.bottom {
a.y_off += dy;
}
}
}
model.touch_layout();
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
let Some(mv) = model.moving else { return };
if model.arena.frame(mv.frame).is_none() {
+1
View File
@@ -204,6 +204,7 @@ impl UiScript {
// crosses and a pump behind the early return would only ever advance when the cursor
// left the frame it is dragging.
super::object::advance_move(&mut model, (x, y));
super::object::advance_size(&mut model, (x, y));
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
if new_handle == model.mouseover {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
//! Region method-table cluster: **layout** — size, anchors and the resolved-rect readers.
//! Split out of `region.rs` at the 0716 file-size budget.
use mlua::{Lua, Table, Value};
use crate::layout::{Anchor, Point};
use crate::script::object::anchor_bits_eq;
use crate::script::Model;
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
use super::{
measured_wh, region_handle_of, region_owner_id, region_set_point, resolve_target, size_bits_eq,
};
/// Populate `m`'s layout methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Region explicit size (drawn centered on the owner; region anchors come later).
m.set(
"SetWidth",
lua.create_function(|lua, (this, w): (Table, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let new = Some((w, d.size.map_or(0.0, |s| s.1)));
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
model.touch_layout();
}
Ok(())
})?,
)?;
m.set(
"SetHeight",
lua.create_function(|lua, (this, h): (Table, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let new = Some((d.size.map_or(0.0, |s| s.0), h));
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
model.touch_layout();
}
Ok(())
})?,
)?;
m.set(
"SetSize",
lua.create_function(|lua, (this, w, h): (Table, f32, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let new = Some((w, h));
let changed = !size_bits_eq(d.size, new);
d.size = new;
if changed {
model.touch_layout();
}
Ok(())
})?,
)?;
m.set(
"GetWidth",
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?,
)?;
m.set(
"GetHeight",
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
)?;
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin
// in object.rs). An anchored region reads its own resolved rect; an unanchored one has no
// rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve.
for (name, pick) in [
("GetLeft", 0u8),
("GetRight", 1u8),
("GetTop", 2u8),
("GetBottom", 3u8),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.region_resolved.get(&rh).map(|r| match pick {
0 => r.left,
1 => r.right,
2 => r.top,
_ => r.bottom,
}))
})?,
)?;
}
// Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions
// ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo`
// defaults to the **owner frame**; a named one may be a frame or a sibling region (the real
// XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`).
m.set(
"SetPoint",
lua.create_function(
|lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| {
region_set_point(lua, &this, &p, [a2, a3, a4, a5])
},
)?,
)?;
m.set(
"ClearAllPoints",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
}
Ok(())
})?,
)?;
m.set(
"SetAllPoints",
lua.create_function(|lua, (this, target): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let owner = region_owner_id(&mut model, rh);
let rel_id = resolve_target(&mut model, &target, owner);
let pair = [
Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
];
let data = model.region_data.entry(rh).or_default();
let same = data.anchors.len() == 2
&& data
.anchors
.iter()
.zip(&pair)
.all(|(a, b)| anchor_bits_eq(a, b));
if !same {
data.anchors.clear();
data.anchors.extend_from_slice(&pair);
model.touch_layout();
}
Ok(())
})?,
)?;
Ok(())
}
@@ -0,0 +1,397 @@
//! Region method-table cluster: **paint** — what a Texture shows and how it is tinted,
//! blended, cropped and layered. Split out of `region.rs` at the 0716 file-size budget.
use mlua::{Lua, Table, Value};
use crate::script::object::{as_f32, draw_layer_from_str};
use crate::script::{Model, TexCoords};
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
use super::region_handle_of;
/// Populate `m`'s paint methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetAlpha/GetAlpha — the region's own alpha, distinct from the owner frame's. The ref kit reads
// it back to ramp a texture (CastingBarFrame's completion flash does
// `CastingBarFlash:SetAlpha(GetAlpha() + CASTING_BAR_FLASH_STEP)`), so the getter must return the
// region's value, never the frame's. Draw law + the open question: [`RegionData::alpha`].
m.set(
"SetAlpha",
lua.create_function(|lua, (this, alpha): (Table, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().alpha = Some(alpha.clamp(0.0, 1.0));
Ok(())
})?,
)?;
m.set(
"GetAlpha",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.region_data
.get(&rh)
.and_then(|d| d.alpha)
.unwrap_or(1.0))
})?,
)?;
m.set(
"SetVertexColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
// The same slot `SetTextColor` writes on a FontString, so it is the same explicit
// colour set as far as font-object inheritance is concerned.
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re
// `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region
// method family). Never set = the untinted white every region draws at by default.
m.set(
"GetVertexColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.vertex_color)
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin
// SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client
// generates into the same `+0xcc` texture slot the colour form of SetTexture fills.
//
// These were missing, and they were the single wall in front of the corpus's largest family:
// `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar
// plugins died there — after the debugstack/chunk-name fix got them that far.
//
// The orientation token is matched case-insensitively and anything that is not "VERTICAL" is
// horizontal, which is the client's own leniency and matters because addons spell it both ways.
// The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint
// because a quad carries one tint today. That approximation is visible and is recorded at the
// field, not hidden here.
for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] {
m.set(
name,
lua.create_function(move |lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let this: Table = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a region")),
};
let orientation = match it.next() {
Some(Value::String(s)) => s.to_str()?.to_string(),
// A missing/!string orientation is horizontal, like any non-"VERTICAL" token.
_ => String::new(),
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
} else {
// SetGradient has no alpha stops: both ends are opaque.
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.gradient = Some(crate::script::Gradient {
vertical: orientation.eq_ignore_ascii_case("VERTICAL"),
start,
end,
});
Ok(())
})?,
)?;
}
m.set(
"SetTexture",
// The trailing three are `Value`, not `Option<f32>`, and that is a fidelity fix rather than
// laxity. The path form reads ONE argument (`0x770200`); only the colour form
// (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and
// ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing
// these as `Option<f32>` made us raise on it, `bad argument #3: error converting Lua
// boolean to f32`, in a call the client accepts silently.
//
// Found by `_LazyPig/LazyPigMenu.lua:182`
// (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which
// reached us only once the survey started seating the addon registry — the whole point of
// that instrument fix. The stray `true` is meaningless in 1.12 and the addon author
// presumably meant a later client's second parameter; either way the client shrugs.
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
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();
// A plain SetTexture makes the region ordinary again — drop any portrait circular mask
// and any live-unit-portrait binding.
data.circular = false;
data.portrait_unit = None;
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
// there (`0x770200`), the colour form generates an 8×8 solid into it
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
// `+0xb8`: a tint outlives the art it was tinting.
match &arg {
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
// art with the empty string (QuestLogFrame.lua:165-166).
Value::String(s) if s.to_str()?.is_empty() => {
data.texture = None;
data.fill = None;
}
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// The colour form, and the ONLY branch that looks at the trailing three. A
// non-numeric there takes the same default a missing one does, which is what
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
Value::Number(_) | Value::Integer(_) => {
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
}
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
// texture region draws nothing.
Value::Nil => {
data.texture = None;
data.fill = None;
}
_ => {}
}
Ok(())
},
)?,
)?;
// SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the
// reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported =
// icon:SetDesaturated(desaturated)`).
//
// **The RETURN is the whole design, and it is the half a plausible implementation drops.**
// 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect
// and FrameXML falls back by hand:
//
// if ( not desaturated ) then r,g,b = 1,1,1
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
// icon:SetVertexColor(r, g, b)
//
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
// desaturating shader lands, flip the return and this comment with it.
//
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
// moment anyone right-clicks.
m.set(
"SetDesaturated",
lua.create_function(|lua, (this, flag): (Table, Value)| {
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
// write `not shaderSupported`.
Ok(Value::Nil)
})?,
)?;
// GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch
// (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a
// plausible implementation gets silently wrong, so each is spelled out:
//
// · **Exactly ONE return value**, never a multi-return.
// · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil.
// `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an
// addon's `if not tex then` passes straight through on a colour-filled region. Returning nil
// here would look tidier and would be wrong in the one direction callers test for.
// · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to
// what was set and the getter strips an extension back off. Taken verbatim rather than
// "strip only a real extension" — a directory containing a dot is mangled by the real client
// too, and this surface is a transcription, not an improvement.
//
// Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and
// `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons.
m.set(
"GetTexture",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let Some(data) = model.region_data.get(&rh) else {
return Ok(None);
};
if data.fill.is_some() {
return Ok(Some("Solid Texture".to_string()));
}
Ok(data.texture.as_ref().map(|t| match t.rfind('.') {
Some(i) => t[..i].to_string(),
None => t.clone(),
}))
})?,
)?;
// 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.
m.set(
"SetAlphaGradient",
lua.create_function(|lua, (this, start, length): (Table, f32, f32)| {
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.alpha_gradient = Some((start, length));
let chars = data.text.as_deref().map_or(0, |t| t.chars().count());
Ok(start < chars as f32)
})?,
)?;
// SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes
// draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap).
m.set(
"SetBlendMode",
lua.create_function(|lua, (this, mode): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD");
Ok(())
})?,
)?;
m.set(
"SetDrawLayer",
lua.create_function(|lua, (this, layer, sub): (Table, String, Option<i64>)| {
let rh = region_handle_of(lua, &this)?;
let dl = draw_layer_from_str(&layer)
.ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(region) = model.arena.region_mut(rh) {
region.draw_layer = dl;
if let Some(s) = sub {
region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8;
}
}
Ok(())
})?,
)?;
// SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the
// later-era Texture API, shipped early: the world-map player arrow's stand-in rotation —
// see `QuadContent::Texture::rotation`). No-arg/nil resets to 0.
m.set(
"SetRotation",
lua.create_function(|lua, (this, radians): (Table, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.region_data
.entry(rh)
.or_default()
.rotation = radians.unwrap_or(0.0);
Ok(())
})?,
)?;
// SetTexCoord(left, right, top, bottom) — the 4-edge form (XML `<TexCoords>`): a UV sub-rect in
// 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art
// (decision 0084). SetTexCoord(ULx,ULy, LLx,LLy, URx,URy, LRx,LRy) — the 8-arg affine form: an
// arbitrary UV quad (rotation/shear — the reference's `DrawRouteLine` route lines), stored per
// corner in the renderer's screen winding.
m.set(
"SetTexCoord",
lua.create_function(|lua, (this, rest): (Table, mlua::Variadic<f32>)| {
let rh = region_handle_of(lua, &this)?;
let coords = match rest.len() {
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
// The live arg order is UL, LL, UR, LR (corner pairs); [`TexCoords::Corners`]
// stores screen order [TL, TR, BR, BL].
8 => Some(TexCoords::Corners([
[rest[0], rest[1]], // UL → TL
[rest[4], rest[5]], // UR → TR
[rest[6], rest[7]], // LR → BR
[rest[2], rest[3]], // LL → BL
])),
// No args resets to the full texture (the live API's clear form).
0 => None,
n => {
return Err(mlua::Error::runtime(format!(
"SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}"
)))
}
};
lua.app_data_mut::<Model>()
.expect("model")
.region_data
.entry(rh)
.or_default()
.tex_coords = coords;
Ok(())
})?,
)?;
// GetTexCoord() → left, right, top, bottom (the 4-edge form; full texture if never set; an
// affine mapping reports its bounding edges).
m.set(
"GetTexCoord",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let [l, r, t, b] = model
.region_data
.get(&rh)
.and_then(|d| d.tex_coords)
.map(|tc| tc.edges())
.unwrap_or([0.0, 1.0, 0.0, 1.0]);
Ok((l, r, t, b))
})?,
)?;
Ok(())
}
+428
View File
@@ -0,0 +1,428 @@
//! Region method-table cluster: **text** — a FontString's string, its faces, colour, shadow
//! and justification. Split out of `region.rs` at the 0716 file-size budget.
//!
//! **These duplicate names the `<Font>` object table also carries, deliberately for now**: a region
//! may override its inherited font object per-property, and the severance mask (`FontExplicit`) is
//! what keeps the two apart. Collapsing them onto one shared block is the follow-on 1231 named.
use mlua::{Lua, Table, Value};
use crate::script::{JustifyH, JustifyV, Model, Outline};
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
use super::{measured_wh, region_handle_of};
/// Populate `m`'s text methods (see the module doc).
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
m.set(
"SetText",
lua.create_function(|lua, (this, text): (Table, Option<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.text = text;
// Fresh text draws whole — an armed write-on gradient belongs to the old string.
data.alpha_gradient = None;
Ok(())
})?,
)?;
// SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's
// positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets).
m.set(
"SetFormattedText",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let format: mlua::Function = lua
.globals()
.get::<Table>("string")?
.get::<mlua::Function>("format")?;
let text: String = format.call(args)?;
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.text = Some(text);
data.alpha_gradient = None;
Ok(())
})?,
)?;
m.set(
"GetText",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let text = {
let model = lua.app_data_ref::<Model>().expect("model");
model.region_data.get(&rh).and_then(|d| d.text.clone())
};
match text {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure
// 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).
// The stored measure only counts while its key matches the CURRENT text/font/wrap
// ([`RegionData::measure_key`]): after a SetText the old string's width is not this string's
// metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on
// the previous header's width. A poll-until-nonzero caller (the chat header machine) now
// converges on the RIGHT measure instead of settling on a stale one.
// `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the
// real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63).
// GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the
// wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter
// re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately
// does NOT fall back to an explicit `SetSize`: the declared width is the very thing a caller
// asks this to be independent of. A kit that sizes a box from this number and then sets a width
// on the string — which is what the reference's own `PanelTemplates_TabResize` does — would
// otherwise read its own output back as its next input and never settle (decision 0997, the
// macro window's character tab changing width every frame). `0` until measured, as ever.
fn natural_w(lua: &Lua, this: &Table) -> mlua::Result<f32> {
let rh = region_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let Some(d) = model.region_data.get(&rh) else {
return Ok(0.0);
};
let scale = model
.arena
.region(rh)
.and_then(|r| model.arena.frame(r.owner))
.map(|f| f.effective_scale)
.unwrap_or(1.0);
Ok(d.measured
.filter(|m| m.key == d.measure_key(scale))
.map(|m| m.natural_w)
.unwrap_or(0.0))
}
m.set(
"GetStringWidth",
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`).
m.set(
"SetJustifyH",
lua.create_function(|lua, (this, j): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let jh = match j.to_ascii_uppercase().as_str() {
"LEFT" => JustifyH::Left,
"RIGHT" => JustifyH::Right,
_ => JustifyH::Center,
};
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.justify_h = jh;
d.font_explicit.justify_h = true;
Ok(())
})?,
)?;
// SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") — a FontString's vertical justification (XML `justifyV`).
m.set(
"SetJustifyV",
lua.create_function(|lua, (this, j): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let jv = match j.to_ascii_uppercase().as_str() {
"TOP" => JustifyV::Top,
"BOTTOM" => JustifyV::Bottom,
_ => JustifyV::Middle,
};
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.justify_v = jv;
d.font_explicit.justify_v = true;
Ok(())
})?,
)?;
// SetFontObject(GameFontNormal) — re-point this FontString at a Font object: its resolved paint
// (face/height/color/outline/shadow) becomes the region's, and the link is kept live, so a later
// `GameFontNormal:SetFont(…)` re-paints this region too ([`super::font`]'s module doc).
//
// All three argument forms the reference's own usage string names (`.rdata 0x87c5cc`:
// `SetFontObject(font or "font" or nil)`) — the **object**, which is what 3,180 of the corpus's
// 3,186 call sites pass (`Gratuity-2.0.lua:57`, every FuBar/Ace label); a **name string**, for
// our own shipped XML and the 6 sites that use it; and **nil**, which severs the link. A frame,
// a number, or an unknown name is an error — never a silent no-op (1203/1205/1211's class).
m.set(
"SetFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = crate::script::font::resolve("SetFontObject", &font)?;
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The nil form: unlink, and leave the paint standing (the reference stores a null
// parent; nothing re-reads and nothing is cleared).
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_objects.get(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name);
// The severance mask is deliberately NOT reset here. §5-verified: the real "stop
// inheriting this property" signal is a CLEARED bit in the inheritMask at
// `FONTINSTANCE+0x2c` (FontString `+0xd4`, per-axis justify at `+0x124`), cleared by
// each local setter and never restored — "a FontString that set its own colour stays
// severed even across a later SetFontObject" (wow-re
// `system/ui/scratch/font-object-lua-surface.md`). This corrects our first cut, which
// reset it.
crate::script::font::repaint(d, &fo);
Ok(())
})?,
)?;
// GetFontObject() → the font OBJECT this FontString last resolved (or nil).
//
// The object, not its name: `Dewdrop-2.0.lua:2181` is
// `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())` — 65 sites across 62
// corpus addons that index the result immediately. A name string there raises.
m.set(
"GetFontObject",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.region_data
.get(&rh)
.and_then(|d| d.font_object.clone())
.filter(|n| model.font_objects.contains_key(n))
};
match name {
Some(n) => Ok(Value::Table(crate::script::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`).
//
// Two contract details from wow-re's batch, both easy to get wrong:
// · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**,
// not a boolean — 1.12 predates that convention and an addon may compare against 1.
// · a **no-argument call ENABLES** it (the default is on), rather than being a query.
//
// `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons.
m.set(
"SetNonSpaceWrap",
lua.create_function(|lua, (this, enable): (Table, Value)| {
let on = match &enable {
Value::Nil => true,
Value::Boolean(b) => *b,
_ => true,
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
Ok(())
})?,
)?;
m.set(
"CanNonSpaceWrap",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let on = model
.region_data
.get(&rh)
.and_then(|d| d.non_space_wrap)
.unwrap_or(true);
Ok(if on { Some(1i64) } else { None })
})?,
)?;
// ── shadow, on the REGION ────────────────────────────────────────────────────────────────
// These already existed on the font-object table; a FontString made by `CreateFontString` had
// none of them, and that is where the corpus calls them:
// `FuBar_NavigatorFu/NavigatorFu.lua:31` does
// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object,
// the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
// `fontstring:SetShadowColor(0,0,0,0.3)`.
//
// **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's
// widget-method batch). Three is the plausible wrong answer and it silently drops the alpha
// that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units.
//
// Either half may be set before the other, so each starts from whatever is there — the same
// rule the font-object versions already follow.
m.set(
"SetShadowColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(crate::script::FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
m.set(
"SetShadowOffset",
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(crate::script::FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
// SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and
// the XML `font=`/`<FontHeight>`/`outline=` join). `flags` is an OUTLINETYPE-ish string
// ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the
// current face (so a FontString with only `<FontHeight>` retains its inherited object's font).
// Returns true (the live API returns whether the font loaded; we always accept — face
// availability is the renderer's concern).
m.set(
"SetFont",
lua.create_function(
|lua, (this, path, height, flags): (Table, Option<String>, Option<f32>, Option<String>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
// Each argument actually supplied is an EXPLICIT set: it must survive a later
// mutation of the font object this region inherits (`FontExplicit`).
if let Some(p) = path.filter(|p| !p.is_empty()) {
d.font_path = Some(p);
d.font_explicit.face = true;
}
if let Some(h) = height {
d.font_height = Some(h);
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(true)
},
)?,
)?;
// SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified,
// wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit
// `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored
// as the distinct [`RegionData::text_height`] — the font object is untouched, so GetFont
// keeps reporting the face's own height like the real API.
m.set(
"SetTextHeight",
lua.create_function(|lua, (this, height): (Table, f32)| {
let rh = region_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.region_data
.entry(rh)
.or_default()
.text_height = Some(height);
Ok(())
})?,
)?;
// GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set).
m.set(
"GetFont",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
let path = d.and_then(|d| d.font_path.clone());
let height = d.and_then(|d| d.font_height);
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
let path = match path {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
Ok((path, height, flags))
})?,
)?;
// SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the
// same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own
// to multiply against, so its vertex colour IS the colour it draws.
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetTextColor() → r, g, b, a — `SetTextColor`'s missing pair, and a real binding in the same
// FontInstance family. 11 corpus sites read it off a FontString directly (`CustomNameplates`
// re-tints a level tag from the name's colour; `TipBuddy` snapshots every tooltip line), on top
// of the 65 that reach it through `GetFontObject()`. Never set = the white every region draws
// at, same convention as `GetVertexColor` (the same slot).
m.set(
"GetTextColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.vertex_color)
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
Ok(())
}
@@ -348,3 +348,67 @@ fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
(true, true)
);
}
/// **`StartSizing(grip)` moves the gripped edges and plants the opposite ones.**
///
/// `0x776830`, verified in wow-re's ledger; the reference's own caller is
/// `FloatingChatFrame.lua:600`. Four corpus addons reach it through ONE line —
/// `FuBar_Panel.lua:980`, replicated into FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2
/// (1207: one library, not four votes).
///
/// What the ledger does NOT record is which edges a grip moves — it has the verb as pure
/// orchestration with no inline math. Taken here as the plain meaning of an anchor point, which is
/// how the reference's caller uses it. This test is where that reading is pinned, so an RE pass
/// that contradicts it fails here first.
#[test]
fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
f = CreateFrame("Frame", "Sizer", UIParent)
f:SetResizable(true)
f:SetWidth(200) f:SetHeight(100)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 100, 50)
"#,
)
.unwrap();
s.resolve();
// Grip the RIGHT edge and drag 40 right: width grows, the left edge stays planted.
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(340.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 240.0);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
s.run("Sizer:StopMovingOrSizing()").unwrap();
// Grip the LEFT edge and drag 30 right: width SHRINKS and the left edge follows the cursor,
// so the right edge is the one that stays put.
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
s.mouse_move(370.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 130.0);
assert_eq!(
s.eval::<f32>("return Sizer:GetRight()").unwrap(),
right_before,
"the ungripped edge must not move"
);
// StopMovingOrSizing ends it: further motion changes nothing.
s.run("Sizer:StopMovingOrSizing()").unwrap();
s.mouse_move(500.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
// A frame that is not resizable refuses, like the movable family's siblings.
s.run("g = CreateFrame(\"Frame\", \"NotSizer\", UIParent)")
.unwrap();
assert!(
s.run("NotSizer:StartSizing(\"RIGHT\")").is_err(),
"StartSizing must refuse a frame that is not resizable"
);
}