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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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`
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>| {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user