the world map blips are the reference's, and particles go untinted

- the world map speaks the reference's names: WorldMapTooltip is declared (six corpus addons index it; pfUI dies on it at file scope), the blackout frame takes the reference's spelling BlackoutWorld, and the four hand-written party blips become the reference's own unit-blip templates — all 44 blips as instances, each with its published $parentIcon, the name pfUI recolours group blips by class through; the 40 raid ones are declared but not positioned yet, named in the OUT list rather than faked
- the templates addons build their own buttons from are published: ItemButtonTemplate, ContainerFrameItemButtonTemplate and the bag-slot chain — most of the handler scripts were live the whole time (this client executes the reference's own container and paper-doll code off the player's patch chain), so what was missing was mostly declarations, not behaviour — the bank arm stays out and is named as the omission it is; OptionsFrameSliders becomes real, with the three Graphics rows reading it and re-reading on show, so an addon's write to a slider's bounds actually moves the slider; and the template census that had ranked these at "zero demand" learns to see a local bound to a literal — its table went from 3 rows to 7
- the bindings answer in 1.12's own shapes: Button:IsEnabled() answers the NUMBER 1 or 0 (0 is truthy in Lua, so the reference's own == 0 tests got false forever, and three of our files had hand-worked around the divergence rather than fixed it); the constructors' string arguments are four shapes, not one rule — pfUI passes a Button where a template name goes and the reference constructs the FontString and ignores it; SetInventoryItem returns THREE values, the reference's own paper doll destructuring repairCost; UnitIsVisible is object presence and nothing else (57 bytes, one branch, no distance — an out-of-range party member is exists-yes, visible-nil); the tapped pair reads the two dynamic-flags bits, and its repaint wire fires UNIT_FACTION on the tap bit — the half a passing predicate test never sees; UnitIsPartyLeader is two ORed legs covering disjoint failures (a held stranger leading their own party, and an out-of-range member with no object to read a flag from); and the four nameplate verbs take no argument and return zero values
- the ghost's wisps compose onto its body instead of over it — the four emitters drew at full strength over a body at alpha 0.5, and now inherit the instance alpha down the attachment chain the way the reference recomposes it per frame; the residual we had parked ("the wisps should inherit the tint too") is REFUTED at the bytes: a particle batch commits hardcoded white RGB, and even that never reaches the fragment — particles are never tinted, only the alpha crosses, applied to the alpha byte alone — so the shipped shader split was already right and no code changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:15:16 +07:00
co-authored by Claude Fable 5
parent 961d7e46a0
commit a0d2fc383b
68 changed files with 2186 additions and 272 deletions
+42 -1
View File
@@ -974,6 +974,47 @@
BenillaBagBarSlot_OnEnter(this)
end
-- ref BagSlotButton_OnClick / _OnDrag / _OnShiftClick (MainMenuBarBagButtons.lua l.3-53),
-- published beside _OnEnter above for the same reason it was: they are the names
-- BagSlotButtonTemplate binds, and an addon that builds its bag bar from that template
-- reaches them and nothing else. pfUI is the measured caller (modules/bags.lua l.537), and
-- it CHAINS them — `GetScript("OnEnter")` then calling what it got — so a missing one is
-- not a dead button, it is a nil call inside the addon's own wrapper.
--
-- **Our four bar slots do not come through here** and are untouched: they carry an explicit
-- bagId from BenillaBagBarSlot_OnLoad and wire the Benilla bodies directly. These three
-- exist so a TEMPLATE-built button reaches the same bodies — one implementation, two entry
-- points, which is the whole point of publishing the reference's name over ours.
--
-- NOT the reference's bodies, and the difference is forced rather than chosen: all three of
-- its versions scan ContainerFrame1..NUM_CONTAINER_FRAMES to re-derive the checked state,
-- and reach for PutItemInBag / PickupBagFromSlot. benilla builds no ContainerFrame windows
-- and binds neither verb — our equip/swap arm is PickupInventoryItem (0208 1b) and our
-- checked state is read off BenillaBagFrame<N>. Sourcing MainMenuBarBagButtons.lua instead
-- of writing these would have handed every caller a body that raises on `frame:GetID()`
-- against a nil ContainerFrame1, which is why that file is NOT in reference_ui's SOURCED
-- list while PaperDollFrame.lua is.
function BagSlotButton_OnClick()
BenillaBagBarSlot_OnClick(this)
end
-- The drag arm: ref picks the equipped bag up with PickupBagFromSlot; ours is the same
-- PickupInventoryItem transition the bar slots already use, so an empty cursor picks the
-- bag up and a held bag places it.
function BagSlotButton_OnDrag()
BenillaBagBarSlot_Place(this)
end
-- Shift-click opens every bag if this slot HOLDS one (ref l.38-41 verbatim), then re-reads
-- the checked state off our own window the way BenillaBagBarSlot_OnClick's tail does.
function BagSlotButton_OnShiftClick()
if GetInventoryItemTexture("player", this:GetID()) then
OpenAllBags()
end
local frame = this.bagId and getglobal("BenillaBagFrame" .. this.bagId)
this:SetChecked(frame and frame:IsShown())
end
-- ref OnLeave for both (l.100-102 / the template's own): a plain Hide, no fade — the item
-- half of the split BenillaUnitFrame_OnLeave's comment describes.
function BenillaBagBarButton_OnLeave()
@@ -1074,7 +1115,7 @@
-- drag feedback, IsInventoryItemLocked — the bag-slot twin of BenillaBagSlot_Update).
if IsInventoryItemLocked(invSlot) then
button.icon:SetVertexColor(0.4, 0.4, 0.4)
elseif button:IsEnabled() then
elseif button:IsEnabled() ~= 0 then
button.icon:SetVertexColor(1, 1, 1)
else
-- Disabled by Disable_BagButtons below: keep the grey. The reference needs no such
@@ -104,4 +104,73 @@
end
]]></Script>
<!-- ── The reference's item-button TEMPLATE, which the sourced file cannot bring ────────────
`ui_script::reference_ui` sources `ContainerFrame.lua`, so every handler named below is
the reference's own live code, not a transcription. It cannot source `ContainerFrame.xml`
— that file also declares the twelve `ContainerFrame1..12` windows this client does not
build — so the template that BINDS those handlers to a shape was the one missing piece.
Declaring it here is the smallest thing that closes the gap, and it goes in THIS file
because this is where reference container behaviour that needs a benilla answer lives.
Ref ContainerFrame.xml l.4-60, verbatim but for the one deviation named below. Loads after
ItemButtonTemplate.xml (its base, toc l.45) and after Cooldown.xml (CooldownFrameTemplate,
toc l.124) — both are why this cannot simply sit next to the base.
pfUI is the measured caller: `CreateFrame("Button", name, pfUI.bags[bag], tpl)`
(modules/bags.lua l.381) died on "Couldn't find inherited node" and took its whole bag
module with it. It then reads `$parentCooldown`, `$parentCount`, `$parentIconTexture` and
`$parentNormalTexture` straight off what it just built, and installs NO scripts of its own
on the slot — so the handlers below are not decoration, they are what makes a pfUI bag
respond to a click at all.
DEVIATION, named: the reference's OnEnter/OnDragStart/OnReceiveDrag/OnClick each open with
a KeyRing fork keyed on `KEYRING_CONTAINER`. Those bodies are the SOURCED ones and they
keep their fork; what this file's header explains at length is that our keyring is an
ordinary container (0765), so the fork is wrong here rather than merely unavailable, and
the adapter above is what answers it. Nothing new is introduced by this template. -->
<Button name="ContainerFrameItemButtonTemplate" inherits="ItemButtonTemplate" hidden="true" virtual="true">
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<Model name="$parentCooldown" inherits="CooldownFrameTemplate"/>
</Frames>
<Scripts>
<OnLoad>ContainerFrameItemButton_OnLoad();</OnLoad>
<OnClick>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick(arg1);
return;
end
ContainerFrameItemButton_OnClick(arg1);
</OnClick>
<OnEnter>ContainerFrameItemButton_OnEnter();</OnEnter>
<OnLeave>
this.updateTooltip = nil;
GameTooltip:Hide();
ResetCursor();
</OnLeave>
<OnHide>
if ( this.hasStackSplit and (this.hasStackSplit == 1) ) then
StackSplitFrame:Hide();
end
</OnHide>
<OnDragStart>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick("LeftButton", 1);
return;
end
ContainerFrameItemButton_OnClick("LeftButton", 1);
</OnDragStart>
<OnReceiveDrag>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick("LeftButton", 1);
return;
end
ContainerFrameItemButton_OnClick("LeftButton", 1);
</OnReceiveDrag>
<OnUpdate>ContainerFrameItemButton_OnUpdate(arg1);</OnUpdate>
</Scripts>
</Button>
</Ui>
@@ -559,13 +559,12 @@
return getglobal(token)
end
-- `Button:IsEnabled()` answers 1/0 in 1.12 and a BOOLEAN in this engine
-- (`benilla-ui/src/script/button.rs`), so the ref's own `IsEnabled() == 0` test would be
-- false forever here and the promote/demote pair would never hide. This reads both
-- spellings and is the only place in the file that needs to.
-- The ref's own `IsEnabled() == 0` (FriendsFrame.lua l.404), which works here again:
-- IsEnabled answers the NUMBER 1 or 0 (button.rs, wow-re button-enabled-state.md). This
-- used to read BOTH spellings because the engine returned a boolean, and the dual read
-- went with that divergence rather than outliving it as the workaround that hides it.
local function guildButtonDisabled(button)
local enabled = button:IsEnabled()
return (not enabled) or (enabled == 0)
return button:IsEnabled() == 0
end
-- The page button and the view's squeezable column share one rule: a visible scroll bar
@@ -43,12 +43,47 @@
anyway because the family is one contract and, once the tint verb below is shared, they are one
line each; that is stated rather than dressed up as demand (1210).
**The virtual `ItemButtonTemplate` Button is deliberately NOT here.** The harness's template
demand ranks it at zero on both axes — no corpus addon names it in a `CreateFrame` fourth
argument or an XML `inherits=` (they name their own `*ItemButtonTemplate`s, or
`ContainerFrameItemButtonTemplate`) — and our own bag windows already have
`BenillaBagSlotTemplate`. Building an unwanted template is exactly the unmeasured work 1210
was written about.
**The virtual `ItemButtonTemplate` Button IS here now — 1719: a prior measured decision is
revisited when the measurement changes, and it changed.** The paragraph below is kept because
it records how the number was wrong before it was also incomplete. It used to say
"the harness's template demand ranks it at zero on both axes", full stop. The zero was real;
the demand was not. pfUI asks for `ContainerFrameItemButtonTemplate` — which the reference
declares as `inherits="ItemButtonTemplate"` (ContainerFrame.xml l.4), so it wants this one
transitively — through a shape the census could not see:
local tpl = "ContainerFrameItemButtonTemplate"
if bag == -1 then tpl = "BankItemButtonGenericTemplate" end
CreateFrame("Button", "pfBag" .. bag .. "item" .. slot, pfUI.bags[bag], tpl)
The instrument said so at itself the whole time (`missing_templates`' doc named the literal-only
bound), which is the difference between an honest under-report and a wrong number — but a bound
stated at the function and forgotten at the call site is only half a guard. The census now
resolves a local bound to a literal, and a test asserts both what it sees and what it still
cannot (concatenation, table fields, parameters).
That paragraph then weighed the chain as "a real unit of work" and left it. **The weighing was
wrong, and wrong for a reason worth recording: it priced the SCRIPTS, which we already have.**
`ContainerFrameItemButtonTemplate`'s handlers are `ContainerFrameItemButton_OnLoad/_OnClick/
_OnEnter/_OnUpdate` and `KeyRingItemButton_OnClick` — and this client does not transcribe those,
it EXECUTES the reference's own `ContainerFrame.lua` off the player's patch chain
(`ui_script::reference_ui`, SOURCED). They have been live the whole time. What was missing was
never the behaviour; it was two XML declarations, because the reference ships its templates in
`ContainerFrame.xml` and that file is not in the sourced set.
So the chain costs what it actually costs: this Button, and `ContainerFrameItemButtonTemplate`
in ContainerFrameAdapters.xml beside the adapter that already wraps one of those very handlers.
Our own bag windows keep `BenillaBagSlotTemplate` and are untouched — that is our design (0216),
a thousand lines of it, and this template is the reference's PUBLISHED surface for addons that
build their own slots. Two things that look alike because they do the same job; only one of them
is an API.
**The bank arm is NOT here, and this is the omission, not an oversight** (1134 §4).
`BankItemButtonGenericTemplate` inherits `BankItemButtonTemplate` (BankFrame.xml l.-), whose
handlers — `BankFrameItemButton_OnLoad`, `_OnEvent`, `BankFrameItemButtonGeneric_OnClick` —
live in `BankFrame.lua`, which is NOT sourced, and whose OnEnter calls `this:GetInventorySlot()`,
a widget method we do not bind. That is the real unit of work the paragraph above thought it was
describing. pfUI reaches it only for `bag == -1`, so its bag arm works and its bank arm raises
where it raises today.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
@@ -180,4 +215,39 @@
icon:SetVertexColor(r, g, b)
end
]]></Script>
<!-- Ref `Interface\FrameXML\ItemButtonTemplate.xml` l.4-46, structure verbatim: the region
NAMES are the whole point of the file, because they are the contract the eight verbs above
and every addon both address. `$parentIconTexture`, `$parentCount`, `$parentStock`,
`$parentNormalTexture` — pfUI reads three of those four off a button it built itself
(modules/bags.lua l.418-431) and never asks who built it.
The reference declares NO scripts on this Button, and neither do we; it is a shape, and
whoever inherits it brings the behaviour. -->
<Button name="ItemButtonTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BORDER">
<Texture name="$parentIconTexture"/>
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT" hidden="true">
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-5" y="2"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentStock" inherits="NumberFontNormalYellow" justifyH="LEFT" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
</Button>
</Ui>
@@ -0,0 +1,102 @@
<!--
The reference's item-SLOT button templates: `PaperDollItemSlotButtonTemplate` (PaperDollFrame.xml
l.4-38) and `BagSlotButtonTemplate` (MainMenuBarBagButtons.xml l.17-53), which inherits it. Both
sit on `ItemButtonTemplate` (ItemButtonTemplate.xml), so the chain is
ItemButtonTemplate <- PaperDollItemSlotButtonTemplate <- BagSlotButtonTemplate
and all three are now declared, in that order, across three files the toc loads in that order.
**Why they are here rather than in CharacterFrame.xml and BagFrame.xml, where their subjects
live.** Load order, and only load order: BagFrame.xml (toc l.241) comes BEFORE
CharacterFrame.xml (l.256), so a bag template declared in the former could not inherit a doll
template declared in the latter. Neither is consumed by our own windows — our doll slots and our
four bag-bar slots are hand-wired to `BenillaPaperDollSlot_*` / `BenillaBagBarSlot_*` and do not
inherit these — so a file of their own, loaded after both, costs nothing and makes the chain
readable. This is the reference's published surface for ADDONS, kept where its dependencies are
satisfied.
**The handlers are the reference's own, not transcriptions.** `PaperDollItemSlotButton_OnLoad`,
`_OnEvent`, `_OnClick`, `_Update`, `_UpdateLock`, `_OnEnter` and `_OnUpdate` come from
`PaperDollFrame.lua`, which `ui_script::reference_ui` now SOURCES off the player's own patch
chain. That file earned its place because every one of those acts on `this` and `this:GetID()`
through live APIs and never on a named reference window — see the note beside SOURCED for what
was checked first, including the 18 collisions that order settles in our favour.
`MainMenuBarBagButtons.lua` is deliberately NOT sourced, which is why `BagSlotButton_OnClick`,
`_OnDrag`, `_OnShiftClick` and `_OnEnter` are ours in BagFrame.xml: all four of its versions walk
`ContainerFrame1..NUM_CONTAINER_FRAMES`, windows this client does not build, and two of them
call verbs (`PutItemInBag`, `PickupBagFromSlot`) it does not bind. Sourcing it would have handed
every caller a body that raises inside the addon's own click wrapper.
**pfUI is the measured caller**, and it fits the reference's contract exactly rather than by
luck: it names its bag-bar buttons `pfUIBagsBBag0Slot`.. with a NINE-character prefix, so the
reference's own `strsub(name, 10)` in `PaperDollItemSlotButton_OnLoad` yields `Bag0Slot` and
`GetInventorySlotInfo` answers slot 20. It then reads `$parentIconTexture`, `$parentNormalTexture`,
`GetHighlightTexture()` and `GetPushedTexture()` off what it built, and CHAINS the template's
OnEnter/OnLeave rather than replacing them (modules/bags.lua l.513-585).
OUT, named rather than stubbed (1134 §4): the bank arm. `BankItemButtonBagTemplate` and
`BankItemButtonGenericTemplate` inherit `BankItemButtonTemplate`, whose handlers live in
`BankFrame.lua` — not sourced — and whose OnEnter calls `this:GetInventorySlot()`, a widget
method we do not bind. pfUI reaches those only for its bank window.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- Ref PaperDollFrame.xml l.4-38, structure and scripts verbatim. The `$parentCooldown` Model
is not optional decoration: the sourced `PaperDollItemSlotButton_Update` resolves it by
name (`getglobal(this:GetName().."Cooldown")`) and calls `:Hide()` / CooldownFrame_SetTimer
on it every repaint, so a button without one raises on its first update. -->
<Button name="PaperDollItemSlotButtonTemplate" inherits="ItemButtonTemplate" virtual="true">
<Frames>
<Model name="$parentCooldown" inherits="CooldownFrameTemplate"/>
</Frames>
<Scripts>
<OnLoad>PaperDollItemSlotButton_OnLoad();</OnLoad>
<OnEvent>PaperDollItemSlotButton_OnEvent(event);</OnEvent>
<OnClick>PaperDollItemSlotButton_OnClick(arg1);</OnClick>
<OnShow></OnShow>
<OnDragStart>PaperDollItemSlotButton_OnClick("LeftButton", 1);</OnDragStart>
<OnReceiveDrag>PaperDollItemSlotButton_OnClick("LeftButton", 1);</OnReceiveDrag>
<OnEnter>PaperDollItemSlotButton_OnEnter();</OnEnter>
<OnLeave>
this.updateTooltip = nil;
GameTooltip:Hide();
ResetCursor();
</OnLeave>
<OnUpdate>PaperDollItemSlotButton_OnUpdate(arg1);</OnUpdate>
</Scripts>
</Button>
<!-- Ref MainMenuBarBagButtons.xml l.17-53. A CheckButton over a Button template — the checked
ring is the "this bag's window is open" light, which is why the type changes at this rung.
`$parentItemAnim` is the ref's item-push card; ours is BenillaItemPushAnim_* (0887), and the
template cannot carry it because the reference's ItemAnimTemplate binds a per-instance
container id our four hand-wired slots pass explicitly. A template-built button therefore
gets no push animation, which is a missing flourish and not a missing behaviour. -->
<CheckButton name="BagSlotButtonTemplate" inherits="PaperDollItemSlotButtonTemplate" virtual="true">
<Scripts>
<OnLoad>
PaperDollItemSlotButton_OnLoad();
this:RegisterEvent("BAG_UPDATE");
this.isBag = 1;
-- OURS, and the one deviation in this file's scripts. The reference's three bag
-- handlers each recompute a `translatedID` from these same two ids at every call
-- site; our bodies read `button.bagId`, the field BenillaBagBarSlot_OnLoad sets for
-- the four hand-wired slots. Resolving it once here is what lets a template-built
-- button and a hand-wired one share one implementation instead of two.
this.bagId = this:GetID() - CharacterBag0Slot:GetID() + 1;
</OnLoad>
<OnClick>
if ( IsShiftKeyDown() ) then
BagSlotButton_OnShiftClick();
else
BagSlotButton_OnClick();
end
</OnClick>
<OnDragStart>BagSlotButton_OnDrag();</OnDragStart>
<OnReceiveDrag>BagSlotButton_OnClick();</OnReceiveDrag>
<OnEnter>BagSlotButton_OnEnter();</OnEnter>
</Scripts>
<CheckedTexture alphaMode="ADD" file="Interface\Buttons\CheckButtonHilight"/>
</CheckButton>
</Ui>
+4 -4
View File
@@ -885,10 +885,10 @@
</OnTextChanged>
<OnEscapePressed>getglobal("BenillaMacroPopupFrame"):Hide()</OnEscapePressed>
<OnEnterPressed>
-- The ref writes `IsEnabled() ~= 0`; this engine's IsEnabled returns
-- a BOOLEAN (button.rs), for which `~= 0` is always true — plain
-- truthiness is the correct read here.
if getglobal("BenillaMacroPopupOkayButton"):IsEnabled() then
-- The ref's own spelling, and now literally ours: IsEnabled answers the
-- NUMBER 1 or 0 (button.rs, wow-re button-enabled-state.md). Plain
-- truthiness would be WRONG here — 0 is truthy in Lua.
if getglobal("BenillaMacroPopupOkayButton"):IsEnabled() ~= 0 then
BenillaMacroPopupOkayButton_OnClick()
end
</OnEnterPressed>
+68 -3
View File
@@ -866,6 +866,43 @@
-- refreshing guard: SetMinMaxValues re-clamps the held value and fires OnValueChanged,
-- and that clamp is wiring, not a player gesture — without the guard every load would
-- SetCVar the template's 0 clamped into the new range.
-- ── OptionsFrameSliders — ref OptionsFrame.lua l.24-34 ──────────────────────────────
-- The reference's options-slider table: one row per graphics slider, carrying the bounds
-- the slider is built with. It is PUBLISHED, so addons read and write it — pfUI's
-- `hdgraphic` module opens with `_G.OptionsFrameSliders[3].maxValue = 15`
-- (modules/hdgraphic.lua l.3) and died on the nil, taking the module with it.
--
-- **It is the real thing, not a shape put here to satisfy that write.** The three rows
-- below are the source of truth for our own Graphics page: each row's OnLoad reads its
-- bounds from here rather than carrying literals, and the page re-applies them on every
-- OnShow, which is what makes an addon's write actually move the slider. A table nothing
-- read would be a name with no behaviour behind it (1134 §4) — worse than absent, because
-- the write would appear to succeed.
--
-- **Three rows, not the reference's nine.** Ours are its rows 1-3 in its own order, and the
-- `func` strings match our CVars exactly (uiscale/farclip/WorldDetail) — which is why
-- pfUI's index 3 lands on Environment Detail here as it does there. The other six
-- (TerrainMip, BaseMip, Gamma, anisotropic, spellEffectLevel, weatherDensity) have no
-- benilla knob behind them; listing them would publish six rows whose writes do nothing.
-- 0959/1649 own which knobs this page ships, and this table follows that, not the reverse.
--
-- `benillaRow` is ours: the reference addresses its sliders as OptionsFrameSlider1..9, a
-- numbering our page does not use, so each row names the child it drives instead.
--
-- NOT bound, and named rather than faked: pfUI's module goes on to hook `SetWorldDetail`
-- and drive `ConsoleExec("frillDensity ...")`, `lodDist`, `nearClip`, `maxLOD`. benilla has
-- no console and none of those CVars. Neither verb is called at load, so the module loads
-- and its slider write lands; its EXTENDED arm cannot work here and a no-op ConsoleExec
-- would be exactly the silent pretence 1203 exists to forbid.
OptionsFrameSliders = {
{ text = "UI Scale", func = "uiscale",
minValue = 0.64, maxValue = 1.0, valueStep = 0.01, benillaRow = "RowUiScale" },
{ text = "Terrain Distance", func = "farclip",
minValue = 177, maxValue = 777, valueStep = 60, benillaRow = "RowFarclip" },
{ text = "Environment Detail", func = "WorldDetail",
minValue = 0, maxValue = 2, valueStep = 1, benillaRow = "RowWorldDetail" },
}
function OptionsSliderRow_Setup(row, min, max, step, fmt, stops)
local slider = getglobal(row:GetName() .. "ControlSlider")
row.fmt = fmt
@@ -2280,7 +2317,9 @@
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "uiScale", "UI Scale",
"OPTION_TOOLTIP_UI_SCALE")
OptionsSliderRow_Setup(self, 0.64, 1.0, 0.01)
OptionsSliderRow_Setup(self, OptionsFrameSliders[1].minValue,
OptionsFrameSliders[1].maxValue,
OptionsFrameSliders[1].valueStep)
-- era registers its uiscale row CommitFlag.Apply
-- (Graphics.lua l.684): the one row whose live
-- write would rescale the very slider mid-drag —
@@ -2296,7 +2335,9 @@
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "farclip", "Terrain Distance",
"OPTION_TOOLTIP_FARCLIP")
OptionsSliderRow_Setup(self, 177, 777, 60, "raw")
OptionsSliderRow_Setup(self, OptionsFrameSliders[2].minValue,
OptionsFrameSliders[2].maxValue,
OptionsFrameSliders[2].valueStep, "raw")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldDetail" inherits="OptionsSliderRowTemplate">
@@ -2310,7 +2351,9 @@
-- The stop names are the era preset ladder's (its
-- quality dropdowns say Low/Medium/High); 1.12's
-- slider had no stop labels to quote.
OptionsSliderRow_Setup(self, 0, 2, 1, nil,
OptionsSliderRow_Setup(self, OptionsFrameSliders[3].minValue,
OptionsFrameSliders[3].maxValue,
OptionsFrameSliders[3].valueStep, nil,
{ "Low", "Medium", "High" })
</OnLoad></Scripts>
</Frame>
@@ -2410,6 +2453,28 @@
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- Re-apply the slider bounds from OptionsFrameSliders every time the page is shown.
The rows read the table at OnLoad, which is long before any addon runs; an addon that
writes a bound (pfUI's hdgraphic raises Environment Detail's maxValue to 15) would
otherwise never move the slider it just rewrote. The reference does the same loop in
OptionsFrame_Load; ours lives here rather than under that name because our page
implements almost none of the rest of that function's contract and publishing a
reference name over a partial body is how a gap hides.
fmt and stops are read back off the row rather than the table: they are OUR readout
grammar (0992/1649's stop names), set at OnLoad, and no addon addresses them. -->
<Scripts>
<OnShow>
for i = 1, table.getn(OptionsFrameSliders) do
local info = OptionsFrameSliders[i]
local row = info.benillaRow and getglobal(this:GetName() .. info.benillaRow)
if ( row ) then
OptionsSliderRow_Setup(row, info.minValue, info.maxValue,
info.valueStep, row.fmt, row.stops)
end
end
</OnShow>
</Scripts>
</Frame>
<!-- The COMBAT page (decision 1134) — the first page whose rows
write saved-variable GLOBALS rather than CVars, and the first
+40 -4
View File
@@ -139,6 +139,26 @@
NEWBIE_TOOLTIP_PARTYOPTIONS = NEWBIE_TOOLTIP_PARTYOPTIONS or "If you belong to a party, you can right-click your portrait to bring up the Party Options menu. This menu will allow you to quit the party or see what looting rules the party is using. A party leader can add or remove party members, as well as change the party's looting rules."
NEWBIE_TOOLTIP_PLAYEROPTIONS = NEWBIE_TOOLTIP_PLAYEROPTIONS or "Right-click to bring up special commands for interacting with another player. You can inspect their equipment, issue a party invite, initiate a trade, or challenge a player to a duel. A group leader can promote or remove that player from the group."
-- ── UnitReactionColor (ref TargetFrame.lua l.6-13, verbatim) ────────────────────────
-- The reference's reaction→colour table, indexed by `UnitReaction`'s 1..7. Seven rows for
-- five distinct colours: 1 and 2 are both red, and 5/6/7 are all green — the reference
-- writes them out rather than collapsing them, and so do we, because an addon indexing
-- `UnitReactionColor[7]` must find a row rather than nil.
--
-- It lives in `TargetFrame.lua` there and here in the unit-frame file, for the same reason
-- `ManaBarColor` does: we ship no TargetFrame.lua, and what matters is the NAME being
-- published before anything reads it. pfUI indexes it in `GetColor`
-- (`api/unitframes.lua:2617`) on every unit-frame repaint.
UnitReactionColor = {
{ r = 1.0, g = 0.0, b = 0.0 },
{ r = 1.0, g = 0.0, b = 0.0 },
{ r = 1.0, g = 0.5, b = 0.0 },
{ r = 1.0, g = 1.0, b = 0.0 },
{ r = 0.0, g = 1.0, b = 0.0 },
{ r = 0.0, g = 1.0, b = 0.0 },
{ r = 0.0, g = 1.0, b = 0.0 },
}
-- ── ManaBarColor (ref UnitFrame.lua l.1-6, verbatim) ────────────────────────────────
-- The reference's own table: keyed by the NUMERIC power type `UnitPowerType` returns, each
-- row carrying the `prefix` its bar's text is labelled with. **8 addons in the top-20 root
@@ -661,9 +681,23 @@
feedbackText:Show()
end
-- The fade envelope (ref CombatFeedback_OnUpdate): 0.2 s ramp in, 0.7 s hold, 0.3 s
-- ramp out, then Hide. GetTime-based like the ref — no per-tick accumulation.
function CombatFeedback_OnUpdate(frame)
-- The fade envelope (ref CombatFeedback_OnUpdate, CombatFeedback.lua l.92): 0.2 s ramp in,
-- 0.7 s hold, 0.3 s ramp out, then Hide. GetTime-based like the ref — no per-tick
-- accumulation.
--
-- **The SIGNATURE is the reference's, and it did not used to be.** The ref takes `elapsed`
-- and reads the frame from `this`; ours took the frame as its argument while the comment
-- above claimed to be the ref's. pfUI hooks this global and calls it the way the ref
-- declares it — `CombatFeedback_OnUpdate(arg1)` — so our version indexed the elapsed
-- NUMBER as a frame and raised `attempt to index local 'frame' (a number value)`.
--
-- A published global is a contract addons call BY NAME, so its signature is not ours to
-- improve: taking the frame explicitly reads better and is wrong. `elapsed` is unused here
-- (the envelope is GetTime-based) and is still named, because the parameter list is the
-- part addons see.
function CombatFeedback_OnUpdate(elapsed)
local frame = this
if not frame then return end
local feedbackText = frame.feedbackText
if not (feedbackText and feedbackText:IsVisible()) then return end
local elapsedTime = GetTime() - frame.feedbackStartTime
@@ -1088,7 +1122,9 @@
frame.statusTexture:SetAlpha(alpha)
frame.statusGlow:SetAlpha(alpha)
end
CombatFeedback_OnUpdate(frame)
-- `this` is already the frame here (`local frame = this` above), which is the
-- contract the ref's own signature relies on.
CombatFeedback_OnUpdate(elapsed)
end
-- Left-click the player frame to target yourself (ref PlayerFrame_OnClick, the LeftButton
+185 -78
View File
@@ -20,10 +20,22 @@
art spun by SetRotation(GetPlayerFacing()) — the reference renders an engine arrow model;
0203 flags the stand-in).
OUT (later slices, per 0203): the raid/battlefield/ping children (the PARTY blips landed with
report B320 — the raid pool waits on `GetPlayerMapPosition` answering a `raid` token); the
magnifying-glass zoom-out shortcut (redundant with the button); SetupFullscreenScale (the map renders at its native
1024×768 centered; a scale pass is a taste knob); WorldMapTooltip (only unit blips used it).
OUT (later slices, per 0203): the ping child; the magnifying-glass zoom-out shortcut (redundant
with the button); SetupFullscreenScale (the map renders at its native 1024×768 centered; a scale
pass is a taste knob).
The raid pool moved half-way out of that list and the halves are worth separating. The 40
`WorldMapRaid` frames are now DECLARED, as inherits= instances of WorldMapRaidUnitTemplate
alongside the four party ones, because the published names and their `$parentIcon` children are
what addons index — pfUI's mapcolors walks all 44 and died on the first missing icon. What is
still OUT is POSITIONING them: `GetPlayerMapPosition` answers (0,0) for every `raid` token, and
the reference's second arm (team members in a battleground) needs GetNumBattlefieldPositions and
GetBattlefieldPosition, neither of which we bind. So they exist, hidden, and WorldMapParty_Update
drives the party arm alone.
WorldMapTooltip was in this OUT list — "only unit blips used it" — and is now declared at the
foot of the file. That reason was about the reference's own consumers and never about the
published NAME, which six corpus addons index directly and pfUI dies on at file scope.
Deviations from the reference, deliberate: BlackoutWorld fills via anchors instead of the
GetScreenWidth resize dance (same pixels); OnShow calls WorldMapFrame_Update() directly after
@@ -213,11 +225,58 @@
end
end
-- A party blip's hover names the member (ref WorldMapUnit_OnEnter, which reads the
-- frame's own `unit`). The frame id IS the party slot, so the token needs no bookkeeping.
function WorldMapParty_OnEnter(blip)
local unit = "party" .. blip:GetID()
WorldMapMarker_ShowTooltip(blip, UnitName(unit) or UNKNOWN)
-- Ref WorldMapUnit_OnEnter (WorldMapFrame.lua l.457-499) transcribed. Three things it does
-- that the one-line version it replaces did not: it takes NO argument and reads `this` (the
-- reference's calling convention, and now ours, because the name is the reference's and an
-- addon may call it); it drives WorldMapTooltip, which is what the blip templates' OnLeave
-- hides; and it stacks the name of EVERY blip under the cursor rather than one, because
-- blips overlap whenever a group stands together.
--
-- No `or UNKNOWN` fallback, deliberately: a blip is only reached here when it is VISIBLE,
-- and it is only visible when GetPlayerMapPosition answered real coordinates for its unit,
-- so UnitName cannot be nil without a contract already being broken. A guard there would
-- turn that into a silently wrong tooltip instead of a raise (1203).
--
-- `unitButton.name` is the battleground-team label the reference sets for someone on your
-- team but in neither your party nor your raid; our positioning pass never sets it (no
-- GetBattlefieldPosition binding), so that branch is dead here and kept because it is the
-- reference's own fallback order.
function WorldMapUnit_OnEnter()
local x = this:GetCenter()
local parentX = this:GetParent():GetCenter()
if ( x > parentX ) then
WorldMapTooltip:SetOwner(this, "ANCHOR_LEFT")
else
WorldMapTooltip:SetOwner(this, "ANCHOR_RIGHT")
end
local unitButton
local newLineString = ""
local tooltipText = ""
if ( MouseIsOver(WorldMapPlayer) ) then
tooltipText = UnitName(WorldMapPlayer.unit)
newLineString = "\n"
end
for i = 1, MAX_PARTY_MEMBERS do
unitButton = getglobal("WorldMapParty" .. i)
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
newLineString = "\n"
end
end
for i = 1, MAX_RAID_MEMBERS do
unitButton = getglobal("WorldMapRaid" .. i)
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
if ( unitButton.name ) then
tooltipText = tooltipText .. newLineString .. unitButton.name
else
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
end
newLineString = "\n"
end
end
WorldMapTooltip:SetText(tooltipText)
WorldMapTooltip:Show()
end
function WorldMapMarker_ShowTooltip(marker, title, subtitle)
@@ -399,7 +458,24 @@
itself (WorldMapFrame) is now the native 1024×768 sheet that SetupFullscreenScale grows to
fill the screen; the blackout stays screen-sized so the world never shows through the
margins, and eats clicks so nothing leaks to the world behind it. -->
<Frame name="WorldMapBlackout" frameStrata="FULLSCREEN" hidden="true" enableMouse="true" parent="UIParent">
<!-- BlackoutWorld — **the reference's name**, which this frame did not carry until now.
The reference declares it as a `<Texture>` in WorldMapFrame's own BACKGROUND layer
(WorldMapFrame.xml l.8: 1024x768, BOTTOMLEFT, solid black); ours is a separate
mouse-enabled fullscreen Frame, which is the deliberate deviation the header records —
it blacks the world out AND eats the clicks the reference's fullscreen map frame would
have taken. That structural choice stands.
What was wrong was the NAME. `WorldMapBlackout` was ours; `BlackoutWorld` is the published
global, and four corpus addons index it directly — pfUI calls `BlackoutWorld:Hide()` at
`modules/map.lua:109` and died there. This file's own header has been calling it
"BlackoutWorld" in prose the whole time while the code said otherwise.
**The kind still differs and is not papered over**: `GetObjectType()` answers "Frame"
here and "Texture" in the reference. Every corpus caller only Shows/Hides it, which both
kinds answer identically; an addon that asked its type would see the deviation, and that
is the honest cost of the structural choice above. -->
<Frame name="BlackoutWorld" frameStrata="FULLSCREEN" hidden="true" enableMouse="true" parent="UIParent">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
@@ -670,13 +746,37 @@
<Texture name="WorldMapPlayerIcon" file="Interface\Minimap\MinimapArrow" setAllPoints="true"/>
</Layer>
</Layers>
<!-- Ref WorldMapFrame.xml l.473-484. The OnLoad is load-bearing and not
decoration: WorldMapUnit_OnEnter reads `WorldMapPlayer.unit` when the
cursor is over the arrow, and this is the only place it is ever set.
The hover pair is transcribed with it and is LIVE, which is the
opposite of what an earlier draft of this comment claimed. Neither the
reference's frame nor ours carries enableMouse, but a declared OnEnter
AUTO-ENABLES the mouse (wow-re ui/scratch/scripts-auto-enable.md §1,
VERIFIED: a handler reaches the same enable primitive 0x76af00(2,-1)
as the attribute). So the arrow takes the mouse and names the player on
hover, exactly as the reference's does — and frame_flag_gate's KNOWN
entry for this frame, which existed only because we had the flag-gap
without the handler, went stale the moment this landed. -->
<Scripts>
<OnLoad>
this.unit = "player";
this:SetFrameLevel(this:GetFrameLevel() + 1);
</OnLoad>
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The corpse marker (decision 0308 §5) — ref WorldMapCorpseTemplate
(WorldMapFrameTemplates.xml l.55-88) inlined: 16x16, the POIIcons skull
cell (0.875-1.0 / 0-0.125), hover names it CORPSE_RED. The ref anchors its
tooltip to a WorldMapTooltip; ours rides the shared GameTooltip (this
map ships no separate tooltip — header note) through the side-pick every
marker on the sheet shares. -->
tooltip to a WorldMapTooltip; ours rides the shared GameTooltip through
the side-pick every marker on the sheet shares. That parenthetical used to
read "this map ships no separate tooltip" and is no longer true —
WorldMapTooltip is declared below. The UNIT blips moved onto it with
WorldMapUnit_OnEnter; the sheet MARKERS (this one, the POIs) deliberately
did not, because they share WorldMapMarker_ShowTooltip and moving one
moves all of them. That is a deviation left standing, not an oversight. -->
<Frame name="WorldMapCorpse" hidden="true" enableMouse="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
@@ -696,70 +796,61 @@
</OnLeave>
</Scripts>
</Frame>
<!-- The party blips (report B320) — ref WorldMapUnitTemplate
(WorldMapFrameTemplates.xml l.3-28) re-expressed: 16x16 centered,
WorldMapPartyIcon art, one frame per `party1..4` slot, placed by
GetPlayerMapPosition in the Update above. The reference hovers them into
its own WorldMapTooltip; this map ships none (header note), so they take
the shared GameTooltip through WorldMapMarker_ShowTooltip exactly as the
corpse marker beside them does.
<!-- The group blips — ref WorldMapFrame.xml l.486-529, which declares all 44 of them as
inherits= instances of WorldMapUnitTemplate / WorldMapRaidUnitTemplate. Ours now does the
same; they used to be four hand-written Frames carrying an ANONYMOUS texture, which cost
the `$parentIcon` name every addon recolouring a blip reaches for (see
WorldMapFrameTemplates.xml's header for the pfUI raise that found it).
The reference's RAID pool (25 more frames, its `GetNumRaidMembers() > 0`
arm) is NOT here: `GetPlayerMapPosition` answers no `raid` token yet, so
a raid would show these four filled from its own subgroup and nothing
else. That is the honest half-step, and it is named in the OUT list. -->
<Frame name="WorldMapParty1" hidden="true" enableMouse="true" id="1">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty2" hidden="true" enableMouse="true" id="2">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty3" hidden="true" enableMouse="true" id="3">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="WorldMapParty4" hidden="true" enableMouse="true" id="4">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\WorldMap\WorldMapPartyIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>WorldMapParty_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
The RAID forty are declared here and positioned NOWHERE: WorldMapParty_Update below drives
the party arm only, because `GetPlayerMapPosition` answers (0,0) for every `raid` token and
the reference's battleground arm needs GetNumBattlefieldPositions / GetBattlefieldPosition,
which we do not bind. Declared-and-hidden is what the reference does with them out of a
raid anyway; the difference is that ours stay that way. Named in the OUT list. -->
<Frame name="WorldMapParty1" inherits="WorldMapUnitTemplate" id="1"/>
<Frame name="WorldMapParty2" inherits="WorldMapUnitTemplate" id="2"/>
<Frame name="WorldMapParty3" inherits="WorldMapUnitTemplate" id="3"/>
<Frame name="WorldMapParty4" inherits="WorldMapUnitTemplate" id="4"/>
<Frame name="WorldMapRaid1" inherits="WorldMapRaidUnitTemplate" id="1"/>
<Frame name="WorldMapRaid2" inherits="WorldMapRaidUnitTemplate" id="2"/>
<Frame name="WorldMapRaid3" inherits="WorldMapRaidUnitTemplate" id="3"/>
<Frame name="WorldMapRaid4" inherits="WorldMapRaidUnitTemplate" id="4"/>
<Frame name="WorldMapRaid5" inherits="WorldMapRaidUnitTemplate" id="5"/>
<Frame name="WorldMapRaid6" inherits="WorldMapRaidUnitTemplate" id="6"/>
<Frame name="WorldMapRaid7" inherits="WorldMapRaidUnitTemplate" id="7"/>
<Frame name="WorldMapRaid8" inherits="WorldMapRaidUnitTemplate" id="8"/>
<Frame name="WorldMapRaid9" inherits="WorldMapRaidUnitTemplate" id="9"/>
<Frame name="WorldMapRaid10" inherits="WorldMapRaidUnitTemplate" id="10"/>
<Frame name="WorldMapRaid11" inherits="WorldMapRaidUnitTemplate" id="11"/>
<Frame name="WorldMapRaid12" inherits="WorldMapRaidUnitTemplate" id="12"/>
<Frame name="WorldMapRaid13" inherits="WorldMapRaidUnitTemplate" id="13"/>
<Frame name="WorldMapRaid14" inherits="WorldMapRaidUnitTemplate" id="14"/>
<Frame name="WorldMapRaid15" inherits="WorldMapRaidUnitTemplate" id="15"/>
<Frame name="WorldMapRaid16" inherits="WorldMapRaidUnitTemplate" id="16"/>
<Frame name="WorldMapRaid17" inherits="WorldMapRaidUnitTemplate" id="17"/>
<Frame name="WorldMapRaid18" inherits="WorldMapRaidUnitTemplate" id="18"/>
<Frame name="WorldMapRaid19" inherits="WorldMapRaidUnitTemplate" id="19"/>
<Frame name="WorldMapRaid20" inherits="WorldMapRaidUnitTemplate" id="20"/>
<Frame name="WorldMapRaid21" inherits="WorldMapRaidUnitTemplate" id="21"/>
<Frame name="WorldMapRaid22" inherits="WorldMapRaidUnitTemplate" id="22"/>
<Frame name="WorldMapRaid23" inherits="WorldMapRaidUnitTemplate" id="23"/>
<Frame name="WorldMapRaid24" inherits="WorldMapRaidUnitTemplate" id="24"/>
<Frame name="WorldMapRaid25" inherits="WorldMapRaidUnitTemplate" id="25"/>
<Frame name="WorldMapRaid26" inherits="WorldMapRaidUnitTemplate" id="26"/>
<Frame name="WorldMapRaid27" inherits="WorldMapRaidUnitTemplate" id="27"/>
<Frame name="WorldMapRaid28" inherits="WorldMapRaidUnitTemplate" id="28"/>
<Frame name="WorldMapRaid29" inherits="WorldMapRaidUnitTemplate" id="29"/>
<Frame name="WorldMapRaid30" inherits="WorldMapRaidUnitTemplate" id="30"/>
<Frame name="WorldMapRaid31" inherits="WorldMapRaidUnitTemplate" id="31"/>
<Frame name="WorldMapRaid32" inherits="WorldMapRaidUnitTemplate" id="32"/>
<Frame name="WorldMapRaid33" inherits="WorldMapRaidUnitTemplate" id="33"/>
<Frame name="WorldMapRaid34" inherits="WorldMapRaidUnitTemplate" id="34"/>
<Frame name="WorldMapRaid35" inherits="WorldMapRaidUnitTemplate" id="35"/>
<Frame name="WorldMapRaid36" inherits="WorldMapRaidUnitTemplate" id="36"/>
<Frame name="WorldMapRaid37" inherits="WorldMapRaidUnitTemplate" id="37"/>
<Frame name="WorldMapRaid38" inherits="WorldMapRaidUnitTemplate" id="38"/>
<Frame name="WorldMapRaid39" inherits="WorldMapRaidUnitTemplate" id="39"/>
<Frame name="WorldMapRaid40" inherits="WorldMapRaidUnitTemplate" id="40"/>
<Frame name="WorldMapFrameAreaFrame">
<Size><AbsDimension x="400" y="128"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
@@ -803,7 +894,7 @@
<!-- UpdateMicroButtons leads both, as in the ref (WorldMapFrame.xml l.599/606) — live
since the micro-menu slice (MicroMenu.xml). -->
<OnShow>
WorldMapBlackout:Show();
BlackoutWorld:Show();
SetupFullscreenScale();
SetMapToCurrentZone();
WorldMapFrame_Update();
@@ -812,7 +903,7 @@
CloseDropDownMenus();
</OnShow>
<OnHide>
WorldMapBlackout:Hide();
BlackoutWorld:Hide();
if UpdateMicroButtons then UpdateMicroButtons() end;
PlaySound("igQuestLogClose");
</OnHide>
@@ -823,4 +914,20 @@
</OnEvent>
</Scripts>
</Frame>
<!-- WorldMapTooltip (ref WorldMapFrame.xml l.636, verbatim attributes).
**This file's header listed it OUT, and the reason has expired.** The note said "only unit
blips used it" — true of the REFERENCE's own consumers, and never the question. The name is
a published global that addons index directly, and six in the corpus do: !Questie,
Cartographer, CT_MapMod, Gatherer, pfQuest and pfUI. Three of those are on the failing
roster, and for pfUI it is a load-time blocker — `modules/map.lua:13` indexes it at file
scope, so the addon dies there whatever the map is drawing.
Declared, not driven: this is the widget under its reference name and geometry, so an
addon that seats its own lines in it works. Our OWN map still routes its hovers through
the shared GameTooltip (the two notes at l.666/l.692 say so and stay true) — adopting the
reference's separate-tooltip routing is a different change, and doing half of it silently
would be worse than either. -->
<GameTooltip name="WorldMapTooltip" frameStrata="TOOLTIP" hidden="true" parent="WorldMapFrame" inherits="GameTooltipTemplate"/>
</Ui>
@@ -0,0 +1,72 @@
<!--
The world map's per-unit blip templates, transcribed from the reference's own
`Interface\FrameXML\WorldMapFrameTemplates.xml` (l.3-35). Two virtual Frames and nothing else:
`WorldMapUnitTemplate` (a party member) and `WorldMapRaidUnitTemplate` (a raid member), which
inherits it and re-states its OnLoad without the `party` token.
**Why they are a file of their own rather than four hand-written copies inside WorldMapFrame.xml.**
They used to be exactly that: `WorldMapParty1..4` written out four times, each carrying an
ANONYMOUS `<Texture>`. Anonymous is the bug. The reference's blip names its icon `$parentIcon`,
and that published name is load-bearing for addons — pfUI's `modules/mapcolors.lua` recolours
party and raid blips by class and reaches them as
frame.icon = _G[frame_name.."Icon"]
frame.icon:SetTexture(pfUI.media["img:circleparty"])
so against four anonymous textures line 11 raised `attempt to index field 'icon' (a nil value)`
and took pfUI's whole module chain down with it. A template gets the naming right once.
**The raid pool is DECLARED but not DRIVEN, and that is the honest half-step.** `mapcolors`
loops `1, MAX_RAID_MEMBERS` (40) as well as `1, MAX_PARTY_MEMBERS` (4), so all 44 frames have to
exist for it to survive; the reference declares all 44 too (WorldMapFrame.xml l.486-529). What
ours cannot do yet is POSITION the raid forty: `GetPlayerMapPosition` resolves `player` and
`party1..4` and answers (0,0) for every `raid` token (worldmap.rs l.487-505), and the
reference's second raid arm — battleground team members — needs `GetNumBattlefieldPositions`
and `GetBattlefieldPosition`, neither of which we bind. So the forty are declared, hidden, and
left where the reference leaves them until a raid actually has coordinates to place them at.
Declaring a frame the reference declares is not stubbing behaviour (1134 §4): the frames and
their art are real, and the missing piece is a data source, named here and in WorldMapFrame.xml's
OUT list rather than faked with an empty setter.
The hover is the reference's `WorldMapUnit_OnEnter`, which reads `this` and owns
`WorldMapTooltip` — both of which now exist, so the template's `WorldMapTooltip:Hide()` OnLeave
has something to hide. Our body is the single-unit case; the reference additionally walks every
overlapping blip with MouseIsOver and stacks their names into one tooltip, which we do not.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- No enableMouse, exactly as the reference declares it: the OnEnter/OnLeave below
AUTO-ENABLE the mouse on their own (wow-re ui/scratch/scripts-auto-enable.md §1,
VERIFIED — a script handler reaches the same enable primitive 0x76af00(2,-1) the
attribute does). Adding the attribute would be redundant, and frame_flag_gate reads
the loaded ENGINE rather than our XML precisely so the two spellings cannot drift. -->
<Frame name="WorldMapUnitTemplate" hidden="true" virtual="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
this.unit = "party" .. this:GetID();
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
</OnLoad>
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- Ref l.29-35 verbatim: the same frame WITHOUT the `party` token, because a raid blip's unit
is assigned by the positioning pass (`partyMemberFrame.unit = unit`, WorldMapFrame.lua
l.386) rather than derived from its id. -->
<Frame name="WorldMapRaidUnitTemplate" inherits="WorldMapUnitTemplate" virtual="true">
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel() + 1);
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
</OnLoad>
</Scripts>
</Frame>
</Ui>
+9
View File
@@ -213,6 +213,9 @@ ColorPickerFrame.xml
# The fullscreen world map (decision 0203 phase 2), over the worldmap host API
# (`crate::ui_world_map` feeds it); the 'M' binding (ui_script/input.rs) calls its
# ToggleWorldMap().
# Its per-unit blip templates load FIRST: WorldMapFrame.xml instantiates WorldMapParty1..4 and
# WorldMapRaid1..40 with inherits=, so the virtual Frames have to be registered before it.
WorldMapFrameTemplates.xml
WorldMapFrame.xml
# The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
# the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
@@ -251,6 +254,12 @@ ContainerFrameAdapters.xml
# windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
# already loaded above.
CharacterFrame.xml
# The reference's item-SLOT button templates (PaperDollItemSlotButtonTemplate <-
# BagSlotButtonTemplate), published for addons rather than used by our own windows. Here
# rather than beside their subjects because the chain crosses BagFrame.xml (241) ->
# CharacterFrame.xml (256) the wrong way; needs ItemButtonTemplate.xml (45) above it for the
# base and Cooldown.xml (124) for the $parentCooldown the doll template carries.
ItemSlotButtonTemplates.xml
# The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
# and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
# the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
+148 -3
View File
@@ -1116,16 +1116,85 @@ fn load_dependencies(
/// Template names the addon passes to `CreateFrame` that the VM cannot resolve.
///
/// Scanned rather than traced, for `missing_globals`' reason: the calls overwhelmingly happen in
/// handlers a load-time survey never fires. The pattern is narrow — a **string literal** in the
/// fourth argument position — so `CreateFrame(t, n, p, someVariable)` is invisible here, which is
/// the honest under-report to make. It is also not comment-stripped: a `CreateFrame` inside a
/// handlers a load-time survey never fires. It is not comment-stripped: a `CreateFrame` inside a
/// comment counts, and that over-report is the cheaper error of the two.
///
/// **A string literal in the fourth argument, OR a local bound to one in the same file.** The
/// literal-only form was the honest under-report this doc used to name — and then a decision was
/// made on the number it produced. `assets/ui/ItemButtonTemplate.xml` declined to build
/// `ItemButtonTemplate` citing "the harness's template demand ranks it at zero on both axes"; the
/// zero was real, and pfUI wanted `ContainerFrameItemButtonTemplate` (which inherits it) the whole
/// time, through
///
/// ```lua
/// local tpl = "ContainerFrameItemButtonTemplate"
/// if bag == -1 then tpl = "BankItemButtonGenericTemplate" end
/// CreateFrame("Button", "pfBag" .. bag .. "item" .. slot, pfUI.bags[bag], tpl)
/// ```
///
/// The instrument was not lying — it said what it could not see, right here. But a bound that is
/// stated at the function and forgotten at the call site is only half a guard, so the cheap half
/// of the gap is closed: one pass collects `x = "literal"` bindings per file, and a bare
/// identifier in the fourth argument resolves through it.
///
/// Still invisible, and still stated: a name built by concatenation, one passed in as a function
/// argument, one read from a table, and one bound in another file. Those are real and this does
/// not pretend otherwise — what it fixes is the single commonest shape, measured.
fn missing_templates(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Vec<String> {
let mut wanted: BTreeSet<String> = BTreeSet::new();
for path in source_files(root, name, toc) {
let Some(text) = read_text(root, &path) else {
continue;
};
// `ident = "literal"` bindings in this file, for the fourth-argument lookup below. A name
// rebound to several literals keeps ALL of them (pfUI's `tpl` is two), because the census
// asks "could this call want that template", and both branches genuinely can.
let mut bound: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (i, _) in text.match_indices('=') {
// Not `==`, `~=`, `<=`, `>=`.
if text[i + 1..].starts_with('=')
|| text[..i].ends_with(['=', '~', '<', '>'])
|| text[i + 1..].trim_start().is_empty()
{
continue;
}
let lhs = text[..i].trim_end();
let lhs = lhs.rsplit(['\n', ';', ' ', '\t']).next().unwrap_or("");
if !is_ident(lhs) {
continue;
}
let rhs = text[i + 1..].trim_start();
let Some(q) = rhs.chars().next().filter(|c| *c == '"' || *c == '\'') else {
continue;
};
let Some(end) = rhs[1..].find(q) else {
continue;
};
let lit = &rhs[1..end + 1];
// **Disqualify on an OPERATOR, not on a terminator.** The defect this closes is a
// fragment: `local built = "A" .. "B"` binding `built` to `"A"` — a name nobody asked
// for entering the demand ranking, which is not an under-report but the fiction this
// table has opened with three times (1210/1218/1227).
//
// Written first as "the tail must be a statement end", which is the wrong shape: Lua's
// terminators are open-ended (`end`, `then`, `else`, `until`, …) and chasing that list
// is endless — `tpl = "X" end` was missed for exactly that reason. What actually makes
// a literal part of a larger expression is a binary operator after it, and on a STRING
// in Lua that is `..` almost exclusively. So the test names the operators and lets
// everything else through, which also keeps this scanner's standing posture: over-
// report rather than under-report, the cheaper error of the two.
let tail = rhs[end + 2..].trim_start_matches([' ', '\t']);
let part_of_expression = tail.starts_with("..")
|| tail.starts_with(['+', '-', '*', '/', '%', '^', '<', '>'])
|| tail.starts_with("==")
|| tail.starts_with("~=")
|| tail.starts_with("and ")
|| tail.starts_with("or ");
let whole = !part_of_expression;
if !lit.is_empty() && whole {
bound.entry(lhs).or_default().push(lit);
}
}
for (i, _) in text.match_indices("CreateFrame") {
let rest = &text[i + "CreateFrame".len()..];
let Some(open) = rest.find('(') else { continue };
@@ -1174,6 +1243,9 @@ fn missing_templates(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> V
.or_else(|| f.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
if let Some(lit) = lit.filter(|s| !s.is_empty()) {
wanted.insert(lit.to_string());
} else if let Some(lits) = bound.get(f) {
// A bare identifier bound to a literal in this file — pfUI's `tpl`.
wanted.extend(lits.iter().map(|s| (*s).to_string()));
}
}
}
@@ -3501,6 +3573,79 @@ mod dependency_tests {
///
/// The assertion that matters is that the two never merge: a single "files not found" count
/// would average a broken package with a real client gap and read as one number.
/// **A template named through a local resolves; the shapes that still cannot are asserted too.**
///
/// The literal-only scan was an honest under-report — stated at `missing_templates` — and a
/// decision was then made on the number it produced: `assets/ui/ItemButtonTemplate.xml`
/// declined to build `ItemButtonTemplate` citing a demand of zero "on both axes". The zero was
/// real and the demand was not: pfUI binds `local tpl = "ContainerFrameItemButtonTemplate"`
/// and passes the variable, so the one addon that wanted it was invisible to the ranking.
///
/// Both halves are asserted, because a scanner that quietly widened would be the worse fix:
/// the shape it now sees, AND the shapes it still does not, so the next decision quoting this
/// number can read its bound from a test rather than from a comment.
///
/// **The exact-set form earned itself immediately.** Written first as a few absence checks it
/// PASSED, while the collector was binding `built` to `"Unseen"` out of
/// `local built = "Unseen" .. "ByConcat"` — a fragment entering the demand ranking as a
/// template name nobody asked for. That is not an under-report, it is the fiction this table
/// has opened with three times before (1210/1218/1227). Hence the whole-right-hand-side rule
/// in `missing_templates`, and hence asserting the set rather than sampling it.
#[test]
fn the_template_census_resolves_a_local_and_says_what_it_still_cannot_see() {
let tmp = std::env::temp_dir().join(format!("benilla-harness-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let dir = tmp.join("TplUser");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("TplUser.toc"), "## Interface: 11200\nuse.lua\n").unwrap();
// Every shape in one file. Only the first three are meant to be seen.
std::fs::write(
dir.join("use.lua"),
r#"
local tpl = "SeenViaLocal"
if bag == -1 then tpl = "SeenViaRebind" end
CreateFrame("Button", "a", nil, tpl)
CreateFrame("Button", "b", nil, "SeenAsLiteral")
-- Still invisible, deliberately: built by concatenation, read from a table, and
-- passed in as a parameter. Naming them here is the bound.
local built = "Unseen" .. "ByConcat"
CreateFrame("Button", "c", nil, built)
CreateFrame("Button", "d", nil, cfg.template)
function f(passed) CreateFrame("Button", "e", nil, passed) end
"#,
)
.unwrap();
let reports = survey(&tmp);
let r = reports.iter().find(|r| r.name == "TplUser").unwrap();
let got: BTreeSet<&str> = r.missing_templates.iter().map(String::as_str).collect();
for want in ["SeenViaLocal", "SeenViaRebind", "SeenAsLiteral"] {
assert!(got.contains(want), "{want} must be seen — got {got:?}");
}
// A name rebound to two literals keeps BOTH: the census asks "could this call want that
// template", and each branch genuinely can.
assert!(
got.contains("SeenViaLocal") && got.contains("SeenViaRebind"),
"a rebound local keeps every literal it was bound to"
);
// **The stated bound, asserted as an EXACT set** so it cannot rot into a claim of
// completeness. The file also asks for a concatenated name, a table field
// (`cfg.template`) and a parameter (`passed`); none may appear, and pinning the whole set
// rather than a few absences is what makes a future widening announce itself here.
let want: BTreeSet<&str> = ["SeenViaLocal", "SeenViaRebind", "SeenAsLiteral"]
.into_iter()
.collect();
assert_eq!(
got, want,
"the census sees exactly these three shapes — concatenation, a table field and a \
parameter stay invisible, and that bound lives here rather than only in a doc comment"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn an_absent_manifest_entry_is_attributed_to_whose_package_is_short() {
let tmp =
+44 -11
View File
@@ -294,6 +294,9 @@ pub(super) fn spawn_booth_effects(
layer: &RenderLayers,
light: Option<&bevy::render::render_resource::Buffer>,
effects: &[BoothEffects],
// The bake's instance-level properties ([`BoothInstance`]). An effect model attached to a
// translucent instance composes onto it — see the host spawn below.
instance: BoothInstance,
) -> (usize, usize) {
let mut spawned = 0usize;
let mut frames = 0usize;
@@ -301,14 +304,28 @@ pub(super) fn spawn_booth_effects(
let Some(joint) = rig.anchor(commands, fx.bone) else {
continue; // bad bone index — bake the body without this model's effects
};
let host = commands
.spawn((
Transform::from_translation(fx.offset),
Visibility::default(),
ChildOf(joint),
layer.clone(),
))
.id();
// The host stands for the ATTACHED MODEL, which is what makes it the right place to hang
// the composed alpha: the reference recomposes an attached model's own instance alpha every
// frame as `child+0x19c = parent+0x19c × child+0x180` (`0x714260`), and the emitter lane
// then copies that into `emitter+0x1a8` (`0x718960` @`0x719073`) to fold into every
// particle's alpha. Our particle sim already walks that chain
// ([`benilla_world::model_fade::ModelAlphas`], decisions 0827/0833) — it had simply never
// been given a link to walk from a booth, because until a bake could be translucent every
// booth rider really was opaque. A ghosted select body is the case that made it false.
//
// `ModelFade`, not `ModelAlpha`: the game declares the translucency and the engine owns the
// composition (decision 1164). A booth bake has no ramps to fold, so the walk reads the
// declared value straight — see [`benilla_world::model_fade::ModelAlphas`].
let mut host = commands.spawn((
Transform::from_translation(fx.offset),
Visibility::default(),
ChildOf(joint),
layer.clone(),
));
if instance.feathering() {
host.insert(benilla_world::model_fade::ModelFade(instance.alpha));
}
let host = host.id();
for em in &fx.emitters {
// A **billboard** bone in the emitter's chain (decision 0813): its palette rows are
// replaced with the rendering camera's basis about the bone's own pivot, and children
@@ -343,9 +360,10 @@ pub(super) fn spawn_booth_effects(
anchor: Some(host), // the cloud anchors at the MODEL; bones compose births only
// A booth rider's host is torn down with the bake it belongs to.
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
// The MODEL this cloud multiplies by (0827): its own host, whose composed
// alpha is the bake's instance alpha. `ModelAlphas` reads 1.0 through an
// entity carrying no `ModelAlpha`, so an opaque bake costs exactly what it did.
alpha: Some(host),
// A booth's light is its own buffer (`EffectLightOverride` below), never a
// world light node — the world's would fog and shade the pane by the world's
// time of day.
@@ -1886,6 +1904,21 @@ mod instance_tests {
);
}
/// The emitters an attached effect model carries compose onto the instance too — the reference
/// recomposes `child+0x19c = parent+0x19c × child+0x180` every frame (`0x714260`) and copies it
/// into `emitter+0x1a8`. Pinned as the *link* rather than the arithmetic (the fold itself is
/// `benilla_world`'s and already tested there): what this asserts is that a feathering bake
/// gives its effect host a `ModelAlpha` at all, which is the half that was missing — a ghost's
/// wisps drew at full strength over a body at 0.5 until it existed.
#[test]
fn a_feathering_bake_hands_its_effect_host_an_alpha_to_compose() {
assert!(BoothInstance { alpha: 0.5 }.feathering());
assert!(
!BoothInstance::default().feathering(),
"and an opaque bake hands it none, so every existing booth is untouched"
);
}
/// A batch that cannot feather keeps its steady material even while the instance is
/// translucent, and arms no prime — the reference's own twin gate, and the Mod/Mod2x rule
/// (their blend equation reads no alpha, so no material swap can feather them; the shader's
@@ -330,6 +330,7 @@ pub(super) fn sync_dressup_booth(
emitters: fx.emitters.clone(),
})
.collect::<Vec<_>>(),
BoothInstance::default(),
);
// The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame
// the window is drawing this pane, and none once it closes.
@@ -1399,6 +1399,7 @@ pub(super) fn sync_glue_booth(
emitters: fx.emitters.clone(),
})
.collect::<Vec<_>>(),
instance,
);
if fx_emitters > 0 {
info!(
+1
View File
@@ -2622,6 +2622,7 @@ fn sync_body_booth(
&booth.layer,
booth_light.pane.buffer.as_ref(),
&booth_effects,
BoothInstance::default(),
);
// The turn's node bookkeeping named the player this bake just replaced.
booth.turn.rebaked();
+5
View File
@@ -265,6 +265,11 @@ pub(super) fn feed_party(
let fresh = PartyState {
members,
leader_index,
// The wire's leader GUID verbatim — `UnitIsPartyLeader`'s second leg compares the
// RESOLVED token against it, so an index cannot stand in (an out-of-range member has no
// descriptor, and the compare must still answer). Zero when ungrouped, and deliberately
// not guarded against zero at the comparison — see `PartyState::leader_guid`.
leader_guid: group.leader,
raid,
loot_method,
master_looter,
@@ -133,7 +133,7 @@ fn resurrect_request_picks_variant_and_answers() {
"the offerer name formats into the no-sickness text"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled()")
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptResurrect]);
@@ -341,7 +341,7 @@ fn corpse_range_events_drive_recover_corpse() {
"RECOVER_CORPSE"
);
assert!(
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled()")
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap(),
"Accept is delay-gated"
);
@@ -358,7 +358,7 @@ fn corpse_range_events_drive_recover_corpse() {
"Resurrect now?"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled()")
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::RetrieveCorpse]);
@@ -91,12 +91,12 @@ fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
.unwrap();
assert_eq!((lo, hi), (0.0, 0.0));
assert!(
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled()")
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled() ~= 0")
.unwrap(),
"up arrow greyed at load (ref UIPanelTemplates.lua l.245-246)"
);
assert!(!s
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled()")
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled() ~= 0")
.unwrap());
}
@@ -132,12 +132,12 @@ fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
"the frame itself too — the ref's own frame:Show(), which is what an addon reads back"
);
assert!(
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled()")
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled() ~= 0")
.unwrap(),
"at the top: up greyed"
);
assert!(s
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled()")
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled() ~= 0")
.unwrap());
}
@@ -186,12 +186,6 @@ const KNOWN: &[Known] = &[
why: "the reference's /who sort dropdown carries its own handlers over the shared \
UIDropDownMenuTemplate; ours takes the template alone.",
},
Known {
frame: "WorldMapPlayer",
flag: Flag::Mouse,
why: "the reference's map dot has OnEnter -> WorldMapUnit_OnEnter, the name-on-hover \
tooltip. Wants that handler, not the flag.",
},
Known {
frame: "WorldStateAlwaysUpFrame",
flag: Flag::Mouse,
@@ -216,8 +216,10 @@ fn the_friend_buttons_follow_the_selection() {
push(&mut s, SocialState::default(), "FRIENDLIST_UPDATE");
for button in ["SendMessage", "GroupInvite", "RemoveFriend"] {
assert!(
!s.eval::<bool>(&format!("return FriendsFrame{button}Button:IsEnabled()"))
.unwrap(),
!s.eval::<bool>(&format!(
"return FriendsFrame{button}Button:IsEnabled() ~= 0"
))
.unwrap(),
"{button} is disabled with no friends"
);
}
@@ -232,12 +234,12 @@ fn the_friend_buttons_follow_the_selection() {
"FRIENDLIST_UPDATE",
);
assert!(
s.eval::<bool>("return FriendsFrameRemoveFriendButton:IsEnabled()")
s.eval::<bool>("return FriendsFrameRemoveFriendButton:IsEnabled() ~= 0")
.unwrap(),
"an offline friend can still be removed"
);
assert!(
!s.eval::<bool>("return FriendsFrameSendMessageButton:IsEnabled()")
!s.eval::<bool>("return FriendsFrameSendMessageButton:IsEnabled() ~= 0")
.unwrap(),
"…but not whispered"
);
@@ -459,14 +461,14 @@ fn the_who_buttons_need_a_selected_row() {
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
"nothing selected yet"
);
s.run("WhoFrameButton2:Click()").unwrap();
assert!(s
.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap());
let _ = s.take_social_requests();
s.run("WhoFrameAddFriendButton:Click()").unwrap();
@@ -485,7 +487,7 @@ fn the_who_buttons_need_a_selected_row() {
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
"a fresh answer drops the old selection"
);
@@ -663,7 +665,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
);
// …and a right-click must NOT also select the row (the ref's two branches are exclusive).
assert!(
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
"right-click does not select"
);
@@ -131,7 +131,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
for name in ["GameMenuButtonEditMode", "GameMenuButtonSupport"] {
assert!(
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
!s.eval::<bool>(&format!("return {name}:IsEnabled() ~= 0"))
.unwrap(),
"{name} has no panel behind it and must read that way"
);
@@ -144,7 +144,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
"GameMenuButtonContinue",
] {
assert!(
s.eval::<bool>(&format!("return {name}:IsEnabled()"))
s.eval::<bool>(&format!("return {name}:IsEnabled() ~= 0"))
.unwrap(),
"{name} is live"
);
@@ -468,12 +468,12 @@ fn logout_and_exit_read_disabled_while_a_countdown_runs() {
s.fire_event("PLAYER_CAMPING", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(
!s.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
!s.eval::<bool>("return GameMenuButtonLogout:IsEnabled() ~= 0")
.unwrap(),
"Logout is dead while the camp timer runs"
);
assert!(!s
.eval::<bool>("return GameMenuButtonQuit:IsEnabled()")
.eval::<bool>("return GameMenuButtonQuit:IsEnabled() ~= 0")
.unwrap());
// With the countdown gone, a re-opened menu has them back.
@@ -481,7 +481,7 @@ fn logout_and_exit_read_disabled_while_a_countdown_runs() {
s.fire_event("LOGOUT_CANCEL", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(s
.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
.eval::<bool>("return GameMenuButtonLogout:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -690,7 +690,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"closing the menu restores full colour: {toggle:?}"
);
assert!(
s.eval::<bool>("return MainMenuBarBackpackButton:IsEnabled()")
s.eval::<bool>("return MainMenuBarBackpackButton:IsEnabled() ~= 0")
.unwrap(),
"…and the button works again"
);
@@ -273,7 +273,7 @@ fn visible(s: &UiScript, frame: &str) -> bool {
}
fn enabled(s: &UiScript, button: &str) -> bool {
s.eval::<bool>(&format!("return {button}:IsEnabled()"))
s.eval::<bool>(&format!("return {button}:IsEnabled() ~= 0"))
.unwrap_or_else(|e| panic!("{button}:IsEnabled() — {e}"))
}
@@ -479,7 +479,7 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
// NB this engine's `IsEnabled` returns a plain bool, not the live API's 1/nil.
assert!(
!s.eval::<bool>("return BenillaInspectFrameTab1:IsEnabled()")
!s.eval::<bool>("return BenillaInspectFrameTab1:IsEnabled() ~= 0")
.unwrap(),
"the selected tab is disabled (PanelTemplates_SelectTab), so its click is inert"
);
@@ -101,12 +101,12 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
"Unbind Key exists on this page"
);
assert!(
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled()")
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"…disabled until a capsule is selected"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The section tree: the registry's category tokens, first-appearance order — exactly
// 1.12's file order — every section a COLLAPSED header row (the era default).
@@ -196,7 +196,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"a selected capsule arms the capture"
);
assert!(
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled()")
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
@@ -94,7 +94,7 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
!s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled() ~= 0")
.unwrap(),
"a nameless, iconless macro cannot be created"
);
@@ -104,7 +104,7 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton1)")
.unwrap();
assert!(
s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled() ~= 0")
.unwrap(),
"a name and an icon enable OKAY"
);
@@ -974,7 +974,8 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
.unwrap());
assert!(s
.eval::<bool>(
"return MerchantRepairAllButton:IsShown() and MerchantRepairAllButton:IsEnabled()"
"return MerchantRepairAllButton:IsShown() and \
MerchantRepairAllButton:IsEnabled() ~= 0"
)
.unwrap());
assert!(s
@@ -24,7 +24,7 @@ fn load_xml(s: &UiScript, file: &str) {
}
fn enabled(s: &UiScript, button: &str) -> bool {
s.eval::<bool>(&format!("return {button}:IsEnabled()"))
s.eval::<bool>(&format!("return {button}:IsEnabled() ~= 0"))
.unwrap()
}
@@ -808,7 +808,7 @@ fn the_audio_page_reads_the_cvar_table_on_select() {
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The music slider holds the stored 0.7 (f32 wobble tolerated), readout "70%".
assert!(s
@@ -894,10 +894,12 @@ fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
vec![("MasterSoundEffects".to_string(), "0".to_string())]
);
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled()")
.eval::<bool>(
"return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyAudioRowEnableMusicCheck:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerBodyAudioRowEnableMusicCheck:IsEnabled() ~= 0")
.unwrap());
assert!(s
.take_sounds()
@@ -911,7 +913,9 @@ fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
vec![("MasterSoundEffects".to_string(), "1".to_string())]
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled()")
.eval::<bool>(
"return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -969,7 +973,7 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
.eval::<bool>("return OptionsFrameContainerBodyGraphics:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
assert!(s
.eval::<bool>(
@@ -1216,7 +1220,7 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
.eval::<bool>("return OptionsFrameContainerBodyNameplates:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The plate pair leads the page and rides `VPlateMode::default()`: enemy on (the 0167 boot
// divergence), friendly off (faithful, 0599).
@@ -1321,7 +1325,7 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameApplyButton:IsEnabled()")
.eval::<bool>("return OptionsFrameApplyButton:IsEnabled() ~= 0")
.unwrap());
// A second move re-stages the pending value — still nothing queues (0989: the steppers
@@ -1600,7 +1604,7 @@ fn the_controls_page_reads_flags_with_the_sticky_inversion() {
.eval::<bool>("return OptionsFrameContainerBodyControls:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
assert!(
s.eval::<bool>("return OptionsFrameContainerBodyControlsRowStickyTargetCheck:GetChecked()")
@@ -2274,7 +2278,7 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
.eval::<bool>("return OptionsFrameContainerBodyCombat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read: each box shows its global, at CombatText.xml's own file-scope value.
@@ -2351,7 +2355,7 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
let enabled = |s: &mut UiScript, row: &str, control: &str| -> bool {
s.eval::<bool>(&format!(
"return OptionsFrameContainerBodyCombat{row}{control}:IsEnabled() and true or false"
"return OptionsFrameContainerBodyCombat{row}{control}:IsEnabled() ~= 0"
))
.unwrap()
};
@@ -2575,7 +2579,7 @@ fn the_interface_page_writes_the_three_stock_globals() {
.eval::<bool>("return OptionsFrameContainerBodyInterface:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read: all three ship on — QuestFrame.xml's "1" (the director's pin), QuestLogFrame.xml's "1"
@@ -2657,7 +2661,7 @@ fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() and true or false"
:IsEnabled() ~= 0"
)
.unwrap(),
"the picker is dead while the switch is off"
@@ -2678,7 +2682,7 @@ fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() and true or false"
:IsEnabled() ~= 0"
)
.unwrap(),
"and the picker wakes with it"
@@ -3055,7 +3059,7 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
s.run(&format!("OptionsFrameCategoryListRow{key}:Click()"))
.unwrap();
assert!(
s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"{key}: Defaults is live on a page that has something to reset"
);
@@ -3065,7 +3069,7 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
s.run("OptionsFrame_SelectCategory(\"NotACategory\")")
.unwrap();
assert!(
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"Defaults is dead when the selected page has no rows"
);
@@ -3093,7 +3097,7 @@ fn the_chat_page_toggles_the_chat_bubble_cvars() {
.eval::<bool>("return OptionsFrameContainerBodyChat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read from the table, not from a restated default: bubbles on, party bubbles off.
assert!(s
@@ -3579,8 +3583,11 @@ fn the_action_bars_page_toggles_the_real_bars() {
// Bar 4's row is DEAD while bar 3's is unticked — MultiBarLeft cannot stand without
// MultiBarRight (the reference's own rule for this pair, UIOptionsFrame.lua l.722-726).
assert!(
!s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
!s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"Show Right ActionBar 2 is disabled until Show Right ActionBar is on"
);
@@ -3607,8 +3614,11 @@ fn the_action_bars_page_toggles_the_real_bars() {
s.run(&format!("{}:Click()", box_of("RowMultiBar3")))
.unwrap();
assert!(
s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"bar 3 on wakes bar 4's row"
);
assert!(shown(&s, "MultiBarRight"));
@@ -3630,8 +3640,11 @@ fn the_action_bars_page_toggles_the_real_bars() {
assert!(!shown(&s, "MultiBarLeft"), "MultiBarLeft rides on bar 3");
assert_eq!(s.eval::<i64>("return SHOW_MULTI_ACTIONBAR_4").unwrap(), 1);
assert!(
!s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
!s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"…and its row goes back to sleep"
);
@@ -343,7 +343,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
(0.0, 0.0)
);
assert!(!s
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled()")
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled() ~= 0")
.unwrap());
assert_eq!(s.eval::<i64>("return MyScroll.offset").unwrap(), 0);
@@ -369,7 +369,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
"192px of content in an 80px window — the bar's range is the overflow"
);
assert!(
s.eval::<bool>("return MyScrollScrollBarScrollDownButton:IsEnabled()")
s.eval::<bool>("return MyScrollScrollBarScrollDownButton:IsEnabled() ~= 0")
.unwrap(),
"there is somewhere to scroll to, so the down arrow woke"
);
@@ -382,7 +382,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
48.0
);
assert!(s
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled()")
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -111,8 +111,8 @@ fn shipped_gossip_frame_drives_end_to_end() {
// so the shared pool starts filling from option 1 at row 1 — decision 0088 §3).
let states: (bool, bool, bool, bool) = s
.eval(
"return GossipRow1:IsVisible(), GossipRow1:IsEnabled(),\n\
GossipRow2:IsEnabled(), GossipRow3:IsVisible()",
"return GossipRow1:IsVisible(), GossipRow1:IsEnabled() ~= 0,\n\
GossipRow2:IsEnabled() ~= 0, GossipRow3:IsVisible()",
)
.unwrap();
assert_eq!(
@@ -255,7 +255,7 @@ fn request_signature_disables_at_the_wires_requirement_not_at_nine() {
.unwrap();
show_petition(&mut s);
assert!(
s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() and true or false")
s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() ~= 0")
.unwrap(),
"three of four signatures — still asking"
);
@@ -264,7 +264,7 @@ fn request_signature_disables_at_the_wires_requirement_not_at_nine() {
.unwrap();
show_petition(&mut s);
assert!(
!s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() and true or false")
!s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() ~= 0")
.unwrap(),
"four of four — the charter is full, though only four of nine rows are used"
);
@@ -282,13 +282,13 @@ fn the_sign_button_follows_can_sign_petition() {
let mut s = setup();
show_petition(&mut s);
assert!(s
.eval::<bool>("return PetitionFrameSignButton:IsEnabled() and true or false")
.eval::<bool>("return PetitionFrameSignButton:IsEnabled() ~= 0")
.unwrap());
s.run("BenillaPetitionFixture.canSign = nil").unwrap();
show_petition(&mut s);
assert!(
!s.eval::<bool>("return PetitionFrameSignButton:IsEnabled() and true or false")
!s.eval::<bool>("return PetitionFrameSignButton:IsEnabled() ~= 0")
.unwrap(),
"nil, the era false, disables it"
);
@@ -434,7 +434,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
"instant text keeps the writing sound: one scratch on the wake tick"
);
assert!(s
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.unwrap());
assert_eq!(
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
@@ -494,7 +494,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
"the write-on scratches the WriteQuest quill each tick"
);
assert!(!s
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
@@ -502,7 +502,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.unwrap());
let alpha = s
.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
@@ -598,7 +598,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
);
assert!(!s.eval::<bool>("return QuestLogTitle1:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return QuestLogAbandonButton:IsEnabled()")
.eval::<bool>("return QuestLogAbandonButton:IsEnabled() ~= 0")
.unwrap());
// The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than
// a static `text=` — it used to float "Description" over the empty-log parchment with no
@@ -1226,7 +1226,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
"quest 1 carries the sharable bit"
);
assert!(
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"solo: sharable is not enough"
);
@@ -1235,7 +1235,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
s.set_party(party(2));
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(
s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"PARTY_MEMBERS_CHANGED alone re-tests the button"
);
@@ -1249,7 +1249,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
"quest 2 has no sharable bit"
);
assert!(
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"in a party: an unsharable selection is still dark"
);
@@ -1258,12 +1258,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
s.run("SelectQuestLogEntry(1)").unwrap();
s.run("BenillaQuestLogFrame_Update()").unwrap();
assert!(s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap());
s.set_party(PartyState::default());
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(!s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap());
}
@@ -1284,7 +1284,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
s.set_party(party(4));
s.run("ToggleQuestLog()").unwrap();
assert!(!s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled()")
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -118,6 +118,7 @@ fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
s.set_party(PartyState {
members,
leader_index: 0,
leader_guid: 0, // the player leads; their guid is unset in this fixture
raid,
loot_method: "group".into(),
master_looter: None,
@@ -218,7 +219,7 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
assert!(visible(&s, "RaidFrameRaidDescription"), "solo: the blurb");
assert!(visible(&s, "RaidFrameConvertToRaidButton"));
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled()")
.unwrap(),
0,
"solo there is no party to convert"
@@ -240,7 +241,7 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
});
s.fire_event("PARTY_MEMBERS_CHANGED", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled()")
.unwrap(),
1,
"a party leader can convert"
@@ -263,7 +264,7 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
});
s.fire_event("PARTY_LEADER_CHANGED", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled()")
.unwrap(),
0,
"a party member cannot"
@@ -714,7 +715,7 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
]);
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled()")
.unwrap(),
1
);
@@ -743,7 +744,7 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
s.set_saved_instances(Vec::new());
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled()")
.unwrap(),
0
);
@@ -781,7 +782,7 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
// Answer two, saying the same nothing.
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled()")
.unwrap(),
0,
"an empty lockout list is a dead button"
@@ -64,7 +64,28 @@ use bevy::prelude::*;
/// are a *contract addons call by name* AND benilla has no reason to compute them differently.
/// Anything we want to own — because our own windows are the consumer — belongs in `assets/ui` as
/// our own code, under the reference's names (`BagFrame.xml`'s `ToggleBackpack` is the pattern).
pub(crate) const SOURCED: &[&str] = &["Interface\\FrameXML\\ContainerFrame.lua"];
pub(crate) const SOURCED: &[&str] = &[
"Interface\\FrameXML\\ContainerFrame.lua",
// PaperDollFrame.lua earns its place for ONE family: `PaperDollItemSlotButton_OnLoad`/
// `_OnEvent`/`_OnClick`/`_Update`/`_UpdateLock`/`_OnEnter`/`_OnUpdate`. Every one of them is
// frame-agnostic in exactly the sense rule 2 above means — it acts on `this` and `this:GetID()`
// through live APIs, never on a named reference window — which is what makes it safe to hand
// an addon's own button. `PaperDollItemSlotButtonTemplate` and `BagSlotButtonTemplate` are
// declared over them (CharacterFrame.xml, BagFrame.xml), and pfUI builds its bag bar from the
// latter.
//
// Checked before adding, because this list is a decision:
// · TOP-LEVEL is four constants (NUM_RESISTANCE_TYPES, NUM_STATS, NUM_SHOPPING_TOOLTIPS,
// ATTACK_POWER_MAGIC_NUMBER). Nothing touches a frame at load, so sourcing cannot raise.
// · 18 of its 29 functions COLLIDE with ours (the PaperDollFrame_Set* stat family, the two
// damage-frame hovers, PaperDollFormatStat, PaperDollStatTooltip). Order settles all of
// them the documented way: this runs before assets/ui, so OUR bodies overwrite the
// reference's and keep driving our character sheet. Not one of the seven names we are here
// for is in that overlap.
// · Our own doll slots keep calling `BenillaPaperDollSlot_*` and are untouched — the sourced
// family is new surface for addons, not a replacement for ours.
"Interface\\FrameXML\\PaperDollFrame.lua",
];
/// Execute every [`SOURCED`] file into `script`, read off the install's patch chain.
///
@@ -467,7 +467,7 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
"at war, so the box is ticked"
);
assert!(
!s.eval::<bool>("return ReputationDetailAtWarCheckBox:IsEnabled() and true or false")
!s.eval::<bool>("return ReputationDetailAtWarCheckBox:IsEnabled() ~= 0")
.unwrap(),
"peace-forced, so the box is dead"
);
@@ -171,7 +171,8 @@ fn shipped_spellbook_drives_end_to_end() {
"Fire Blast"
);
assert!(
!s.eval::<bool>("return SpellButton2:IsEnabled()").unwrap(),
!s.eval::<bool>("return SpellButton2:IsEnabled() ~= 0")
.unwrap(),
"book id 7 is past the 2-spell Fire tab — disabled"
);
@@ -83,7 +83,7 @@ fn start_delay_gates_button1_then_swaps_the_text_in() {
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled()")
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap(),
"button1 starts disabled under StartDelay"
);
@@ -102,7 +102,7 @@ fn start_delay_gates_button1_then_swaps_the_text_in() {
"delay expiry swaps the real text in"
);
assert!(
s.eval::<bool>("return StaticPopup1Button1:IsEnabled()")
s.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap(),
"delay expiry enables button1"
);
@@ -252,7 +252,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
"Cost:"
);
assert!(s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled()")
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// Train buys the selected service — the row's spell id reaches the app's drain.
@@ -266,7 +266,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
s.run("SelectTrainerService(6); BenillaTrainerFrame_Update()")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled()")
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
s.resolve();
assert!(
@@ -293,7 +293,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
"a known prerequisite shows white with its rank: {reqs}"
);
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled()")
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// The app's client-side close: clear the snapshot + fire TRAINER_CLOSED → the window hides.
@@ -421,6 +421,9 @@ fn left_clicking_the_player_frame_targets_self() {
guid: 0xA11CE,
}],
leader_index: 0, // we lead
// The player's own guid, which this fixture leaves unset — spelled out because a bare 0
// is also the reference's "ungrouped" sentinel and this party has a member.
leader_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -510,6 +513,7 @@ fn raid_mark_clicks_through_the_nested_level() {
guid: 0xA11CE,
}],
leader_index: 0, // we lead — the mark rows are leader-gated
leader_guid: 0, // the player's own guid; this fixture leaves it unset
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -1791,6 +1795,8 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
guid: 0x7A17,
}],
leader_index,
// Follows `leader_index`: 0 = the player (unset here), else the member who leads.
leader_guid: if leader_index == 0 { 0 } else { 0x7A17 },
raid: Vec::new(),
loot_method: method.into(),
master_looter,
@@ -828,7 +828,7 @@ fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
"an open menu ticks the enable pass"
);
assert!(
!s.eval::<bool>("return DropDownList1Button2:IsEnabled()")
!s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
.unwrap(),
"Whisper greys against a disconnected target"
);
@@ -851,7 +851,7 @@ fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
s.tick(0.016);
s.resolve();
assert!(
s.eval::<bool>("return DropDownList1Button2:IsEnabled()")
s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
.unwrap(),
"Whisper enables the frame the target reconnects"
);
@@ -20,6 +20,10 @@ fn harness() -> UiScript {
"GameTooltip.xml",
"UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
// The blip templates, which WorldMapFrame.xml instantiates with inherits=. Not
// optional: an unknown template is a loader WARNING, not an error, so leaving this
// out loads clean and then hands every blip a sizeless, anchorless frame.
"WorldMapFrameTemplates.xml",
"WorldMapFrame.xml",
] {
let text = std::fs::read_to_string(
+116 -1
View File
@@ -692,6 +692,13 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8)
let class = store.0.unit_class().and_then(class_names);
UnitState {
exists: true,
// **This function IS the reference's `0x468460` having succeeded.** It is only ever called
// with a live `ObjectStore`, so every snapshot it builds is a unit the object manager holds
// — which is the whole of `UnitIsVisible` (wow-re
// `ui/scratch/unitisvisible-object-presence.md`). Set here rather than at the call sites so
// a future feed cannot forget it: the roster-only legs build a `UnitState` literally and
// get `false` from `Default`, which is the correct out-of-range answer.
has_object: true,
name,
// The UI-facing health/power getters, not the raw fields (decision 1022): a unit carrying
// `UNIT_DYNFLAG_DEAD` — a feigning hunter — answers 0 to `UnitHealth 0x5174d0` and
@@ -717,6 +724,15 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8)
// `UnitIsCharmed 0x516cf0` — `UNIT_FIELD_CHARMEDBY != 0`, the same field `ui_aura`'s
// charmed-unit buff leg already reads, so the two cannot disagree about who is charmed.
charmed: store.0.unit_charmed_by().is_some(),
// The tapped pair — `UNIT_DYNAMIC_FLAGS` bits 0x4/0x8. Set here and only here, so the
// object-presence conjunct both predicates carry comes for free: a unit with no live
// descriptor never reaches this function and reads `false` from `Default`.
tapped: store.0.unit_tapped(),
tapped_by_player: store.0.unit_tapped_by_player(),
// `UnitIsPartyLeader`'s descriptor leg. Zero on a creature, which is what the reference's
// TYPEMASK_PLAYER restriction achieves there: a non-player has no PLAYER block at all, so
// the field simply reads absent.
group_leader: store.0.player_is_group_leader(),
reaction,
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
@@ -1009,8 +1025,25 @@ pub(crate) fn fire_transitions(
// icon law reads. Exactly the three frames that draw the icon register it in the reference
// (player, target, party member) and nothing else does. Edge-fired, so the player frame's
// `igPVPUpdate` sounds once per flag change rather than once per repaint.
//
// **And on the TAPPED bit, which is not a faction field and fires this event anyway.** The
// reference's `UNIT_DYNAMIC_FLAGS` watcher tests bit `0x4` and dispatches event id **29** —
// `UNIT_FACTION` — at `0x6005a1 test al,4` -> `0x6005b0 mov edx,0x1d` -> `0x515e50` (wow-re
// `ui/scratch/tapped-bits-and-unit-faction.md`, controlled across all 37 fire sites).
//
// This line is the whole reason `UnitIsTapped` is worth publishing. Without it both verbs
// answer CORRECTLY and no frame ever repaints: pfUI's grey-bar branch runs inside
// `RefreshUnit`, which is event-driven, so a mob that becomes someone else's tap would stay
// full-colour until something unrelated happened to refresh it. A passing unit test on the
// predicates would have said nothing about that — the mechanism existing is not the mechanism
// applying (`method.md` step 5).
//
// Bit `0x8` deliberately has NO arm here: the reference's watcher has none for it either
// (proven by enumerating all 122 instructions and 12 branches of `0x600440`). The pair is read
// together, and the `0x4` edge is what moves.
if prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group) != (cur.pvp, cur.is_pvp_ffa, &cur.faction_group)
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
}) {
script.fire_event("UNIT_FACTION", vec![tok()]);
}
@@ -1623,6 +1656,88 @@ fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
mod tests {
use super::*;
/// **The tapped bit fires `UNIT_FACTION`, and without this the verbs are decorative.**
///
/// `UnitIsTapped` answering correctly is only half of it. pfUI's grey-bar branch lives inside
/// `RefreshUnit`, which is event-driven — so a mob that becomes someone else's tap would stay
/// full-colour until something unrelated happened to repaint the frame. The predicate would be
/// right and the screen would be wrong, which no test of the predicate can see (`method.md`
/// step 5: the mechanism existing is not the mechanism applying).
///
/// The reference dispatches event id **29** — `UNIT_FACTION`, not a tapped-specific event —
/// from its `UNIT_DYNAMIC_FLAGS` watcher's bit-`0x4` arm (`0x6005a1 test al,4` ->
/// `0x6005b0 mov edx,0x1d` -> `0x515e50`; wow-re `ui/scratch/tapped-bits-and-unit-faction.md`,
/// controlled across all 37 fire sites image-wide). Reusing the faction event for a
/// non-faction field is not something anyone would invent, which is exactly why it is pinned.
///
/// Bit `0x8` has **no** arm, in the reference or here — proven there by enumerating all 122
/// instructions and 12 branches of the watcher. The negative half is asserted too, because
/// "fire on both, it's cheaper" is the obvious wrong simplification.
#[test]
fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() {
let fired = |prev: UnitState, cur: UnitState| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FACTION")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "target", Some(&prev), &cur);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
let base = UnitState {
exists: true,
has_object: true,
..Default::default()
};
// The 0x4 edge fires it.
assert_eq!(
fired(
base.clone(),
UnitState {
tapped: true,
..base.clone()
}
),
vec!["UNIT_FACTION".to_string()],
"a unit becoming tapped must repaint the frames that draw it"
);
// ...and back the other way, when the tap is released.
assert_eq!(
fired(
UnitState {
tapped: true,
..base.clone()
},
base.clone()
),
vec!["UNIT_FACTION".to_string()],
);
// The 0x8 edge fires NOTHING — the reference's watcher has no arm for it.
assert!(
fired(
UnitState {
tapped: true,
..base.clone()
},
UnitState {
tapped: true,
tapped_by_player: true,
..base.clone()
}
)
.is_empty(),
"bit 0x8 has no delta arm — firing on it would be an invention"
);
// Nothing moved: nothing fires. The control that stops this passing by firing always.
assert!(fired(base.clone(), base.clone()).is_empty());
}
/// **Report B304 — Quiver's range indicator read Dead Zone on a far target.**
///
/// Its Dead Zone rung is `IsActionInRange(AutoShot) ~= 1` **and**
+2 -2
View File
@@ -118,8 +118,8 @@ pub(crate) struct VPlateMode {
/// The persisted names of the two toggles — see [`VPlateMode`]. Welded to [`crate::cvars`]'s
/// registered table by its own test, so the spelling here and the row on the Nameplates page can
/// never drift apart.
pub(crate) const CVAR_ENEMIES: &str = "nameplateShowEnemies";
pub(crate) const CVAR_FRIENDS: &str = "nameplateShowFriends";
pub(crate) const CVAR_ENEMIES: &str = benilla_ui::script::CVAR_NAMEPLATE_ENEMIES;
pub(crate) const CVAR_FRIENDS: &str = benilla_ui::script::CVAR_NAMEPLATE_FRIENDS;
impl Default for VPlateMode {
fn default() -> Self {
+13 -13
View File
@@ -352,10 +352,10 @@ fn the_bid_and_buyout_gates_read_the_purse() {
// Nothing selected: both shut.
assert!(!s
.eval::<bool>("return BrowseBidButton:IsEnabled()")
.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return BrowseBuyoutButton:IsEnabled()")
.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap());
// Select the affordable row: both open, and the bid box is seated at the required bid.
@@ -366,12 +366,12 @@ fn the_bid_and_buyout_gates_read_the_purse() {
1
);
assert!(
s.eval::<bool>("return BrowseBidButton:IsEnabled()")
s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"10s is affordable on 2g"
);
assert!(
s.eval::<bool>("return BrowseBuyoutButton:IsEnabled()")
s.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap(),
"50s is affordable on 2g"
);
@@ -390,12 +390,12 @@ fn the_bid_and_buyout_gates_read_the_purse() {
2
);
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled()")
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"5g is not affordable on 2g"
);
assert!(
!s.eval::<bool>("return BrowseBuyoutButton:IsEnabled()")
!s.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap(),
"6g is not affordable on 2g"
);
@@ -410,7 +410,7 @@ fn the_bid_and_buyout_gates_read_the_purse() {
assert_eq!(bids[0].index, 1);
assert_eq!(bids[0].amount, 1000);
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled()")
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"the button disables itself so a second click cannot outrun the answer"
);
@@ -437,13 +437,13 @@ fn you_cannot_bid_on_your_own_auction() {
s.run("BrowseButton1:Click()").unwrap();
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled()")
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"the row's owner is the player"
);
// The buyout gate has no such leg — the reference lets you buy out your own listing.
assert!(s
.eval::<bool>("return BrowseBuyoutButton:IsEnabled()")
.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty());
}
@@ -510,7 +510,7 @@ fn the_create_gate_and_the_deposit() {
// Empty slot: shut, whatever the prices say.
assert!(
!s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled()")
!s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap(),
"no item in the sell slot"
);
@@ -540,7 +540,7 @@ fn the_create_gate_and_the_deposit() {
.unwrap();
s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap();
assert!(!s
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled()")
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return AuctionsBuyoutErrorText:IsShown()")
@@ -605,7 +605,7 @@ fn the_create_gate_and_the_deposit() {
"a 50s buyout under a 1g start price is an error, and it is shown"
);
assert!(!s
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled()")
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap());
// Clear the buyout and the form opens; pressing Create sends exactly what is on screen.
@@ -613,7 +613,7 @@ fn the_create_gate_and_the_deposit() {
.unwrap();
s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap();
assert!(
s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled()")
s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap(),
"an item, a 1g start price and no buyout is a valid auction"
);
+4 -4
View File
@@ -164,18 +164,18 @@ fn paging_math_enables_next_only_when_overflowing() {
// Page 1: prev disabled, next enabled.
assert!(!s
.eval::<bool>("return InboxPrevPageButton:IsEnabled()")
.eval::<bool>("return InboxPrevPageButton:IsEnabled() ~= 0")
.unwrap());
assert!(s
.eval::<bool>("return InboxNextPageButton:IsEnabled()")
.eval::<bool>("return InboxNextPageButton:IsEnabled() ~= 0")
.unwrap());
// Turn the page: prev enabled, next disabled (only 2 mails on page 2).
s.run("InboxNextPage()").unwrap();
assert!(s
.eval::<bool>("return InboxPrevPageButton:IsEnabled()")
.eval::<bool>("return InboxPrevPageButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return InboxNextPageButton:IsEnabled()")
.eval::<bool>("return InboxNextPageButton:IsEnabled() ~= 0")
.unwrap());
}
+1 -1
View File
@@ -223,7 +223,7 @@ fn accept_update_drives_the_column_glows() {
.eval::<bool>("return TradeHighlightPlayer:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return TradeFrameTradeButton:IsEnabled()")
.eval::<bool>("return TradeFrameTradeButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.take_errors().is_empty());
}
@@ -447,6 +447,21 @@ impl ObjectFields {
pub fn player_flags(&self) -> u32 {
self.get_u32(FIELD_PLAYER_FLAGS).unwrap_or(0)
}
/// **`PLAYER_FLAGS` bit `0x1` — this player leads their group.** The sole consumer image-wide
/// is `UnitIsPartyLeader 0x516210`'s first leg (`0x51624a`), out of 55 sites reading this
/// field, and it is the only test of the bit anywhere (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`).
///
/// The bit's POSITION is verified; the name `PLAYER_FLAGS_GROUP_LEADER` is inferred, earned
/// from two disjoint in-binary controls that pin its neighbours — `0x5eca0c` → `"CHAT_FLAG_AFK"`
/// and `0x5eca49` → `"CHAT_FLAG_DND"` fix bits 1 and 2, so this is the one before them.
///
/// **Server-sourced, and that is the point.** It answers for any held player, including a
/// stranger who leads their *own* party — something a comparison against our group's leader
/// GUID cannot express. It is one of the predicate's two legs, never the whole of it.
pub fn player_is_group_leader(&self) -> bool {
self.player_flags() & 0x1 != 0
}
/// Whether the player is a released **ghost** — `PLAYER_FLAGS_GHOST (0x10)`, set/cleared by
/// the ghost aura 8326 at release/resurrect (decision 0308 §1). NB a ghost's health is **1**
/// on the wire, so `unit_is_dead` is FALSE for a ghost: the three predicates are
@@ -400,6 +400,22 @@ impl ObjectFields {
pub fn unit_dynamic_flags(&self) -> u32 {
self.get_u32(FIELD_UNIT_DYNAMIC_FLAGS).unwrap_or(0)
}
/// `UNIT_DYNAMIC_FLAGS` bit `0x4` — **tapped**: this unit is someone's kill credit.
/// `UnitIsTapped 0x519c90` is exactly `0x519cd0 test BYTE PTR [eax+0x224],0x4` behind an
/// object-presence check, nothing else — no ownership, GUID compare, party/raid or health
/// conjunct anywhere in its body (wow-re `ui/scratch/tapped-bits-and-unit-faction.md`).
pub fn unit_tapped(&self) -> bool {
self.unit_dynamic_flags() & 0x4 != 0
}
/// `UNIT_DYNAMIC_FLAGS` bit `0x8` — **tapped by me** (`UnitIsTappedByPlayer 0x519d00`, the same
/// 108-byte body with the mask changed). Per-viewer: the server decides whose credit it is
/// before it ships the field, so the client reads it verbatim and does nothing else with it.
///
/// The pair is read together — `tapped && !tapped_by_player` is "someone ELSE's kill", the
/// grey-bar condition every unit-frame addon draws.
pub fn unit_tapped_by_player(&self) -> bool {
self.unit_dynamic_flags() & 0x8 != 0
}
/// `UNIT_DYNAMIC_FLAGS` bit `0x1` — **lootable by me** (the server strips the bit per viewer
/// before it ships — vmangos's "hide lootable animation for unallowed players"). Gates the
/// loot cursor, the right-click loot route, and the corpse sparkle (wow-re
+1 -1
View File
@@ -35,7 +35,7 @@ impl Loader<'_> {
self.call(wrapper, "SetClampedToScreen", true, dbg);
}
// `enableMouse` lands as the real EnableMouse call (the hit test gates on it —
// pointer.rs): a ref frame authored mouse-blocking (StaticPopup, WorldMapBlackout, the
// pointer.rs): a ref frame authored mouse-blocking (StaticPopup, BlackoutWorld, the
// minimap cluster) must actually capture. The loader ignored it for a while after the
// native landed — the stale "v1 gap" note here — which left those frames click-through
// and the minimap deaf to its ping click (0434 phase 6c's root cause).
@@ -95,6 +95,38 @@ pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
}
}
/// **[`string_arg`]'s shape-C partner**: coerce like the reference, and treat everything else as
/// **absent** rather than raising.
///
/// The pair is 1717's rule applied to string positions — *which* shape a position takes is settled
/// per binding, never generalised — and the model-pane and region constructors are the two ends of
/// it, verified together (wow-re `ui/scratch/xml-template-name-lookup.md` §5.2):
///
/// | position | fetch | a table there |
/// |---|---|---|
/// | `Model:SetModel` arg 1 | `0x6f3510` → `0x6f3690`, result **tested** | **raises** ([`string_arg`]) |
/// | `CreateFontString`/`CreateTexture` `name`, `layer` | `0x6f3510` → `0x6f3690`, result **not** tested | absent — this fn |
/// | `CreateFrame` `name`, `inherits` | `0x6f3690` **unguarded** | absent — this fn |
///
/// **The discriminator is not the argument's type; it is whether the binding TESTS its parser's
/// return.** That is the sentence `numeric-arg-coercion-law.md` §6 had wrong ("an unrecognised
/// string argument raises"), refuted by this round.
///
/// A **number** is accepted and stringified, exactly as `lua_tostring` coerces it — so
/// `CreateFrame("Frame", 5)` names the frame `"5"`. Callers that must *not* coerce a number (the
/// `lua_type(L,idx) == LUA_TSTRING` gate `CreateTexture`/`CreateFontString` put on their **fourth**
/// argument, and only there) test the tag themselves before calling this.
pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
match v {
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
.coerce_string(v.clone())
.ok()
.flatten()
.and_then(|s| s.to_str().ok().map(|t| t.to_owned())),
_ => None,
}
}
/// `GetBoolOrDefault` (`0x6f1c10`) — the reference's **boolean argument** coercion, which is *not*
/// Lua truthiness and gets three arms backwards from the obvious reading. Byte-VERIFIED: wow-re
/// `system/ui/scratch/action-bar-toggles.md` §2.1 re-derives the whole jump table at `0x6f1ce8`
+22 -1
View File
@@ -653,9 +653,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"Disable",
lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled = false))?,
)?;
// IsEnabled() → the NUMBER 1 or the NUMBER 0 — never a boolean, and never nil.
//
// wow-re `ui/scratch/button-enabled-state.md`, VERIFIED off the Button method table at
// `0x879d10`: `IsEnabled 0x7800b0` reads the three-valued STATE at `[obj+0x328]`
// (0 DISABLED / 1 NORMAL / 2 PUSHED), does `setne cl` for "not disabled", and pushes that as a
// NUMBER. So the reference's own `IsEnabled() == 0` and `== 1` tests (FriendsFrame.lua l.404,
// StaticPopup.lua l.713) are live code.
//
// **This answered a Lua boolean until now, and the difference is not cosmetic, because 0 is
// TRUTHY in Lua.** A caller writing the reference's `== 0` got false forever; a caller writing
// `IsEnabled() > 0` got "attempt to compare number with boolean" — which is where pfUI's
// `api/ui-widgets.lua:812` icon-fade died, taking the widget module with it. The divergence was
// known and worked around by hand in three of our own files (FriendsFrame.xml's
// `guildButtonDisabled` read BOTH spellings and said so); those workarounds go with this
// change, which is 1719's bar — a prior call revisited because a new measurement arrived.
//
// Slider carries its own `IsEnabled` (slider.rs) and is deliberately NOT changed here: the
// note above is the BUTTON method table, and whether the slider widget shares the contract is
// unverified. Assuming they match is exactly the guess this comment exists to have avoided.
m.set(
"IsEnabled",
lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled))?,
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| i64::from(bs.enabled))
})?,
)?;
// RegisterForClicks(...): replaces the button's registered-click set outright (the live API's
@@ -865,6 +865,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInventoryItemCooldown(unit, slot) → (start, duration, enable), the EQUIPPED twin of
// `GetContainerItemCooldown` (container.rs) and the same `GetTime`-clock convention.
//
// **It answers "no cooldown" and that is an absent FEED, not a pretended one.** benilla has no
// equipped-item cooldown source yet — the same gap `tooltip_item`'s `SetInventoryItem` already
// records where it leaves `hasCooldown` nil — so there is nothing to report and `(0, 0, 1)` is
// exactly what the container twin answers for a slot with no record. It is bound rather than
// left absent because the SOURCED `PaperDollFrame.lua` calls it UNCONDITIONALLY, once per
// `PaperDollItemSlotButton_Update`: without it every button built from
// `PaperDollItemSlotButtonTemplate` or `BagSlotButtonTemplate` raises inside its own OnLoad,
// which is how pfUI's bag bar would meet it. A raise there is not the loud-and-correct kind
// (1203) — it is a raise on a verb whose honest answer we already know.
//
// When an equipped-cooldown feed lands, this reads it the way the container twin reads
// `container_cooldowns`, and the CooldownFrame the doll slots already carry starts sweeping
// with no caller change.
g.set(
"GetInventoryItemCooldown",
lua.create_function(|_, (_token, _slot): (Option<String>, i64)| {
Ok((0.0f64, 0.0f64, 1i64))
})?,
)?;
// GetInventoryAlertStatus(index) → the region's alert status (`INV_ALERT_STATUS`: 0 none,
// 3 damaged/low-ammo, 4 broken; 1/2 = temp-enchant alerts, unfed) — DurabilityFrame's
// armor-guy read. Valid 1..=12 (the client's own 12-entry table; index 12 = low ammo, which
+74 -1
View File
@@ -204,6 +204,18 @@ fn value_to_string(v: &Value) -> Option<String> {
}
/// Register the `GetCVar`/`SetCVar`/`GetCVarDefault`/`RegisterCVar` globals.
/// The two nameplate toggles' registered CVar names — **defined here, in the crate that publishes
/// the four verbs that write them**, so the binding and the name cannot drift apart. The app reads
/// these consts rather than re-spelling the strings, and welds them to its registered table with
/// its own test.
///
/// They are the LATER era's names: 1.12 registers no nameplate CVar at all (censused — see
/// [`install_nameplate_verbs`]), so benilla's persistence takes the era spelling rather than
/// inventing one, the same posture as `autoLootDefault`.
pub const CVAR_NAMEPLATE_ENEMIES: &str = "nameplateShowEnemies";
/// The friendly-plate half of [`CVAR_NAMEPLATE_ENEMIES`].
pub const CVAR_NAMEPLATE_FRIENDS: &str = "nameplateShowFriends";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `RegisterCVar(name, value)` — declare a CVar the client does not ship (decision 1195).
//
@@ -378,7 +390,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
Ok(())
})?,
)
)?;
install_nameplate_verbs(lua)
}
/// **The four nameplate verbs** — `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480` (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, §5 four-worker round).
///
/// Each is a **10-byte body** over one of two setters, and the reference's shape is worth stating
/// because two obvious readings are wrong:
///
/// - **They read NO argument.** `ShowNameplates(false)` still shows. There is no `lua_gettop`, no
/// `lua_toboolean`, nothing — the verb *is* the value, which is why there are four of them
/// rather than two taking a flag.
/// - **They return ZERO Lua values** — not `nil`, nothing. (`eax` at the `ret` is the return
/// count, read off the VM's own C arm `0x6f61a8`.)
/// - They are **four separate bodies**, not a masked pair like `UnitIsTapped`/`ByPlayer`:
/// `0x489450` and `0x489470` are literally identical byte strings, rel32 included, over setters
/// that differ only in their `or`/`and` masks.
///
/// The engine state is bits `0x1` (enemy) and `0x8` (friend) of one runtime dword `[0xc4da34]`.
/// **There is no getter** — 15 references image-wide, all `.text`, and the 1153 `{name, fn}`
/// records across all 54 registrar tables intersect the readers in the empty set — so an addon can
/// set the state and never read it back, and neither can we.
///
/// **Where benilla diverges, deliberately and now knowingly.** 1.12 registers no nameplate CVar
/// (censused: `0x63db90`'s 214 sites, 207 literal names, zero matching `/plate/i`), and the engine
/// clears **both bits on every `EnterWorld`** — not once at boot. FrameXML replays them from its
/// own `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` saved variables. Our two CVars reproduce *FrameXML's
/// replay*, not the engine's state model: a benilla player's plates survive a zone-in because the
/// CVar store outlives it. That is a divergence in favour of the player, recorded here rather than
/// mistaken for fidelity.
fn install_nameplate_verbs(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (name, cvar, on) in [
("ShowNameplates", CVAR_NAMEPLATE_ENEMIES, true),
("HideNameplates", CVAR_NAMEPLATE_ENEMIES, false),
("ShowFriendNameplates", CVAR_NAMEPLATE_FRIENDS, true),
("HideFriendNameplates", CVAR_NAMEPLATE_FRIENDS, false),
] {
g.set(
name,
// `MultiValue` and not `()`: mlua would otherwise reject a call that passes anything,
// and the reference accepts and ignores every argument. Returning `MultiValue::new()`
// is the zero-value return, which is NOT the same as pushing nil.
lua.create_function(move |lua, _: mlua::MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let key = cvar.to_ascii_lowercase();
if let Some(slot) = model.cvars.get_mut(&key) {
let value = if on { "1" } else { "0" };
if slot.value != value {
slot.value = value.to_string();
let reg_name = slot.name.clone();
model.cvar_changes.push((reg_name, value.to_string()));
}
}
Ok(mlua::MultiValue::new())
})?,
)?;
}
Ok(())
}
#[cfg(test)]
+1 -1
View File
@@ -167,7 +167,7 @@ pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::MultisampleFormat;
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
+30 -9
View File
@@ -18,6 +18,7 @@ use std::ffi::c_void;
use mlua::{LightUserData, Lua, Table, Value};
use super::binding_abi::optional_string;
use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS};
use crate::layout::Point;
use crate::order::{DrawLayer, Strata};
@@ -378,16 +379,33 @@ fn create_frame(
) -> mlua::Result<Table> {
let frame_kind = frame_kind_from_str(&kind)
.ok_or_else(|| mlua::Error::runtime(format!("CreateFrame: unknown frame type '{kind}'")))?;
let name: Option<String> = match &name {
Some(Value::String(s)) => Some(s.to_str()?.to_string()),
_ => None,
};
let template: Option<String> = match &inherits {
Some(Value::String(s)) => Some(s.to_str()?.to_string()),
_ => None,
};
// **`name` and `inherits` are `lua_tostring` positions, and a NUMBER is a string to it.**
// `0x7060b0` reads both through `0x6f3690` with no type guard at all, so `CreateFrame("Frame",
// 5)` names the frame `"5"` — a `Value::String`-only match drops it (wow-re
// `ui/scratch/xml-template-name-lookup.md` §5.2).
//
// The fourth argument's quirk, which is real and reads like a bug in the client: `lua_tostring`
// runs at `0x70613f` **before** the `cmp 4` gate, and `0x6f7cb1 mov dword ptr [esi],0x4`
// retags the number's stack slot **in place** — so by the time the tag is tested it IS a
// string. `CreateFrame("Frame", nil, nil, 5)` therefore raises `Couldn't find inherited node
// "5"`, while `f:CreateTexture(nil, nil, 5)` silently ignores the 5, because the region
// constructors ask `lua_type` first and never coerce. Same value, opposite outcome, decided by
// argument order alone — which is why this is spelled out rather than shared with them.
let name: Option<String> = name.as_ref().and_then(|v| optional_string(lua, v));
let template: Option<String> = inherits.as_ref().and_then(|v| optional_string(lua, v));
// Resolve the parent handle (a wrapper table, or a name string) under a short read borrow.
//
// **The name-string arm is OURS, and the reference RAISES on it** — `0x7060b0` asks
// `lua_type` three times and `cmp eax,5`: a table is the parent, nil/absent means no parent,
// and a string takes the error leg (`xml-template-name-lookup.md` §5.2's per-position table).
//
// It is **parked, not pruned**, on the same footing as `TEXTURE_ONLY_METHODS`' tail three: the
// §5 established *that* it raises but did not quote the message, and inventing the text of a
// client error is exactly what decision 1721 was written about — a plausible string in the
// voice of a finding. Measured meanwhile: **zero** call sites pass a string parent, in the
// 109-folder corpus or in our own FrameXML, so the superset is unreachable in practice and
// removing it blind would buy nothing. Pruning it needs the message, not more confidence.
let parent_handle: Option<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &parent {
@@ -399,7 +417,10 @@ fn create_frame(
}
};
// A 4th argument that is present but not a string is the caller's bug, not a template.
// A 4th argument that is present but neither a string nor a number is **silently ignored by
// the reference** — `lua_tostring` returns NULL for it and the `cmp 4` gate then falls into
// the ordinary construction path. So this is a benilla-only diagnostic and stays a WARNING,
// never a raise: it tells a reader something was dropped without changing what runs.
if template.is_none() {
if let Some(v) = inherits.as_ref().filter(|v| !v.is_nil()) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -8,6 +8,7 @@ use std::collections::HashSet;
use mlua::{Function, Lua, MultiValue, Table, Value};
use crate::script::binding_abi::optional_string;
use crate::script::region::region_wrapper;
use crate::script::{Model, RegionData, REG_SCRIPTS, SCRIPT_KINDS};
use crate::widget::RegionKind;
@@ -140,18 +141,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// (CustomNameplates:437, a four-argument later-client form). Accepted rather than skipped
// because dropping it silently is the very class this fixes, and one real site is still a site.
m.set(
// **The three argument positions are NOT one rule, and the fourth is the odd one.** Verified
// together in wow-re `ui/scratch/xml-template-name-lookup.md` §5.2:
//
// · `name` and `layer` go through `0x6f3510` (is-number-**or**-string) then `0x6f3690`, and
// **the result is never tested** — so a table is simply absent (unnamed / the pre-staged
// default layer), and a NUMBER is accepted and stringified.
// · `inherits` is gated by a raw `lua_type(L, 4) == LUA_TSTRING` (`0x773d28` / `0x773b06`)
// asked BEFORE anything reads the slot — so a table AND a number are both ignored, and the
// non-string leg jumps to the very block the two template-HIT legs jump to, with both
// template handles NULL. It is the success path, not a bolted-on case.
//
// **Nothing here raises.** This mattered: benilla typed all three `Option<String>`, so mlua
// raised `bad argument #4: error converting Lua table to String` — and pfUI, the most-installed
// 1.12 addon, builds every buff-stack label as
// `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])`, passing the BUTTON as the third
// argument. The real client constructs it, ignores the argument, and logs nothing.
"CreateTexture",
lua.create_function(
|lua,
(this, name, layer, inherits): (
Table,
Option<String>,
Option<String>,
Option<String>,
)| {
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
let name = optional_string(lua, &name);
let layer = optional_string(lua, &layer);
let wrapper = create_region(lua, &this, RegionKind::Texture, name, layer)?;
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, from)?;
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, &from)?;
}
Ok(wrapper)
},
@@ -255,16 +268,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
m.set(
"CreateFontString",
lua.create_function(
|lua,
(this, name, layer, inherits): (
Table,
Option<String>,
Option<String>,
Option<String>,
)| {
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
let name = optional_string(lua, &name);
let layer = optional_string(lua, &layer);
let wrapper = create_region(lua, &this, RegionKind::FontString, name, layer)?;
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, from)?;
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, &from)?;
}
Ok(wrapper)
},
@@ -442,6 +451,23 @@ fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result
)))
}
/// **The `lua_type(L, idx) == LUA_TSTRING` gate, asked before anything reads the slot** — the
/// region constructors' `inherits` argument and nowhere else (`0x773d28` for `CreateFontString`,
/// `0x773b06` for `CreateTexture`).
///
/// Strictly narrower than [`optional_string`]: a **number is refused here**, because the tag is
/// tested raw with no coercion. That one-argument difference is the whole reason
/// `f:CreateTexture(nil, nil, 5)` silently ignores the 5 while `CreateFrame("Frame", nil, nil, 5)`
/// **raises** `Couldn't find inherited node "5"` — `CreateFrame` runs `lua_tostring(L, 4)` BEFORE
/// its own `cmp 4`, and that retags the number's stack slot in place, so it arrives as a string.
/// Same value, opposite outcome, from argument order alone.
fn strict_string_arg(v: &Value) -> Option<String> {
match v {
Value::String(s) => s.to_str().ok().map(|s| s.to_owned()),
_ => None,
}
}
fn create_region(
lua: &Lua,
this: &Table,
+17
View File
@@ -139,6 +139,22 @@ pub struct PartyState {
/// The party leader, on the Lua `GetPartyLeaderIndex` scale: `0` the player leads, `1..=4` that
/// `members` slot (1-based) leads.
pub leader_index: u32,
/// **The cached group-leader GUID** — the reference's `[0xbc75f8]:[0xbc75fc]` pair, `0` when
/// ungrouped. Fed straight from the wire's leader GUID, not derived from
/// [`Self::leader_index`].
///
/// It exists because `UnitIsPartyLeader`'s second leg is a GUID compare and an index cannot
/// stand in for it: the leg has to answer for a member whose object the client does **not**
/// hold — an out-of-range `party3`, any `raidN` — where there is no descriptor to read the
/// flag from, and it has to answer for the *resolved token's* GUID rather than for a slot.
///
/// **Compared WITHOUT a zero guard**, which is the load-bearing part and reads like a bug:
/// `IsPartyLeader 0x4e9130` short-circuits on a `0:0` cached leader, and
/// `UnitIsPartyLeader 0x516210` does not. So an unresolvable-but-non-raising argument (none,
/// `nil`, `""`, `"target"` with no target) resolves to `0:0`, matches the zeroed leader, and
/// the reference answers **`1` while solo**. A client that answers `nil` there is not
/// reproducing 1.12 (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
pub leader_guid: u64,
/// The **whole raid roster**, `GetRaidRosterInfo`'s 1-based array — empty outside a raid,
/// and **including the player** (the reference's array does; it is why `UnitInRaid("player")`
/// answers `1` in a raid and why this list is not `members`'s recipient-excluded shape).
@@ -763,6 +779,7 @@ mod tests {
},
],
leader_index: 1, // Alice (party1) leads
leader_guid: 0xA11CE,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
+2 -1
View File
@@ -81,7 +81,8 @@ fn button_state_textures_switch_with_interaction() {
// Disabled: disabled texture only, and no highlight even under the cursor.
s.run("StateBtn:Disable()").unwrap();
assert_eq!(visible(&s), vec!["Interface\\D.blp".to_string()]);
assert!(!s.eval::<bool>("return StateBtn:IsEnabled()").unwrap());
// 0, not false, and not nil — the number the real client answers.
assert_eq!(s.eval::<i64>("return StateBtn:IsEnabled()").unwrap(), 0);
s.run("StateBtn:Enable()").unwrap();
assert_eq!(s.eval::<String>("return StateBtn:GetText()").unwrap(), "Go");
assert!(s.errors().is_empty(), "{:?}", s.errors());
@@ -1551,3 +1551,74 @@ fn every_installed_region_method_lands_in_a_leaf() {
"installed on the region table but in NO leaf list, so no region can call them: {orphans:?}"
);
}
/// **The four nameplate verbs take no argument and return nothing.**
///
/// `ShowNameplates 0x489450` / `HideNameplates 0x489460` / `ShowFriendNameplates 0x489470` /
/// `HideFriendNameplates 0x489480` — four 10-byte bodies over two setters, differing only in an
/// `or`/`and` mask (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
///
/// Both halves of the shape are asserted because both are easy to invent differently:
///
/// - **No argument is read.** `ShowNameplates(false)` still shows — there is no `lua_gettop` and
/// no `lua_toboolean` in any of the four. That is *why* there are four verbs instead of two
/// taking a flag, and a binding typed `fn(bool)` would reject a call the reference accepts.
/// - **Zero return values, not nil.** `select('#', ...)` sees 0, which is observably different
/// from one nil for a caller that counts.
#[test]
fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() {
let mut s = script();
s.register_cvars([
(crate::script::CVAR_NAMEPLATE_ENEMIES, "1"),
(crate::script::CVAR_NAMEPLATE_FRIENDS, "0"),
]);
let get = |s: &crate::script::UiScript, n: &str| s.cvar(n);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("1")
);
// Each verb writes its own bit and leaves the other alone.
s.run("HideNameplates()").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("0")
);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
Some("0"),
"the friendly bit is a separate setter — hiding enemies must not touch it"
);
s.run("ShowFriendNameplates()").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
Some("1")
);
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("0"),
"...and back the other way"
);
// **An argument is accepted and IGNORED.** `ShowNameplates(false)` shows, which is the whole
// reason the reference ships four verbs rather than two taking a flag.
s.run("ShowNameplates(false)").unwrap();
assert_eq!(
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
Some("1"),
"the verb IS the value — a falsy argument does not invert it"
);
for call in ["HideNameplates(1, 2, 3)", "ShowFriendNameplates({})"] {
s.run(call)
.unwrap_or_else(|e| panic!("{call} must not raise: {e}"));
}
// ZERO return values, not one nil.
assert_eq!(
s.eval::<usize>("return select('#', ShowNameplates())")
.unwrap(),
0,
"zero values — observably different from nil for a caller that counts"
);
}
@@ -1096,3 +1096,107 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// **The three constructors' string arguments are FOUR different shapes, not one rule.**
///
/// Verified together in wow-re `ui/scratch/xml-template-name-lookup.md` §5.2 (§5 pair, orchestrator
/// byte-arbitration). The discriminator is **not** the argument's type — it is whether the binding
/// *tests* its parser's return, which is the sentence `numeric-arg-coercion-law.md` §6 had wrong:
///
/// | position | fetch | a table there | a number there |
/// |---|---|---|---|
/// | `CreateFontString`/`CreateTexture` `name`, `layer` | `0x6f3510` → `0x6f3690`, untested | absent | **accepted, stringified** |
/// | `CreateFontString`/`CreateTexture` `inherits` | raw `lua_type == LUA_TSTRING`, no coercion | ignored | **ignored** |
/// | `CreateFrame` `name`, `inherits` | `0x6f3690` **unguarded** | absent | **accepted, stringified** |
/// | `CreateFrame` `kind` | `0x6f3510`, tested | **raises** | accepted |
///
/// The last two rows are the same value taking opposite paths from argument order alone:
/// `CreateFrame` coerces at `0x70613f` *before* its `cmp 4`, and `0x6f7cb1` retags the number's
/// stack slot **in place**, so a number arrives at the gate already a string.
#[test]
fn the_constructors_string_arguments_are_four_shapes_not_one() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"host = CreateFrame("Frame", "ArgHost", UIParent)"#)
.unwrap();
// ── pfUI's line, verbatim. `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])` passes
// the BUTTON as `inheritsFrom`. The real client constructs it, ignores the argument, and
// logs nothing — this raised `bad argument #4` until 2026-08-30 and stopped pfUI, and with
// it pfQuest, pfQuest-turtle and ShaguDPS.
s.run(r#"fs = ArgHost:CreateFontString(nil, "OVERLAY", ArgHost)"#)
.unwrap_or_else(|e| panic!("a table in `inherits` must be ignored, not raise: {e}"));
assert_eq!(
s.eval::<String>("return type(fs)").unwrap(),
"table",
"...and the FontString is really constructed"
);
// Every other non-string in every region-constructor position: also silent, also constructed.
for pos in ["{}, \"OVERLAY\"", "nil, {}", "true, false"] {
for ctor in ["CreateFontString", "CreateTexture"] {
s.run(&format!("r = ArgHost:{ctor}({pos})"))
.unwrap_or_else(|e| panic!("{ctor}({pos}) must not raise: {e}"));
assert_eq!(s.eval::<String>("return type(r)").unwrap(), "table");
}
}
// ── A NUMBER is a string to `lua_isstring` — so it NAMES the region.
s.run("named = ArgHost:CreateTexture(4242)").unwrap();
assert_eq!(
s.eval::<String>("return named:GetName()").unwrap(),
"4242",
"a number in `name` is stringified, not dropped"
);
// ...but NOT in `inherits`, whose gate reads the raw tag and never coerces. A number there is
// ignored, where the same number in CreateFrame's fourth argument would raise.
s.run("ArgHost:CreateTexture(nil, nil, 5)")
.unwrap_or_else(|e| panic!("a number in a region ctor's `inherits` is ignored: {e}"));
// ── CreateFrame: `name` coerces the same way.
s.run("nf = CreateFrame(\"Frame\", 77)").unwrap();
assert_eq!(
s.eval::<String>("return nf:GetName()").unwrap(),
"77",
"CreateFrame reads `name` through an UNGUARDED lua_tostring"
);
// ...and an unresolvable `inherits` still raises, which is the pre-existing contract and the
// control for the changes above: loosening the OTHER positions must not have loosened this.
assert!(
s.run(r#"CreateFrame("Frame", nil, nil, "NoSuchTemplateAnywhere")"#)
.is_err(),
"an unresolvable template name still raises — the miss branch is luaL_error"
);
// ── The layer argument is PERMISSIVE, and an unrecognised one is ARTWORK, not an error.
// `0x6f18b0` returns 0 with its out-param unwritten and neither constructor reads the
// result, so the pre-staged ARTWORK (2) stands. Matching is case-insensitive.
s.run(r#"lay = ArgHost:CreateTexture(nil, "not-a-layer")"#)
.unwrap_or_else(|e| panic!("an unrecognised layer must not raise: {e}"));
// Read off the arena — 1.12 publishes no `GetDrawLayer`, so there is no Lua getter to ask,
// the same reason the sequence scrub is read this way in `modelframe`'s tests.
let layer_of = |s: &UiScript, name: &str| {
let lua = s.lua();
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
let id = *model.region_names.get(name).expect("published region");
let rh = *model.id_to_region.get(&id).expect("live region");
model.arena.region(rh).expect("region").draw_layer
};
s.run(r#"ArgHost:CreateTexture("LayLower", "overlay")"#)
.unwrap();
s.run(r#"ArgHost:CreateTexture("LayUpper", "OVERLAY")"#)
.unwrap();
s.run(r#"ArgHost:CreateTexture("LayJunk", "not-a-layer")"#)
.unwrap();
assert_eq!(
layer_of(&s, "LayLower"),
layer_of(&s, "LayUpper"),
"layer matching is case-insensitive (SStrCmpI 0x64a4c0)"
);
assert_eq!(
layer_of(&s, "LayJunk"),
crate::order::DrawLayer::Artwork,
"an unrecognised layer leaves the PRE-STAGED default standing — ARTWORK (2), not an error \
and not BACKGROUND: `0x6f18b0` returns 0 with its out-param unwritten and neither \
constructor reads the result"
);
}
@@ -224,6 +224,18 @@ fn set_inventory_item_renders_full_outside_compare() {
assert(quoted, "full render keeps the description")
assert(tt:SetInventoryItem("player", 5) == nil, "empty slot answers nil")
assert(tt:SetInventoryItem("target", 16) == nil, "self-only feed")
-- THREE returns, on every leg. The reference's own PaperDollFrame.lua:741 destructures
-- `hasItem, hasCooldown, repairCost` unconditionally and only then tests hasItem, so an
-- empty slot answering ONE value hands its caller a nil where a number belongs. pfUI's
-- durability scan (panel.lua:499) does `totalRep + repCost` with no guard at all and died
-- exactly there.
local n = { tt:SetInventoryItem("player", 16) }
assert(table.getn(n) == 3, "occupied: three returns, got " .. table.getn(n))
assert(n[3] == 0, "repairCost is a NUMBER — the reference always pushes one; 0 INTERIM")
local e = { tt:SetInventoryItem("player", 5) }
assert(table.getn(e) == 3, "empty: three returns too, got " .. table.getn(e))
assert(e[1] == nil and e[3] == 0, "empty slot: no item, but still a numeric repairCost")
"#,
)
.unwrap();
@@ -340,7 +340,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GameTooltip:SetInventoryItem(unit, slot) → hasItem, hasCooldown — the equipped-slot hover
// GameTooltip:SetInventoryItem(unit, slot) → hasItem, hasCooldown, repairCost — the equipped-slot hover
// (paperdoll slots, buff-frame weapon enchants) and the shopping-compare listener's render
// (ref PaperDollFrame.lua:626). **Unit-keyed** through `Model::inv_slot`, the same router the
// `GetInventoryItem*` getters use: `"player"` from the self feed, the inspected token from the
@@ -350,6 +350,25 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// tooltip this renders the byte law's compare shape; the arm is consumed either way.
// hasCooldown is nil INTERIM (no equipped-cooldown feed yet — its truthiness gates the ref's
// re-poll only).
//
// **THREE returns, not two.** The reference's own `PaperDollFrame.lua:741` reads
// `local hasItem, hasCooldown, repairCost = GameTooltip:SetInventoryItem("player", this:GetID())`
// — Blizzard's file, out of the shipped `patch.MPQ`, and its very next use is
// `if ( InRepairMode() and repairCost and (repairCost > 0) ) then ... SetTooltipMoney(...)`.
// (l.632 in the same file reads only two, which is a caller not needing the third, not a
// different contract.) We pushed two, so pfUI's durability scan died on
// `panel.lua:499: attempt to perform arithmetic on local 'repCost' (a nil value)`.
//
// `repairCost` is **0 INTERIM**, the same posture and the same reason as `SetBagItem`'s below:
// the per-item repair feed is the paper-doll arc's and does not exist yet. Zero rather than nil
// because the reference **always pushes a number** there — wow-re `tooltip-money.md` §return-2
// reads `fild [ebp-0x30]; call 0x6f3810` on `SetBagItem`'s unconditional leg — and an
// undamaged item's cost genuinely is 0, so the interim value is a *real* value of the domain
// rather than a stand-in. Every reference consumer guards on `> 0`.
//
// Arity provenance, stated because it differs from the rest of this module: the *three* is the
// reference's own consumer, not a byte-read `mov eax, 3`. `SetBagItem`'s two IS byte-verified
// (`mov eax,2` @0x534985). A carve of `0x532ee0`'s tail would upgrade this line.
m.set(
"SetInventoryItem",
lua.create_function(|lua, (this, unit, slot): (Table, String, usize)| {
@@ -394,7 +413,19 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
});
match view {
Some((id, name, q, inst)) => (id, name, q, inst, armed),
None => return Ok(MultiValue::from_vec(vec![Value::Nil])),
// An EMPTY slot answers `nil` for hasItem — and still pushes the other two,
// because the reference's own caller destructures all three unconditionally
// and only then tests `hasItem`. Answering one value here would hand
// `PaperDollItemSlotButton_OnEnter` a nil `repairCost` on every empty slot,
// which its `repairCost and (repairCost > 0)` guard survives but pfUI's bare
// `totalRep + repCost` does not.
None => {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
]))
}
}
};
{
@@ -421,7 +452,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
}
show_or_hide_empty(lua, h);
Ok(MultiValue::from_vec(vec![Value::Integer(1), Value::Nil]))
Ok(MultiValue::from_vec(vec![
Value::Integer(1),
Value::Nil,
Value::Integer(0),
]))
})?,
)?;
@@ -32,6 +32,123 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `UnitIsVisible(unit)` — `0x516030`, and it is **object presence, nothing else**:
// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. 57 bytes, one branch, no field
// read and no comparison beyond `test eax,eax`.
//
// **Not a synonym for `UnitExists`, and neither implies the other.** An out-of-range party
// member has a roster entry and no object: `UnitExists` = 1 through its GUID fallback,
// `UnitIsVisible` = nil. That pair is the branch pfUI takes seven times — most visibly
// `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)`, which chooses between a 3D
// portrait and a flat one.
//
// **The return is the NUMBER 1, never a boolean** — `0x6f3810` writes tag 3 with the operands
// of `1.0` on the true leg, `lua_pushnil` on the false one. `UnitExists` above answers a Rust
// `bool` and so hands Lua `true`/`false`; that is its own pre-existing question, and matching
// it here would have been the wrong kind of consistency.
g.set(
"UnitIsVisible",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.has_object)? {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// `UnitIsTapped(unit)` / `UnitIsTappedByPlayer(unit)` — `0x519c90` / `0x519d00`, a masked-byte
// pair (108 bytes each; only the mask and the `Usage:` string differ). Each is
// `object present && (UNIT_DYNAMIC_FLAGS & mask)` and nothing else: no ownership, no GUID
// compare, no party/raid or health conjunct anywhere in either body.
//
// **Shape A, unlike `UnitIsVisible` directly above** — these two carry an `lua_isstring` gate
// and a `Usage:` `luaL_error`, where their neighbour has none. Two adjacent verbs in one
// family with opposite argument shapes is exactly why 1717's taxonomy is settled per binding;
// inheriting the sibling's shape here would have been wrong in the quiet direction.
//
// The reference raises TWO different messages — `Usage:` for a non-string/non-number, and the
// resolver's `"Unknown unit name: %s"` for a bad token (and for any NUMBER, since the
// `lua_isstring` gate admits tag 3 and hands the resolver `"5"`). `check_unit_token` inside
// `with_unit` is the second of those; the first is the `?` on the argument type below.
// The gate is the BINDING's, not `with_unit`'s: `check_unit_token` lets a nil through by
// design, because that is right for `UnitExists`, `UnitIsVisible` and most of this family. It
// is wrong for exactly these two, so the shape-A test happens here, before the shared path.
for (name, usage, by_player) in [
("UnitIsTapped", r#"Usage: UnitIsTapped("unit")"#, false),
(
"UnitIsTappedByPlayer",
r#"Usage: UnitIsTappedByPlayer("unit")"#,
true,
),
] {
g.set(
name,
lua.create_function(move |lua, token: Value| {
// Shape A: a string OR a number passes (`lua_isstring` admits tag 3, and the
// client hands the resolver `"5"` — which then raises `Unknown unit name: 5`,
// the family's SECOND message). Everything else — nil, absent, boolean, table,
// function — is the `Usage:` raise.
let token = Some(crate::script::binding_abi::string_arg(lua, token, usage)?);
let hit = with_unit(lua, &token, false, |u| {
if by_player {
u.tapped_by_player
} else {
u.tapped
}
})?;
// One value, and it is the NUMBER 1 or nil — never a boolean, the same shape the
// rest of this family answers in.
Ok(if hit { Value::Integer(1) } else { Value::Nil })
})?,
)?;
}
// `UnitIsPartyLeader(unit)` — `0x516210`. **Two legs, ORed, covering disjoint failures:**
//
// o = ObjPtr(resolve(token), TYPEMASK_PLAYER) -- 0x10, not this family's 8
// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(token) == g_groupLeaderGuid
//
// The descriptor leg answers for any held player — including a stranger who leads their OWN
// party, which a comparison against our group's leader can never express. The GUID leg answers
// for a group member whose object the client does not hold (an out-of-range `party3`, any
// `raidN`), where there is no descriptor to read. Neither is sufficient alone, which is why
// this is not derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however it is
// arranged (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED).
//
// **No zero guard, and that is deliberate.** `IsPartyLeader 0x4e9130` short-circuits on a
// `0:0` cached leader; this one does not. An unresolvable-but-non-raising token resolves to
// `0:0`, which equals the zeroed leader while ungrouped — so `UnitIsPartyLeader(nil)` answers
// **1 solo**. It reads like a bug and it is the behaviour; answering nil there would be the
// divergence.
//
// Shape C with a shape-A tail: no `lua_isstring` gate and no `Usage:` of its own, but a bad
// token — and any Lua NUMBER, which the resolver stringifies first — raises
// `"Unknown unit name: %s"` from `check_unit_token`. One value on both legs, the number 1 or
// nil, never a boolean.
g.set(
"UnitIsPartyLeader",
lua.create_function(|lua, token: Option<String>| {
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let by_flag = token
.as_ref()
.and_then(|t| model.unit(t))
.is_some_and(|u| u.group_leader);
// The GUID leg. A token with no snapshot resolves to GUID 0 — the reference's `0:0` —
// and 0 == the zeroed leader is exactly the solo case above.
let guid = token
.as_ref()
.and_then(|t| model.unit(t))
.map_or(0, |u| u.guid);
Ok(if by_flag || guid == model.party.leader_guid {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
g.set(
"UnitName",
lua.create_function(|lua, token: Option<String>| {
+59
View File
@@ -30,7 +30,66 @@ use super::Model;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct UnitState {
/// Whether the unit exists (`UnitExists`). A token with no snapshot is treated as `false` too.
///
/// **Stays TRUE for an out-of-range party member** — that is the whole point of the roster
/// fallback, and it is what separates this field from [`Self::has_object`].
///
/// *Named gap, second order:* 1.12's `UnitExists 0x515fb0` is a **conjunction** — the object
/// lookup AND a virtual `[edx+0x58]` that resolves to `IsSelectable 0x60be60`
/// (`!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == you`) — falling back to the
/// GUID-only roster test on a miss. We model the fallback and not the conjunct, so a
/// not-selectable unit created by someone else reads `exists` here and `nil` there. No corpus
/// caller reaches it; recorded rather than folded in (wow-re
/// `ui/scratch/unitisvisible-object-presence.md`).
pub exists: bool,
/// **`UnitIsVisible` — the client holds a live object for this token, and nothing else.**
///
/// The reference is 57 bytes with one branch (`0x516030`):
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. Its body reads **zero** object
/// fields, makes **zero** comparisons beyond `test eax,eax`, and contains **zero** float
/// opcodes — so there is no distance term, no radius, no alpha/fade/interpolation flag and no
/// "known to the client" bit in it. Addons read it as a range gate because **the range test is
/// the server's**: `SMSG_DESTROY_OBJECT` and the OUT_OF_RANGE demotion unlink the object from
/// the manager index this query searches, so the client answers with the presence of the
/// object the server chose to send.
///
/// Distinct from [`Self::exists`], and neither implies the other. The one that matters here:
/// an out-of-range party member is `exists == true`, `has_object == false` — which is exactly
/// pfUI's `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)` portrait branch.
///
/// The app already computed this and did not publish it: `ui_party::feed`'s
/// `member_unit_state` takes `store: Option<&ObjectStore>` and its own comment calls that
/// argument "the reference's `0x468460`". Every snapshot built from a live store sets it
/// (`ui_unit::snapshot`); the roster-record leg and a token with no snapshot leave it `false`.
///
/// **One presence field serves three verbs** — `UnitIsVisible`, and the object-presence
/// conjunct of both tapped predicates below.
pub has_object: bool,
/// `UnitIsTapped` — `UNIT_DYNAMIC_FLAGS` (UpdateField **143**) bit `0x4`: this unit is
/// someone's kill credit. `0x519c90` is `test BYTE PTR [eax+0x224],0x4` behind the same
/// object-presence check [`Self::has_object`] carries, and **nothing else** — no ownership, no
/// GUID compare, no party/raid or health conjunct anywhere in the body (wow-re
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
///
/// Set only on the live-descriptor leg, so an out-of-range unit reads `false` — which is the
/// object-presence conjunct, for free, by the same route `has_object` gets it.
pub tapped: bool,
/// **`UnitIsPartyLeader`'s FIRST leg** — the server's `PLAYER_FLAGS & 0x1` on this unit's own
/// descriptor, restricted to players (`TYPEMASK_PLAYER`, `0x10` — **not** this family's usual
/// `8`, which is the one difference the predicate makes to the object lookup).
///
/// Not derived client-side, and not the same question as "leads MY group": it is true for a
/// stranger who leads their own party, which no comparison against our group's leader GUID can
/// express. The second leg is that comparison, in `script::party` — the two cover disjoint
/// failures and the reference ORs them (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`).
pub group_leader: bool,
/// `UnitIsTappedByPlayer` — the same field's bit `0x8` (`0x519d00`, a masked-byte clone of its
/// sibling: 108 bytes each, differing only in the mask and the `Usage:` string).
///
/// Read as a **pair**: `tapped && !tapped_by_player` is "someone else's kill", which is the
/// grey-bar condition unit-frame addons draw (pfUI `api/unitframes.lua:2012`).
pub tapped_by_player: bool,
/// The unit's name (`UnitName` → the string, or nil). `None` while the app's name-query cache
/// hasn't answered (or the server doesn't know the unit).
pub name: Option<String>,
+285 -1
View File
@@ -1,6 +1,6 @@
//! The `Unit*` binding tests (the parent module is the unit under test).
use crate::script::{UiScript, UnitState};
use crate::script::{PartyState, UiScript, UnitState};
fn player() -> UnitState {
UnitState {
@@ -958,3 +958,287 @@ fn a_multibyte_unit_token_raises_rather_than_panicking() {
"Benilla"
);
}
/// **`UnitIsVisible` is object presence, and it is NOT `UnitExists`** — the pair an out-of-range
/// party member separates, and the branch pfUI takes seven times.
///
/// `0x516030` is 57 bytes with one branch:
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. No field read, no comparison
/// beyond `test eax,eax`, no float opcode — so no distance, no radius, no visibility flag. The
/// range test is the *server's*: the out-of-range demotion unlinks the object from the very
/// manager index this query searches (wow-re `ui/scratch/unitisvisible-object-presence.md`).
///
/// The state that backs it is [`UnitState::has_object`], which `ui_party::feed`'s
/// `member_unit_state` has always branched on — its `store: Option<&ObjectStore>` argument, whose
/// own comment names `0x468460`. This test is what stops the two fields being conflated back
/// together, because in every *in-range* case they agree and only the out-of-range one tells them
/// apart.
#[test]
fn unit_is_visible_is_object_presence_not_existence() {
let mut s = UiScript::new().unwrap();
// In range: the object is held, both answer yes.
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsVisible("party1")"#).unwrap(),
1,
"a held object is visible — and the return is the NUMBER 1, never a boolean"
);
// **Out of range: the roster still has them, the object manager does not.** This is the row
// that matters; `exists` MUST stay true here, because that is what UnitExists's own GUID
// fallback answers, and folding the two fields into one would silently break it.
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsVisible("party1")"#)
.unwrap(),
None,
"no object -> nil, even though the token still exists"
);
assert!(
s.eval::<bool>(r#"return UnitExists("party1") and true or false"#)
.unwrap(),
"...and UnitExists must NOT have moved with it — the roster fallback is the difference"
);
// pfUI's actual line (`unitframes.lua:1873`), which chooses a flat portrait over a 3D one.
assert!(
s.eval::<bool>(
r#"return (not UnitIsVisible("party1") or not UnitIsConnected("party1")) and true or false"#
)
.unwrap(),
"the portrait branch fires for an out-of-range member"
);
// A token with no snapshot at all: nil, via the same "no snapshot" path every predicate uses.
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsVisible("party4")"#)
.unwrap(),
None
);
}
/// **The tapped pair, and the two ways it goes wrong.**
///
/// `UnitIsTapped 0x519c90` / `UnitIsTappedByPlayer 0x519d00` are a masked-byte pair — 108 bytes
/// each, differing only in the `UNIT_DYNAMIC_FLAGS` mask (`0x4`/`0x8`, UpdateField 143) and the
/// `Usage:` string. Each is `object present && (flags & mask)` and nothing else: no ownership, no
/// GUID compare, no party/raid or health conjunct (wow-re
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
///
/// Asserted here because both are easy to get wrong in a way that still looks right:
///
/// - **Shape A, not shape C.** `UnitIsVisible` beside them has *no* `Usage:` raise; these two do.
/// Inheriting the neighbour's shape would fail quietly, in the permissive direction.
/// - **The pair is read as a conjunction.** `tapped && not tappedByPlayer` — someone *else's* kill
/// — is the condition addons actually draw, and either bit alone says nothing useful.
#[test]
fn the_tapped_pair_is_two_masks_of_one_field_and_raises_on_a_bad_argument() {
let mut s = UiScript::new().unwrap();
let set = |s: &mut UiScript, tapped, by_player| {
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
tapped,
tapped_by_player: by_player,
..Default::default()
}),
);
};
// Untapped: both nil.
set(&mut s, false, false);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsTapped("target")"#)
.unwrap(),
None
);
// Tapped by someone ELSE — the grey-bar case, pfUI `api/unitframes.lua:2012`.
set(&mut s, true, false);
assert_eq!(
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
1,
"the return is the NUMBER 1, never a boolean"
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsTappedByPlayer("target")"#)
.unwrap(),
None
);
assert!(
s.eval::<bool>(
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
)
.unwrap(),
"someone else's kill — the conjunction addons actually draw"
);
// Tapped by ME: both set, so the grey-bar branch must NOT fire.
set(&mut s, true, true);
assert!(
!s.eval::<bool>(
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
)
.unwrap(),
"my own tap is not greyed"
);
// Object presence is a conjunct of BOTH: an out-of-range unit is neither, whatever the bits
// say — and the bits cannot even be read, because there is no descriptor to read them from.
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: false,
tapped: true,
tapped_by_player: false,
..Default::default()
}),
);
// (The feed can never build that combination — `ui_unit::snapshot` sets all three together —
// but the binding is asserted against it anyway, because a future feed that forgot would
// otherwise report a tapped unit the object manager does not hold.)
assert_eq!(
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
1,
"the binding reads the field it is given; the conjunct is enforced at the FEED, and this \
assertion records which of the two owns it"
);
// SHAPE A. `UnitIsVisible` next door has no `Usage:` raise; these do — and **nil is in the
// list**, which is the half that does not come free: the shared `check_unit_token` lets a nil
// through by design (right for `UnitExists` and `UnitIsVisible`, wrong for these two), so the
// gate has to live in the binding. This assertion is what found that; the original spelling
// used `print`, which is not defined in our VM and so was quietly testing nil all along.
for bad in ["{}", "true", "nil", "", "function() end"] {
assert!(
s.run(&format!("UnitIsTapped({bad})")).is_err(),
"UnitIsTapped({bad}) must raise — shape A, not the neighbour's shape C"
);
assert!(
s.run(&format!("UnitIsTappedByPlayer({bad})")).is_err(),
"UnitIsTappedByPlayer({bad}) must raise too — the pair shares its gate"
);
}
}
/// **`UnitIsPartyLeader` is two legs ORed, and the solo case answers 1.**
///
/// `0x516210` is
///
/// ```text
/// o = ObjPtr(resolve(t), TYPEMASK_PLAYER)
/// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(t) == g_groupLeaderGuid
/// ```
///
/// and it is **not** derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however arranged —
/// the two legs cover disjoint failures (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED). Each leg is asserted with the
/// other one dead, because either alone looks sufficient until the case it cannot reach.
#[test]
fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
let mut s = UiScript::new().unwrap();
const ME: u64 = 0x1111;
const THEM: u64 = 0x2222;
// ── LEG 1 alone: the descriptor flag, with the leader GUID matching nobody.
// This is the leg that answers for a STRANGER who leads their own party — something no
// comparison against our group's leader could ever express.
s.set_party(PartyState {
leader_guid: 0x9999,
..Default::default()
});
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
guid: THEM,
group_leader: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
.unwrap(),
1,
"the server's PLAYER_FLAGS bit answers on its own"
);
// ── LEG 2 alone: no descriptor flag, but the resolved GUID IS our leader. This is the
// out-of-range member — the client holds no object, so leg 1 has nothing to read.
s.set_party(PartyState {
leader_guid: THEM,
..Default::default()
});
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: false,
guid: THEM,
group_leader: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("party1")"#)
.unwrap(),
1,
"the GUID compare answers where there is no descriptor to read a flag from"
);
// ── Neither leg: a grouped member who does not lead.
s.set_unit(
"party2",
Some(UnitState {
exists: true,
has_object: true,
guid: ME,
group_leader: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsPartyLeader("party2")"#)
.unwrap(),
None
);
// ── **NO ZERO GUARD.** Ungrouped, the cached leader is 0 and an unresolvable-but-non-raising
// token resolves to 0 — so they match and the answer is 1. `IsPartyLeader 0x4e9130`
// short-circuits on a `0:0` leader and `0x516210` does not; that asymmetry is the finding,
// and answering nil here would be the divergence, however much it reads like a bug.
s.set_party(PartyState::default());
assert_eq!(
s.eval::<i64>("return UnitIsPartyLeader(nil)").unwrap(),
1,
"solo + unresolvable token: 0 == 0, and the reference answers 1"
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
.unwrap(),
1,
"...and so does a token with no snapshot, by the same route"
);
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
}
+79 -6
View File
@@ -224,11 +224,27 @@ pub const MAX_MODEL_CHAIN: usize = 8;
/// an effect multiplies into its vertex alpha; **an effect passes its OWN instance root** and the
/// chain supplies everything above it.
///
/// Missing components read as opaque throughout: an entity with no `ModelAlpha` and no parent is
/// `1.0`, which is why the steady-state world pays nothing for this.
/// Missing components read as opaque throughout: an entity with no alpha and no parent is `1.0`,
/// which is why the steady-state world pays nothing for this.
///
/// **A link the engine does not compose reads its DECLARED fade.** [`ModelAlpha`] is published by
/// [`publish_model_alpha`], which runs over streamed objects — a model tree the engine never
/// composes (a booth bake, which has no appear ramp, no despawn ramp and no self feather, and whose
/// value is final the moment it is built) therefore carries only the [`ModelFade`] its owner
/// declared. Reading that when there is no published alpha is what lets one law cover both: the
/// game still only ever declares (1164), and the walk is still the reference's `0x714000` recursion.
/// A streamed unit carries both and the published one wins, because that one has the ramps folded in.
#[derive(bevy::ecs::system::SystemParam)]
pub struct ModelAlphas<'w, 's> {
chain: Query<'w, 's, (Option<&'static ModelAlpha>, Option<&'static ParentModel>)>,
chain: Query<
'w,
's,
(
Option<&'static ModelAlpha>,
Option<&'static ModelFade>,
Option<&'static ParentModel>,
),
>,
}
impl ModelAlphas<'_, '_> {
@@ -238,11 +254,12 @@ impl ModelAlphas<'_, '_> {
let mut alpha = 1.0;
let mut at = instance;
for _ in 0..MAX_MODEL_CHAIN {
let Ok((own, parent)) = self.chain.get(at) else {
let Ok((published, declared, parent)) = self.chain.get(at) else {
break;
};
if let Some(a) = own {
alpha *= a.0;
// Published wins: it is the declared fade with this frame's ramps already folded in.
if let Some(a) = published.map(|a| a.0).or(declared.map(|d| d.0)) {
alpha *= a;
}
match parent {
Some(p) => at = p.0,
@@ -883,6 +900,62 @@ pub fn plugin(app: &mut App) {
);
}
/// The composition walk, over a tree the engine never publishes an alpha for — a booth bake, whose
/// value is final at build time.
#[cfg(test)]
mod chain_tests {
use super::*;
fn composed(app: &mut App, at: Entity) -> f32 {
let mut sys = bevy::ecs::system::SystemState::<ModelAlphas>::new(app.world_mut());
let alphas = sys.get(app.world());
alphas.get(at)
}
/// A model the engine composes nothing for reads its **declared** fade — the half that was
/// missing when a ghosted character-select body's wisps drew at full strength over it. And a
/// child composes onto it, which is the reference's `0x714000` recursion
/// (`child+0x19c = parent+0x19c × child+0x180`).
#[test]
fn an_uncomposed_tree_reads_its_declared_fade_and_children_compose_onto_it() {
let mut app = App::new();
let parent = app.world_mut().spawn(ModelFade(0.5)).id();
let child = app
.world_mut()
.spawn((ModelFade(0.5), ParentModel(parent)))
.id();
let plain = app.world_mut().spawn(ParentModel(parent)).id();
assert_eq!(composed(&mut app, parent), 0.5);
assert_eq!(composed(&mut app, child), 0.25, "0.5 × 0.5, the recursion");
assert_eq!(
composed(&mut app, plain),
0.5,
"a child declaring nothing is its parent's"
);
}
/// The PUBLISHED alpha wins where both exist — a streamed unit, whose published value already
/// has this frame's appear/despawn/self-feather ramps folded into the declared one. Reading
/// both would apply the declared fade twice.
#[test]
fn a_published_alpha_beats_the_declaration_it_was_computed_from() {
let mut app = App::new();
let unit = app
.world_mut()
.spawn((ModelFade(0.5), ModelAlpha(0.2)))
.id();
assert_eq!(composed(&mut app, unit), 0.2);
}
/// The steady-state world still pays nothing: an entity with neither is opaque.
#[test]
fn an_entity_with_neither_is_opaque() {
let mut app = App::new();
let bare = app.world_mut().spawn_empty().id();
assert_eq!(composed(&mut app, bare), 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;