the numerals kit goes stock, and a chat link is its own mouse target

- the status-bar numerals kit (`TextStatusBar.xml` + `.lua`) is the reference's own, and the attempt found a class of silent failure: `TextStatusBar_Initialize()` takes no argument in 1.12 and acts on `this`; ours took an optional bar, our unit frames passed one, and the reference's version ignores it — so the numerals would simply never appear, no error, nothing missing; seventeen of our signatures differ from the reference's that way (Lua drops a surplus argument in silence), written down before their files migrate, and the three "ours narrower" ones — `StaticPopup_Hide(which, data)`, `StaticPopup_FindVisible`, `WorldMapButton_OnClick(button, frame)` — closed now, because that direction bites when a STOCK file arrives and calls our version
- a chat link needs no mouse on its frame: wow-re read the client's ctors as mouse-enabling Button, CheckButton, Slider, ColorSelect and EditBox and NOT the scroll frames; the objection ("something makes a 1.12 chat link clickable") resolved as the engine synthesising one mouse-enabled `CSimpleHyperlinkButton` per link span (`0x7a3240`) — so a link span is a hit-test candidate on its own, the chat window is inert everywhere else, and the wheel is its own predicate rather than a borrowed mouse bit; seven `*ScrollFrame` known divergences retire
- the relic fit rule: `IsValidForSlot`'s slot-17 leg is an EQUALITY — `(InventoryType == RELIC) == hasRelicSlot` — so a relic lights the ranged slot for a druid and a bow does not, on the bag bar and the paper doll alike; closing it half-right would have lit the highlight for a druid dragging a bow
- a chain `.xml` whose `.lua` never loaded: `MoneyInputFrame.xml` had been on the chain without its `.lua`, so the template loaded and all ten `MoneyInputFrame_*` verbs read nil, silently — a gate now fails any chain `.xml` whose `.lua` sibling is neither sourced nor listed; the gap report's LOAD column carries the probe's collision caveat, its fx column stops crying wolf (it follows `<Script file=>`, stops reading `.Field(` as a global, subtracts a file's own locals), and a window is its xml PLUS the lua it sources

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:01:18 +07:00
co-authored by Claude Fable 5.1
parent 112e82d276
commit feca23de3f
30 changed files with 981 additions and 404 deletions
@@ -76,7 +76,7 @@
forced-show path). A real forced-highlight affordance is a `benilla-ui` follow-up, not
blocking this slice — `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight` below
tint the slot's Quickslot2 ring instead, flagged at their own definition.
- The relic slot: **restored, and this note used to be wrong** (decision 1785). It read
- The relic slot: **restored, and this note used to be wrong** (decision 1796). It read
"`UnitHasRelicSlot` is vanilla-always-false (there is no relic slot in 1.12), so the
ref's branches are DEAD CODE" — they are not. `ChrClasses.dbc` field 16 is set for
Paladin, Shaman and Druid, and INVSLOT 17 doubles as their relic slot. All four of the
@@ -527,7 +527,7 @@
-- ref PaperDollFrame_OnShow (l.568-585): SetGuild OMITTED (named deferral, this file's
-- header). The relic branch (l.580-584) is transcribed verbatim — a relic class has no
-- ranged weapon and therefore no ammo, so its ammo slot hides. This used to be dropped on
-- the belief that UnitHasRelicSlot is always false; decision 1785.
-- the belief that UnitHasRelicSlot is always false; decision 1796.
function PaperDollFrame_OnShow()
paintRows(paperDollRows)
if UnitHasRelicSlot("player") then
@@ -823,7 +823,7 @@
end
-- ref PaperDollFrame_SetRangedAttack (l.419-466). `hasRelic` is transcribed (it used to
-- be dropped — decision 1785). It is the whole reason a paladin's ranged block reads
-- be dropped — decision 1796). It is the whole reason a paladin's ranged block reads
-- "n/a" rather than the stats of the libram sitting in that slot: `noRanged` is set when
-- there is no ranged texture OR the class has a relic slot, and `SetRangedDamage` /
-- `SetRangedAttackPower` both read it (ref l.482 / l.552), so one flag blanks all three
@@ -1020,7 +1020,7 @@
button.emptyTexture = texture
-- `checkRelic` is the number 1 for the ranged slot alone (a property of the SLOT); the
-- class half is `UnitHasRelicSlot`, and the ref reads the two as a conjunction. Both
-- halves are live — decision 1785.
-- halves are live — decision 1796.
button.checkRelic = checkRelic
button.icon = getglobal(button:GetName() .. "IconTexture")
button.count = getglobal(button:GetName() .. "Count")
@@ -1105,7 +1105,7 @@
else
-- ref l.678-681: the empty ranged slot of a relic class shows the relic plate,
-- not the bow plate. The art ships (`UI-PaperDoll-Slot-Relic.blp`, 3916 bytes,
-- patch.MPQ). Restored — decision 1785.
-- patch.MPQ). Restored — decision 1796.
local emptyTexture = button.emptyTexture
if button.checkRelic and UnitHasRelicSlot("player") then
emptyTexture = "Interface\\Paperdoll\\UI-PaperDoll-Slot-Relic.blp"
@@ -1145,7 +1145,7 @@
-- the doll popped a "Currently Equipped" 0/XX beside a template-full main tooltip).
-- The ref's repair-mode branch (l.757-762) is INTERIM-omitted with the binding's
-- repairCost. checkRelic (l.744-746) is transcribed — it was called TBC-flagged dead
-- weight here, which was wrong (decision 1785): a paladin hovering their empty ranged
-- weight here, which was wrong (decision 1796): a paladin hovering their empty ranged
-- slot reads "Relic", not "Ranged".
function BenillaPaperDollSlot_OnEnter(button)
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
@@ -48,7 +48,7 @@
1059/1060): ctrl previews the inspected item on your own body, shift posts its link.
Nothing else happens on a click, which is still the reference's own shape.
- `UnitHasRelicSlot` (ref l.123, l.22) — this note used to say "vanilla-always-false, so
the relic branch can never fire". **That is wrong** (decision 1785): the flag is set
the relic branch can never fire". **That is wrong** (decision 1796): the flag is set
for Paladin, Shaman and Druid, and the binding now exists and answers for any token,
with no `"player"` fast path — inspecting an enemy druid answers 1. The branch is
still not transcribed here, but for a different and honest reason: 1.12's inspect pane
@@ -1,183 +0,0 @@
<!--
The shared status-bar readout machinery — the contract 1.12's TextStatusBar.lua/.xml defines
(decision 1082), written here in our own terms. A bar opts in by calling
TextStatusBar_Initialize() in its OnLoad (setting `textLockable`), wiring a FontString via
SetTextStatusBarText(bar, text), and routing its OnEnter/OnLeave/OnValueChanged through the
Show/Hide/Update functions here. The text is "value / max", shown while hovered (a lockShow
refcount) or always when the `statusBarText` cvar is "1" ("Status Text" in the options; our
GetCVar returns nil for a cvar the engine doesn't carry, which reads as OFF — the 1.12
default).
Customers: the XP bar (ActionBar.xml) and, since decision 1143, the player and pet frames'
health/power bars (UnitFrames.xml). `textLockable` is what the statusBarText cvar pins, and
which bars carry it is the REFERENCE's own split, not an omission of ours: 1.12 sets it on the
player's two bars, the pet's two and the XP bar, and on nothing else. The target frame's bars
are deliberately not lockable there — its numerals are hover-only — so ours have the machinery
wired and no text regions yet; the hover half needs mouse-enabled bars we do not have.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The readout has exactly three reasons to be on screen, and every function below is some
-- combination of them: the Status Text option pins it (only on a bar that opted in with
-- `textLockable`), the pointer is on the bar (a refcount, so nested hovers balance), or the
-- bar is showing its zero text — a phrase rather than a number, which stands on its own.
--
-- The option test is the one of the three that reads from outside the bar, and it is asked
-- from three places, so it lives here once. It takes the option's VALUE rather than calling
-- GetCVar itself: the CVAR_UPDATE handler is handed the new value along with the event and
-- must not go asking the engine for the one it is being told about.
local function optionPins(bar, setting)
return setting == "1" and bar.textLockable
end
-- `bar` is optional and falls back to `this`, the same shape TextStatusBar_UpdateTextString
-- below already has (and for the same reason): the ref calls this from a bar's own OnLoad,
-- where `this` is the bar, but our unit frames wire their children from ONE shared
-- BenillaUnitFrame_OnLoad that holds each bar as a value, not as `this` (decision 1143).
function TextStatusBar_Initialize(bar)
if not bar then
bar = this
end
bar:RegisterEvent("CVAR_UPDATE")
bar.lockShow = 0
end
-- Hand a bar the FontString its readout lives in. A bar without one is never an error
-- below — it simply has nothing to say, and every entry point here checks for that.
function SetTextStatusBarText(bar, text)
if not bar or not text then
return
end
bar.TextString = text
end
function TextStatusBar_OnEvent(cvar, value)
if event ~= "CVAR_UPDATE" or cvar ~= "STATUS_BAR_TEXT" or not this.TextString then
return
end
-- Switching the option ON pins the readout up at once. Switching it off only puts the
-- readout away when nothing else is holding it there: a hover in progress keeps its own,
-- and gets to hide it when it ends.
if optionPins(this, value) then
this.TextString:Show()
elseif this.lockShow == 0 then
this.TextString:Hide()
end
end
-- Repaint one bar's readout from the value it is currently carrying. `textStatusBar` is
-- optional and falls back to `this` for the same reason TextStatusBar_Initialize's is.
function TextStatusBar_UpdateTextString(textStatusBar)
if not textStatusBar then
textStatusBar = this
end
local readout = textStatusBar.TextString
if not readout then
return
end
local value = textStatusBar:GetValue()
local _, valueMax = textStatusBar:GetMinMaxValues()
-- No ceiling is not the same thing as a bar at zero (below): it means the bar does not
-- apply to this unit at all, so the BAR leaves the screen rather than reading out a
-- number. Its readout is left exactly as it stands and comes back with it.
if valueMax <= 0 then
textStatusBar:Hide()
return
end
textStatusBar:Show()
if value == 0 and textStatusBar.zeroText then
-- Empty, and this bar has a phrase for empty. The phrase displaces the numbers and
-- is shown outright: it is a message, so neither the option nor a hover gates it,
-- and `isZero` is the flag that tells the hover not to take it away again.
textStatusBar.isZero = 1
readout:SetText(textStatusBar.zeroText)
readout:Show()
return
end
textStatusBar.isZero = nil
if textStatusBar.prefix then
readout:SetText(textStatusBar.prefix .. " " .. value .. " / " .. valueMax)
else
readout:SetText(value .. " / " .. valueMax)
end
if optionPins(textStatusBar, GetCVar("statusBarText")) or textStatusBar.lockShow > 0 then
readout:Show()
else
readout:Hide()
end
end
function TextStatusBar_OnValueChanged()
TextStatusBar_UpdateTextString()
end
function SetTextStatusBarTextPrefix(bar, prefix)
if bar and bar.TextString then
bar.prefix = prefix
end
end
function SetTextStatusBarTextZeroText(bar, zeroText)
if bar and bar.TextString then
bar.zeroText = zeroText
end
end
-- One more hold on the readout. `lockShow` counts rather than flags because two things can
-- be holding it open at once: the character sheet takes a hold for as long as it is open
-- (CharacterFrame_OnShow, decision 1146) and a hover on the same bar takes a second one on
-- top. Only the last release puts the readout away, so the hover still balances.
function ShowTextStatusBarText(bar)
if bar and bar.TextString then
bar.lockShow = (bar.lockShow or 0) + 1
bar.TextString:Show()
end
end
-- One hold released. The count floors at none rather than going negative, so an unbalanced
-- release — a frame hidden out from under a hover — cannot strand the readout on screen.
function HideTextStatusBarText(bar)
if bar and bar.TextString then
bar.lockShow = math.max((bar.lockShow or 0) - 1, 0)
-- Releasing a hold only puts the readout away if none of the three reasons is left.
-- The ref reads `this.isZero` for the middle one — a leak of the handler's own frame
-- into a helper that otherwise takes `bar`. Equivalent at every call site (each
-- caller passes its handler's own frame), so it is written as `bar`, which also
-- keeps the function callable outside a handler, where `this` is nil.
if bar.lockShow > 0 or bar.isZero == 1
or optionPins(bar, GetCVar("statusBarText")) then
bar.TextString:Show()
else
bar.TextString:Hide()
end
end
end
]]></Script>
<!--
The opt-in template (ref-TextStatusBar.xml): every script routed through the functions
above. One transcription cut, named: the ref OnEnter's TargetFrame arm (re-anchor the unit
tooltip under the target frame's bars) references a TextStatusBar-inheriting TargetFrame
bar we don't build yet — it is dead until that slice, so it is left out rather than
shipped unreachable. A frame that overrides all five scripts (the XP bar does) takes only
the StatusBar widget type from inheriting this.
-->
<StatusBar name="TextStatusBar" virtual="true">
<Scripts>
<OnLoad>TextStatusBar_Initialize()</OnLoad>
<OnEvent>TextStatusBar_OnEvent(arg1, arg2)</OnEvent>
<OnEnter>
TextStatusBar_UpdateTextString()
ShowTextStatusBarText(this)
if this.tooltipTitle and GameTooltip_AddNewbieTip then
BenillaTooltip_AddNewbieTipFor(this, this.tooltipTitle, 1.0, 1.0, 1.0, this.tooltipText, 1)
end
</OnEnter>
<OnLeave>
HideTextStatusBarText(this)
if GameTooltip then GameTooltip:Hide() end
</OnLeave>
<OnValueChanged>TextStatusBar_OnValueChanged()</OnValueChanged>
</Scripts>
</StatusBar>
</Ui>
+28 -6
View File
@@ -1025,10 +1025,24 @@
return "seconds"
end
function StaticPopup_FindVisible(which)
-- ref StaticPopup.lua l.1414-1426, `data` included. Ours took `which` alone until 1793
-- found the arity drift; the reference's second argument is what makes a `multiple` dialog
-- addressable, and dropping it is silent in both directions (Lua discards a surplus
-- argument without complaint).
--
-- The `info.multiple` gate is the reference's own and is why `data` is not simply an
-- equality: a dialog kind declared `multiple` can have several instances up at once, so it
-- is matched by data; every other kind has at most one, so its data is not consulted at
-- all. An unknown `which` answers nil rather than scanning — the reference's own early out.
function StaticPopup_FindVisible(which, data)
local info = StaticPopupDialogs[which]
if not info then
return nil
end
for index = 1, STATICPOPUP_NUMDIALOGS do
local frame = getglobal("StaticPopup" .. index)
if frame:IsShown() and frame.which == which then
if frame:IsShown() and frame.which == which
and (not info.multiple or frame.data == data) then
return frame
end
end
@@ -1044,11 +1058,19 @@
return nil
end
function StaticPopup_Hide(which)
-- ref StaticPopup.lua l.1703-1710, `data` included (1793). With a `data` the reference
-- hides only the instance carrying it; without one it hides every instance of the kind,
-- which is what every one-argument caller means and gets.
--
-- Note the reference does NOT test `IsShown()` here, where `FindVisible` does — hiding an
-- already-hidden dialog is a no-op, and the missing test is not an oversight to correct:
-- `Hide()` on a hidden frame still runs nothing, and adding the guard would change which
-- frames the loop touches for no visible difference. Transcribed as it is.
function StaticPopup_Hide(which, data)
for index = 1, STATICPOPUP_NUMDIALOGS do
local frame = getglobal("StaticPopup" .. index)
if frame:IsShown() and frame.which == which then
frame:Hide()
local dialog = getglobal("StaticPopup" .. index)
if dialog.which == which and (not data or data == dialog.data) then
dialog:Hide()
end
end
end
+48 -6
View File
@@ -277,9 +277,9 @@
-- and the only conditional here: 1.12 sets it on the player's two bars, the pet's two
-- and the XP bar, and on nothing else, which is exactly why Status Bar Text pins your
-- own numbers and your pet's but never the target's (decision 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthbar)
TextStatusBar_Initialize(frame.manabar)
-- `TextStatusBar_Initialize` is NOT called here any more — each bar calls it from its
-- own OnLoad, the reference's way (see the bar declarations below).
if SetTextStatusBarText then -- engine tests load file subsets
SetTextStatusBarText(frame.healthbar, getglobal(name .. "HealthBarText"))
SetTextStatusBarText(frame.manabar, getglobal(name .. "ManaBarText"))
if unit == "player" then
@@ -1265,6 +1265,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="1.0" b="0.0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
@@ -1281,6 +1288,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
@@ -1609,6 +1623,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="1.0" b="0.0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
@@ -1625,6 +1646,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
@@ -2265,9 +2293,9 @@
frame.healthbar:SetFrameLevel(level - 1)
frame.manabar:SetFrameLevel(level - 1)
-- The pet's numerals, textLockable like the ref's (PetFrame.xml l.139/164; 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthbar)
TextStatusBar_Initialize(frame.manabar)
-- `TextStatusBar_Initialize` is NOT called here any more — each bar calls it from its
-- own OnLoad, the reference's way (PetFrame.xml l.138/163).
if SetTextStatusBarText then -- engine tests load file subsets
SetTextStatusBarText(frame.healthbar, PetFrameHealthBarText)
SetTextStatusBarText(frame.manabar, PetFrameManaBarText)
frame.healthbar.textLockable = 1
@@ -2574,6 +2602,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="1.0" b="0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
@@ -2587,6 +2622,13 @@
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="0" b="1.0"/>
<Scripts>
<!-- The reference initialises a numerals bar from the BAR'S OWN OnLoad
(PlayerFrame.xml l.300-303, PetFrame.xml l.138/163), never from the parent
with the bar as an argument — `TextStatusBar_Initialize()` takes no
argument in 1.12 and acts on `this`. Ours called it from the parent's
OnLoad, which needed a widened signature and would have broken silently
the day TextStatusBar.xml came off the player's chain (decisions 1793). -->
<OnLoad>if TextStatusBar_Initialize then TextStatusBar_Initialize() end</OnLoad>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
+14 -4
View File
@@ -389,12 +389,22 @@
end
-- Left click drills into whatever the host finds at that map position; every other button
-- is the zoom-out shortcut (ref WorldMapButton_OnClick — ours takes no button argument,
-- since nothing but the map button itself routes here).
function WorldMapButton_OnClick(mouseButton)
-- is the zoom-out shortcut. ref WorldMapFrame.lua l.277-296. The SECOND argument is the button the click landed on,
-- defaulting to `this` — restored in 1793's sweep, having been dropped in favour of naming
-- `WorldMapButton` directly.
--
-- Equivalent for 1.12's own caller, which passes exactly that button
-- (`WorldMapFrame.lua:174`) — and not equivalent for anyone else, which is the whole
-- reason the reference takes it: a caller aiming the click at a different map surface was
-- silently redirected to the main one, because Lua discards a surplus argument without
-- complaint.
function WorldMapButton_OnClick(mouseButton, button)
CloseDropDownMenus();
if ( mouseButton == "LeftButton" ) then
ProcessMapClick(cursorMapPosition(WorldMapButton));
if ( not button ) then
button = this
end
ProcessMapClick(cursorMapPosition(button));
else
WorldMapZoomOutButton_OnClick();
end
+31 -9
View File
@@ -149,6 +149,14 @@ MoneyFrame.xml
# `_SetCopper`/`_ClearFocus`/`_SetTextColor`/`_SetOnvalueChangedFunc` — six of the names below —
# and neither can be sourced until they exist. (Trade still needs four ENGINE verbs beyond this:
# `AddTradeMoney`, `GetCursorMoney`, `GetTradePlayerItemLink`, `GetTradeTargetItemLink`.)
#
# **TWO entries, and the `.lua` was missing.** This `.xml` declares only the template and does not
# source its `.lua`; the reference's toc lists both (l.11-12). Listing the `.xml` alone brought the
# template and none of the ten verbs — the header above has claimed otherwise since the day it was
# written, and a probe of a full manifest load answered `nil` for every one of them. Same shape as
# `TextStatusBar`, found by sweeping every chain entry for it once that one bit;
# `every_chain_xml_brings_its_own_lua` is now a gate so neither can recur.
Interface\FrameXML\MoneyInputFrame.lua
Interface\FrameXML\MoneyInputFrame.xml
# The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
# before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
@@ -210,10 +218,24 @@ UnitPopup.xml
# (`script/object/movable.rs`), so that note was stale too.
Interface\FrameXML\ItemRef.xml
# The shared status-bar numerals machinery (decision 1082): TextStatusBar_* + the opt-in
# template — before its customers (the XP bar in ActionBar.xml today; the unit-frame
# health/mana numerals are the ref's other users, when that slice lands). The ref's own TOC
# loads TextStatusBar right before its unit frames, same seat as here.
TextStatusBar.xml
# template. The ref's own TOC loads TextStatusBar right before its unit frames, same seat.
#
# ══ THE STATUS-BAR NUMERALS — the reference's own, off the player's chain (1751) ═══════════
# `TextStatusBar_*` + the `TextStatusBar` template. The reference's own toc loads it right
# before its unit frames; this is the same seat.
#
# Nothing of ours inherits the template — it exists for the reference's windows, whose rows
# carry `inherits="TextStatusBar"` and would have found ours. What blocked the swap was a
# signature WE widened (`TextStatusBar_Initialize`), fixed by giving our own bars the OnLoad
# the reference's have.
#
# **TWO entries, because the reference has two** (its toc l.32-33). This `.xml` declares only the
# template and does NOT source its `.lua` — unlike most stock windows, whose `<Script file=>`
# brings their code in. Listing the `.xml` alone loads a template whose OnLoad calls a function
# nothing defined: every `TextStatusBar_*` name reads nil, every guarded call is a silent no-op,
# and the numerals simply never appear. The `.lua` comes first, as it does there.
Interface\FrameXML\TextStatusBar.lua
Interface\FrameXML\TextStatusBar.xml
# ══ THE UNIT-FRAME KIT — the reference's own, off the player's chain (1751, window 22) ══
# `UnitFrame.xml` declares NO frames — it is a `<Script file="UnitFrame.lua"/>` and nothing else —
# so this is a pure addition of thirteen globals: `UnitFrame_Initialize`, `_Update`, `_OnEvent`,
@@ -674,10 +696,10 @@ RaidFrame.xml
# label, our measurement being a frame late; that is what the swap tests.
Interface\FrameXML\GossipFrame.xml
# ══ THE VENDOR WINDOW — the reference's own, off the player's chain (1751) ══════════════════
# It took three things, in this order: `PickupMerchantItem` (the vendor cursor, 1786), the
# It took three things, in this order: `PickupMerchantItem` (the vendor cursor, 1797), the
# `BenillaMoney_*` coin kit moving out to MoneyFrame.xml where eight other windows can reach it
# without loading this one, and `SetMerchantCompareItem` (1791) — which the gap report could not
# see at all until 1787 taught it to read `:Method(` sites, and which would have raised on the
# without loading this one, and `SetMerchantCompareItem` (1802) — which the gap report could not
# see at all until 1798 taught it to read `:Method(` sites, and which would have raised on the
# first vendor-row hover with every check green.
#
# That is why the notes below say "grouping" rather than "reuses that file's helpers": the
@@ -779,11 +801,11 @@ TradeSkillFrame.xml
CraftFrame.xml
# ══ THE LOOT WINDOW — the reference's own, off the player's chain (1751) ════════════════════
# It never needed an engine VERB. It needed a widget TYPE: `<LootButton>` is a real registered
# `CreateFrame` type (decision 1788), and registering it as a plain Button would have loaded the
# `CreateFrame` type (decision 1799), and registering it as a plain Button would have loaded the
# file and left every row dead — 1.12 has no Lua verb for "take slot N".
#
# Its ONE method, `SetSlot`, is also what the gap report's new `method=` column found from the
# call site while wow-re was deriving it from the vtable (1787). Both columns read 0 now, and
# call site while wow-re was deriving it from the vtable (1798). Both columns read 0 now, and
# every verb the stock file calls at runtime — not just at load — was checked by hand against
# the bindings before this line changed.
Interface\FrameXML\LootFrame.xml
+1 -1
View File
@@ -1149,7 +1149,7 @@ pub(crate) enum ClientCommand {
entry: u32,
count: u8,
},
/// Buy into a **named** container slot (`CMSG_BUY_ITEM_IN_SLOT`, decision 1786) — what a
/// Buy into a **named** container slot (`CMSG_BUY_ITEM_IN_SLOT`, decision 1797) — what a
/// vendor row dragged off the merchant window and dropped into a bag or an equipment slot
/// sends, as against [`ClientCommand::BuyItem`]'s auto-place from a plain row click.
/// `bag_guid` is the container object's guid, or the player's own for the backpack and the
+20 -1
View File
@@ -565,6 +565,9 @@ fn slot_view(
// `ItemSubClass.dbc` — the count gate below reads its `DisplayFlags` bit 2. `None` (the DBC
// failed to load) leaves every bag at 0, which is the plain-bag answer and the safe one.
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
// The player's own `ChrClasses.dbc` relic flag — `find_equip_slot`'s second half (1803). It
// decides INVSLOT 17 both ways, so it reaches every fit-rule read rather than one of them.
has_relic_slot: bool,
guid: u64,
live_id: u32,
) -> Option<InvSlotView> {
@@ -630,7 +633,7 @@ fn slot_view(
Some(crate::ui_items::item_link_full(
entry, 0, roll, 0, &name, t.quality,
)),
find_equip_slot(t.inventory_type),
find_equip_slot(t.inventory_type, has_relic_slot),
(t.class, t.subclass),
t.placeable_on_action_bar(),
)
@@ -725,7 +728,14 @@ fn inventory_slots(
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
classes: Option<&benilla_formats::ChrClasses>,
) -> InventorySlots {
// Resolved once from the player's own class byte, right here — the store is already in scope,
// so nothing new is threaded through the callers for the class half.
let has_relic_slot = store
.0
.unit_class()
.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c))));
let mut inv: InventorySlots = Default::default();
let ammo_id = store.0.player_ammo_id().unwrap_or(0);
if ammo_id != 0 {
@@ -786,6 +796,7 @@ fn inventory_slots(
pending,
names,
sub_classes,
has_relic_slot,
guid,
live,
);
@@ -825,6 +836,10 @@ fn bank_bag_slots(
pending,
names,
sub_classes,
// A bank bag slot holds a BAG, and `find_equip_slot(INVTYPE_BAG)` answers the four
// bag slots whatever this says — the flag decides INVSLOT 17 alone, which no item in
// this band can reach. `false` here is provable, not a default.
false,
guid,
BANK_BAG_LIVE_FIRST + i as u32,
);
@@ -941,6 +956,9 @@ pub(crate) fn fire_stat_transitions(
#[allow(clippy::too_many_arguments)]
pub(crate) fn feed_char(
// `ChrClasses.dbc` field 16, for the fit rule's relic half — `ui_items::find_equip_slot`
// (1803). Absent client data reads every class as an ordinary ranged wielder.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
@@ -1075,6 +1093,7 @@ pub(crate) fn feed_char(
&pending,
&mut names,
sub_classes.as_deref(),
classes.as_deref().map(|t| &t.0),
);
let bank_bags = bank_bag_slots(
store,
+22 -1
View File
@@ -553,6 +553,10 @@ fn resolve_slot(
petitions: &mut crate::ui_petition::PetitionState,
now: std::time::Instant,
ui_now: f64,
// The player's own `ChrClasses.dbc` relic flag — `find_equip_slot`'s second half. It decides
// ONE slot (INVSLOT 17) and it decides it both ways, so it has to reach every fit-rule read
// rather than being applied at one of them (1803).
has_relic_slot: bool,
) -> Option<ContainerSlot> {
if guid == 0 {
return None;
@@ -674,7 +678,7 @@ fn resolve_slot(
flags,
already_bound,
enchants: enchant_lines,
equip_slots: find_equip_slot(t.inventory_type),
equip_slots: find_equip_slot(t.inventory_type, has_relic_slot),
bar_placeable: t.placeable_on_action_bar(),
cooldown: t.use_spell.and_then(|u| {
let sd = spells.and_then(|s| s.get(u.spell_id));
@@ -765,6 +769,8 @@ pub(crate) fn resolve_item_locks(
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // the param list IS the input set
pub(crate) fn feed_containers(
script: Option<NonSendMut<UiScript>>,
// `ChrClasses.dbc` field 16, for the fit rule's relic half — see `find_equip_slot` (1803).
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// The two item-DBC catalogs, as one param (the 16-SystemParam ceiling): `SpellItemEnchantment`'s
@@ -961,6 +967,16 @@ pub(crate) fn feed_containers(
let transitioned: Vec<(i64, u32)> = std::mem::take(&mut lock_cleared.0);
let mut fresh: HashMap<i64, ContainerState> = HashMap::new();
// The player's own relic flag, resolved once. `find_equip_slot` reads it for INVSLOT 17 and
// reads it BOTH ways, so an absent table (no client data) answers false — every class then
// reads as an ordinary ranged wielder, which is the safe degradation.
let has_relic_slot = player.is_some_and(|p| {
p.0.unit_class().is_some_and(|c| {
classes
.as_deref()
.is_some_and(|t| t.0.has_relic_slot(u32::from(c)))
})
});
if let Some(store) = player {
// Bag 0: the backpack — its slots live directly in the player descriptor.
let mut slots = HashMap::new();
@@ -978,6 +994,7 @@ pub(crate) fn feed_containers(
&mut petitions,
now,
ui_now,
has_relic_slot,
) {
slot.locked = pending.contains(0, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
@@ -1032,6 +1049,7 @@ pub(crate) fn feed_containers(
&mut petitions,
now,
ui_now,
has_relic_slot,
) {
slot.locked = pending.contains(i64::from(bag), j as u32 + 1);
slots.insert(j as u32 + 1, slot);
@@ -1066,6 +1084,7 @@ pub(crate) fn feed_containers(
&mut petitions,
now,
ui_now,
has_relic_slot,
) {
slot.locked = pending.contains(BANK_CONTAINER, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
@@ -1116,6 +1135,7 @@ pub(crate) fn feed_containers(
&mut petitions,
now,
ui_now,
has_relic_slot,
) {
slot.locked = pending.contains(bag_id, j as u32 + 1);
slots.insert(j as u32 + 1, slot);
@@ -1154,6 +1174,7 @@ pub(crate) fn feed_containers(
&mut petitions,
now,
ui_now,
has_relic_slot,
) {
slot.locked = pending.contains(KEYRING_CONTAINER, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
+105 -59
View File
@@ -928,21 +928,17 @@ pub(super) const INVTYPE_AMMO: u32 = 24;
/// actual equip still round-trips through `SMSG_INVENTORY_CHANGE_FAILURE`
/// (`EQUIP_ERR_CANT_DUAL_WIELD`) if the class can't. Simpler than threading class into every
/// caller for a highlight-only consequence.
/// - **`INVTYPE_RELIC` answers no slots, and this one is a real gap** (decision 1785). It used to
/// be excused on the grounds that the relic slot is vanilla-UI-invisible — that
/// `UnitHasRelicSlot` is always false and no relic slot ever shows on the 1.12 paper doll.
/// **That is false.** `ChrClasses.dbc` field 16 is set for Paladin, Shaman and Druid, and
/// `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg requires `(InventoryType == 28) ==
/// hasRelicSlot` — so for those three classes INVSLOT 17 takes a relic and refuses a ranged
/// weapon, and for the other six the reverse.
/// - **`INVTYPE_RELIC` is CLOSED** (decision 1803, over the gap 1796 named). `has_relic_slot` is
/// the player's own, read off `ChrClasses.dbc` field 16, and `IsValidForSlot 0x5da1d0`'s
/// `slot == 0x11` leg is an EQUALITY — it takes the slot iff
/// `(InventoryType == 28 RELIC) == hasRelicSlot`. So RELIC and the three ranged types are exact
/// complements on that one slot rather than a union: a druid dragging a bow is offered nothing,
/// a warrior dragging an idol likewise, and a druid dragging an idol is offered INVSLOT 17.
///
/// What remains true is the *consequence*: this function drives the `CURSOR_UPDATE` highlight
/// only, and the equip still round-trips through `SMSG_INVENTORY_CHANGE_FAILURE`. So the gap
/// costs a relic-class player the highlight on their ranged slot when dragging a libram, and
/// over-offers it when dragging a bow. Closing it needs the class threaded through
/// [`crate::ui_char::slot_view`], which is the same threading the dual-wield bullet above
/// declines — one change would close both.
pub(super) fn find_equip_slot(inventory_type: u32) -> Vec<u8> {
/// The dual-wield bullet above is NOT closed by the same threading, which is why it is still
/// here: "knows dual wield" is a SPELL the player has learned, not a fact about their class, so
/// it needs the known-spell set rather than the class byte.
pub(super) fn find_equip_slot(inventory_type: u32, has_relic_slot: bool) -> Vec<u8> {
// Live-API ids (`char_stats::SLOT_INFO`'s own numbering): wire `EQUIPMENT_SLOT_*` + 1. The
// ammo slot is the client's own `GetInventorySlotInfo("AmmoSlot")` == 0 (not a real equip slot;
// ammo loads by entry via `CMSG_SET_AMMO`, decision 0526) — it just names the fit-rule target.
@@ -972,31 +968,36 @@ pub(super) fn find_equip_slot(inventory_type: u32) -> Vec<u8> {
const BAG3: u8 = 23;
match inventory_type {
1 => vec![HEAD], // INVTYPE_HEAD
2 => vec![NECK], // INVTYPE_NECK
3 => vec![SHOULDERS], // INVTYPE_SHOULDERS
4 => vec![BODY], // INVTYPE_BODY (the shirt)
5 | 20 => vec![CHEST], // INVTYPE_CHEST / INVTYPE_ROBE (same slot)
6 => vec![WAIST], // INVTYPE_WAIST
7 => vec![LEGS], // INVTYPE_LEGS
8 => vec![FEET], // INVTYPE_FEET
9 => vec![WRISTS], // INVTYPE_WRISTS
10 => vec![HANDS], // INVTYPE_HANDS
11 => vec![FINGER1, FINGER2], // INVTYPE_FINGER
12 => vec![TRINKET1, TRINKET2], // INVTYPE_TRINKET
13 => vec![MAINHAND, OFFHAND], // INVTYPE_WEAPON (dual-wield simplified, see doc)
14 => vec![OFFHAND], // INVTYPE_SHIELD
15 => vec![RANGED], // INVTYPE_RANGED
16 => vec![BACK], // INVTYPE_CLOAK
17 => vec![MAINHAND], // INVTYPE_2HWEAPON
18 => vec![BAG0, BAG1, BAG2, BAG3], // INVTYPE_BAG
19 => vec![TABARD], // INVTYPE_TABARD
21 => vec![MAINHAND], // INVTYPE_WEAPONMAINHAND
22 => vec![OFFHAND], // INVTYPE_WEAPONOFFHAND
23 => vec![OFFHAND], // INVTYPE_HOLDABLE
24 => vec![AMMO], // INVTYPE_AMMO → the ammo slot (loaded via SET_AMMO)
25 => vec![RANGED], // INVTYPE_THROWN
26 => vec![RANGED], // INVTYPE_RANGEDRIGHT
1 => vec![HEAD], // INVTYPE_HEAD
2 => vec![NECK], // INVTYPE_NECK
3 => vec![SHOULDERS], // INVTYPE_SHOULDERS
4 => vec![BODY], // INVTYPE_BODY (the shirt)
5 | 20 => vec![CHEST], // INVTYPE_CHEST / INVTYPE_ROBE (same slot)
6 => vec![WAIST], // INVTYPE_WAIST
7 => vec![LEGS], // INVTYPE_LEGS
8 => vec![FEET], // INVTYPE_FEET
9 => vec![WRISTS], // INVTYPE_WRISTS
10 => vec![HANDS], // INVTYPE_HANDS
11 => vec![FINGER1, FINGER2], // INVTYPE_FINGER
12 => vec![TRINKET1, TRINKET2], // INVTYPE_TRINKET
13 => vec![MAINHAND, OFFHAND], // INVTYPE_WEAPON (dual-wield simplified, see doc)
14 => vec![OFFHAND], // INVTYPE_SHIELD
// The RANGED slot is the relic slot for Paladin/Shaman/Druid, and `IsValidForSlot
// 0x5da1d0`'s `slot == 0x11` leg is an EQUALITY: it takes the slot iff
// `(InventoryType == 28 RELIC) == hasRelicSlot`. So the three ranged types and RELIC are
// exact complements here, not a union — a druid dragging a bow is offered nothing, and a
// warrior dragging an idol likewise (decisions 1796, 1803).
15 if !has_relic_slot => vec![RANGED], // INVTYPE_RANGED
16 => vec![BACK], // INVTYPE_CLOAK
17 => vec![MAINHAND], // INVTYPE_2HWEAPON
18 => vec![BAG0, BAG1, BAG2, BAG3], // INVTYPE_BAG
19 => vec![TABARD], // INVTYPE_TABARD
21 => vec![MAINHAND], // INVTYPE_WEAPONMAINHAND
22 => vec![OFFHAND], // INVTYPE_WEAPONOFFHAND
23 => vec![OFFHAND], // INVTYPE_HOLDABLE
24 => vec![AMMO], // INVTYPE_AMMO → the ammo slot (loaded via SET_AMMO)
25 | 26 if !has_relic_slot => vec![RANGED], // INVTYPE_THROWN / INVTYPE_RANGEDRIGHT
28 if has_relic_slot => vec![RANGED], // INVTYPE_RELIC — the same slot, the other way
// INVTYPE_NON_EQUIP(0), INVTYPE_QUIVER(27), INVTYPE_RELIC(28, see doc), and anything past
// MAX_INVTYPE(29): not equippable.
_ => Vec::new(),
@@ -1326,30 +1327,75 @@ mod tests {
/// `ItemPrototype::GetAllowedEquipSlots` (`Objects/Item.cpp:577-696`).
#[test]
fn find_equip_slot_matches_the_vmangos_table() {
assert_eq!(find_equip_slot(1), vec![1], "HEAD");
assert_eq!(find_equip_slot(2), vec![2], "NECK");
assert_eq!(find_equip_slot(4), vec![4], "BODY (shirt)");
assert_eq!(find_equip_slot(5), vec![5], "CHEST");
assert_eq!(find_equip_slot(20), vec![5], "ROBE aliases CHEST");
assert_eq!(find_equip_slot(11), vec![11, 12], "FINGER, two slots");
assert_eq!(find_equip_slot(12), vec![13, 14], "TRINKET, two slots");
assert_eq!(find_equip_slot(1, false), vec![1], "HEAD");
assert_eq!(find_equip_slot(2, false), vec![2], "NECK");
assert_eq!(find_equip_slot(4, false), vec![4], "BODY (shirt)");
assert_eq!(find_equip_slot(5, false), vec![5], "CHEST");
assert_eq!(find_equip_slot(20, false), vec![5], "ROBE aliases CHEST");
assert_eq!(
find_equip_slot(13),
find_equip_slot(11, false),
vec![11, 12],
"FINGER, two slots"
);
assert_eq!(
find_equip_slot(12, false),
vec![13, 14],
"TRINKET, two slots"
);
assert_eq!(
find_equip_slot(13, false),
vec![16, 17],
"WEAPON offers both hands (dual-wield simplified)"
);
assert_eq!(find_equip_slot(14), vec![17], "SHIELD -> off hand");
assert_eq!(find_equip_slot(15), vec![18], "RANGED");
assert_eq!(find_equip_slot(16), vec![15], "CLOAK -> back");
assert_eq!(find_equip_slot(17), vec![16], "2HWEAPON -> main hand only");
assert_eq!(find_equip_slot(19), vec![19], "TABARD");
assert_eq!(find_equip_slot(21), vec![16], "WEAPONMAINHAND");
assert_eq!(find_equip_slot(22), vec![17], "WEAPONOFFHAND");
assert_eq!(find_equip_slot(18), vec![20, 21, 22, 23], "BAG");
assert_eq!(find_equip_slot(24), vec![0], "AMMO -> the ammo slot (id 0)");
// Not equippable: no vanilla paper-doll slot, or a named deferral (quiver/relic).
for t in [0u32, 27, 28, 100] {
assert!(find_equip_slot(t).is_empty(), "inventory type {t}");
assert_eq!(find_equip_slot(14, false), vec![17], "SHIELD -> off hand");
assert_eq!(find_equip_slot(15, false), vec![18], "RANGED");
assert_eq!(find_equip_slot(16, false), vec![15], "CLOAK -> back");
assert_eq!(
find_equip_slot(17, false),
vec![16],
"2HWEAPON -> main hand only"
);
assert_eq!(find_equip_slot(19, false), vec![19], "TABARD");
assert_eq!(find_equip_slot(21, false), vec![16], "WEAPONMAINHAND");
assert_eq!(find_equip_slot(22, false), vec![17], "WEAPONOFFHAND");
assert_eq!(find_equip_slot(18, false), vec![20, 21, 22, 23], "BAG");
assert_eq!(
find_equip_slot(24, false),
vec![0],
"AMMO -> the ammo slot (id 0)"
);
// Not equippable for anyone: no vanilla paper-doll slot, or a named deferral (quiver).
for t in [0u32, 27, 100] {
for relic in [false, true] {
assert!(find_equip_slot(t, relic).is_empty(), "inventory type {t}");
}
}
}
/// **INVSLOT 17 is one slot with two meanings, and the flag decides it BOTH ways** —
/// `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg is an equality, not a union
/// (decisions 1796/1803). A relic class is offered the slot for a libram and refused it for a
/// bow; everyone else the reverse. Getting this half-right — offering relics without
/// withdrawing bows — would light the highlight for a druid dragging a bow, which is the one
/// case the reference is unambiguous about.
#[test]
fn the_ranged_slot_takes_a_relic_or_a_weapon_never_both() {
const RANGED: u8 = 18;
// Paladin / Shaman / Druid.
assert_eq!(find_equip_slot(28, true), vec![RANGED], "RELIC");
for t in [15u32, 25, 26] {
assert!(
find_equip_slot(t, true).is_empty(),
"a relic class is offered no ranged slot for inventory type {t}"
);
}
// Everyone else.
assert!(
find_equip_slot(28, false).is_empty(),
"RELIC, no relic slot"
);
for (t, who) in [(15u32, "RANGED"), (25, "THROWN"), (26, "RANGEDRIGHT")] {
assert_eq!(find_equip_slot(t, false), vec![RANGED], "{who}");
}
}
@@ -11,27 +11,19 @@ use benilla_ui::script::{QuadContent, ScriptValue, UiScript, UnitState};
fn exp_bar_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Through the shared both-stores loader, not a disk read off `assets/ui`: `TextStatusBar.xml`
// is the reference's own since 1751, and a hand-rolled reader here would look for it in the
// wrong store — and would leave its `<Script file="TextStatusBar.lua"/>` unresolved besides.
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
super::test_ui::load_ui(&s, file);
}
s
}
@@ -309,7 +301,12 @@ fn the_xp_bar_numerals_show_on_hover() {
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
s.run("BenillaExpBar_OnLeave(MainMenuExpBar)").unwrap();
// `this` bound, because the engine binds it: the stock `HideTextStatusBarText` reads
// `this.isZero` on one line where every other line reads `bar` (TextStatusBar.lua:95 — the
// reference's own slip, benign there because `this` is always SOME frame during dispatch, and
// a nil index only from bare Lua). Called from the bar's own `<OnLeave>` in play.
s.run("this = MainMenuExpBar BenillaExpBar_OnLeave(MainMenuExpBar) this = nil")
.unwrap();
assert!(
!s.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap(),
@@ -115,28 +115,19 @@ const MOUSE_HANDLERS: &[&str] = &[
"OnDragStart",
];
/// Widget kinds whose **constructor** enables the mouse, so an element of that tag needs no
/// Whether an element's TAG is a widget kind the constructor mouse-enables — so it needs no
/// `enableMouse` and no handler to be clickable.
///
/// This is deliberately the SAME list [`benilla_ui::widget::WidgetArena::create`] uses, and it has
/// to be: both are one claim about the client's ctors (`CSimpleButton`/`CSimpleEditBox` enable
/// input in theirs; a `Slider` thumb must be draggable; a scroll frame takes the wheel — each
/// carries its own citation there). Reading the engine's list here means a wrong entry is wrong in
/// one place rather than two, and the sweep never reports a divergence that is only the two models
/// disagreeing with each other.
///
/// It is also what answers the chat window: `ChatFrame1` is a `ScrollingMessageFrame`, so the
/// reference's takes the mouse from its ctor even though `FloatingChatFrameTemplate` writes
/// `enableMouse="false"` — which is a no-op, the XML path only ever ENABLES
/// (`scripts-auto-enable.md` §1.3). That is how a 1.12 chat link is clickable at all.
const MOUSE_BY_CTOR: &[&str] = &[
"Button",
"CheckButton",
"EditBox",
"ScrollingMessageFrame",
"ScrollFrame",
"Slider",
];
/// **Answered by the engine, not by a copy of its list.** This used to be a `MOUSE_BY_CTOR` array
/// here whose own comment said it was "deliberately the SAME list `WidgetArena::create` uses… so a
/// wrong entry is wrong in one place rather than two". It was a second list, and it drifted
/// silently the first time the engine's was edited — this sweep then reported six scroll frames as
/// divergences that were only the two models disagreeing with each other, which is precisely what
/// the comment claimed could not happen.
fn tag_mouse_enabled_by_ctor(tag: &str) -> bool {
benilla_ui::script::frame_kind_from_tag(tag)
.is_some_and(benilla_ui::widget::mouse_enabled_by_ctor)
}
/// One accepted difference between benilla and the reference, with the reason it is accepted.
///
@@ -185,6 +176,102 @@ const KNOWN: &[Known] = &[
"the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; \
ours is a plain StatusBar. Wants the status-text arc, not the flag.",
},
// Seven `*ScrollFrame` mouse entries RETIRED here (1795). They read "our faux scroll pane is a
// Frame with an explicit `<OnMouseWheel>`, not a ScrollFrame" — a divergence that existed only
// because OUR `ScrollFrame` ctor took the mouse and the reference's does not. Correcting the
// ctor list to the client's made both sides agree, and an accepted divergence that has been
// fixed is documentation claiming a defect we do not have.
Known {
frame: "ChatFrame1",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame2",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame3",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame4",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame5",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame6",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame7",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "TargetofTargetHealthBar",
flag: Flag::Mouse,
@@ -242,41 +329,6 @@ const KNOWN: &[Known] = &[
//
// The two mail panes are a further step out: ours are flat art with a FontString body, a
// render approximation MailFrame.xml names at the site, so neither side scrolls them.
Known {
frame: "FriendsFrameFriendsScrollFrame",
flag: Flag::Mouse,
why: "our faux scroll pane is a Frame with an explicit <OnMouseWheel>, not a ScrollFrame",
},
Known {
frame: "FriendsFrameIgnoreScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "GuildListScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "WhoListScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "SkillListScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "ReputationListScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "QuestLogListScrollFrame",
flag: Flag::Mouse,
why: "as FriendsFrameFriendsScrollFrame",
},
// ── id ─────────────────────────────────────────────────────────────────────────────────────
//
// Empty, and it is worth saying why rather than leaving a bare header. The reference side of
@@ -482,10 +534,7 @@ fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool
let Some(el) = frames.get(name) else {
return false;
};
if MOUSE_BY_CTOR
.iter()
.any(|t| t.eq_ignore_ascii_case(&el.tag))
{
if tag_mouse_enabled_by_ctor(&el.tag) {
return true;
}
if resolved_attr(name, frames, "enableMouse", 0).is_some_and(|v| v.eq_ignore_ascii_case("true"))
@@ -71,7 +71,8 @@ fn load_page(s: &UiScript) {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"OptionsFrameTemplates.xml",
@@ -728,7 +728,8 @@ fn interface_harness() -> UiScript {
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"TextStatusBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UnitFrames.xml",
"Cooldown.xml",
"ActionBar.xml",
+389 -13
View File
@@ -395,13 +395,13 @@ mod tests {
/// manifest in a fresh VM — the same pass [`chain_readiness_report`] makes, run here so the
/// answer is in one table.
///
/// That last one is 1790, and it is the same mistake as the third column one step later.
/// That last one is 1801, and it is the same mistake as the third column one step later.
/// `<LootButton>` and `<TaxiRouteFrame>` are element TAGS: no census of `Name(` or `:Name(`
/// can reach them, so `LootFrame.xml` sat in this report's "needs NO engine work" list while
/// the readiness probe was printing `4 issue(s)` for it in a different table. Both tables were
/// right. Joining them was left to whoever read them, and I read it wrong.
///
/// Until 1787 the method sites were **silently dropped**: a name in neither the engine nor the
/// Until 1798 the method sites were **silently dropped**: a name in neither the engine nor the
/// FrameXML half of `1.12-globals.tsv` was assumed to be a widget method and skipped, on the
/// reasoning that widget methods are not in `_G`. True, and it meant the report could not see
/// a widget method we had *not built*. `MerchantFrame.xml` read `0 engine` and
@@ -410,10 +410,16 @@ mod tests {
/// file loaded, every check passed, and hovering a vendor row would have raised in play. Both
/// instruments were right about what they measure. Neither measured the window.
///
/// **A remaining `<?>` in the `fx=` column is usually a LoadOnDemand addon**, not something to
/// build. `ClassTrainerFrame_Show`, `CraftFrame_Show`, `MacroFrame_SaveMacro`,
/// `InspectFrame_Show`, `TalentFrame_Toggle` and their siblings live in `Blizzard_*` addons the
/// install ships **packed as `.pub`**, so no amount of scanning the extracted FrameXML finds
/// them. They arrive when that addon does, exactly like an `fx=` name with a home.
///
/// One crudeness remains, stated because it decides how to read the output: neither scan can
/// see a name reached through `getglobal`, so both can under-report.
///
/// They no longer over-report on comments. Both counted commented-out calls until 1789 — 1.12
/// They no longer over-report on comments. Both counted commented-out calls until 1800 — 1.12
/// comments out whole blocks, and `PaperDollFrame.lua:754-756`'s `ShowInventorySellCursor` is
/// three commented lines that this report named as that window's last engine gap. A real
/// binding, never called, blocking a window that was not blocked. [`strip_comments`] takes
@@ -457,7 +463,7 @@ mod tests {
.collect();
// Strip what is not code before either scan. Both `Name(` and `:Name(` counted calls
// inside comments until 1789: the round that built `PickupMerchantItem` also asked wow-re
// inside comments until 1800: the round that built `PickupMerchantItem` also asked wow-re
// for `ShowInventorySellCursor`, which this report had named as `PaperDollFrame.xml`'s
// last engine gap — and the answer was that stock `PaperDollFrame.lua:754-756` has the
// call **commented out**, all three lines. A real binding, never called, blocking a window
@@ -604,7 +610,7 @@ mod tests {
.collect()
};
// The instrument's own tripwire: if the probe stops working, the `method=` column goes
// silently empty — which is precisely the failure mode 1787 exists to end.
// silently empty — which is precisely the failure mode 1798 exists to end.
let control: Vec<String> = ["SetPoint", "SetMerchantItem", "BenillaNotAMethod"]
.iter()
.map(|s| (*s).to_string())
@@ -636,10 +642,48 @@ mod tests {
.map(str::to_string)
.collect();
// The `.lua` files a stock `.xml` SOURCES through `<Script file=>`. A window's own sourced
// code is part of the window: its functions are not gaps, and they are where most of its
// `fx=` names would otherwise be looked for.
let sourced_luas = |xml_leaf: &str| -> Vec<String> {
let Some(b) = super::read(&format!("Interface\\FrameXML\\{xml_leaf}")) else {
return Vec::new();
};
let text = String::from_utf8_lossy(&b).into_owned();
let mut out = Vec::new();
for (i, _) in text.match_indices("<Script") {
let rest = &text[i..];
let Some(end) = rest.find("/>").or_else(|| rest.find('>')) else {
continue;
};
let tag = &rest[..end];
if let Some(fi) = tag.find("file=\"") {
let after = &tag[fi + 6..];
if let Some(q) = after.find('"') {
let leaf = after[..q].rsplit(['\\', '/']).next().unwrap_or("");
if leaf.ends_with(".lua") {
out.push(leaf.to_string());
}
}
}
}
out
};
// `function Name(` across the whole corpus, so an fx gap can name the file that holds it.
//
// Each toc `.xml` is scanned together with **every `.lua` it SOURCES**, not just the
// `X.lua` its own name suggests. Guessing missed the common case: `ActionBarFrame.xml`
// sources `ActionButton.lua`, there is no `ActionBarFrame.lua`, and `ActionButton.lua` is
// not a toc line of its own — so the whole `ActionButton_*` family read `<?>`, which says
// "nothing defines this" about five functions a stock file defines and brings with it.
// The difference matters for how the column is read: a name with a home ARRIVES when that
// file does; a `<?>` is something to build.
let mut home: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for f in &stock {
for cand in [f.clone(), format!("{}.lua", &f[..f.len() - 4])] {
let mut cands = vec![f.clone(), format!("{}.lua", &f[..f.len() - 4])];
cands.extend(sourced_luas(f));
for cand in cands {
let Some(bytes) = super::read(&format!("Interface\\FrameXML\\{cand}")) else {
continue;
};
@@ -662,7 +706,16 @@ mod tests {
let mut out = std::collections::HashSet::new();
let mut i = 0;
while i < b.len() {
if b[i].is_ascii_uppercase() && (i == 0 || !super::is_word(b[i - 1])) {
// A GLOBAL call: uppercase, not inside a word, and **not preceded by `.` or
// `:`**. Without that last test `info.UpdateFunc(` and `dialog.OnAccept(` read as
// globals — `.` is not a word character — and a dozen `StaticPopupDialogs` /
// `MoneyTypeInfo` FIELD names showed up as `<?>` gaps. `:` belongs to the method
// scan below; `.` belongs to nobody, since a field call arrives with its table.
let after_field = i > 0 && (b[i - 1] == '.' || b[i - 1] == ':');
if b[i].is_ascii_uppercase()
&& !after_field
&& (i == 0 || !super::is_word(b[i - 1]))
{
let mut j = i;
while j < b.len() && super::is_word(b[j]) {
j += 1;
@@ -696,13 +749,39 @@ mod tests {
if migrated.contains(f) {
continue;
}
// The window IS its xml plus every `.lua` it sources — `ActionBarFrame.xml` sources
// `ActionButton.lua`, and the whole `ActionButton_*` family is that window's own code,
// not a dependency on somebody else's. Gathering only `X.xml` + `X.lua` reported five
// of its own functions as gaps.
let mut text = String::new();
for cand in [f.clone(), format!("{}.lua", &f[..f.len() - 4])] {
let mut parts = vec![f.clone(), format!("{}.lua", &f[..f.len() - 4])];
parts.extend(sourced_luas(f));
for cand in parts {
if let Some(b) = super::read(&format!("Interface\\FrameXML\\{cand}")) {
text.push_str(&String::from_utf8_lossy(&b));
}
}
let text = strip_comments(&text);
// …and the names the file declares as LOCALS, which a bare call resolves to. The
// idiom that needs it is the reference's own dispatch shape:
//
// local OnAccept = StaticPopupDialogs[dialog.which].OnAccept
// if ( OnAccept ) then dontHide = OnAccept(dialog.data, dialog.data2) end
//
// The call is bare, so the global scan sees `OnAccept(` and reports a gap for a name
// that is a table field one line up. `OnAccept`, `OnCancel`, `OnShow`, `OnHide` and
// the three `EditBoxOn*` all arrived that way. A `local` declaration in the same file
// is proof enough: nothing else could be meant.
let locals: std::collections::HashSet<String> = text
.lines()
.filter_map(|l| l.trim_start().strip_prefix("local "))
.map(|r| {
r.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>()
})
.filter(|n| !n.is_empty())
.collect();
let own: std::collections::HashSet<String> = text
.lines()
.filter_map(|l| l.trim_start().strip_prefix("function "))
@@ -715,7 +794,7 @@ mod tests {
let (mut eng, mut fx) = (Vec::new(), Vec::new());
let mut names: Vec<String> = called(&text)
.into_iter()
.filter(|c| !own.contains(c) && !have.contains(c))
.filter(|c| !own.contains(c) && !locals.contains(c) && !have.contains(c))
.collect();
names.sort();
for c in names {
@@ -737,8 +816,30 @@ mod tests {
// while `chain_readiness_report` was printing `4 issue(s)` for it in another table.
//
// Both tables were right. Joining them was left to whoever read them, and I got it
// wrong (1790). So this one runs the load itself — the same fresh-VM-plus-manifest
// wrong (1801). So this one runs the load itself — the same fresh-VM-plus-manifest
// pass the readiness probe does — and reports it in the same row.
// **A `LOAD:` line for a window we ALSO ship is suspect**, and the reason is the same
// one `chain_readiness_report` carries: `publish_global` is non-overwriting (RF-0023),
// so loading the stock file on top of our identically-named one leaves every colliding
// frame's global pointing at OUR frame while the stock file's handlers run against
// THEIRS. A field set on `this` in an OnLoad is then invisible to `getglobal(name)`,
// and the failure looks like a bug in whatever read it back.
//
// Worked example, because it cost an hour: stock `TradeFrame.xml` reported
// `MoneyFrame.xml:525: attempt to index local 'info'`. Nothing was wrong with our
// MoneyFrame — `MoneyFrame_SetType` had set `this.info` correctly, and
// `getglobal(this:GetName())` answered a DIFFERENT table, because our own
// `TradeFrame.xml` already owned `TradeRecipientMoneyFrame`.
//
// So the column is marked, not trusted. A row whose file we do not ship is a real
// load failure; a row whose file we do ship needs the swap attempted to know.
let ours_too = !migrated.contains(f)
&& std::fs::metadata(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(f),
)
.is_ok();
let loads = {
let mut probe = UiScript::new().expect("VM");
probe.set_screen_size(1024.0, 768.0);
@@ -759,8 +860,22 @@ mod tests {
let known = answers(&s, &asked);
let mut meth: Vec<String> = asked.into_iter().filter(|m| !known.contains(m)).collect();
meth.sort();
// A suspect LOAD line does not count as a blocker — it is a question, not an answer.
let load_blockers = if ours_too { 0 } else { loads.len() };
let loads: Vec<String> = loads
.into_iter()
.map(|e| {
let one = e.replace('\n', " ");
let mark = if ours_too {
" (ours too — suspect)"
} else {
""
};
format!("{mark} {}", &one[..one.len().min(150)])
})
.collect();
rows.push((
eng.len() + meth.len() + loads.len(),
eng.len() + meth.len() + load_blockers,
f.clone(),
eng,
fx,
@@ -778,8 +893,7 @@ mod tests {
println!(" method: {}", meth.join(" "));
}
for e in loads {
let one = e.replace('\n', " ");
println!(" LOAD: {}", &one[..one.len().min(150)]);
println!(" LOAD:{e}");
}
if !fx.is_empty() {
println!(" fx: {}", fx.join(" "));
@@ -816,12 +930,103 @@ mod tests {
/// load, which is what this reports: a name ours defines that a chain entry in our own
/// manifest also defines.
///
/// **Two halves, both exact, because both sides DECLARE rather than mention.** The function
/// half is above. The frame half asks the other question a swap has to answer: *can this stock
/// window be added at all* — which is different from "does it load", because a stock window we
/// do not ship under its own name usually has a counterpart of ours under a different one, and
/// both would declare the same frames.
///
/// That half doubles as the map nothing else holds: `ActionBarFrame.xml` is our
/// `ActionBar.xml`, `FloatingChatFrame.xml` is our `ChatFrame.xml`, `PlayerFrame.xml` and
/// `TargetFrame.xml` and `PetFrame.xml` are all our one `UnitFrames.xml`,
/// `MainMenuBarMicroButtons.xml` is our `MicroMenu.xml`, `StaticPopup.xml` is our
/// `UiPanels.xml`. `chain_gap_report` calls several of those unblocked, and they are —
/// individually. They just cannot load beside the file of ours already holding their names.
///
/// Run it before attempting a swap. It predicts which ones will fail without attempting them.
#[test]
#[ignore = "instrument: run by hand before attempting a window swap"]
fn shadowed_reference_functions() {
let _data = benilla_formats::wow_data_or_skip!();
// The FRAME NAMES a document declares — `name="X"` on a widget element, minus the
// `$parent`-relative and `virtual` template forms, which name nothing globally.
//
// The sibling of the function check below and exactly as exact, because both sides
// DECLARE rather than mention. This is the version of the "frame names" idea that works:
// an earlier attempt scanned names a file *referenced* and was pure noise (it found `UI`
// and missed the case it was built for), because a reference can live in any file. A
// declaration cannot.
//
// What it catches: our `UnitFrames.xml` declares `PetFrame`, and so does the stock
// `PetFrame.xml`. Adding that stock file alongside ours would declare the name twice.
// Several stock windows we do not ship under their own name have an equivalent of ours
// under a different one, and `chain_gap_report` calls every one of them unblocked —
// truthfully, because the stock file WOULD load; it just cannot load *beside* ours.
let declares = |text: &str| -> Vec<String> {
let mut out = Vec::new();
for (i, _) in text.match_indices("name=\"") {
let rest = &text[i + 6..];
let Some(end) = rest.find('"') else { continue };
let name = &rest[..end];
if name.starts_with('$') || name.is_empty() {
continue;
}
// A `virtual="true"` element is a template: its name is a registry key, not a
// frame, and two files may legitimately hold the same template name only if one
// replaces the other — which is the same question, so they are reported too.
out.push(name.to_string());
}
out
};
// `function Name(a, b, c)` → the name and its PARAMETER LIST. Arity is the third way our
// files and the reference's can disagree about a name, and the one that fails most
// quietly: `TextStatusBar_Initialize()` takes no argument in 1.12 and acts on `this`, ours
// took an optional bar, and `UnitFrames.xml` calls it with one. Swapping that file stopped
// initialising the unit-frame bars with no error and no missing global — the numerals
// simply never appeared (decision 1793).
//
// **Both directions are silent, and that is the point.** Lua drops extra arguments without
// complaint, so neither an over- nor an under-supplied call raises; they differ only in
// WHO loses information:
//
// * ours WIDER — our callers pass the extra argument and the reference's version drops
// it. Breaks when OUR file is swapped out (the `TextStatusBar` case).
// * ours NARROWER — the reference's callers pass more than ours takes and ours drops it.
// Breaks when a STOCK file is added beside ours and calls our version.
//
// The report names the direction because it says which swap the difference is waiting for,
// not because one of them is safe.
let params_in = |text: &str| -> Vec<(String, usize)> {
let mut out = Vec::new();
for line in text.lines() {
let Some(rest) = line.trim_start().strip_prefix("function ") else {
continue;
};
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() {
continue;
}
let Some(open) = rest.find('(') else { continue };
let Some(close) = rest[open..].find(')') else {
continue;
};
let args = rest[open + 1..open + close].trim();
// `...` is a vararg, which has no fixed arity to compare.
let n = if args.is_empty() || args == "..." {
0
} else {
args.split(',').count()
};
out.push((name, n));
}
out
};
let defined_in = |text: &str| -> Vec<String> {
text.lines()
.filter_map(|l| l.trim_start().strip_prefix("function "))
@@ -841,6 +1046,8 @@ mod tests {
toc.iter().enumerate().map(|(i, f)| (f, i)).collect();
let mut chain_home: std::collections::HashMap<String, (String, usize)> =
std::collections::HashMap::new();
let mut chain_frames: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
let leaf = entry.rsplit(['\\', '/']).next().unwrap_or(entry);
let mut cands = vec![entry.clone()];
@@ -878,6 +1085,67 @@ mod tests {
}
hits.sort();
hits.dedup();
// The frame names every STOCK window declares — read off the reference's own toc, not off
// our manifest's chain entries. Scanning only what we already load was the first attempt
// and it answered 0 by construction: a stock file we do not load is exactly the one whose
// names could collide, and it was never opened.
let ref_toc = String::from_utf8_lossy(
&super::read("Interface\\FrameXML\\FrameXML.toc").expect("the reference's own toc"),
)
.into_owned();
let mut ref_arity: std::collections::HashMap<String, (usize, String)> =
std::collections::HashMap::new();
for line in ref_toc.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') || !line.ends_with(".xml") {
continue;
}
for cand in [line.to_string(), format!("{}.lua", &line[..line.len() - 4])] {
let Some(b) = super::read(&format!("Interface/FrameXML/{cand}")) else {
continue;
};
let text = String::from_utf8_lossy(&b).into_owned();
if cand.ends_with(".xml") {
for name in declares(&text) {
chain_frames.entry(name).or_insert_with(|| line.to_string());
}
}
for (name, n) in params_in(&text) {
ref_arity.entry(name).or_insert((n, cand.clone()));
}
}
}
assert!(
chain_frames.contains_key("GameTooltip") && chain_frames.contains_key("PetFrame"),
"the stock frame-name scan found nothing recognisable ({} names) — an empty answer \
here reads as \"no collisions\", which is what the first version of this reported \
for the wrong reason",
chain_frames.len()
);
// …and the frame-name half, over the stock files the manifest does NOT already take off
// the chain. A file of ours whose stock counterpart we already load is a swap that has
// happened; this is about the ones that have not.
let mut frame_hits: Vec<(String, String, String)> = Vec::new();
for entry in toc.iter().filter(|f| !super::is_chain_entry(f)) {
let Ok(text) = std::fs::read_to_string(dir.join(entry)) else {
continue;
};
for name in declares(&text) {
if let Some(home) = chain_frames.get(&name) {
let already = toc
.iter()
.filter(|f| super::is_chain_entry(f))
.any(|f| f.ends_with(home.as_str()));
// A template's name is a registry key rather than a frame, but two files
// holding one is the same question, so it is reported the same way.
if !already {
frame_hits.push((name, entry.clone(), home.clone()));
}
}
}
}
frame_hits.sort();
frame_hits.dedup();
println!("\n=== names ours redefines that a CHAIN entry already defines ===");
println!("{:<36} {:<28} {:<34} winner", "name", "ours", "chain");
@@ -901,6 +1169,114 @@ mod tests {
for (f, n) in &by_file {
println!(" {n:>3} {f}");
}
// The ARITY half: a name we define that the reference also defines, with a different
// parameter count. Not a collision — the two need never both load for this to bite — so it
// is its own section rather than a column.
let mut arity: Vec<(String, String, usize, usize, String)> = Vec::new();
for entry in toc.iter().filter(|f| !super::is_chain_entry(f)) {
let Ok(text) = std::fs::read_to_string(dir.join(entry)) else {
continue;
};
for (name, ours_n) in params_in(&text) {
if let Some((ref_n, home)) = ref_arity.get(&name) {
if *ref_n != ours_n {
arity.push((name, entry.clone(), ours_n, *ref_n, home.clone()));
}
}
}
}
arity.sort();
arity.dedup();
println!(
"\n=== {} signatures of ours differ in ARITY from the reference's ===",
arity.len()
);
println!("{:<34} {:<26} ours ref direction", "name", "ours");
for (name, ours, a, b, home) in &arity {
// Which swap this one is waiting for — see the note above; both are silent.
let dir = if a > b {
"ours WIDER — bites when OUR file goes"
} else {
"ours NARROWER — bites when THEIRS arrives"
};
println!("{name:<34} {ours:<26} {a:>4} {b:>3} {dir} — ref in {home}");
}
// The frame half, grouped the other way — by the STOCK file, because that is the unit of
// the question it answers: "can this stock window be added?"
let mut by_stock: std::collections::BTreeMap<&String, std::collections::BTreeSet<&String>> =
std::collections::BTreeMap::new();
for (_, ours, home) in &frame_hits {
by_stock.entry(home).or_default().insert(ours);
}
println!(
"\n=== {} FRAME-NAME collisions: {} stock windows we do not load already have their \
names declared by a file of ours ===",
frame_hits.len(),
by_stock.len()
);
for (stock, ours) in &by_stock {
let n = frame_hits.iter().filter(|(_, _, h)| h == *stock).count();
let mine: Vec<&str> = ours.iter().map(|s| s.as_str()).collect();
println!(" {n:>3} {stock:<34} vs {}", mine.join(", "));
}
}
/// **A chain `.xml` that does not source its own `.lua` needs TWO manifest lines**, and
/// getting it wrong is silent.
///
/// Most stock windows pull their code in with `<Script file="X.lua"/>`, so naming the `.xml`
/// brings both. A few do not — `TextStatusBar.xml` and `MoneyInputFrame.xml` declare only a
/// template, and the reference's own toc lists their `.lua` on the preceding line (l.32-33,
/// l.11-12). Name the `.xml` alone and the template loads against nothing: every global that
/// file was supposed to define reads nil, every guarded call becomes a no-op, and there is no
/// error anywhere.
///
/// Both were live. `TextStatusBar` was caught by three tests that happened to assert on the
/// numerals; `MoneyInputFrame` was caught only by sweeping for the shape afterwards, and its
/// manifest header had claimed for months to bring "the ten `MoneyInputFrame_*` verbs" while a
/// full-manifest probe answered nil for all of them.
///
/// A gate rather than an instrument, because the answer should always be zero and the failure
/// mode is invisible.
#[test]
fn every_chain_xml_brings_its_own_lua() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let listed: std::collections::HashSet<&str> = toc
.iter()
.map(|f| f.rsplit(['\\', '/']).next().unwrap_or(f))
.collect();
let mut orphans = Vec::new();
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
let leaf = entry.rsplit(['\\', '/']).next().unwrap_or(entry);
let Some(stem) = leaf.strip_suffix(".xml") else {
continue;
};
let lua = format!("{stem}.lua");
// No sibling in the archive means there is nothing to miss.
if super::read(&format!("Interface/FrameXML/{lua}")).is_none() {
continue;
}
let xml = super::read(&entry.replace('\\', "/"))
.unwrap_or_else(|| panic!("{entry}: not in the chain"));
let text = String::from_utf8_lossy(&xml);
let sourced = text
.to_ascii_lowercase()
.contains(&format!("file=\"{}\"", lua.to_ascii_lowercase()));
if !sourced && !listed.contains(lua.as_str()) {
orphans.push(format!(
"{leaf} does not source {lua}, and {lua} is not a manifest entry"
));
}
}
assert!(
orphans.is_empty(),
"a chain window whose code never loads — silent, every global it defines reads nil:\n {}",
orphans.join("\n ")
);
}
/// A path is a chain entry; a bare name is ours. The one-line rule the manifest rests on.
@@ -87,7 +87,8 @@ fn load_page(s: &UiScript) {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
@@ -191,3 +191,68 @@ fn the_death_countdown_text_rerenders_each_tick() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`StaticPopup_Hide`'s and `StaticPopup_FindVisible`'s second argument** (decisions 1793).
///
/// Both took `which` alone until the arity scan found the drift. The reference takes `(which,
/// data)`, and the difference is invisible without a test: Lua discards a surplus argument without
/// complaint, so a stock caller's `StaticPopup_Hide("CONFIRM_BATTLEFIELD_ENTRY", i)` — the shape
/// `BattlefieldFrame.lua:120` uses — would have hidden every instance of that dialog instead of
/// the one it named, and said nothing.
///
/// The `multiple` gate is the reference's own and is why `data` is not a plain equality: a kind
/// declared `multiple` can have several instances up at once and is matched by data; every other
/// kind has at most one, so its data is never consulted.
#[test]
fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
let s = setup();
s.run(
r#"
StaticPopupDialogs["T_MULTI"] = { text = "multi", button1 = "Ok", multiple = 1, timeout = 0 }
StaticPopupDialogs["T_ONE"] = { text = "one", button1 = "Ok", timeout = 0 }
a = StaticPopup_Show("T_MULTI") a.data = "alpha"
b = StaticPopup_Show("T_MULTI") b.data = "beta"
c = StaticPopup_Show("T_ONE") c.data = "gamma"
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
// A `multiple` kind is addressed by data — both ways round, so this cannot pass by finding
// whichever instance happens to be first.
for (data, want) in [("alpha", "a"), ("beta", "b")] {
assert!(
s.eval::<bool>(&format!(
"return StaticPopup_FindVisible(\"T_MULTI\", {data:?}) == {want}"
))
.unwrap(),
"FindVisible picks the instance carrying {data:?}"
);
}
// …and a data nothing carries finds nothing, rather than the first instance.
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_MULTI", "nobody") == nil"#)
.unwrap());
// Hiding by data takes ONE instance and leaves its sibling up.
s.run(r#"StaticPopup_Hide("T_MULTI", "alpha")"#).unwrap();
assert!(s
.eval::<bool>("return not a:IsShown() and b:IsShown()")
.unwrap());
// No data hides every instance of the kind — which is what every one-argument caller means.
s.run(r#"StaticPopup_Hide("T_MULTI")"#).unwrap();
assert!(s.eval::<bool>("return not b:IsShown()").unwrap());
// A NON-multiple kind ignores data entirely: the reference gates the comparison on
// `info.multiple`, so a wrong data still finds it.
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_ONE", "wrong") == c"#)
.unwrap());
// An unknown kind answers nil rather than scanning — the reference's own early out.
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_NOPE") == nil"#)
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -17,7 +17,8 @@ fn load_unit_frames(s: &UiScript) {
load_xml(s, "UIParent.xml");
// The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
// UnitFrames.xml and which every bar's OnLoad wires into since 1143.
load_xml(s, "TextStatusBar.xml");
load_xml(s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(s, "GameTooltip.xml");
load_xml(s, "UIDropDownMenu.xml");
load_xml(s, "UnitPopup.xml");
@@ -1516,8 +1517,13 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
.unwrap();
assert!(shown(&s, "TargetFrameHealthBarText"));
assert_eq!(text(&s, "TargetFrameHealthBarText"), "50 / 100");
s.run("BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar)")
.unwrap();
// `this` bound, as the engine binds it for a bar's own `<OnLeave>`: the stock
// `HideTextStatusBarText` reads `this.isZero` on one line where every other reads `bar`
// (TextStatusBar.lua:95 — the reference's own slip; a nil index only from bare Lua).
s.run(
"this = TargetFrameHealthBar BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar) this = nil",
)
.unwrap();
assert!(
!shown(&s, "TargetFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
@@ -1535,8 +1541,10 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
shown(&s, "PlayerFrameHealthBarText"),
"hover shows them even with the option off"
);
s.run("BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar)")
.unwrap();
s.run(
"this = PlayerFrameHealthBar BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar) this = nil",
)
.unwrap();
assert!(!shown(&s, "PlayerFrameHealthBarText"));
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
+1 -1
View File
@@ -49,7 +49,7 @@
//! (`0x54240e`/`0x542446`), so the patch copy is the only one the client can read. Read the base
//! archive alone — or an early-vanilla 16-column struct — and the flag simply is not there, which
//! is how "the relic slot post-dates 1.12" became a settled belief in this codebase and stayed one
//! across two decision records. It is false; see decision 1785. [`CHR_CLASSES_FIELDS`] is what
//! across two decision records. It is false; see decision 1796. [`CHR_CLASSES_FIELDS`] is what
//! keeps us on the patch copy, and it is load-bearing, not defensive.
//!
//! The flag is engine-enforced, not a UI conceit: `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg
@@ -782,7 +782,7 @@ pub const SMSG_SELL_ITEM: u16 = 0x01A1; // 417
pub const CMSG_BUY_ITEM: u16 = 0x01A2; // 418
/// Buy into a **specific** bag slot. This is what a vendor row dropped out of the merchant
/// cursor sends (`PickupMerchantItem` mode 5 → the container/doll drop), as against
/// `CMSG_BUY_ITEM`'s auto-place, which is what clicking the row sends. Both ship (decision 1786).
/// `CMSG_BUY_ITEM`'s auto-place, which is what clicking the row sends. Both ship (decision 1797).
pub const CMSG_BUY_ITEM_IN_SLOT: u16 = 0x01A3; // 419
pub const SMSG_BUY_ITEM: u16 = 0x01A4; // 420
pub const SMSG_BUY_FAILED: u16 = 0x01A5; // 421
+1 -1
View File
@@ -1115,7 +1115,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// a property of the SLOT, not of the player. It pairs with `UnitHasRelicSlot("player")`,
// which is the class half, and stock reads the two as a conjunction
// (`PaperDollFrame.lua:680`/`:744`). This comment used to say `UnitHasRelicSlot` is a
// later-era concept, always false in vanilla; it is not — decision 1785.
// later-era concept, always false in vanilla; it is not — decision 1796.
g.set(
"GetInventorySlotInfo",
lua.create_function(|lua, name: String| {
+1
View File
@@ -101,6 +101,7 @@ mod model;
mod modelframe;
mod net_stats;
mod object;
pub use object::frame_kind_from_tag;
mod party;
mod pet;
mod petition;
+9 -1
View File
@@ -192,12 +192,20 @@ pub(super) fn draw_layer_name(l: DrawLayer) -> &'static str {
}
}
/// A widget tag or `CreateFrame` type string → its [`FrameKind`], case- and separator-insensitive
/// ([`enum_token`]). Public because it is the ONE mapping: `benilla-app`'s `frame_flag_gate` sweep
/// needs it to ask the engine whether a tag is mouse-enabled by construction, and a second copy
/// there is exactly what drifted.
pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
frame_kind_from_str(s)
}
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
Some(match enum_token(s).as_str() {
"FRAME" => FrameKind::Frame,
"BUTTON" => FrameKind::Button,
"CHECKBUTTON" => FrameKind::CheckButton,
// A real registered type (`0x4959a6`), not an alias for Button — decision 1788. Registering
// A real registered type (`0x4959a6`), not an alias for Button — decision 1799. Registering
// it AS a Button would load `LootFrame.xml` and leave every row dead, because 1.12 has no
// Lua verb for "take slot N": `LootSlot` is the bind-confirm continuation, hard-wired to
// `flag = 1`.
+53 -3
View File
@@ -50,7 +50,7 @@ use crate::order;
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{button, cursor, editbox, event, UiScript};
use super::{button, cursor, editbox, event, Model, UiScript};
/// The double-click interval — **300 ms, VERIFIED off the bytes**, a hardcoded instruction
/// immediate: `0x77937b 81 f9 2c 01 00 00 cmp ecx, 0x12c`, comparing `now − [this+0x334]` inside
@@ -127,6 +127,15 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
Ok(())
}
/// Whether `(x, y)` falls inside one of this frame's hyperlink spans — the engine half of the
/// reference's per-span `CSimpleHyperlinkButton` children.
fn link_span_hit(model: &Model, fh: FrameHandle, x: f32, y: f32) -> bool {
model
.link_spans
.get(&fh)
.is_some_and(|spans| spans.iter().any(|(r, _, _)| point_in_rect(*r, x, y)))
}
impl UiScript {
/// Hit-test the cursor at `(x, y)` and return the **handle** of the captured frame (the
/// topmost-drawn mouse-enabled, effective-visible frame whose rect contains the point **and**
@@ -146,7 +155,22 @@ impl UiScript {
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
order::hit_test(&sorted, |fh| {
model.arena.is_mouse_enabled(fh)
// Mouse-enabled, **or inside one of this frame's hyperlink spans**.
//
// That second half is how the reference makes a chat link clickable on a frame that
// takes no mouse at all. `CSimpleMessageScrollFrame`'s ctor leaves `[+0xcc] = 0`, so
// `ChatFrame1` is never in the hit-test index — instead the engine synthesises one
// mouse-enabled `CSimpleHyperlinkButton` child PER SPAN (`0x7a3240`, born through the
// `CSimpleButton` ctor) and routes the three hyperlink scripts back to the parent
// (wow-re `mouse-enable-law.md` + the follow-up round `68987021`).
//
// We hold the spans as rects on the parent rather than as child frames, so the same
// law expresses as a disjunct here. What it buys is everything the reference gets from
// the split: the chat window is a hit candidate only over a LINK, so `GetMouseFocus()`
// answers what is behind it elsewhere, its `movable="true"` +
// `<TitleRegion setAllPoints="true"/>` stay inert so it drags by its tab and not its
// body, and a hover over plain text reassigns to whatever is underneath.
(model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y))
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
@@ -155,6 +179,26 @@ impl UiScript {
})
}
/// [`Self::hit_test`] over the frames that take the WHEEL — either bit, since a mouse-enabled
/// frame with an `<OnMouseWheel>` is a candidate for both. Same rect and clip rules; only the
/// enable predicate differs.
fn hit_test_wheel(&self, x: f32, y: f32) -> Option<u32> {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
let fh = order::hit_test(&sorted, |fh| {
(model.arena.is_mouse_wheel_enabled(fh)
|| model.arena.is_mouse_enabled(fh)
|| link_span_hit(&model, fh, x, y))
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
})?;
model.frame_to_id.get(&fh).copied()
}
/// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame, or `None` —
/// [`Self::hit_test_frame`] with the id lookup every caller in the input path wants. The id is
/// the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app can
@@ -749,7 +793,13 @@ impl UiScript {
/// [`UiScript::errors`].
pub fn mouse_wheel(&mut self, x: f32, y: f32, delta: f32) {
let target: Option<u32> = {
let hit = self.hit_test(x, y);
// **Wheel-enabled OR mouse-enabled**, not mouse-enabled alone. The reference's wheel
// dispatcher `0x7664f0` walks its OWN index and calls the hit probe directly, ignoring
// hover, capture and the mouse bit — `EnableMouse` is not a prerequisite
// (wow-re `mouse-enable-law.md` §6). Ours went through the mouse hit test, which
// coupled the two: correcting the ctor list to the reference's (ScrollFrame is inert
// on the mouse bit) silently stopped every scroll pane from taking the wheel.
let hit = self.hit_test_wheel(x, y);
let model = self.model_ref();
hit.and_then(|id| {
let mut h = *model.id_to_frame.get(&id)?;
@@ -599,7 +599,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// **This shipped absent for a long time, on a claim that was simply false** — that the relic
// slot post-dates 1.12 and `UnitHasRelicSlot` is a later-era verb. The base `dbc.MPQ` copy of
// `ChrClasses.dbc` really does have no field 16; the `patch.MPQ` copy the client actually
// reads has it, set for Paladin, Shaman and Druid. Decision 1785. Stock `PaperDollFrame.lua`
// reads has it, set for Paladin, Shaman and Druid. Decision 1796. Stock `PaperDollFrame.lua`
// calls this **unconditionally** at l.429 and l.580 — so while it was missing, the character
// sheet raised `attempt to call global 'UnitHasRelicSlot' (a nil value)` for *every* class,
// not just the three.
+1 -1
View File
@@ -173,7 +173,7 @@ pub struct UnitState {
/// which is the nil leg.
///
/// True for exactly Paladin, Shaman and Druid in 1.12 (Libram, Totem, Idol). It was believed
/// here for a long time that no 1.12 class had one; see decision 1785 for how that survived.
/// here for a long time that no 1.12 class had one; see decision 1796 for how that survived.
pub has_relic_slot: bool,
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
+1 -1
View File
@@ -1295,7 +1295,7 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
/// `UnitHasRelicSlot` — the number 1 or nil, per token, never a boolean.
///
/// This shipped **absent** for months on the belief that the relic slot post-dates 1.12, which is
/// false (decision 1785). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
/// false (decision 1796). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
/// so while it was missing the character sheet raised `attempt to call global` for every class —
/// which is why the nil-global case is asserted here too, not just the answer.
#[test]
+1 -1
View File
@@ -48,7 +48,7 @@ pub enum FrameKind {
///
/// Its whole Lua surface of its own is **one method, `SetSlot(index)`** (table `0x847ce4`,
/// count 1 read off the registrar's own `mov edx,1`) — 1-based in, 0-based stored, no returns.
/// `LootFrame.lua:94` is its only caller. Decision 1788; wow-re `e5338caf`.
/// `LootFrame.lua:94` is its only caller. Decision 1799; wow-re `e5338caf`.
LootButton,
CheckButton,
EditBox,
+42 -21
View File
@@ -447,6 +447,47 @@ impl Default for WidgetArena {
}
}
/// Whether a widget of this kind is **mouse-enabled by its constructor**, needing neither an
/// `enableMouse` attribute nor an auto-enabling `<Scripts>` handler to be clickable.
///
/// One list, because there are two readers: this arena, and `benilla-app`'s `frame_flag_gate`
/// sweep, which compares our frames against a model of the reference's XML. That sweep used to
/// carry its own copy with a comment claiming it was "deliberately the SAME list" — it was not,
/// and it drifted silently the first time this one was edited. A wrong entry should be wrong in
/// one place.
///
/// **Read off the client's ctors** (wow-re `ui/scratch/mouse-enable-law.md` `774f7eb6`, and the
/// follow-up `68987021`). `[frame+0xcc]` is a four-bit input mask — char, keyboard, MOUSE, wheel —
/// zeroed by the base `CSimpleFrame` ctor `0x769090` and set by exactly five subclass ctors:
/// Button `0x778771`, CheckButton (through Button), Slider `0x789467`, ColorSelect `0x78b2b5`,
/// and EditBox `0x779ced`, which takes char+keyboard+mouse together because it must take a click
/// to focus. `Minimap` is an engine frame born `0x4` (`0x4edc38`), and ours is a `FrameKind`.
///
/// **`ScrollFrame` and `ScrollingMessageFrame` are NOT here, and that was the hard one.** They
/// were, on the reasoning that a scroll frame "takes the wheel in its ctor" — the wheel is a
/// different bit, and no ctor writes it at all. Against that stood a recorded observation:
/// `ChatFrame1` is a `ScrollingMessageFrame`, and something has to make a 1.12 chat link
/// clickable. The follow-up round settled it: nothing about the frame does. The engine synthesises
/// one mouse-enabled `CSimpleHyperlinkButton` child PER LINK SPAN (`0x7a3240`, born through the
/// `CSimpleButton` ctor) and routes the hyperlink scripts back to the parent. We hold spans as
/// rects rather than child frames, so `script::pointer`'s hit test carries the same law as a
/// disjunct: the chat window is a candidate over a link and inert everywhere else.
pub fn mouse_enabled_by_ctor(kind: FrameKind) -> bool {
matches!(
kind,
FrameKind::Button
| FrameKind::CheckButton
| FrameKind::EditBox
// A Slider's thumb must be draggable: every scrollbar is a UIPanelScrollBarTemplate
// Slider that declares no `enableMouse` yet is draggable in-game (decision 0250).
| FrameKind::Slider
| FrameKind::ColorSelect
| FrameKind::Minimap
// `LootButton` extends `CSimpleButton` and inherits its ctor's bit (decision 1799).
| FrameKind::LootButton
)
}
impl WidgetArena {
/// A new, empty arena in **1.12 mode** (alpha does not propagate).
pub fn new() -> WidgetArena {
@@ -602,27 +643,7 @@ impl WidgetArena {
effective_alpha,
shown,
effective_visible: shown && parent_visible,
// A button/editbox is mouse-enabled by construction (the client's CSimpleButton and
// CSimpleEditBox enable input in their ctors — an XML button needs no enableMouse attr
// to be clickable, and an editbox must take clicks to focus, RF-0082 §1).
mouse_enabled: matches!(
kind,
FrameKind::Button
| FrameKind::CheckButton
| FrameKind::EditBox
// The scroll frame takes the wheel in its ctor (msgframe-runtime.md) — an XML
// ScrollingMessageFrame is wheel-interactive with no enableMouse attr, exactly
// like a Button is clickable without one.
| FrameKind::ScrollingMessageFrame
// A plain ScrollFrame likewise takes the wheel/drag by construction (decision
// 0112) — an XML ScrollFrame is scroll-interactive with no enableMouse attr,
// the same rationale as the ScrollingMessageFrame above.
| FrameKind::ScrollFrame
// A Slider takes the mouse in its ctor too (the thumb must be draggable): every
// scrollbar is a UIPanelScrollBarTemplate Slider that declares no enableMouse yet
// is draggable in-game (decision 0250), so the enablement is by construction.
| FrameKind::Slider
),
mouse_enabled: mouse_enabled_by_ctor(kind),
// The two kinds whose ctor takes the WHEEL rather than the mouse generally — the same
// by-construction argument as above, narrowed to the flag it is actually about
// (decision 1198): a ScrollingMessageFrame and a ScrollFrame are wheel-interactive