the target-of-target frame, and talents can be unlearned again

- the target-of-target frame and its option: 1.12's frame with its five display modes, fed one hop off the selected unit's own target field through the same read `/assist` runs, in four feeds (snapshot and transitions, the debuff buttons, the portrait booth, TargetUnit's click arm); ships off, as 1.12 does, on its own pair of saved globals
- talents can be unlearned again: the trainer's `MSG_TALENT_WIPE_CONFIRM` had no handler since the wipe flow was deferred, so the gossip line closed the menu and did nothing — the question is an NPC session (walking away retracts it), the popup shows the cost on the StaticPopup money variant, Accept answers it behind the client's own purse check
- Sprint's sparkles hang in the air behind you along the trail instead of collapsing into your torso: ride-vs-trail is the emitter's own file flag 0x10, not the host class — the finding wow-re returned eighteen days ago and nobody folded; the particle dump names the two flag bits it had been mis-reporting
- `/sleep` takes both movement masks (the strictest posture), correcting the day's sit gate which let you lie down on a lake bottom while `/sit` was refused; a Lua-driven selection (TargetUnit on an attackable token) starts the swing exactly as a click does, through the one select path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:40:16 +07:00
co-authored by Claude Fable 5
parent 55b2713f24
commit 3d10e7415b
96 changed files with 2639 additions and 277 deletions
+71 -4
View File
@@ -629,8 +629,13 @@
-- left to right: A gives Status Text (CheckButton2), B gives Show Cloak → Show Helm
-- (31 → 20, sitting under the name toggles that live on our Nameplates page), C gives
-- the 42 → 39 → 66 chain. Help (29) closes the page, as before.
-- The target-of-target pair (decision 1576) breaks that left-to-right reading, and
-- it has to: 1.12 keeps it in a box this window has no page for — Advanced Options'
-- "Raid & Party" — so there is no column order to preserve, only a neighbour to pick.
-- It sits under Status Text because that is the other unit-frame readout on the page.
Interface = {
"RowStatusText", "RowShowCloak", "RowShowHelm",
"RowStatusText", "RowTargetOfTarget", "RowTargetOfTargetMode",
"RowShowCloak", "RowShowHelm",
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
"RowNewbieTips",
},
@@ -876,7 +881,11 @@
-- indexed 0/1/2, so a config or addon carrying that value means Never — and the
-- nearest-stop fallback below, which assumes the values are ordinal, would show
-- "Always" for it. A categorical dropdown says which strays are its own.
if e.value == v or e.alt == v then match = e end
-- `e.alt ~= nil` guards the nil-eats-everything case: a row whose store is not
-- there yet reads nil, and every entry without an `alt` would otherwise ANSWER to
-- it (nil == nil), leaving the capsule showing the last entry and the row wearing
-- its description. An entry answers to a second value only if it has one.
if e.value == v or (e.alt ~= nil and e.alt == v) then match = e end
end
if not match and tonumber(v) then
local best
@@ -1098,6 +1107,14 @@
end
return
end
if key == "Interface" then
-- 1.12's own rule for this pair (UIOptionsFrame_UpdateDependencies l.694-700):
-- the mode picker is dead while the switch is unticked. It is the only
-- cross-row dependency on this page.
OptionsRow_SetEnabled(OptionsFrameContainerBodyInterfaceRowTargetOfTargetMode,
SHOW_TARGET_OF_TARGET ~= "0")
return
end
if key == "ActionBars" then
-- 1.12's own rule for this pair, verbatim in effect (UIOptionsFrame.lua l.722-726):
-- Show Right ActionBar 2 is dead while Show Right ActionBar is unticked, because
@@ -2624,6 +2641,56 @@
self.cvarEvent = "STATUS_BAR_TEXT"
</OnLoad></Scripts>
</Frame>
<!-- The target-of-target pair (decision 1576): the switch
and, indented under it, when the frame is allowed to
stand. Both are `uvar` rows — 1.12 keeps these two as
saved globals, not CVars (UnitFrames.xml declares and
RegisterForSave's them) — and both carry the same
applyFunc, because the saved chunk lands after the XML
that read it: without the re-run, a saved "1" would
leave the frame hidden until the next target change.
The mode row is a CHILD (indent + small font + greyed
with the switch off), which is the reference's own
relationship: its dropdown hangs off CheckButton50's
BOTTOMLEFT and OptionsFrame_DisableDropDown greys it
from the same test (UIOptionsFrame.lua l.694-700). -->
<Frame name="$parentRowTargetOfTarget" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_TARGET_OF_TARGET",
"Show Target of Target", "OPTION_TOOLTIP_SHOW_TARGET_OF_TARGET")
self.applyFunc = TargetofTarget_Update
</OnLoad></Scripts>
</Frame>
<!-- The label is ours: the reference declares a Label
FontString for this dropdown and never sets its text,
so in 1.12 the picker sits under the checkbox with no
name at all. An indented row in this window has to say
what it picks, and the row above supplies the subject.
The five values are the reference's own strings, and
its own order (its OPTION_TOOLTIP_TARGETOFTARGET1..5
describe them in exactly this sequence). -->
<Frame name="$parentRowTargetOfTargetMode" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTargetOfTarget" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTargetOfTarget" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_TARGET_OF_TARGET_STATE",
"Show When", "OPTION_TOOLTIP_TARGETOFTARGET5", 1)
OptionsDropdownRow_Setup(self, {
{ text = "Raid", value = "1", tip = "OPTION_TOOLTIP_TARGETOFTARGET1" },
{ text = "Party", value = "2", tip = "OPTION_TOOLTIP_TARGETOFTARGET2" },
{ text = "Solo", value = "3", tip = "OPTION_TOOLTIP_TARGETOFTARGET3" },
{ text = "Party or Raid", value = "4", tip = "OPTION_TOOLTIP_TARGETOFTARGET4" },
{ text = "Always", value = "5", tip = "OPTION_TOOLTIP_TARGETOFTARGET5" },
})
self.applyFunc = TargetofTarget_Update
</OnLoad></Scripts>
</Frame>
<!-- The two equipment-display toggles (decision 1472,
B123). Neither has a store: the preference is a bit of
this character's own server-side PLAYER_FLAGS, so the
@@ -2634,8 +2701,8 @@
both, the reference's own hand-written arm. -->
<Frame name="$parentRowShowCloak" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="$parentRowTargetOfTargetMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTargetOfTargetMode" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self, ShowingCloak, ShowCloak, "1",
@@ -0,0 +1,82 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The class trainer's "unlearn all of your talents?" dialog (decision 1580): a registry entry
on the shared StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against
the patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1289-1304 (the
entry) and UIParent.lua l.123 / l.533-538 (the registration and the show arm). The string is
GlobalStrings.lua l.853, verbatim.
It is BinderConfirm.xml's twin, one line above it in the TOC, and free-standing for the
identical reason: selecting the trainer's "I wish to unlearn my talents" line makes the
server CLOSE the gossip menu and then ask, so the window that raised this dialog is already
gone by the time it appears.
The whole flow behind it is one two-way opcode — MSG_TALENT_WIPE_CONFIRM in with the
trainer's guid and the cost, the same opcode back out with that guid — and only the second
one unlearns anything (`crate::ui_talent_wipe`'s header carries the law). Accepting is
therefore the ONLY path to a reset spec; there is nothing to undo on Cancel, which is why
this entry has no OnCancel.
It is also the first entry in the registry to raise a MONEY frame, because the cost is the
interesting half of the question and the sentence below deliberately does not name it — the
engine shows the coin row for `hasMoneyFrame`, and the arm at the bottom fills it.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local CONFIRM_TALENT_WIPE =
"Do you want to unlearn all of your talents? The cost will increase each time you do it." -- l.853
local ACCEPT = "Accept" -- l.10
local CANCEL = "Cancel" -- l.464
-- The dialog's only teardown, and like the innkeeper's it is a poll rather than an event:
-- walk away from the trainer and the question leaves the screen by itself, with no packet
-- either way. `CheckTalentMasterDist()` is the engine binding behind it
-- (`benilla-ui/src/script/talent.rs`), false as soon as the app's NPC-session guard has
-- closed the question. Named rather than inlined, because "the question expires when you
-- walk away" is the interesting half of this file.
local function hideOnceOutOfRange(_, dialog)
if not CheckTalentMasterDist() then
dialog:Hide()
end
end
-- ── The confirm — answering to StaticPopup.lua l.1289-1304 ─────────────────────────────
StaticPopupDialogs["CONFIRM_TALENT_WIPE"] = {
text = CONFIRM_TALENT_WIPE,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function()
ConfirmTalentWipe()
end,
OnUpdate = hideOnceOutOfRange,
hasMoneyFrame = 1,
timeout = 0,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.533-538) ────────────────────────────────────
-- arg1 is the cost in COPPER, and it goes to the money frame rather than into the text:
-- the sentence above is fixed. The nil guard is the reference's own — Show returns nothing
-- when the dialog is refused (dead without whileDead, or no free instance), and there is
-- then no money frame to fill.
function BenillaTalentWipeConfirmDriver_OnEvent()
if event == "CONFIRM_TALENT_WIPE" then
local dialog = StaticPopup_Show("CONFIRM_TALENT_WIPE")
if dialog then
MoneyFrame_Update(dialog:GetName() .. "MoneyFrame", arg1)
end
end
end
]]></Script>
<Frame name="BenillaTalentWipeConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_TALENT_WIPE")
</OnLoad>
<OnEvent>BenillaTalentWipeConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
+43 -5
View File
@@ -906,8 +906,13 @@
-- SET_GUILDOFFICERNOTE, registered in FriendsFrame.xml): the 350x64 centred box
-- replaces the narrow one, and the dialog widens to 420. See the widen arm below for
-- the reference's own oddity — the widen is keyed on `which`, not on hasWideEditBox.
-- · hasMoneyFrame is live (decision 1580's CONFIRM_TALENT_WIPE is the first customer,
-- and the reference's other five are all price confirms): a SmallMoneyFrameTemplate
-- child under the text, shown per entry, with the buttons dropping to -24 to clear it
-- and the frame growing 16px. The AMOUNT is not the engine's — the entry's own
-- OnShow/the event arm calls MoneyFrame_Update, exactly as the reference does.
-- · Accepted but v1-inert: exclusive, interruptCinematic (cinematics are acked
-- instantly — 0196), notClosableByLogout, preferredIndex, and the money variant.
-- instantly — 0196), notClosableByLogout, preferredIndex.
-- · text_arg1/2 stash on the DIALOG frame (the ref stashes them on the Text region;
-- our regions don't carry Lua fields).
-- · GetText("SECONDS"/"MINUTES") localization collapses to English singular/plural.
@@ -956,18 +961,25 @@
end
end
-- Ref l.1428-1438, the no-money leg only (v1 has no money variant).
-- Ref l.1428-1438, all three legs.
--
-- Note the ref measures the NARROW `EditBox` for the height even when the shown box is the
-- wide one — the wide box is 64 tall, anchored CENTER, and floats over a frame sized for a
-- 32-tall box. Transcribed as written: the guild MOTD dialog's look is that arithmetic.
--
-- The money leg does NOT measure the coin row either: it is the plain height plus a flat
-- 16, which is the room the -24 button drop below leaves for it. Same arithmetic, same
-- reason — the ref sizes these from constants, not from what is in the frame.
function StaticPopup_Resize(dialog, which)
local text = getglobal(dialog:GetName() .. "Text")
local button1 = getglobal(dialog:GetName() .. "Button1")
local editBox = getglobal(dialog:GetName() .. "EditBox")
if StaticPopupDialogs[which] and StaticPopupDialogs[which].hasEditBox then
local info = StaticPopupDialogs[which]
if info and info.hasEditBox then
dialog:SetHeight(16 + text:GetHeight() + 8 + editBox:GetHeight() + 8
+ button1:GetHeight() + 16)
elseif info and info.hasMoneyFrame then
dialog:SetHeight(16 + text:GetHeight() + 8 + button1:GetHeight() + 32)
else
dialog:SetHeight(16 + text:GetHeight() + 8 + button1:GetHeight() + 16)
end
@@ -1100,18 +1112,30 @@
wideEditBox:Hide()
end
-- The money frame (ref l.1627-1632): shown for a hasMoneyFrame entry, hidden
-- otherwise. The engine never fills it — the AMOUNT belongs to whoever raised the
-- dialog, who calls MoneyFrame_Update on it (the ref's own division: its
-- CONFIRM_TALENT_WIPE arm in UIParent.lua does exactly that with the event's arg1).
getglobal(dialog:GetName() .. "MoneyFrame"):Hide()
if info.hasMoneyFrame then
getglobal(dialog:GetName() .. "MoneyFrame"):Show()
end
-- The buttons (ref l.1634-1676; fixed 120 widths — header note). Button2 shows only
-- when the entry names it AND DisplayButton2 (when present) allows it; a solo button1
-- re-seats centered under the text. With an edit box the pair hangs off the BOX's
-- bottom instead of the text's (ref l.1641-1643), which is what leaves room for it.
-- bottom instead of the text's (ref l.1641-1643), which is what leaves room for it;
-- with a money frame the pair stays on the TEXT and drops 24 instead of 8, which is
-- what leaves room for the coins (ref l.1644-1646).
local buttonAnchor = info.hasEditBox and (dialog:GetName() .. "EditBox")
or (dialog:GetName() .. "Text")
local buttonDrop = info.hasMoneyFrame and -24 or -8
local button1 = getglobal(dialog:GetName() .. "Button1")
local button2 = getglobal(dialog:GetName() .. "Button2")
if info.button2 and (not info.DisplayButton2 or info.DisplayButton2()) then
button1:ClearAllPoints()
button2:ClearAllPoints()
button1:SetPoint("TOPRIGHT", buttonAnchor, "BOTTOM", -6, -8)
button1:SetPoint("TOPRIGHT", buttonAnchor, "BOTTOM", -6, buttonDrop)
button2:SetPoint("LEFT", dialog:GetName() .. "Button1", "RIGHT", 13, 0)
button2:SetText(info.button2)
button2:Show()
@@ -1932,6 +1956,20 @@
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
<!-- The coin row (ref StaticPopup.xml l.203-217, verbatim seat): a
SmallMoneyFrameTemplate under the text, hidden unless the entry sets
hasMoneyFrame. Type "STATIC" is the money kit's "a number somebody parked on the
frame" source, which is what MoneyFrame_Update(name, amount) leaves behind — so
the dialog shows a cost its raiser worked out, never a live purse. First customer:
the class trainer's CONFIRM_TALENT_WIPE (decision 1580). -->
<Frame name="$parentMoneyFrame" inherits="SmallMoneyFrameTemplate" hidden="true">
<Anchors>
<Anchor point="TOP" relativeTo="$parentText" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC")</OnLoad>
</Scripts>
</Frame>
<!-- The WIDE entry box (ref StaticPopup.xml l.138-202): 350x64 at CENTER, hidden
unless the entry sets hasWideEditBox. Its border is the same chat-input art
stretched over three slices — a 256 left cap, a 75 right cap, and a 5-wide middle
+471 -42
View File
@@ -82,8 +82,9 @@
- The rest/attack state icons landed with decision 1082, the hit indicator with 0576, and
the leader / master-looter pair with report B317 — the wire those three waited on has
existed since 0434/0453, and the list below is what is still genuinely unbuilt.
- OUT (no wire / future arcs): group indicator, raid target icon, playtime,
target-of-target.
- The target-of-target frame landed with decision 1576 — its own block at the bottom of
this file, on the `"targettarget"` unit token the app now feeds.
- OUT (no wire / future arcs): group indicator, raid target icon, playtime.
TARGET AURA ROWS (0255's deferred slice): 5 buffs / 16 debuffs under the frame, geometry +
law QUOTED from ref-TargetFrame.xml (button templates l.26-87, instances l.293-481) and
@@ -94,7 +95,9 @@
list UnitBuff/UnitDebuff enumerate for the target is the app's straight descriptor read,
ascending raw slot within the half — the verified other-unit law (0257) — so icons sit in
server slot order, not insertion order, exactly like the reference. The target-of-target
branches take the not-shown leg (wrap 6, buffs under Debuff7) until a ToT frame exists.
branches are live since 1576: with that frame shown the rows wrap at 5 instead of 6 and
the buffs open under the second debuff row, because the frame stands where a six-icon row
would reach.
-->
<!-- ── TargetBuffButtonTemplate (ref TargetFrame.xml l.9-27, VERBATIM) ──────────────────────
A reference template addons INHERIT and we did not declare. `CT_UnitFrames` builds its own
@@ -683,6 +686,40 @@
end
end
-- ── One unit's two bars, from its token ───────────────────────────────────────────────
-- Three frames in this file paint exactly this, and until 1576 each wrote it out: the
-- health bar's range and value, then the power bar's colour off BENILLA_POWER_COLORS and
-- its own range and value — or no power bar at all, this file's convention for a unit with
-- no resource. Returns whether the unit HAS one, which is what the pet frame swaps its
-- plate on (the reference has no such art for the others and leaves an empty rail drawn).
--
-- The bars are ARGUMENTS rather than fields: the player and target frames call theirs
-- `powerBar` and the pet frame calls its `manaBar`, following the reference's own two names
-- for one rail, and a helper that reached for a field would have to pick a side.
--
-- NO label on the power bar. 1.12 names the resource there (ref UnitFrame_UpdateManaType
-- l.129 → "Rage 0 / 100" off ManaBarColor's own prefix field, and CharacterFrame_OnLoad
-- labels health and XP the same way). Shipped in 1146, cut on the director's call the same
-- day: bare numbers read better on these bars (decision 1147). The mechanism stays —
-- SetTextStatusBarTextPrefix is live and one call brings any label back — only the calls
-- are gone.
function BenillaUnitFrame_PaintBars(healthBar, powerBar, unit)
healthBar:SetMinMaxValues(0, UnitHealthMax(unit))
healthBar:SetValue(UnitHealth(unit))
local maxPower = UnitManaMax(unit)
if maxPower <= 0 then
powerBar:Hide()
return false
end
powerBar:Show()
local _, powerToken = UnitPowerType(unit)
local swatch = BENILLA_POWER_COLORS[powerToken] or BENILLA_POWER_COLORS.MANA
powerBar:SetStatusBarColor(swatch.r, swatch.g, swatch.b)
powerBar:SetMinMaxValues(0, maxPower)
powerBar:SetValue(UnitMana(unit))
return true
end
function UnitFrame_Update()
local frame = this
local unit = frame.unit
@@ -692,26 +729,7 @@
end
frame:Show()
frame.healthBar:SetMinMaxValues(0, UnitHealthMax(unit))
frame.healthBar:SetValue(UnitHealth(unit))
local maxPower = UnitManaMax(unit)
if maxPower > 0 then
frame.powerBar:Show()
local _, token = UnitPowerType(unit)
local c = BENILLA_POWER_COLORS[token] or BENILLA_POWER_COLORS.MANA
frame.powerBar:SetStatusBarColor(c.r, c.g, c.b)
-- NO label. 1.12 names the resource here (ref UnitFrame_UpdateManaType l.129 →
-- "Rage 0 / 100" off ManaBarColor's own prefix field, and CharacterFrame_OnLoad
-- labels health and XP the same way). Shipped in 1146, cut on the director's call
-- the same day: bare numbers read better on these bars (decision 1147). The
-- mechanism stays — SetTextStatusBarTextPrefix is live and one call brings any
-- label back — only the calls are gone.
frame.powerBar:SetMinMaxValues(0, maxPower)
frame.powerBar:SetValue(UnitMana(unit))
else
frame.powerBar:Hide()
end
BenillaUnitFrame_PaintBars(frame.healthBar, frame.powerBar, unit)
frame.nameText:SetText(UnitName(unit) or "Unknown")
@@ -863,17 +881,33 @@
end
end
-- The dynamic anchors (ref l.312-331). No target-of-target frame exists yet, so its
-- branches take the not-shown leg: wrap 6, buffs open under Debuff7.
local targetofTarget = false
-- The dynamic anchors (ref l.312-331). The target-of-target frame stands at the
-- target frame's bottom-RIGHT (decision 1576), which is where a six-icon row of
-- debuffs would reach: hence the wrap at 5 and, with a full buff row, the shrink
-- below. Both legs are the reference's, and both are live now that the frame exists.
local targetofTarget = TargetofTargetFrame:IsShown()
local debuffWrap = 6
if targetofTarget then
debuffWrap = 5
end
if UnitIsFriend("player", "target") then
TargetFrameBuff1:SetPoint("TOPLEFT", "TargetFrame", "BOTTOMLEFT", 5, 32)
TargetFrameDebuff1:SetPoint("TOPLEFT", "TargetFrameBuff1", "BOTTOMLEFT", 0, -2)
else
TargetFrameDebuff1:SetPoint("TOPLEFT", "TargetFrame", "BOTTOMLEFT", 5, 32)
TargetFrameBuff1:SetPoint("TOPLEFT", "TargetFrameDebuff7", "BOTTOMLEFT", 0, -2)
if targetofTarget then
-- ref l.318-326 writes this as THREE branches (< 5, 5..9, >= 10) whose first
-- two are character-for-character identical; the buffs open under the second
-- debuff row until there is a third. Two branches say the same thing.
if numDebuffs >= 10 then
TargetFrameBuff1:SetPoint("TOPLEFT", "TargetFrameDebuff11", "BOTTOMLEFT", 0, -2)
else
TargetFrameBuff1:SetPoint("TOPLEFT", "TargetFrameDebuff6", "BOTTOMLEFT", 0, -2)
end
else
TargetFrameBuff1:SetPoint("TOPLEFT", "TargetFrameDebuff7", "BOTTOMLEFT", 0, -2)
end
end
-- Shrink when the debuffs would overlap the frame (ref l.340-347): 21→17 icons,
@@ -902,8 +936,10 @@
button:SetHeight(debuffSize)
end
-- Re-seat the wrap points (ref l.370-385) — constant with wrap pinned at 6, kept
-- verbatim so the ToT wrap-5 leg drops in unchanged.
-- Re-seat the wrap points (ref l.370-385). Written verbatim while the wrap was
-- pinned at 6 so the ToT wrap-5 leg would drop in unchanged — it did (1576), and
-- both directions are covered: every button the wrap-5 pass moves is put back by the
-- wrap-6 pass, and Debuff5 is only ever re-seated onto the anchor it is authored with.
getglobal("TargetFrameDebuff" .. debuffWrap):ClearAllPoints()
getglobal("TargetFrameDebuff" .. debuffWrap):SetPoint("LEFT", getglobal("TargetFrameDebuff" .. (debuffWrap - 1)), "RIGHT", 3, 0)
getglobal("TargetFrameDebuff" .. (debuffWrap + 1)):ClearAllPoints()
@@ -1680,6 +1716,9 @@
PlaySound("INTERFACESOUND_LOSTTARGETUNIT")
CloseDropDownMenus()
]]></OnHide>
<!-- The target-of-target reconcile (decision 1576, ref TargetFrame_OnUpdate): one
boolean compare, and only while a target exists. See its note below. -->
<OnUpdate><![CDATA[TargetFrame_OnUpdate()]]></OnUpdate>
<OnClick><![CDATA[TargetFrame_OnClick(arg1)]]></OnClick>
</Scripts>
</Frame>
@@ -1694,6 +1733,406 @@
</Scripts>
</Frame>
<!--
THE TARGET-OF-TARGET FRAME (decision 1576). The 93×45 satellite at the target frame's
bottom-right: who the unit you have selected is fighting. Geometry, art and law are
MEASURED off the reference (ref-TargetFrame.xml l.515-690, ref-TargetFrame.lua l.494-585);
the code is our own, on this file's snapshot → Unit* → event data flow.
THE TOKEN IS THE SLICE. `"targettarget"` is 1.12's own token grammar and our resolver
already RECOGNISED it (the `target` prefix — benilla-ui unit/mod.rs); it simply never named
a unit, because nothing fed one. The app now does: the selected unit's own
`UNIT_FIELD_TARGET`, one hop through the guid→entity index (ui_unit.rs), with the aura list
(ui_aura.rs) and the portrait booth (portrait/mod.rs) following the same hop. Everything
below is then ordinary FrameXML over an ordinary unit token.
DRIVEN BY EVENTS, NOT BY A POLL. The reference calls `TargetofTarget_Update()` from this
frame's own OnUpdate, every frame, unconditionally — it has no other way to notice, since
its engine fires no UNIT_* event for this token. Ours fires them (the feed diffs the
snapshot and fires per field, exactly as it does for `"target"`), so the repaint rides the
events. What events cannot carry is a token going AWAY — the feed clears it silently, by
the same convention `"target"` uses — and that is precisely what the reference's own
reconcile line covers (`TargetFrame_OnUpdate`, ref l.256-260): one boolean compare per
frame, on a frame that only ticks while you have a target. That line we keep.
DEVIATIONS, noted:
- No hover tooltip and no right-click menu. The reference gives this frame neither — its
Scripts are OnLoad/OnEvent/OnUpdate/OnClick and nothing else — so neither is built here.
The four debuff buttons do carry the reference's own aura tooltip.
- The five display modes are a PREDICATE, not the reference's ladder. ref l.505-528 tests
them as an if/elseif chain in which the solo leg (state "3") calls neither Show nor Hide
when you are in a raid: the frame keeps whatever state it had, which is a leaked state,
not a rule. `TargetofTarget_ModeAllows` answers the question the five modes ask.
- A powerless unit hides its mana bar, this file's convention on every frame; the
reference leaves an empty 0/0 rail drawn.
- `TargetofTargetHealthCheck` guards its own divide. The reference divides by
`UnitManaMax`'s sibling `unitHPMax` with no zero test.
-->
<!-- The debuff button — ref TargetofTargetBuffButtonTemplate (TargetFrame.xml l.4-27): the
party template shrunk to 12×12 with a 13×13 border, which is why the reference re-sets
both in an OnLoad rather than authoring them. We author them. No stack-count region: the
reference's party template has none, and at 12 px a numeral is not readable anyway. -->
<Button name="BenillaTargetofTargetDebuffTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="12" y="12"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
<Layer level="OVERLAY">
<Texture name="$parentBorder" file="Interface\Buttons\UI-Debuff-Overlays">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.296875" right="0.5703125" top="0" bottom="0.515625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter><![CDATA[
if GameTooltip then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetUnitDebuff("targettarget", this:GetID())
end
]]></OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- The two saved settings (ref UIOptionsFrame.lua l.116-119), with the reference's own
-- defaults: the frame ships OFF, and its mode ships at "always". They are saved-variable
-- globals rather than CVars because that is what they are in 1.12 — the Interface panel's
-- `uvar` store (OptionsFrame.xml's three-store note), and RegisterForSave is what persists
-- them into benilla-config (saved.rs).
SHOW_TARGET_OF_TARGET = "0"
RegisterForSave("SHOW_TARGET_OF_TARGET")
SHOW_TARGET_OF_TARGET_STATE = "5"
RegisterForSave("SHOW_TARGET_OF_TARGET_STATE")
-- Does the group state allow the frame right now? The five modes, in the reference's own
-- numbering (its dropdown writes these strings, and OPTION_TOOLTIP_TARGETOFTARGET1..5
-- describes them in this order): 1 raid, 2 party, 3 solo, 4 party or raid, 5 always.
-- Written as a predicate rather than the reference's if/elseif ladder — see the DEVIATIONS
-- note above for the leaked state that ladder has in its solo leg. LOCAL: the reference has
-- no such function, and inventing a global for it would put a name in `_G` that no addon
-- written against 1.12 can expect (the `Benilla*` prefix is for the ones we must expose).
local function TargetofTarget_ModeAllows()
local mode = SHOW_TARGET_OF_TARGET_STATE
local raid = GetNumRaidMembers()
local party = GetNumPartyMembers()
if mode == "1" then return raid > 0 end
if mode == "2" then return party > 0 and raid == 0 end
if mode == "3" then return raid == 0 and party == 0 end
if mode == "4" then return raid > 0 or party > 0 end
if mode == "5" then return true end
return false
end
-- The visibility law + the repaint (ref TargetofTarget_Update, l.503-541). Four gates
-- besides the switch and the mode, all the reference's: there must BE a target, it must
-- have a target, it must not be YOU (your own target's target is your own target — the
-- frame would restate the frame it hangs off), and it must be alive.
function TargetofTarget_Update()
local frame = TargetofTargetFrame
local was = frame:IsShown()
local show = SHOW_TARGET_OF_TARGET == "1"
and UnitExists("target")
and UnitExists("targettarget")
and not UnitIsUnit("player", "target")
and UnitHealth("target") > 0
and TargetofTarget_ModeAllows()
if show then
frame:Show()
BenillaUnitFrame_PaintBars(frame.healthBar, frame.manaBar, "targettarget")
TargetofTargetName:SetText(UnitName("targettarget") or "")
TargetofTarget_CheckDead()
-- The reference's own line (l.536), and it is not redundant with the health
-- check's tint: that one only ever touches a PLAYER's portrait, so a creature
-- inherits whatever alpha the last player left behind without this.
TargetofTargetPortrait:SetAlpha(1.0)
BenillaTargetofTarget_UpdateDebuffs()
else
frame:Hide()
end
-- The target's OWN aura rows wrap at 5 instead of 6 while this frame stands beside
-- them (BenillaTargetAuras_Update), so a change of shown state re-lays them out. The
-- reference re-runs them on the SHOW leg only (l.534) and never on the hide, which
-- leaves the rows wrapped short after the frame goes away.
if frame:IsShown() ~= was and UnitExists("target") then
BenillaTargetAuras_Update()
end
end
-- ref TargetofTarget_CheckDead (l.559-567): the DEAD word over a dimmed trough. The
-- connected test is the reference's and it is what tells "dead" from "linkdead" —
-- a disconnected player reads 0 health here and is not dead.
function TargetofTarget_CheckDead()
local down = UnitHealth("targettarget") <= 0 and UnitIsConnected("targettarget")
TargetofTargetBackground:SetAlpha(down and 0.9 or 1)
if down then
TargetofTargetDeadText:Show()
else
TargetofTargetDeadText:Hide()
end
end
-- The four readings a PLAYER's portrait can carry here (ref TargetofTargetHealthCheck's
-- own four SetVertexColor calls, l.573-583): a corpse greys, a released ghost goes blue,
-- a fifth of health or less runs red, and anything else is untinted.
local TOT_PORTRAIT_TINT = {
dead = { r = 0.35, g = 0.35, b = 0.35 },
ghost = { r = 0.2, g = 0.2, b = 0.75 },
hurt = { r = 1.0, g = 0.0, b = 0.0 },
alive = { r = 1.0, g = 1.0, b = 1.0 },
}
-- ref TargetofTargetHealthCheck (l.569-585), the health bar's OnValueChanged: the
-- portrait tints with the unit's state — grey dead, blue ghost, red under a fifth of its
-- health, white otherwise. PLAYERS ONLY, which is the reference's gate and not an
-- oversight: a creature's portrait carries no such reading anywhere in 1.12.
function TargetofTargetHealthCheck()
if not UnitIsPlayer("targettarget") then return end
local bar = this
local _, maxHP = bar:GetMinMaxValues()
local percent = 0
if maxHP > 0 then
percent = bar:GetValue() / maxHP
end
bar:GetParent().unitHPPercent = percent
-- The reading, then one paint. Priority is the reference's ladder: dead outranks
-- ghost outranks hurt, and anything else is untinted.
local tint = TOT_PORTRAIT_TINT.alive
if UnitIsDead("targettarget") then
tint = TOT_PORTRAIT_TINT.dead
elseif UnitIsGhost("targettarget") then
tint = TOT_PORTRAIT_TINT.ghost
elseif percent > 0 and percent <= 0.2 then
tint = TOT_PORTRAIT_TINT.hurt
end
TargetofTargetPortrait:SetVertexColor(tint.r, tint.g, tint.b, 1.0)
end
-- The four debuff buttons (ref calls RefreshBuffs(this, 0, "targettarget"), BuffFrame.lua
-- l.262-312, over MAX_PARTY_DEBUFFS = 4). Same shape as the pet frame's row, plus the
-- dispel tint the party/pet rows do not carry — RefreshBuffs colours its border by
-- debuffType exactly as the target frame's row does.
function BenillaTargetofTarget_UpdateDebuffs()
for i = 1, 4 do
local button = getglobal("TargetofTargetFrameDebuff" .. i)
local _, icon, _, debuffType = UnitDebuff("targettarget", i)
if icon then
getglobal(button:GetName() .. "Icon"):SetTexture(icon)
local color = DebuffTypeColor[debuffType] or DebuffTypeColor["none"]
getglobal(button:GetName() .. "Border"):SetVertexColor(color.r, color.g, color.b)
button:Show()
else
button:Hide()
end
end
end
function TargetofTarget_OnLoad()
local frame = this
frame.unit = "targettarget"
frame.healthBar = TargetofTargetHealthBar
frame.manaBar = TargetofTargetManaBar
-- The recessed-bar look, the same mechanism every frame in this file uses (decision
-- 0884): the bars one frame level BELOW the art's TextureFrame, because the draw layer
-- is bucket-wide and the frame level is the one key term above it. The reference
-- spends its level the other way round (RaiseFrameLevel on the TextureFrame); same
-- ordering, and ours matches its siblings here.
local level = TargetofTargetTextureFrame:GetFrameLevel()
frame.healthBar:SetFrameLevel(level - 1)
frame.manaBar:SetFrameLevel(level - 1)
-- The live model portrait, bound once by token (see BenillaUnitFrame_OnLoad's note):
-- the app keeps this slot's bake current, and it only bakes at all while this frame is
-- on screen (portrait/mod.rs's `"targettarget"` arm).
SetPortraitTexture(TargetofTargetPortrait, "targettarget")
-- Every event the shown frame's paint reads. The `"target"` half is not incidental:
-- the visibility law gates on the TARGET being alive, so the target's own health has
-- to reach this frame too.
for _, wire in ipairs({
"PLAYER_ENTERING_WORLD", "PLAYER_TARGET_CHANGED", "PARTY_MEMBERS_CHANGED",
"RAID_ROSTER_UPDATE", "UNIT_HEALTH", "UNIT_MAXHEALTH", "UNIT_POWER_UPDATE",
"UNIT_MAXPOWER", "UNIT_DISPLAYPOWER", "UNIT_NAME_UPDATE", "UNIT_AURA",
}) do
frame:RegisterEvent(wire)
end
frame:EnableMouse(true)
TargetofTarget_Update()
end
-- Every UNIT_* event this frame registers names a unit in arg1, and exactly two of them
-- concern it: its own, and the target whose health gates its visibility. Everything else
-- (a party member's tick, the player's own) is somebody else's update.
function TargetofTarget_OnEvent(event)
if string.sub(event, 1, 5) == "UNIT_"
and arg1 ~= "targettarget" and arg1 ~= "target" then
return
end
TargetofTarget_Update()
end
-- ref TargetofTarget_OnClick (l.543-557).
function TargetofTarget_OnClick(button)
local armed = SpellIsTargeting()
if button ~= "LeftButton" then
-- Right-click cancels an armed spell and does nothing else: unlike the player,
-- target and pet frames, this one has no unit menu in the reference.
if armed and button == "RightButton" then SpellStopTargeting() end
return
end
-- Most specific first, the pet frame's order: an armed spell binds to the unit, then
-- a held item drops on it, and with an empty cursor the click SELECTS it — which is
-- the whole point of the frame, and the one leg the target frame's click does not have.
if armed then
SpellTargetUnit("targettarget")
return
end
if CursorHasItem() then
DropItemOnUnit("targettarget")
return
end
TargetUnit("targettarget")
end
-- ref TargetFrame_OnUpdate (l.256-260) — the one thing the event feed cannot carry. A
-- token that goes away is CLEARED, not announced (ui_unit.rs: "clearing a token isn't a
-- UNIT_* event"), so this reconciles the frame's shown state against the token's existence
-- once per frame. It runs only while the target frame itself is shown, which is only while
-- you have a target — the cost is one compare, and it is the reference's own line.
-- Both sides are normalised to booleans on purpose. They happen to agree today (a frame's
-- IsShown answers true/false, and so does UnitExists), but a REGION's IsShown answers the
-- 1.12 shape of 1/nil, and the day UnitExists is moved onto it a bare `~=` here would be
-- true forever — a silent per-frame full repaint, which is the one thing this line exists
-- to avoid.
function TargetFrame_OnUpdate()
local shown = TargetofTargetFrame:IsShown() and true or false
local exists = UnitExists("targettarget") and true or false
if shown ~= exists then
TargetofTarget_Update()
end
end
]]></Script>
<!-- 93×45 at the target frame's BOTTOMRIGHT (-35,-10) — ref TargetFrame.xml l.515-525. A
Frame, not the reference's Button: every unit frame in this file is one, and this engine
dispatches OnClick on a mouse-enabled frame (see TargetFrame/PetFrame above). -->
<Frame name="TargetofTargetFrame" parent="TargetFrame" hidden="true" frameStrata="LOW">
<Size><AbsDimension x="93" y="45"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-35" y="-10"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<!-- The bars' black trough, 46×15 BOTTOMLEFT (42,13) (ref l.528-541). Its alpha is
what CheckDead dims. -->
<Texture name="TargetofTargetBackground">
<Size><AbsDimension x="46" y="15"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="42" y="13"/></Offset></Anchor>
</Anchors>
<Color r="0" g="0" b="0" a="0.5"/>
</Texture>
<!-- Portrait 35×35 TOPLEFT (6,-6) (ref l.543-556, BORDER→BACKGROUND for the same
ring-over-portrait layering the player and target frames use). -->
<Texture name="TargetofTargetPortrait">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Health bar 46×7 TOPRIGHT (-2,-15), UI-StatusBar, green (ref l.593-611). -->
<StatusBar name="TargetofTargetHealthBar">
<Size><AbsDimension x="46" y="7"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-2" y="-15"/></Offset></Anchor>
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="1.0" b="0.0"/>
<Scripts>
<OnValueChanged>TargetofTargetHealthCheck()</OnValueChanged>
</Scripts>
</StatusBar>
<!-- Power bar 46×7 TOPRIGHT (-2,-23); colour set at runtime (ref l.612-625). -->
<StatusBar name="TargetofTargetManaBar">
<Size><AbsDimension x="46" y="7"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-2" y="-23"/></Offset></Anchor>
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.0" g="0.0" b="1.0"/>
</StatusBar>
<!-- The plate art + text, over the bars (ref TargetofTargetTextureFrame l.558-591) —
it draws over the bar edges because OnLoad pushes the bars one frame level below
it, exactly as on the player and target frames. -->
<Frame name="TargetofTargetTextureFrame">
<Size><AbsDimension x="93" y="45"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<!-- The small plate, its own art file. The TexCoords are the reference's
(l.561-563) and they cut the 128×64 file to exactly this frame's
93×45: 0.015625..0.7265625 is x 2..93, 0..0.703125 is y 0..45. -->
<Texture name="TargetofTargetTexture" file="Interface\TargetingFrame\UI-TargetofTargetFrame">
<Size><AbsDimension x="93" y="45"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.015625" right="0.7265625" top="0" bottom="0.703125"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- Name 100×10 BOTTOMLEFT (42,2) (ref l.564-576). -->
<FontString name="TargetofTargetName" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="100" y="10"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="42" y="2"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- DEAD, over the trough, CENTER (15,1) (ref l.576-587). -->
<FontString name="TargetofTargetDeadText" inherits="GameFontNormalSmall" text="DEAD" hidden="true">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="15" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<!-- The four debuffs, ref geometry (l.626-663): the first hangs off the frame's
TOPRIGHT, then a 2×2 block. -->
<Button name="$parentDebuff1" inherits="BenillaTargetofTargetDebuffTemplate" id="1">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="4" y="-10"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="$parentDebuff2" inherits="BenillaTargetofTargetDebuffTemplate" id="2">
<Anchors>
<Anchor point="LEFT" relativeTo="TargetofTargetFrameDebuff1" relativePoint="RIGHT"><Offset><AbsDimension x="1" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="$parentDebuff3" inherits="BenillaTargetofTargetDebuffTemplate" id="3">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="TargetofTargetFrameDebuff1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="$parentDebuff4" inherits="BenillaTargetofTargetDebuffTemplate" id="4">
<Anchors>
<Anchor point="LEFT" relativeTo="TargetofTargetFrameDebuff3" relativePoint="RIGHT"><Offset><AbsDimension x="1" y="0"/></Offset></Anchor>
</Anchors>
</Button>
</Frames>
<Scripts>
<OnLoad><![CDATA[TargetofTarget_OnLoad()]]></OnLoad>
<OnEvent><![CDATA[TargetofTarget_OnEvent(event)]]></OnEvent>
<OnClick><![CDATA[TargetofTarget_OnClick(arg1)]]></OnClick>
</Scripts>
</Frame>
<!--
THE PET FRAME (decision 0990). Geometry and art are MEASURED off ref PetFrame.xml +
PetFrame.lua and the behaviour is theirs; the code is our own. 128×53 parented to
@@ -1798,23 +2237,13 @@
SetPortraitTexture(frame.portrait, "pet")
frame.nameText:SetText(UnitName("pet") or "")
frame.healthBar:SetMinMaxValues(0, UnitHealthMax("pet"))
frame.healthBar:SetValue(UnitHealth("pet"))
-- The ref's art swap (PetFrame.lua's Update): a powerless pet wears the -NoMana plate,
-- which has no mana rail to leave empty. The bar hides with it, as on every other frame.
local maxPower = UnitManaMax("pet")
if maxPower > 0 then
-- which has no mana rail to leave empty. Which plate is exactly the shared paint's
-- return — the pet is the only frame here whose art carries that distinction.
if BenillaUnitFrame_PaintBars(frame.healthBar, frame.manaBar, "pet") then
PetFrameTexture:SetTexture("Interface\\TargetingFrame\\UI-SmallTargetingFrame")
frame.manaBar:Show()
local _, token = UnitPowerType("pet")
local c = BENILLA_POWER_COLORS[token] or BENILLA_POWER_COLORS.MANA
frame.manaBar:SetStatusBarColor(c.r, c.g, c.b)
frame.manaBar:SetMinMaxValues(0, maxPower)
frame.manaBar:SetValue(UnitMana("pet"))
else
PetFrameTexture:SetTexture("Interface\\TargetingFrame\\UI-SmallTargetingFrame-NoMana")
frame.manaBar:Hide()
end
BenillaPetFrame_UpdateDebuffs()
+16 -8
View File
@@ -30,6 +30,17 @@ BasicControls.xml
# slot calls these, ours and third-party alike: their absence is what left the director's Bagnon
# window with a title, a gold line and no bag slots at all (see the file's own header).
ItemButtonTemplate.xml
# The reference's money-frame kit: MoneyFrameTemplate/SmallMoneyFrameTemplate + MoneyTypeInfo
# + the MoneyFrame_* API. Needs only Fonts (NumberFontNormal/NumberFontNormalLarge) — and it must
# come BEFORE UiPanels.xml, because the shared StaticPopup's coin row is a child frame declaring
# `inherits="SmallMoneyFrameTemplate"`, which is resolved at LOAD (decision 1580). That is the
# reference's own order too (FrameXML.toc: MoneyFrame.xml l.10, StaticPopup.xml l.23); this file
# used to sit 140 lines lower, beside the other shared widget kits, which was only ever tenable
# while nothing above it showed coins. AuctionFrame far below is laid out on it as well (its price
# columns need a money TYPE, which the other kit has no notion of); our other eight coin displays
# still run on MerchantFrame.xml's BenillaMoney_* slot kit, and MoneyFrame.xml's header is where
# the two are reconciled.
MoneyFrame.xml
# The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
# before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
# when those frames' OnLoad/OnEvent reference them.
@@ -92,6 +103,11 @@ EnchantConfirm.xml
# Not GossipFrame's, and the wire is why — the server closes the gossip menu BEFORE it asks, so
# this dialog is free-standing by the time it shows. Needs only UiPanels.xml, far above.
BinderConfirm.xml
# The class trainer's respec confirm + its event driver (decision 1580): the binder confirm's twin
# one line up, free-standing for the identical reason — the server closes the gossip menu before it
# asks. The only entry in the registry that raises a money frame, so it needs MoneyFrame.xml (far
# above, ahead of UiPanels.xml) as well as the StaticPopup engine itself.
TalentWipeConfirm.xml
# The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
# consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
Cooldown.xml
@@ -168,14 +184,6 @@ UIPanelTemplates.xml
# UICheckButtonTemplate and UIPanelButtonTemplate. Not benilla's own options window
# (OptionsFrame.xml, far below): these are the shared names addons build panels out of.
OptionsFrameTemplates.xml
# The reference's money-frame kit: MoneyFrameTemplate/SmallMoneyFrameTemplate + MoneyTypeInfo
# + the MoneyFrame_* API. Needs only Fonts (NumberFontNormal/NumberFontNormalLarge). AuctionFrame
# below is laid out on it (its price columns need a money TYPE, which the other kit has no notion
# of); our other eight coin displays still run on MerchantFrame.xml's BenillaMoney_* slot kit, and
# MoneyFrame.xml's header is where the two are reconciled. Seated beside the scroll kit (the other
# shared widget kit) and ahead of every window that shows coins, which is where the reference's own
# TOC puts it too.
MoneyFrame.xml
# The shared colour dialog + its opacity slider (`ColorPickerFrame`, `OpacitySliderFrame`,
# `ColorPickerOkayButton`, `ColorPickerCancelButton`). Last of the "shared FrameXML surface with no
# benilla consumer yet" cluster that runs from UIPanelTemplates.xml down — the same posture as
+1
View File
@@ -1445,6 +1445,7 @@ mod tests {
script.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -796,9 +796,6 @@ pub(super) fn attach_entity_visuals(
// (appear ramp, stream-out ramp, the self-avatar feather) multiplies
// its clouds — decision 0827.
alpha: Some(entity),
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// The emitters' rate/enabled read this instance's PLAYING sequence — a
// unit's or GameObject's `AnimationPlayer` on the root. A quest object's
@@ -654,9 +654,6 @@ fn spawn_slot(
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
// and vanishes with the avatar in first person (0827/0833).
alpha: Some(root),
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A held item spawns no rig; its emitters run the item model's own slot-0
// loop on the spawn clock (the torch burns always — the doodad law).
@@ -159,9 +159,9 @@ fn fx_part_material(
#[derive(Clone, Copy, Default)]
pub(crate) struct EffectHost {
/// Is this an **attached** model in the client's sense (`[model+0x17c] ≠ 0`)? It sets the
/// emitters' attach frame `A`: a kit effect on a unit and the `fxview` fixture are attached
/// (the cloud fans with the host's motion), a missile is not — its trail stays world-frozen
/// (wow-re `part-kit-effect-attach-orient.md`).
/// emitters' attach frame `A` — the birth orientation a kit effect fans by (wow-re
/// `part-kit-effect-attach-orient.md`, decision 0395). It does **not** decide ride-vs-trail:
/// that is the emitter's own file flag `0x10`, on every host class alike (1578).
pub attached: bool,
/// The model instance this one is **chained to** ([`benilla_world::model_fade::ParentModel`]): the
/// unit a kit effect is hung on, the item root a weapon glow rides. `None` for a model that
@@ -432,12 +432,6 @@ pub(crate) fn attach_effect_visuals(
// This instance IS the model these particles belong to; its chain (set above)
// carries the host's fade down to them — decision 0833.
alpha: Some(root),
// The ride-vs-trail baseline (0986): an UNATTACHED instance is a free world model
// whose own transform IS its world placement, so its motion reaches the particles
// through the emitter matrix and each one hangs where it was born — the hunter
// arrow's bead trail. An attached one is carried by the model it hangs on (the
// reference's device-stack `S`), and its cloud rides.
world_composed: !host.attached,
},
// The emitters' rate/enabled windows ride the played sequence: a `CEffect` ADVANCES
// (`Stand` → `Hold` → `Decay`), so it reads the live one off its own player like a
@@ -360,9 +360,6 @@ pub(super) fn spawn_wmo_gameobject_props(
// A placed prop carries no fade component of its own; its emitters
// take the doodad distance fade in the sim instead (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A placed prop: the doodad law — which is NOT one arm for life. It re-rolls
// its variation every play-window (decision 0768), so the emitter resolves
+7
View File
@@ -145,6 +145,7 @@ mod ui_shapeshift;
mod ui_social;
mod ui_spellbook;
mod ui_talent;
mod ui_talent_wipe;
mod ui_taxi;
mod ui_text;
mod ui_tooltip;
@@ -211,6 +212,7 @@ use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_talent::UiTalentPlugin;
use ui_talent_wipe::UiTalentWipePlugin;
use ui_taxi::UiTaxiPlugin;
use ui_text::UiTextPlugin;
use ui_tooltip::UiTooltipPlugin;
@@ -624,6 +626,11 @@ pub fn run(build: BuildId) -> AppExit {
// talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin
// (shares its `Spells` catalog), beside the spellbook it mirrors.
.add_plugins(UiTalentPlugin)
// Unlearning them again (decision 1580): the class trainer's respec question, its
// CONFIRM_TALENT_WIPE dialog, and the answer that is the only packet in the flow which
// unlearns anything. Beside UiTalentPlugin for the subject, but it is UiBinderPlugin's twin
// in shape — a guid-carrying question over an already-closed gossip menu.
.add_plugins(UiTalentWipePlugin)
// The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from
// PlayerActions.spells per the byte-verified admission/order, drives StanceBar.xml through
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
+6
View File
@@ -1088,6 +1088,12 @@ pub(crate) enum ClientCommand {
/// the question. Answered by `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND` once the innkeeper's
/// Bind cast lands; declining sends nothing.
BinderActivate { binder: u64 },
/// Unlearn every talent (`MSG_TALENT_WIPE_CONFIRM` outbound, decision 1580): the
/// `CONFIRM_TALENT_WIPE` dialog's Accept, carrying the guid the trainer's question asked with.
/// This is the ONLY packet in the flow that resets anything — selecting the gossip line just
/// raises the question. Answered by the un-learn of every rank spell plus the refreshed
/// `PLAYER_CHARACTER_POINTS1`; declining sends nothing.
TalentWipeConfirm { trainer: u64 },
/// Open the bank (`CMSG_BANKER_ACTIVATE`, decision 0604): the direct opener a right-click on
/// a pure banker (bit 8 the lowest service bit) uses — a gossip-flagged banker routes through
/// the gossip menu instead, whose bank option makes the server volunteer the same answer.
+17
View File
@@ -209,6 +209,11 @@ pub(super) fn apply_net_updates(
// parks the innkeeper's guid here and `crate::ui_binder` turns it into the
// CONFIRM_BINDER dialog, whose Accept is the only thing that binds anything.
ResMut<crate::ui_binder::BinderState>,
// The class trainer's pending respec question (decision 1580) — the inbound
// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and
// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose
// Accept is the only thing that unlearns anything. The binder's twin above.
ResMut<crate::ui_talent_wipe::TalentWipeState>,
// The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be
// stamped with — the wire carries no map field, so "where you were standing when
// the guard told you" is the client's to remember (`crate::poi_marker`).
@@ -380,6 +385,7 @@ pub(super) fn apply_net_updates(
mut played_time_answer,
mut guild,
mut binder,
mut talent_wipe,
mut poi_marker,
current_map,
mut inspect_honor,
@@ -704,6 +710,17 @@ pub(super) fn apply_net_updates(
});
}
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
// A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a
// question — there is nothing to ask about, so nothing goes on screen (decision 1580;
// `crate::ui_talent_wipe`'s header carries why the reference instead re-sends here).
SessionEvent::TalentWipeConfirm { trainer, cost } => {
if trainer == 0 {
debug!("net: talent wipe refused (no talents to reset) — no dialog");
} else {
debug!("net: trainer {trainer:#x} asks to wipe talents for {cost} copper");
talent_wipe.ask(trainer, cost);
}
}
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
crate::ui_binder::apply::bound(
+1
View File
@@ -767,6 +767,7 @@ fn writer_loop(
w.repair_item(vendor, item_guid)
}
ClientCommand::BinderActivate { binder } => w.binder_activate(binder),
ClientCommand::TalentWipeConfirm { trainer } => w.talent_wipe_confirm(trainer),
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
ClientCommand::BuyBankSlot { guid } => w.buy_bank_slot(guid),
ClientCommand::AutoBankItem { bag, slot } => w.autobank_item(bag, slot),
+74 -36
View File
@@ -905,44 +905,62 @@ pub(super) fn autorun_cancelled(
/// read (decision 1056) — against a per-target-state mask, and **returns before the packet is
/// built** when any masked bit is set: no `CMSG_STANDSTATECHANGE`, no local apply, no message.
/// Standing up is the asymmetry: `newState == 0` jumps straight to the send and never consults the
/// word at all, so movement can always stand you (VERIFIED `0x5ed4d8`–`0x5ed501`, wow-re
/// `object-layer/scratch/standstate-movement-trigger.md` §1).
/// word at all, so movement can always stand you.
///
/// Because the refusal lives in the **one setter**, it covers every caller at once — the `X`
/// keybind (`SitOrStand`, Lua `0x48b920`) and the posture emotes (`/sit`, `/sleep`, `/kneel`,
/// `/lay`, which reach the same `0x5ed430` through `DoEmote`'s `EmoteSpecProc == 1` arm). That is
/// the only thing covering them: the emote layer's own swim suppression
/// ([`crate::ui_chat::input::emote_send_eligible`]'s `EmoteFlags & 0x0080`) does **not** fire here
/// — the shipped `Emotes.dbc` gives STATE_SIT (13), STATE_SLEEP (12) and STATE_STAND (26) flags
/// `0x6202`, with that bit clear — read off the 5875 data by
/// `ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`.
/// Byte-for-byte (`0x5ed4d8`–`0x5ed501`; wow-re `object-layer/scratch/standstate-movement-trigger.md`
/// §5.1, a §5 trio carve, decisions 1581/1582):
///
/// The masks are the reference's own two:
/// ```c
/// if (newState != 0) {
/// if (newState == 3 && (mov->flags & 0x30)) return; // 0x5ed4e6, `test byte [ecx+0x40],0x30`
/// if (mov->flags & 0x20000f) return; // 0x5ed4f8, `test dword [edx+0x40],0x20000f`
/// }
/// ```
///
/// | target state | mask | means |
/// **SLEEP takes BOTH tests, not a different one.** `0x5ed4ec eb 04` is an unconditional `jmp`
/// *into* the second test, not around it — an either/or would have jumped to the send at
/// `0x5ed501`. 1581 shipped it as an alternative, off a clause §1 had recorded in passing; §5
/// carved the block for its own sake and corrected it. The `newState == 3` test is a plain
/// equality: `2` (SIT_CHAIR) and `8` (KNEEL) take the shared leg exactly like `1` (SIT).
///
/// | target state | effective mask | refused while |
/// |---|---|---|
/// | 0 STAND | — | never refused |
/// | 3 SLEEP | `0x30` | refused while **turning** |
/// | 1 SIT · 2 SIT_CHAIR · 8 KNEEL · … | `0x20000f` | refused while **translating or swimming** |
/// | 0 STAND | — (word never read) | never |
/// | 1 SIT · 2 SIT_CHAIR · 8 KNEEL · … | `0x20000f` | translating **or swimming** |
/// | 3 SLEEP | `0x20003f` | that, **plus** turning |
///
/// `0x20000f` is not a mask invented here: it is byte-verified at a second, unrelated site as the
/// client's own "am I moving" test — the stationary-cast pin's `[9e8] & 0x20000f`
/// ([`crate::creature_anim::move_flags::CAST_PIN_MOVE`]) — the four direction bits plus
/// [`SWIMMING`](crate::creature_anim::move_flags::SWIMMING), turn bits deliberately absent. The
/// swim bit in it is the whole of B155: a swimmer is *always* carrying it, so the press is refused
/// for as long as they are in the water, whether or not they are stroking.
/// Neither mask is invented here; both are byte-verified twice over, at unrelated sites, as this
/// client's own movement tests — `0x20000f` is the stationary-cast pin's `[9e8] & 0x20000f` at
/// `0x5fde80` ([`crate::creature_anim::move_flags::CAST_PIN_MOVE`]) and `0x20003f` is the one-shot
/// route's `[9e8] & 0x20003f` at `0x5fe6dc` ([`ROUTE_COMMITTED_MOVE`](crate::creature_anim::move_flags::ROUTE_COMMITTED_MOVE)).
/// The swim bit in them is the whole of B155: a swimmer is *always* carrying it, so the press is
/// refused for as long as they are in the water, whether or not they are stroking. Note what is
/// **absent** from both — pitch, walk-mode, ROOT, and `FALLING` (`0x2000`): a standing jump does
/// not block a sit, while a running one does, through `FORWARD` rather than through the jump.
///
/// Because the refusal lives in the **one setter**, it covers every caller at once. wow-re's caller
/// census closed that exhaustively (§5.2: six `e8` callers, no tail-jumps, no dword reference
/// anywhere in the image, in no vtable) — the `X` keybind (`SitOrStand`, Lua `0x48b920`), the
/// posture emotes through `DoEmote`'s `EmoteSpecProc == 1` leg, `StartAttack`, the movement-input
/// wrapper `0x60be30`, and the five-minute AFK auto-sit in `WorldFrame::Render`.
///
/// The emote layer does **not** double up on the water half: the shipped `Emotes.dbc` gives
/// STATE_SIT (13), STATE_SLEEP (12), STATE_KNEEL (68) and STATE_STAND (26) flags `0x6202`, with the
/// swim-suppress bit `0x0080` clear — read off the 5875 data by
/// `ui_chat::tests::the_posture_emotes_carry_no_swim_suppression_flag`. (It has a *separate*,
/// louder gate that fires on movement rather than water — `0x4000` → `ERR_NOEMOTEWHILERUNNING` —
/// which benilla does not model; see [`crate::ui_chat::input::emote_send_eligible`].)
pub(super) fn stand_state_refused(move_flags: u32, new_state: u8) -> bool {
use crate::creature_anim::move_flags as f;
// The stand-up asymmetry: never gated.
// The stand-up asymmetry: never gated (`0x5ed4f0 je 0x5ed501`).
if new_state == 0 {
return false;
}
let mask = if new_state == 3 {
f::TURN_LEFT | f::TURN_RIGHT
} else {
f::ANY_MOVE | f::SWIMMING
};
move_flags & mask != 0
// SLEEP's extra test, which falls THROUGH into the shared one below rather than replacing it.
if new_state == 3 && move_flags & (f::TURN_LEFT | f::TURN_RIGHT) != 0 {
return true;
}
move_flags & (f::ANY_MOVE | f::SWIMMING) != 0
}
#[cfg(test)]
@@ -996,23 +1014,43 @@ mod stand_state_tests {
}
}
/// SLEEP (3) takes the *other* mask — the turn bits — so `/sleep` and `/sit` genuinely disagree
/// about which inputs block them. Asserted so the reference's odd pair is recorded rather than
/// quietly "corrected" into one mask.
/// SLEEP (3) takes the shared mask **and one more test**, not a different one — the `0x30` turn
/// pair falls THROUGH into `0x20000f` (`0x5ed4ec eb 04`, a jmp *into* the second test). 1581
/// shipped it as an alternative and this is the assertion that pins the correction: a swimmer
/// cannot `/sleep` any more than they can `/sit`, and turning blocks only the sleep.
#[test]
fn sleep_reads_the_turn_mask_not_the_translate_mask() {
fn sleep_takes_both_tests_so_it_is_the_strictest_posture() {
// The extra test, SLEEP's alone.
assert!(
stand_state_refused(f::TURN_LEFT, 3),
"turning: /sleep refused"
);
assert!(stand_state_refused(f::TURN_RIGHT, 3));
assert!(
!stand_state_refused(f::FORWARD, 3),
"walking: /sleep granted"
!stand_state_refused(f::TURN_LEFT, 1),
"turning blocks ONLY the sleep — a sit is granted"
);
// …and the shared one it falls into, which 1581's either/or wrongly skipped.
assert!(
stand_state_refused(f::SWIMMING, 3),
"swimming: /sleep refused"
);
assert!(
!stand_state_refused(f::SWIMMING, 3),
"swimming: /sleep granted"
stand_state_refused(f::FORWARD, 3),
"walking: /sleep refused"
);
// So SLEEP's effective mask is exactly `0x20003f` — the one-shot route's own constant.
assert_eq!(
f::TURN_LEFT | f::TURN_RIGHT | f::ANY_MOVE | f::SWIMMING,
f::ROUTE_COMMITTED_MOVE,
"`0x20003f`, byte-verified at 0x5fe6dc as well as 0x5ed4e6+0x5ed4f8"
);
// Standing up is still ungated from SLEEP, which is what makes it escapable.
assert!(!stand_state_refused(f::ROUTE_COMMITTED_MOVE, 0));
// FALLING is in neither mask: a standing jump does not block a posture.
assert!(
!stand_state_refused(f::FALLING, 3),
"falling: /sleep granted"
);
}
}
-5
View File
@@ -246,9 +246,6 @@ pub(super) fn spawn_booth_effects(
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
benilla_world::particles::EmitClock::Pinned, // an item's effects loop forever
) else {
@@ -334,8 +331,6 @@ pub(super) fn spawn_booth_own_emitters(
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
// A booth bake has no appear/despawn ramp and no self-avatar feather (0827).
alpha: None,
// Scene-graph-carried: this model's motion arrives on the device stack (0986).
world_composed: false,
},
// A booth loops its one authored clip forever — the doodad law.
benilla_world::particles::EmitClock::Pinned,
+48 -6
View File
@@ -100,8 +100,18 @@ mod test_bake;
/// frames, `"pet"` (decision 0990's frame), and `"npc"` — the NPC an interaction window (gossip /
/// quest / merchant / trainer / taxi) is bound to ([`crate::ui_session::InteractNpc`]), so those
/// windows show the creature's face instead of the `?` placeholder.
const SLOTS: [&str; 8] = [
"player", "target", "pet", "npc", "party1", "party2", "party3", "party4",
/// `"targettarget"` (decision 1576) is the ninth and the odd one: it is the only slot whose unit
/// resolution is gated on the UI actually drawing it — see [`sync_portraits`]'s arm for why.
const SLOTS: [&str; 9] = [
"player",
"target",
"targettarget",
"pet",
"npc",
"party1",
"party2",
"party3",
"party4",
];
/// The character-window **paper-doll** slot (decision 0208 §5): a full-body bake of the dressed
/// player, sampled *square* (not circular) by the character frame's model pane. Its own booth —
@@ -713,9 +723,13 @@ fn wake_booth<'a>(
struct Booths(HashMap<String, Booth>);
/// Where each booth's bake is actually being **sampled on screen this frame**: slot token → the
/// destination region's aspect (width ÷ height). Published by the UI extract for every
/// `BenillaSetBoothTexture` binding it emits — the *square* portrait binding (decision 0208 §5); a
/// round `SetPortraitTexture` unit portrait is not a pane and never appears here.
/// destination region's aspect (width ÷ height). Published by the UI extract for every portrait
/// binding it emits — the *square* `BenillaSetBoothTexture` pane (decision 0208 §5) and, since
/// 1576, the round `SetPortraitTexture` unit portraits beside it. The two consumers below are
/// body-booth-only and unreachable for a round slot (`sync_body_booth` is never called with one,
/// and `live_pane` needs a `live` booth, which only a body pane ever is), so the round rows are
/// inert here and exist for a third reader: [`sync_portraits`]'s `"targettarget"` gate, which asks
/// this resource whether the frame it feeds is on screen at all.
///
/// Two things need it, and neither can be a constant (decision 1069):
///
@@ -753,7 +767,9 @@ pub(crate) struct BoothBridge<'w> {
/// serves the party slots and the pet alike.
///
/// One struct rather than five loose params because `sync_portraits` sits at Bevy's 16-parameter
/// ceiling; it was a bare tuple until the name cache made that tuple's type unreadable.
/// ceiling; it was a bare tuple until the name cache made that tuple's type unreadable. It has
/// since become that system's overflow bag outright — [`BoothPanes`] is not group-facing at all,
/// it rides here because there is no room left in the signature.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct PartyBooths<'w> {
roster: Res<'w, crate::ui_party::GroupState>,
@@ -761,6 +777,8 @@ pub(crate) struct PartyBooths<'w> {
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
pet_bar: Res<'w, crate::ui_pet::PetBar>,
names: Res<'w, crate::names::NameCache>,
/// What the UI drew last frame ([`BoothPanes`]) — read by the `"targettarget"` slot alone.
panes: Res<'w, BoothPanes>,
}
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
@@ -1302,6 +1320,30 @@ fn sync_portraits(
let unit: Option<Entity> = match token {
"player" => self_q.single().ok(),
"target" => selection.target,
// The target's own target (decision 1576) — one hop off `UNIT_FIELD_TARGET`, the same
// read the `"targettarget"` unit snapshot makes.
//
// **Gated on the UI actually drawing this slot**, which no other slot here is, and
// which this one needs. The other eight are gated by their own resolution: no pet, no
// party member, no interact NPC means no unit and no work. This one resolves for as
// long as anything you have selected is fighting anything — while the frame that
// draws it ships HIDDEN (1.12's `SHOW_TARGET_OF_TARGET` default is `"0"`), so
// ungated it would pay a descendants walk every frame and a re-bake on every mob's
// target switch for a circle nobody is looking at. One frame stale by construction
// (the extract runs after this), which costs the first bake a frame and nothing else.
"targettarget" => party
.panes
.0
.contains_key("targettarget")
.then(|| {
selection
.target
.and_then(|e| stores_q.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| party.index.0.get(&g).copied())
})
.flatten(),
// The pet, off the bar's cached guid — the same word its unit token and `UNIT_PET`
// read (`crate::ui_pet::feed_pet_unit`).
"pet" => {
+14 -6
View File
@@ -608,25 +608,33 @@ pub(super) fn follow_requests(
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct SelectCommit<'w, 's> {
selection: ResMut<'w, Selection>,
pub(super) selection: ResMut<'w, Selection>,
seam: crate::creature_anim::AttackSeam<'w, 's>,
// `Entity` first because a caller can need our own body as a SELECTION target, not only as the
// comparison the classification runs against (`TargetUnit("player")`, the player frame's click).
me: Query<
'w,
's,
(
Entity,
&'static Guid,
Option<&'static ObjectStore>,
Has<crate::creature_anim::Engaged>,
),
With<SelfPlayer>,
>,
stores: Query<'w, 's, &'static ObjectStore>,
pub(super) stores: Query<'w, 's, &'static ObjectStore>,
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
impl SelectCommit<'_, '_> {
fn commit(&mut self, entity: Entity, guid: u64) {
/// Our own body's entity + guid, for a caller that can be asked to select it.
pub(super) fn me(&self) -> Option<(Entity, u64)> {
self.me.single().ok().map(|(e, g, _, _)| (e, g.0))
}
pub(super) fn commit(&mut self, entity: Entity, guid: u64) {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
// re-derive one) — the same `can_attack` the cursor and the TAB scan pass.
@@ -634,15 +642,15 @@ impl SelectCommit<'_, '_> {
self.stores.get(entity).ok(),
self.factions.as_deref(),
&self.reputations,
me.and_then(|(_, store, _)| store),
me.and_then(|(_, _, store, _)| store),
);
scan::commit(
&mut self.selection,
&mut self.seam,
entity,
guid,
me.is_some_and(|(_, _, engaged)| engaged),
me.map(|(g, _, _)| g.0),
me.is_some_and(|(_, _, _, engaged)| engaged),
me.map(|(_, g, _, _)| g.0),
attackable,
);
}
+21 -22
View File
@@ -728,17 +728,19 @@ pub(super) fn clear_target_requests(
/// `"target"` → the current selection (a dedup no-op); `"partyN"`/`"raidN"` → that roster slot
/// when its entity is in range (an out-of-range member needs the guid-only selection the phase-4
/// out-of-range slice owns — until then the click no-ops, like the real client on a nonexistent
/// unit); `"pet"` → the bar's cached pet guid (decision 0990, the pet frame's left click).
/// unit); `"pet"` → the bar's cached pet guid (decision 0990, the pet frame's left click);
/// `"targettarget"` → the selection's own `UNIT_FIELD_TARGET` (decision 1576, the ToT frame's).
/// Everything else (mouseover/name) waits for its wire.
#[allow(clippy::too_many_arguments)]
pub(super) fn target_unit_requests(
script: Option<NonSendMut<UiScript>>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_q: Query<(Entity, &Guid, Has<Engaged>), With<SelfPlayer>>,
group: Res<crate::ui_party::GroupState>,
index: Res<crate::net::GuidIndex>,
pet: Res<crate::ui_pet::PetBar>,
// The one SetSelection tail, shared with `/target` and `/assist` (decision 1583). It carries
// the classification too, which is why this drain no longer states one: hand-stating it here
// is exactly how a `false` that the binary refutes got written down.
mut commit: super::by_name::SelectCommit,
) {
let Some(mut script) = script else {
return;
@@ -747,13 +749,22 @@ pub(super) fn target_unit_requests(
if requests.is_empty() {
return;
}
let me = self_q.single().ok();
let engaged = me.is_some_and(|(_, _, e)| e);
let self_guid = me.map(|(_, g, _)| g.0);
let me = commit.me();
let self_guid = me.map(|(_, g)| g);
for token in requests {
let resolved = match token.as_str() {
"player" => me.map(|(e, g, _)| (e, g.0)),
"target" => selection.target.zip(selection.guid),
"player" => me,
"target" => commit.selection.target.zip(commit.selection.guid),
// The ToT frame's left click (decision 1576) — the target's own target, one hop off
// `UNIT_FIELD_TARGET`, resolved exactly as the `"targettarget"` snapshot and `/assist`
// resolve it. An unstreamed guid no-ops like every other arm here.
"targettarget" => commit
.selection
.target
.and_then(|e| commit.stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
// The pet resolves off the bar's cached guid, the same word `"pet"`'s snapshot and
// `UNIT_PET` read (`crate::ui_pet::feed_pet_unit`). An unstreamed pet no-ops, exactly
// as an out-of-range party member does.
@@ -783,19 +794,7 @@ pub(super) fn target_unit_requests(
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
};
if let Some((entity, guid)) = resolved {
// `new_attackable: false` on every arm, and it is correct rather than a shortcut:
// yourself and the current target reach the engaged-switch law's self exception and
// its dedup, and a party member or your own pet is a unit you cannot attack. So a
// switch fired from here can only ever STOP the swing — never re-point it.
scan::commit(
&mut selection,
&mut seam,
entity,
guid,
engaged,
self_guid,
false,
);
commit.commit(entity, guid);
}
}
}
+51
View File
@@ -229,6 +229,10 @@ struct AuraFeedMemo {
/// The pet half, same shape and same reason (decision 0990): a stable swap between two pets
/// carrying identical auras must still re-fire, so the guid joins the key.
pet_last: Option<(u64, Vec<AuraProjection>)>,
/// The target-of-target half (decision 1576) — the ToT frame's four debuff buttons. Same shape
/// and same reason as the two above: the guid joins the key, because your target swinging from
/// one identically-debuffed add to another is still a change the row has to hear.
tot_last: Option<(u64, Vec<AuraProjection>)>,
}
/// The `BENILLA_AURA_TRACE` period in seconds, or `None` when the trace is off. A set-but-unparsable
@@ -729,6 +733,41 @@ fn feed_auras(
// rides on it until that arm is derived.
.map(|store| other_unit_auras(store, catalog, true));
// The target's target's list — the ToT frame's four debuff buttons (decision 1576). Same
// other-unit law as the target and the pet above, resolved through the same one-hop
// `UNIT_FIELD_TARGET` read the unit feed's `"targettarget"` snapshot uses. The self-mirror leg
// is NOT dead code here the way it would be for a pet: while you tank, your target's target is
// YOU, and 0257 §2's player-bar law is what the row must draw then.
let tot_guid = selection
.target
.and_then(|e| stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.unwrap_or(0);
let tot_list: Option<Vec<AuraState>> = (tot_guid != 0)
.then(|| index.0.get(&tot_guid))
.flatten()
.and_then(|&e| {
if tot_guid == self_guid.0 {
return Some(list.clone());
}
let tot_store = stores.get(e).ok()?;
let buffs = buffs_visible_on(
tot_store,
Some(store),
factions.as_deref(),
&reputations,
|owner| {
index
.0
.get(&owner)
.and_then(|&e| stores.get(e).ok())
.cloned()
},
);
Some(other_unit_auras(tot_store, catalog, buffs))
});
let target_cur = selection
.guid
.zip(target_list.as_deref())
@@ -758,11 +797,19 @@ fn feed_auras(
let pet_changed = pet_cur.is_some() && pet_cur != memo.pet_last;
memo.pet_last = pet_cur;
let tot_cur = (tot_guid != 0)
.then_some(tot_list.as_deref())
.flatten()
.map(|l| (tot_guid, projection_of(l)));
let tot_changed = tot_cur.is_some() && tot_cur != memo.tot_last;
memo.tot_last = tot_cur;
script.set_auras("player", Some(list));
// Clearing the token isn't a UNIT_* event (the frame reacts to PLAYER_TARGET_CHANGED, and the
// pet frame to UNIT_PET) — same convention as the unit feed's snapshot clear.
script.set_auras("target", target_list);
script.set_auras("pet", pet_list);
script.set_auras("targettarget", tot_list);
script.set_tracking(tracking);
if changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]);
@@ -778,6 +825,9 @@ fn feed_auras(
if target_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
}
if tot_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]);
}
}
/// The apply/refresh-to-descriptor slack: a duration packet is accepted for an aura if it arrived no
@@ -830,6 +880,7 @@ fn end_session_aura_state(
if let Some(mut script) = script {
script.set_auras("player", None);
script.set_auras("target", None);
script.set_auras("targettarget", None);
script.set_tracking(None);
}
cache.auras.clear();
+28 -4
View File
@@ -1116,10 +1116,34 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
/// site that reads an `Emotes.dbc` `EmoteFlags` — called from `DoEmote` (`0x5ef560`) *before*
/// `CMSG_TEXT_EMOTE` is built, so a suppressed emote sends no packet and plays no local anim at all
/// (a seated `/bow` self-censors; the server round-trip never happens). Byte-verified predicate,
/// exactly these four tests in the note's site order — the fifth flag the note decodes (`0x4000`,
/// "requires standing still") only sets an *out* param the client acts on while fear/confuse-
/// controlled, which benilla doesn't model, so it's deliberately not implemented here. `true` =
/// eligible (send + play); `false` = suppress both.
/// exactly these four tests in the note's site order. `true` = eligible (send + play); `false` =
/// suppress both.
///
/// # The fifth flag, `0x4000` — NOT built, and the reason recorded here was WRONG
///
/// This doc used to say `0x4000` ("requires standing still") *"only sets an out param the client
/// acts on while fear/confuse-controlled, which benilla doesn't model"*. A §5 trio carve of the
/// neighbouring stand-state gate re-read the leg and **inverted that polarity**
/// (wow-re `standstate-movement-trigger.md` §5.6, 2026-08-23; decision 1582). The bytes:
///
/// - `0x47dbab` tests `EmoteFlags & 0x4000`, and if set, `0x47dbb3` tests the live `CMovement`
/// word against **`0x20ff`** — the four direction bits, the two turn bits, the two pitch bits and
/// `FALLING` — writing `*out = 1`. Pointedly **not** `SWIMMING`.
/// - `DoEmote` then reads that out-param at `0x5ef5d0` and, when `0x5fa550` returns non-zero,
/// raises message `0x139` = **`ERR_NOEMOTEWHILERUNNING`** ("You can't do that while moving!") and
/// **aborts**: no emote packet, no `SetStandState`.
/// - `0x5fa550` is `IsSelfControlled`, not "is fear/confuse-controlled" — it returns **1** for an
/// ordinary player and **0** while `UNIT_FIELD_FLAGS & 0xc00004` (DISABLE_MOVE / CONFUSED /
/// FLEEING). So the toast fires for the **ordinary moving player** and is *suppressed* while
/// feared. That is the exact reversal of what this comment claimed.
///
/// It stays unbuilt — deliberately, and as a named gap rather than a settled reading: implementing
/// it puts a red error line on screen for every emote typed while moving, turning, pitching or
/// falling, which is a visible behaviour change for the director to weigh rather than a bug fix to
/// slip in. What it is **not** is a water gate: `0x20ff` carries no `SWIMMING`, so it has nothing
/// to do with B155 and could never have covered it (`super::super::tests::
/// the_posture_emotes_carry_no_swim_suppression_flag` is the data half of that same negative). The
/// posture path's water refusal lives in [`crate::player::state`]'s `stand_state_refused`.
pub(super) fn emote_send_eligible(emote_flags: u32, stand_state: u8, swimming: bool) -> bool {
// `0x0400`: unconditional suppress (client `0x47db58`).
if emote_flags & 0x0400 != 0 {
@@ -38,6 +38,7 @@ fn bag_bar_icons_draw_above_the_action_bar_art() {
// ExpBar dwarf art); MerchantFrame.xml is BagFrame's documented purse-helper dep.
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -82,6 +83,7 @@ fn backpack_toggle_plays_open_and_close_kits() {
// BenillaMoney_Set (the bag's purse helper) lives in MerchantFrame.xml — the bag's documented
// isolation dep; Fonts.xml first so both files' `inherits=` FontStrings resolve.
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -131,6 +133,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -217,6 +220,7 @@ fn shift_b_toggles_every_bag_at_once() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -305,6 +309,7 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -418,6 +423,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
@@ -498,6 +504,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
@@ -606,6 +613,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
@@ -744,6 +752,7 @@ fn readable_letter_hover_shows_the_inspect_magnifier() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set — the bag window's money strip
@@ -820,6 +829,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml"); // BenillaBagSlot_OnClick's :Hide() dep
@@ -909,6 +919,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -986,6 +997,7 @@ fn equipped_bag_window_snug_fits_its_row_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -1037,6 +1049,7 @@ fn equipped_bag_window_snug_fits_its_row_count() {
/// the shift-click/split tests below. Returns the slot-1 button's screen center.
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
load_xml(s, "Fonts.xml");
load_xml(s, "MoneyFrame.xml");
load_xml(s, "UiPanels.xml");
load_xml(s, "MerchantFrame.xml");
load_xml(s, "GameTooltip.xml");
@@ -1391,6 +1404,7 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml"); // the shared CooldownFrame_SetTimer
@@ -1459,6 +1473,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -1578,6 +1593,7 @@ fn drawn_with(s: &mut UiScript, needle: &str) -> usize {
fn keyring_surface(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -1841,6 +1857,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.set_screen_size(1600.0, 900.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -2093,6 +2110,7 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -2149,6 +2167,7 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -2200,6 +2219,7 @@ fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
@@ -35,6 +35,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
@@ -29,6 +29,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "BinderConfirm.xml");
s.set_binder_pending(true);
@@ -138,6 +138,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
fn shipped_character_frame_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -154,6 +155,7 @@ fn shipped_character_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -368,6 +370,7 @@ fn close_button_draws_above_the_paper_doll_page() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -412,6 +415,7 @@ fn level_line_survives_no_player_snapshot_yet() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -444,6 +448,7 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -497,6 +502,7 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -539,6 +545,7 @@ fn model_pane_click_auto_equips_a_held_bag_item() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -575,6 +582,7 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
@@ -652,6 +660,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
@@ -845,6 +854,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// ROTATIONS_PER_SECOND lives here (the ref's UIParent.lua:2), like it does in the real client.
@@ -937,6 +947,7 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
@@ -1046,6 +1057,7 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
@@ -242,6 +242,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
@@ -33,6 +33,7 @@ fn picker() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -37,6 +37,7 @@ fn load_combo_frame() -> UiScript {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "ComboFrame.xml");
@@ -28,6 +28,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "DeathFrame.xml");
s
@@ -60,6 +60,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, BagFrame's isolation dep
load_xml(&s, "Cooldown.xml");
@@ -34,6 +34,7 @@ fn load_xml(s: &UiScript, file: &str) {
fn load_room(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -208,6 +209,7 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -273,6 +275,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -28,6 +28,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "DuelFrame.xml");
s
@@ -30,6 +30,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -386,6 +387,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -31,6 +31,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "EnchantConfirm.xml");
s
@@ -124,6 +124,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ErrorsFrame.xml");
load_xml(&s, "GameTooltip.xml");
@@ -59,6 +59,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, used by the bag's Update
load_xml(&s, "LootFrame.xml");
@@ -137,6 +138,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -172,6 +174,7 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
@@ -216,6 +219,7 @@ fn escape_closes_an_open_stack_split_frame() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
@@ -296,6 +300,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -392,6 +397,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
@@ -459,6 +465,7 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameMenuFrame.xml");
@@ -927,14 +927,20 @@ fn convert_entry(
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
// Publish the rect's aspect so a booth can bake at the shape it will be
// stretched into, and know it is on screen at all (decision 1069). Recorded
// before the readiness `continue` below — a pane whose bake hasn't landed yet is
// still a pane being drawn, which is also what keeps the gate below from being a
// chicken-and-egg (nothing drawn → no bake → nothing drawn). The region's rect is
// the whole answer because no pane crops its bake; a pane that grew `<TexCoords>`
// would have to fold that UV window in here too.
//
// ROUND bindings are recorded too, since 1576. The aspect is inert for them (both
// of 1069's consumers are body-booth-only — see [`crate::portrait::BoothPanes`]);
// what the row carries for a round slot is the fact of being drawn, which is the
// `"targettarget"` portrait's cost gate. The `circular` flag itself still decides
// the draw below, and nothing else changed here.
if rect.height() > 0.0 {
booths
.panes
.0
@@ -33,6 +33,7 @@ fn load_xml(s: &UiScript, file: &str) {
fn an_idle_fade_driver_parks_itself_off_the_tick() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(
@@ -69,6 +70,7 @@ fn an_idle_fade_driver_parks_itself_off_the_tick() {
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
@@ -48,6 +48,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
@@ -23,11 +23,16 @@ use benilla_ui::script::{
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = ["Fonts.xml", "UiPanels.xml", "UIPanelTemplates.xml"]
.into_iter()
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
.collect();
let files: Vec<&str> = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIPanelTemplates.xml",
]
.into_iter()
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
.collect();
for file in files {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -82,6 +82,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS + GameFontNormalSmall
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Cooldown.xml");
@@ -488,6 +489,7 @@ fn managed_positions_engage_for_the_bare_frame_name() {
// (`ui_script::load_default_ui`): the roll file's CONFIRM_LOOT_ROLL entry indexes
// `StaticPopupDialogs`, and indexing a nil there aborts the WHOLE inline <Script> chunk —
// taking every BenillaGroupLootFrame_* function down with it, not just the popup.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GroupLootFrame.xml");
@@ -238,6 +238,7 @@ fn setup() -> UiScript {
);
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
@@ -83,6 +83,7 @@ const RANK_GLOBALS: &str = r#"
fn load_page(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -372,6 +373,7 @@ fn shown_inspect_honor_page() -> UiScript {
s.run(RANK_GLOBALS).unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
// Before InspectFrame.xml — the honor page inherits this file's row templates and calls
@@ -126,6 +126,7 @@ fn armed() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
@@ -145,6 +146,7 @@ fn armed() -> UiScript {
fn shipped_inspect_frame_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
@@ -44,6 +44,7 @@ pub(crate) fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
@@ -109,6 +109,7 @@ fn shipped_loot_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// window + 4 rows + up + down + close.
assert_eq!(
@@ -288,6 +289,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
@@ -327,6 +329,7 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
@@ -377,6 +380,7 @@ fn shipped_loot_frame_pages_five_items() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
@@ -461,6 +465,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
@@ -535,6 +540,7 @@ fn the_loot_window_draws_over_the_party_frames() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// PartyFrame's per-member dropdown OnLoad walks the whole popup kit (app manifest order).
@@ -594,6 +600,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"LootFrame.xml",
@@ -44,6 +44,7 @@ fn harness() -> UiScript {
.unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"ScrollTemplates.xml",
"MicroMenu.xml",
@@ -55,6 +55,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
// The named virtual Font objects the re-skinned rows/title inherit through — loaded first at
// runtime (ui_script's shipped list) so `inherits="GameFontNormalSmall"` resolves here too.
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// The window's frame census: window 1 + item rows 12 (10 merchant + the 2 buyback-only) +
@@ -335,6 +336,7 @@ fn merchant_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -375,6 +377,7 @@ fn vendor_open_opens_the_backpack_and_layers_the_sound() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
@@ -419,6 +422,7 @@ fn vendor_leaves_an_already_open_backpack_alone() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
@@ -458,6 +462,7 @@ fn vendor_opens_and_closes_all_equipped_bags() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
@@ -517,6 +522,7 @@ fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -559,6 +565,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// The quality→colour table (BENILLA_LOOT_QUALITY_COLORS) ships in LootFrame.xml; at runtime
@@ -778,6 +785,7 @@ fn money_display_shrinks_to_content_and_stays_flush() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -916,6 +924,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
@@ -1033,6 +1042,7 @@ fn merchant_tabs_fit_their_labels() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
@@ -1082,6 +1092,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
@@ -1168,6 +1179,7 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml"); // the BenillaMoney_* helpers
@@ -1224,6 +1236,7 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"GameTooltip.xml",
+6
View File
@@ -709,6 +709,9 @@ mod pet_bar_tests;
#[cfg(test)]
mod pet_frame_tests;
#[cfg(test)]
mod tot_frame_tests;
#[cfg(test)]
mod micro_menu_tests;
@@ -808,6 +811,9 @@ mod static_popup_tests;
#[cfg(test)]
mod binder_tests;
#[cfg(test)]
mod talent_wipe_tests;
#[cfg(test)]
mod death_tests;
@@ -38,6 +38,7 @@ fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
@@ -662,9 +663,21 @@ fn interface_harness() -> UiScript {
&s,
&[
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
// The target-of-target pair's definer (1576) and the three files ahead of it, all in
// their manifest seats. The chain is a real load-ORDER requirement rather than
// tidiness: `UnitFrames`' three menu hosts initialize into the dropdown kit at load
// (`UIDropDownMenu_Initialize` is nil without it and the OnLoad raises), and the kit's
// own backdrop reads `GameTooltip`'s TOOLTIP_DEFAULT_COLOR. `harness_on` loads two of
// these again after these — re-running a UI file is what `/reload` does, and the
// loader takes it.
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"TextStatusBar.xml",
"UnitFrames.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
@@ -1890,14 +1903,16 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
}
// 22 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's; Camera Following Style
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 4 (3 from
// 1136, Buff Durations 1139) and the Action Bars page's 2 (the lock 1136, Always Show
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
// 1136, Buff Durations 1139, the target-of-target pair 1576) and the Action Bars page's 2 (the
// lock 1136, Always Show
// ActionBars 1500) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1); the other two ride the same census as the selection moves.
assert_eq!(checked, 48, "every tipped row carries a live 1.12 key");
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
assert_eq!(checked, 50, "every tipped row carries a live 1.12 key");
assert_eq!(
untipped,
vec![
@@ -1996,10 +2011,11 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// 22 of the 23 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's; Terrain
// Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
// page's 4 (1136, + Buff Durations 1139), Action Bars' 2 (the lock 1136, Always Show
// page's 6 (1136, + Buff Durations 1139, + the target-of-target pair 1576), Action Bars' 2
// (the lock 1136, Always Show
// ActionBars 1500) and 6 API rows (Show Cloak / Show Helm, 1472; the four multibar switches,
// 1500).
assert_eq!(raised, 48, "every row but Auto Loot has a 1.12 description");
assert_eq!(raised, 50, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2365,6 +2381,112 @@ fn the_interface_page_writes_the_three_stock_globals() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **target-of-target pair** (decision 1576) — the Interface page's first dependent rows and
/// the window's first `uvar` DROPDOWN. Three things here belong to no other row:
///
/// * the picker is dead while the switch is off, which is 1.12's own rule for exactly this pair
/// (`OptionsFrame_DisableDropDown`, UIOptionsFrame.lua l.694-700);
/// * both rows carry the same `applyFunc`, and unlike the three stock globals above they NEED one
/// — the frame's visibility is decided once and kept, and the saved chunk lands after the file
/// that decided it, so without the re-run a saved "1" would leave the frame hidden;
/// * the picker's five values are the reference's own strings, in its own order.
#[test]
fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
let mut s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Ships off, at "always" — 1.12's own two defaults, read off UnitFrames.xml's file scope.
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the switch ships off"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownText:GetText()"
)
.unwrap(),
"Always"
);
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() and true or false"
)
.unwrap(),
"the picker is dead while the switch is off"
);
// The switch: the global moves, the CVar table is never reached, and the picker wakes.
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET").unwrap(),
"1"
);
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() and true or false"
)
.unwrap(),
"and the picker wakes with it"
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTarget.applyFunc \
== TargetofTarget_Update \
and OptionsFrameContainerBodyInterfaceRowTargetOfTargetMode.applyFunc \
== TargetofTarget_Update"
)
.unwrap(),
"both rows re-decide the frame when they are written"
);
// The picker: five entries in the reference's order, the stored value checked, and a pick
// writes the reference's own value string.
s.run("OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton:Click()")
.unwrap();
assert_eq!(
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
5.0
);
assert!(
s.eval::<bool>("return DropDownList1Button5Check:IsVisible()")
.unwrap(),
"Always is the one checked"
);
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET_STATE")
.unwrap(),
"3",
"Solo is the reference's third value"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownText:GetText()"
)
.unwrap(),
"Solo"
);
assert!(
s.take_cvar_changes().is_empty(),
"still nothing in the CVar table"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **equipment-display rows** — Show Cloak and Show Helm (decision 1472, B123). They are the
/// window's third kind of row and the only one with no store at all: the preference is a bit of the
/// character's own server-side `PLAYER_FLAGS`, so the row reads an engine getter and writes an
@@ -55,6 +55,7 @@ fn harness() -> UiScript {
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
@@ -52,6 +52,7 @@ fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Re
fn shipped_gossip_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// The window + its scroll frame (bar + child) + the 32-row shared pool (quest rows and option
@@ -229,6 +230,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
fn shipped_gossip_frame_renders_quest_rows_above_options() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
@@ -344,6 +346,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
@@ -450,6 +453,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
fn gossip_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
@@ -486,6 +490,7 @@ fn gossip_show_hide_plays_open_and_close_kits() {
fn shipped_panel_slot_replaces_gossip_with_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
@@ -567,6 +572,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
fn displacing_an_npc_window_ends_the_displaced_session() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "GossipFrame.xml");
@@ -613,6 +619,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
fn shipped_panel_slot_pushable_promotes_to_center() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -704,6 +711,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
@@ -775,6 +783,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "GossipFrame.xml");
@@ -920,6 +929,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "GossipFrame.xml");
@@ -992,6 +1002,7 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
#[test]
fn the_1507_registry_rows_match_the_reference_bytes() {
let s = UiScript::new().unwrap();
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
for probe in [
// ItemTextFrame — UIParent.lua l.20 (the B288 row).
@@ -1027,6 +1038,7 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
fn a_dead_player_opens_only_whiledead_windows() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
s.set_unit(
"player",
@@ -1091,6 +1103,7 @@ fn a_dead_player_opens_only_whiledead_windows() {
fn a_frame_arriving_at_center_puts_the_child_windows_away() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// The letter is open; two shipped-row stand-ins take the seats: MerchantFrame (pushable 0)
@@ -26,6 +26,7 @@ fn load_xml(s: &UiScript, file: &str) {
/// (`CooldownFrame_SetTimer`), ActionBar (the `MainMenuBar` anchor target) — then the bar.
fn load_pet_bar(s: &UiScript) {
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"Cooldown.xml",
@@ -363,6 +364,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"Cooldown.xml",
@@ -644,6 +646,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
fn load_pet_bar_with_tooltip(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -34,6 +34,7 @@ fn load_pet_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// `UIParent.xml` is the manifest's third file and this page needs one thing out of it:
// `BuildListString`, the reference's own `UIParent.lua:1051` joiner behind the diet tooltip
@@ -34,6 +34,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// The BenillaMoney_* purse helpers the quest reward/progress panels repaint through live in
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
@@ -92,6 +93,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -165,6 +167,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -257,6 +260,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -319,6 +323,7 @@ fn greeting_goodbye_button_closes_the_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -353,6 +358,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -461,6 +467,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -521,6 +528,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -582,6 +590,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
@@ -665,6 +674,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
@@ -99,6 +99,7 @@ const HAMMER_LINK: &str = "|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r";
fn shipped_questlog_frame_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -113,6 +114,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -287,6 +289,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -400,6 +403,7 @@ fn watch_guards_no_op_without_erroring() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ErrorsFrame.xml"); // the guards' red-line surface
@@ -487,6 +491,7 @@ fn progress_auto_watches_for_five_minutes() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -542,6 +547,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -574,6 +580,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -616,6 +623,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -763,6 +771,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -818,6 +827,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -857,6 +867,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -906,6 +917,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "LootFrame.xml"); // BENILLA_LOOT_QUALITY_COLORS (the quality->color table)
@@ -987,6 +999,7 @@ fn popup_children_inherit_the_dialog_stratum() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
s.run(
"StaticPopupDialogs[\"TEST_STRATUM\"] = { text = \"Abandon?\", button1 = \"Yes\", \
@@ -1034,6 +1047,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -1106,6 +1120,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -52,6 +52,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
@@ -99,6 +99,7 @@ fn state() -> ReputationState {
fn load_page(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -29,6 +29,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"ScreenshotStatus.xml",
@@ -66,6 +66,7 @@ fn shown_skills_page() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
@@ -103,6 +103,7 @@ fn click(s: &mut UiScript, name: &str, button: &str) {
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
let text = std::fs::read_to_string(
@@ -133,6 +134,7 @@ fn shipped_spellbook_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
@@ -224,6 +226,7 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
@@ -300,6 +303,7 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(640.0, 700.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "SpellBookFrame.xml");
@@ -404,6 +408,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
@@ -645,6 +650,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
.unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
@@ -26,6 +26,7 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
s
}
@@ -0,0 +1,149 @@
//! The class trainer's respec confirm (decision 1580, TalentWipeConfirm.xml): the dialog
//! `ui_talent_wipe`'s feed raises, the money frame that carries the cost, its Accept, and the range
//! poll that takes it away — driven exactly as that feed and the app's NPC-session guard drive it.
use benilla_ui::script::{ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the binder tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
/// The app's own pre-state: a question is pending and in range, which is what
/// `CheckTalentMasterDist()` reports while the dialog is up. MoneyFrame.xml loads FIRST because
/// the dialog's coin row inherits `SmallMoneyFrameTemplate` — the TOC's own order (1580).
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "TalentWipeConfirm.xml");
s.set_talent_master_pending(true);
s
}
/// `CONFIRM_TALENT_WIPE(cost)` raises the dialog, and Accept queues the one `ConfirmTalentWipe()`
/// that becomes the outbound `MSG_TALENT_WIPE_CONFIRM`. The whole point of the arc: before this
/// wiring the trainer's line produced no dialog and no packet at all — the packet that asks was
/// parsed as an unknown opcode and dropped.
#[test]
fn the_confirm_shows_and_accept_queues_the_wipe() {
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"CONFIRM_TALENT_WIPE shows the dialog"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to unlearn all of your talents? The cost will increase each time you do it.",
"the GlobalStrings sentence, which names no number of its own"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_talent_wipe_confirms(), 1);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"accepting closes it"
);
}
/// The cost rides the event's `arg1` into the dialog's MONEY frame, not into its text — so the
/// coin row is what tells the player a respec costs 1g 50s. This is the first StaticPopup entry to
/// raise one, and the engine's `hasMoneyFrame` leg is what shows it.
#[test]
fn the_cost_lands_in_the_money_frame() {
let mut s = setup();
assert!(
!s.eval::<bool>("return StaticPopup1MoneyFrame:IsVisible()")
.unwrap(),
"the coin row is hidden until an entry asks for it"
);
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
assert!(
s.eval::<bool>("return StaticPopup1MoneyFrame:IsVisible()")
.unwrap(),
"a hasMoneyFrame entry shows the coin row"
);
assert_eq!(
s.eval::<String>("return StaticPopup1MoneyFrameGoldButton:GetText()")
.unwrap(),
"1",
"15000 copper is 1 gold"
);
assert_eq!(
s.eval::<String>("return StaticPopup1MoneyFrameSilverButton:GetText()")
.unwrap(),
"50",
"…50 silver"
);
// A second, dearer question repaints the same row — the cost climbs with every reset, so a
// stale number here would be the one thing the dialog exists to say, said wrong.
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(50_000)]);
assert_eq!(
s.eval::<String>("return StaticPopup1MoneyFrameGoldButton:GetText()")
.unwrap(),
"5"
);
}
/// Cancel and ESC both send **nothing**: declining a respec is silent on the wire (there is no
/// decline opcode — the question is one direction of a two-way opcode and the answer is the
/// other), so the only observable is that no confirm was queued.
#[test]
fn declining_sends_nothing() {
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_talent_wipe_confirms(), 0);
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
s.run("ToggleGameMenu()").unwrap();
assert_eq!(s.take_talent_wipe_confirms(), 0);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"hideOnEscape takes it down"
);
}
/// Walking away takes the question off screen: the entry's OnUpdate polls
/// `CheckTalentMasterDist()`, which the app drives from the shared NPC-session range guard — the
/// same guard, and the same byte-verified distance, that the innkeeper's question runs on. While it
/// holds, ticking changes nothing; the frame it goes false, the dialog hides itself.
#[test]
fn leaving_the_trainers_range_hides_the_confirm() {
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"in range, the tick leaves it alone"
);
s.set_talent_master_pending(false);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"out of range, the dialog takes itself down"
);
assert_eq!(s.take_talent_wipe_confirms(), 0, "and sends nothing");
}
@@ -31,6 +31,7 @@ fn taxi_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
load_xml(&s, "ErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal
@@ -34,6 +34,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
@@ -0,0 +1,487 @@
//! The shipped target-of-target frame, driven end to end (decision 1576) — `UnitFrames.xml`'s
//! `TargetofTargetFrame` over synthetic `"targettarget"` snapshots and the events the app's feed
//! fires for that token.
//!
//! Two things here are not ordinary unit-frame plumbing and are what most of these test:
//!
//! * **The visibility law is six gates deep** — a switch, five display modes, and the four unit
//! tests the reference wraps them in — and a frame that gets any of them wrong is either always
//! there or never there. The mode leg is also where we knowingly leave the reference: its solo
//! arm calls neither `Show` nor `Hide` when you are in a raid, so the frame keeps whatever state
//! it had; ours answers the question the option asks.
//! * **A token going away is silent.** The feed clears `"targettarget"` without an event, by the
//! same convention `"target"` uses, so the frame's own events cannot take it down — that is what
//! `TargetFrame_OnUpdate`'s one-compare reconcile is for, and it has a test.
use benilla_ui::script::{
AuraState, PartyMemberInfo, PartyState, QuadContent, RaidMemberInfo, ScriptValue, UiScript,
UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the unit-frame tests').
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// A unit snapshot with a guid — the identity `UnitIsUnit` compares, which the "your target is
/// you" gate reads.
fn unit(name: &str, guid: u64, health: u32) -> UnitState {
UnitState {
exists: true,
is_connected: true,
name: Some(name.into()),
guid,
health,
max_health: 100,
level: 60,
power_type: 0,
power: 50,
max_power: 100,
..UnitState::default()
}
}
/// The frame's production load prefix, plus a player, a target and a target's target — the state
/// in which everything but the switch itself already allows the frame.
///
/// The tail three are the target-aura tests' set and are here for the same reasons: `BuffFrame`
/// defines `DebuffTypeColor`, which the debuff row's dispel tint indexes, and it will not TICK
/// without `ActionBar`'s `TOOLTIP_UPDATE_TIME` — which this file's reconcile test needs, since it
/// is the only one here that runs the clock.
fn load_tot() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "BuffFrame.xml");
s.set_unit("player", Some(unit("Tri", 0x100, 100)));
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s
}
/// Turn the frame on the way the option row does — the write plus its `applyFunc`.
fn switch_on(s: &mut UiScript) {
s.run(r#"SHOW_TARGET_OF_TARGET = "1" TargetofTarget_Update()"#)
.unwrap();
}
fn shown(s: &mut UiScript) -> bool {
s.eval::<bool>("return TargetofTargetFrame:IsShown() and true or false")
.unwrap()
}
/// Every texture path the UI actually draws this frame (the pet-frame tests' render probe).
fn drawn(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect()
}
fn debuff(spell_id: u32, name: &str, debuff_type: Option<&str>) -> AuraState {
AuraState {
spell_id,
name: Some(name.into()),
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count: 1,
debuff_type: debuff_type.map(str::to_string),
// No unit but yourself carries a duration on the 1.12 wire (decision 0257 B6).
duration: 0.0,
expiration_time: 0.0,
helpful: false,
cancelable: false,
until_cancelled: false,
channeled: false,
}
}
/// A party of `n` others; `GetNumPartyMembers` is that list's length.
fn party(n: usize) -> PartyState {
PartyState {
members: (0..n)
.map(|i| PartyMemberInfo {
name: format!("Mate{i}"),
guid: 0x300 + i as u64,
})
.collect(),
..PartyState::default()
}
}
/// A raid of `n` (the roster INCLUDES the player, per `GetRaidRosterInfo`'s array).
fn raid(n: usize) -> PartyState {
PartyState {
raid: (0..n)
.map(|i| RaidMemberInfo {
name: format!("Raider{i}"),
guid: 0x400 + i as u64,
..RaidMemberInfo::default()
})
.collect(),
..PartyState::default()
}
}
/// The switch is the outer gate, and it ships OFF — 1.12's own default (`SHOW_TARGET_OF_TARGET`
/// = `"0"`, ref UIOptionsFrame.lua l.116). Everything else about the state already allows the
/// frame, so this is the switch alone.
#[test]
fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
let mut s = load_tot();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET").unwrap(),
"0",
"the shipped default is the reference's: off"
);
assert!(
!shown(&mut s),
"off means hidden with everything else ready"
);
switch_on(&mut s);
assert!(shown(&mut s), "the switch alone brings it up");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four unit gates the reference wraps the switch in (ref TargetofTarget_Update l.504): a
/// target, a target OF it, a target that is not you, and a target that is alive.
#[test]
fn the_four_unit_gates_each_take_the_frame_down() {
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
// No target of target.
s.set_unit("targettarget", None);
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "nothing to show");
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
// No target at all.
s.set_unit("target", None);
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "no target, no target's target");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// The target is YOU: its target is your own target, and the frame would restate the frame it
// hangs off.
s.set_unit("target", Some(unit("Tri", 0x100, 100)));
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "self-target: nothing to add");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// A dead target.
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 0)));
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "a corpse is fighting nobody");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
s.run("TargetofTarget_Update()").unwrap();
assert!(shown(&mut s), "and back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The five display modes, each against solo / party / raid. Mode 3 in a raid is the one that
/// diverges from the reference on purpose: its ladder calls neither Show nor Hide there and the
/// frame keeps its last state (ref l.513-520), which is a leak rather than a rule.
#[test]
fn the_five_modes_answer_solo_party_and_raid() {
let mut s = load_tot();
switch_on(&mut s);
let states = [
("solo", PartyState::default()),
("party", party(2)),
("raid", raid(10)),
];
// mode -> (solo, party, raid)
let expected = [
("1", [false, false, true]), // raid only
("2", [false, true, false]), // party, and not while raiding
("3", [true, false, false]), // solo only
("4", [false, true, true]), // grouped at all
("5", [true, true, true]), // always
];
for (mode, want) in expected {
for (i, (label, state)) in states.iter().enumerate() {
s.set_party(state.clone());
s.run(&format!(
r#"SHOW_TARGET_OF_TARGET_STATE = "{mode}" TargetofTarget_Update()"#
))
.unwrap();
assert_eq!(
shown(&mut s),
want[i],
"mode {mode} while {label}: expected shown={}",
want[i]
);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Mode 3 in a raid, specifically as a STATE question rather than a fresh evaluation: the frame
/// must come down when the raid forms, not keep whatever it had. This is the reference's leaked
/// branch, and it can only be seen by showing the frame first.
#[test]
fn the_solo_mode_comes_down_when_a_raid_forms() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"SHOW_TARGET_OF_TARGET_STATE = "3" TargetofTarget_Update()"#)
.unwrap();
assert!(shown(&mut s), "solo, in solo mode");
s.set_party(raid(10));
s.fire_event("RAID_ROSTER_UPDATE", vec![]);
assert!(!shown(&mut s), "the raid forms and the frame goes away");
}
/// The paint: name, health, and the powerless unit's hidden mana bar (this file's convention on
/// every frame — the reference leaves an empty 0/0 rail drawn).
#[test]
fn the_frame_paints_its_unit() {
let mut s = load_tot();
switch_on(&mut s);
assert_eq!(
s.eval::<String>("return TargetofTargetName:GetText()")
.unwrap(),
"Tri"
);
let (value, max) = s
.eval::<(f64, f64)>(
"local _, m = TargetofTargetHealthBar:GetMinMaxValues() \
return TargetofTargetHealthBar:GetValue(), m",
)
.unwrap();
assert_eq!((value, max), (100.0, 100.0));
assert!(
s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
.unwrap(),
"a unit with mana shows its rail"
);
let mut powerless = unit("Skeleton", 0x100, 100);
powerless.max_power = 0;
s.set_unit("targettarget", Some(powerless));
s.fire_event(
"UNIT_MAXPOWER",
vec![ScriptValue::Str("targettarget".into())],
);
assert!(
!s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
.unwrap(),
"no power, no rail"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// DEAD over a dimmed trough — and the reference's connected test, which is what tells a corpse
/// from a linkdead player (both read zero health).
#[test]
fn the_dead_word_needs_a_connected_corpse() {
let mut s = load_tot();
switch_on(&mut s);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"alive: no word"
);
let mut corpse = unit("Tri", 0x100, 0);
corpse.dead = true;
s.set_unit("targettarget", Some(corpse.clone()));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
assert!(
s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"dead: the word"
);
let mut linkdead = corpse;
linkdead.is_connected = false;
s.set_unit("targettarget", Some(linkdead));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"disconnected, not dead"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The portrait tint (ref TargetofTargetHealthCheck), and its PLAYERS-ONLY gate: a creature's
/// portrait is never tinted, which is why the check has to run off the bar's own value rather
/// than off the snapshot.
#[test]
fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
let mut s = load_tot();
switch_on(&mut s);
let mut hurt = unit("Tri", 0x100, 15);
hurt.is_player = true;
s.set_unit("targettarget", Some(hurt));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0), "a player under a fifth: red");
let mut healthy = unit("Tri", 0x100, 90);
healthy.is_player = true;
s.set_unit("targettarget", Some(healthy));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 1.0, 1.0), "and white again");
// A creature at the same 15%: the tint must not move (the check returns before touching it).
s.set_unit("targettarget", Some(unit("Kobold", 0x500, 15)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 1.0, 1.0), "creatures carry no reading");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The left click selects — the one leg the target frame's own click does not have, and the whole
/// reason the frame is clickable.
#[test]
fn the_left_click_targets_the_unit() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"TargetofTarget_OnClick("LeftButton")"#).unwrap();
assert_eq!(s.take_target_requests(), vec!["targettarget".to_string()]);
// A right click is not a menu here: the reference gives this frame none.
s.run(r#"TargetofTarget_OnClick("RightButton")"#).unwrap();
assert!(s.take_target_requests().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four debuff buttons, dispel-tinted (ref RefreshBuffs over MAX_PARTY_DEBUFFS).
#[test]
fn the_debuff_row_draws_what_the_unit_carries() {
let mut s = load_tot();
switch_on(&mut s);
s.set_auras(
"targettarget",
Some(vec![
debuff(1000, "Rend", None),
debuff(1001, "Curse of Agony", Some("Curse")),
]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]);
let paths = drawn(&mut s);
assert!(
paths.iter().any(|p| p == "Interface\\Icons\\Spell_1000"),
"the first debuff's icon draws: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "Interface\\Icons\\Spell_1001"),
"and the second"
);
assert!(
!s.eval::<bool>("return TargetofTargetFrameDebuff3:IsShown() and true or false")
.unwrap(),
"the empty slots stay down"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The target's OWN aura rows re-wrap around this frame: 5 to a row instead of 6, and the buffs
/// open under the second debuff row instead of the seventh icon (ref TargetDebuffButton_Update
/// l.310-334). Both directions — the reference only re-runs the rows on the way in.
#[test]
fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
let mut s = load_tot();
// A hostile target, so the debuffs lead and the buffs hang off them.
s.set_auras("target", Some(vec![debuff(2000, "Sunder", None)]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
let anchor = |s: &mut UiScript, frame: &str| -> String {
s.eval::<String>(&format!(
"local _, rel = {frame}:GetPoint(1) return rel:GetName()"
))
.unwrap()
};
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff7",
"hidden: the sixth icon closes the first row"
);
switch_on(&mut s);
assert!(shown(&mut s));
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff6",
"shown: the row wraps a slot early"
);
assert_eq!(
anchor(&mut s, "TargetFrameDebuff6"),
"TargetFrameDebuff1",
"and the sixth icon starts the second row"
);
// Back off — the reference never re-lays the rows here, so they would stay wrapped short.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" TargetofTarget_Update()"#)
.unwrap();
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff7",
"and back to six"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reconcile. A token going away is CLEARED, not announced — no UNIT_* event carries it — so
/// the frame's own registrations cannot take it down. `TargetFrame_OnUpdate` is what does, and it
/// runs only while you have a target.
#[test]
fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() {
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
// Exactly what the feed does: clear the token, fire nothing.
s.set_unit("targettarget", None);
assert!(shown(&mut s), "no event, so nothing has told the frame yet");
s.tick(0.016);
assert!(!shown(&mut s), "the reconcile notices within a frame");
// And the other direction: a token appearing DOES fire, but the reconcile covers it anyway.
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
s.tick(0.016);
assert!(shown(&mut s), "and brings it back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -42,6 +42,7 @@ fn trainer_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "UIDropDownMenu.xml"); // the filter dropdown's kit
@@ -596,6 +596,7 @@ fn shipped_target_frame_runs_the_level_law() {
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
// GetDifficultyColor's own load chain (the quest log window, its ref home).
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
@@ -1135,6 +1136,7 @@ fn the_party_art_paints_over_the_bars() {
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
@@ -436,6 +436,7 @@ fn load_pet_menu_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
@@ -14,6 +14,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIPanelTemplates.xml",
"GameTooltip.xml",
@@ -51,6 +51,7 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "ZoneText.xml");
+5 -1
View File
@@ -41,7 +41,11 @@ impl Plugin for UiSessionPlugin {
/// [`crate::ui_gossip::GossipState`], [`crate::ui_quest::QuestGiver`],
/// [`crate::ui_trainer::TrainerOpen`], [`crate::ui_taxi::TaxiState`],
/// [`crate::ui_mail::MailOpen`], [`crate::ui_trade::TradeSession`], [`crate::ui_bank::BankOpen`]
/// and [`crate::ui_auction::AuctionOpen`]; each *NPC* window registers
/// and [`crate::ui_auction::AuctionOpen`] — plus the two free-standing *questions*, which are
/// sessions for the same reason a window is ([`crate::ui_binder::BinderState`],
/// [`crate::ui_talent_wipe::TalentWipeState`]: the reference re-runs its own interact-range test
/// against a latched guid, so "walked away" retracting the dialog is its law, not our tidiness).
/// Each *NPC* window registers
/// [`close_npc_session_out_of_range::<T>`] ahead of its feed so the clear turns into the window's
/// `*_CLOSED` event the same frame — trade does **not** (its cancel is server-driven, decision 0592).
/// [`feed_interact_npc`] collapses the portrait-bound sessions into the [`InteractNpc`] the
+249
View File
@@ -0,0 +1,249 @@
//! Unlearning your talents — the class trainer's respec question, its dialog, and its answer
//! (decision 1580).
//!
//! **The law this module exists for: selecting the trainer's "I wish to unlearn my talents" line
//! unlearns nothing.** It makes the server close the gossip menu and ask
//! (`MSG_TALENT_WIPE_CONFIRM` inbound, vmangos `Player::SendTalentWipeConfirm` off
//! `GOSSIP_OPTION_UNLEARNTALENTS`); the reset happens only when the client sends the **same
//! opcode** back with the trainer's guid, at which point `Player::ResetTalents` runs and the
//! trainer casts 14867 "Untalent Visual Effect". Before this module the question was parsed as an
//! unknown opcode and dropped, so the line looked like it did nothing at all — the director's
//! "I can't unlearn my talents, the confirm window never pops up".
//!
//! It is the innkeeper bind's twin, not a talent-window affordance, and that is why it lives beside
//! [`crate::ui_binder`] rather than inside [`crate::ui_talent`]: same shape on the wire (a question
//! carrying a guid, an answer echoing it), same latch, same range gate, same free-standing dialog
//! over a gossip menu that is already gone. wow-re says so at the bytes
//! (`system/ui/scratch/gossip-icon-and-binder-flow.md` §5.3): the talent master
//! (`0xc4d7a0`/`0xc4d7a4`), the pet untrainer and the binder are **one latch/range family**, three
//! copies of a single shape.
//!
//! The Era surface it drives is the reference's own (`StaticPopup.lua:1289-1304`,
//! `UIParent.lua:123`/`533-538`):
//!
//! - the `CONFIRM_TALENT_WIPE` event, whose **one argument is the cost in copper** — the dialog's
//! money frame, not its text (the text is a fixed GlobalString);
//! - `ConfirmTalentWipe()` — the dialog's Accept, the only call that sends the answer;
//! - `CheckTalentMasterDist()` — polled from the dialog's `OnUpdate`, and the dialog hides itself
//! the frame it goes false.
//!
//! Both halves are **byte-pinned** against the reference's `0x5df980`, one function that serves
//! both directions of the opcode (wow-re `system/ui/scratch/talent-api.md` §ConfirmTalentWipe, and
//! the disassembly under it):
//!
//! 1. **Arrival** (`guid != 0`): resolve the unit, gate on `d² <= [0xc4c28c]` — the identical
//! constant behind [`crate::target::SERVICE_RANGE_SQ`], which is what makes modelling the
//! question as an [`NpcSession`] faithful rather than convenient — then latch guid + cost and
//! `SignalEvent2(CONFIRM_TALENT_WIPE, "%d", cost)` at `0x5dfa3f`.
//! 2. **Accept** (`ConfirmTalentWipe()` calls the same function with a **zeroed** guid, which
//! substitutes the latch): the same range gate, then a **client-side money check** —
//! `cost > coinage` shows `ERR_NOT_ENOUGH_MONEY` (`DisplayError(0x25)`, `0x5dfa81`) and sends
//! **nothing**; otherwise the answer goes out carrying the latched guid.
//!
//! **One deliberate deviation, and it is the zero guid.** vmangos answers a failed reset (no points
//! spent) by asking again with `trainer = 0` (`SkillHandler.cpp:52`; the field's own comment reads
//! "0 if player has no talents to reset"). At the bytes that lands on leg 2, not leg 1 — a zero
//! guid *is* the Accept path — so a 1.12 client re-sends the answer, is refused again, and the two
//! sit there trading packets. We treat a zero-guid question as what it means, "there is nothing to
//! ask", and drop it: no dialog, no send. A packet loop is not a look we owe anyone.
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// `ERR_NOT_ENOUGH_MONEY` (GlobalStrings.lua, verbatim) — the red line the reference's
/// `DisplayError(0x25)` resolves when Accept is clicked without the cost in your purse. The same
/// string [`crate::ui_merchant`] and [`crate::ui_mail`] already inline for their own refusals.
const ERR_NOT_ENOUGH_MONEY: &str = "You don't have enough money.";
/// The pending respec question. Written by the net drain's `TalentWipeConfirm` arm, read by
/// [`feed_talent_wipe`] (which fires `CONFIRM_TALENT_WIPE` and publishes `CheckTalentMasterDist`'s
/// answer) and by [`drain_talent_wipe`] (which turns the dialog's Accept into the answer packet).
///
/// [`crate::ui_binder::BinderState`]'s shape exactly, down to the per-packet `ask` flag: everything
/// the dialog reads arrives as the event's argument, so there is no snapshot beside it.
#[derive(Resource, Default)]
pub(crate) struct TalentWipeState {
/// The trainer that asked; `None` = no question pending. This guid is what goes back on the
/// wire, and vmangos resolves it to a live `UNIT_NPC_FLAG_TRAINER` in range — a stale one
/// unlearns nothing.
npc: Option<u64>,
/// What the reset costs, in copper — the event's `arg1`, and the number the Accept gate
/// compares against the purse.
cost: u32,
/// A question the feed still owes the UI. Set per *packet*, not per state edge: declining and
/// clicking the line again is two dialogs, and an edge-diff would swallow the second.
ask: bool,
}
impl TalentWipeState {
/// The inbound `MSG_TALENT_WIPE_CONFIRM` — park the trainer's guid and cost, and owe the UI a
/// dialog. A **zero** guid never reaches here (the net arm drops it; module doc's deviation).
pub(crate) fn ask(&mut self, npc: u64, cost: u32) {
self.npc = Some(npc);
self.cost = cost;
self.ask = true;
}
/// The guid to answer with, if a question is live.
fn pending(&self) -> Option<u64> {
self.npc
}
/// Retract the question — the range guard's close, and the drain's once the answer is sent.
pub(crate) fn clear(&mut self) {
self.npc = None;
self.cost = 0;
self.ask = false;
}
}
/// The question is an NPC session: the standardized range guard closes it — the same no-packet
/// clear declining does — when the player walks out of the trainer's service range or the trainer
/// despawns. That close is what `CheckTalentMasterDist()` reports, and it is the reference's own
/// second range test rather than a convenience of ours (module doc, leg 2).
impl NpcSession for TalentWipeState {
fn npc(&self) -> Option<u64> {
self.npc
}
fn close(&mut self) {
self.clear();
}
}
/// Fire `CONFIRM_TALENT_WIPE(cost)` for a question the UI is still owed, and publish
/// `CheckTalentMasterDist()`'s answer every frame.
fn feed_talent_wipe(script: Option<NonSendMut<UiScript>>, mut wipe: ResMut<TalentWipeState>) {
let Some(mut script) = script else {
return;
};
script.set_talent_master_pending(wipe.pending().is_some());
if !wipe.ask {
return;
}
wipe.ask = false;
// arg1 is the cost in COPPER, as a number — the reference's own `"%d"` fire (`0x5dfa3f`), and
// what `MoneyFrame_Update` on the dialog's money frame takes.
script.fire_event(
"CONFIRM_TALENT_WIPE",
vec![ScriptValue::Int(i64::from(wipe.cost))],
);
}
/// Turn the dialog's Accept into the outbound `MSG_TALENT_WIPE_CONFIRM` — or into the red
/// not-enough-money line, which is the reference's own fork at Accept time (module doc, leg 2).
///
/// Gated on a question still being pending, exactly as [`crate::ui_binder`]'s accept is: a
/// `ConfirmTalentWipe()` typed at the console with no trainer asking would otherwise send a zero
/// guid the server can only drop.
fn drain_talent_wipe(
script: Option<NonSendMut<UiScript>>,
mut wipe: ResMut<TalentWipeState>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let confirms = script.take_talent_wipe_confirms();
if confirms == 0 {
return;
}
let Some(npc) = wipe.pending() else {
return;
};
// The client's own purse check (`0x5dfa79`): over budget is a red line and no packet, so the
// player is told rather than met with silence — vmangos's refusal for the same case is the
// zero-guid question this module drops.
let money = self_q
.single()
.ok()
.and_then(|store| store.0.player_money())
.unwrap_or(0);
if wipe.cost > money {
debug!(
"ui_talent_wipe: respec costs {} copper, purse holds {money} — refused client-side",
wipe.cost
);
script.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str(ERR_NOT_ENOUGH_MONEY.to_string())],
);
return;
}
for _ in 0..confirms {
debug!("ui_talent_wipe: confirming the wipe at trainer {npc:#x}");
let _ = commands
.0
.send(ClientCommand::TalentWipeConfirm { trainer: npc });
}
// A question that has been answered is not a question. The reference never clears its latch
// (`0xc4d7a0`) — the same deviation [`crate::ui_binder`] takes, and for the same reason: its
// own dialog is gone by now either way, and a second Accept would be a second reset.
wipe.clear();
}
/// The respec flow: the range guard, the dialog's feed, and its answer.
pub(crate) struct UiTalentWipePlugin;
impl Plugin for UiTalentWipePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TalentWipeState>().add_systems(
Update,
(
// Range-close before the feed so walking away takes the dialog down the same
// frame (the binder question's ordering, for the same reason).
close_npc_session_out_of_range::<TalentWipeState>.before(feed_talent_wipe),
feed_talent_wipe.before(UiInput),
drain_talent_wipe.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A second question from the same trainer is a second dialog (decline, then click the line
/// again). The `ask` flag is per packet precisely so a state diff cannot swallow it.
#[test]
fn asking_twice_owes_two_dialogs() {
let mut wipe = TalentWipeState::default();
assert_eq!(wipe.pending(), None);
wipe.ask(0x2a, 15_000);
assert!(wipe.ask);
wipe.ask = false; // the feed fired the first dialog
assert_eq!(wipe.pending(), Some(0x2a), "the guid outlives the fire");
assert_eq!(
wipe.cost, 15_000,
"and so does the cost the money frame reads"
);
wipe.ask(0x2a, 25_000);
assert!(wipe.ask, "the same trainer asking again owes a dialog");
assert_eq!(
wipe.cost, 25_000,
"at the NEW cost — a reset climbs the price"
);
}
/// Closing (the range guard, or the answer going out) retracts the guid, the cost and any
/// unfired question — so `CheckTalentMasterDist()` goes false and a later `ConfirmTalentWipe()`
/// sends nothing.
#[test]
fn closing_retracts_the_guid_the_cost_and_the_unfired_dialog() {
let mut wipe = TalentWipeState::default();
wipe.ask(0x2a, 15_000);
wipe.close();
assert_eq!(wipe.pending(), None);
assert_eq!(wipe.cost, 0);
assert!(!wipe.ask);
}
}
+82 -2
View File
@@ -7,7 +7,9 @@
//! ([`crate::ui_script::UiInput`]), and pushes a [`UnitState`] snapshot for each unit token into the
//! VM via [`UiScript::set_unit`]. The `"player"` token reads our own avatar's [`ObjectStore`] (tagged
//! [`SelfPlayer`]); `"target"` reads the [`Selection`]'s entity. Both are found by their ECS entity,
//! not by re-deriving a guid — the ECS already owns the guid↔entity map.
//! not by re-deriving a guid — the ECS already owns the guid↔entity map. `"targettarget"` is the one
//! token here that IS reached by a guid: the target's own `UNIT_FIELD_TARGET`, looked up in the same
//! index (decision 1576).
//!
//! Names come from the [`crate::names::NameCache`] (the 1.12 wire has no descriptor names — the
//! query-cache seam): the feed resolves each token's guid, which asks the server once on a miss and
@@ -840,6 +842,12 @@ fn feed_units(
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
stores: Query<&ObjectStore>,
// The guid -> entity map the `"targettarget"` hop lands in, the same index `/assist` resolves
// its basis' `UNIT_FIELD_TARGET` through (`target::by_name`). `Option` because it belongs to
// `NetPlugin` (net.rs's `init_resource`) and this feed does not: a UI-only harness runs this
// system with no net stack at all, and a bare `Res` turns that into a system-validation panic
// rather than a token that names nobody. Same shape as `factions` below, same reason.
index: Option<Res<crate::net::GuidIndex>>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut feed: ResMut<UnitFeedState>,
@@ -911,6 +919,15 @@ fn feed_units(
let name = names.resolve(guid.0, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
s.is_player = true;
// The stated `is_connected` gap, closed for every token this feed pushes — the field's own
// doc names the feed as what must set it ("mirroring `exists`"), and a token we push at all
// is one whose descriptor is streamed. It was reachable, not academic: `GREY_ROW["WHISPER"]`
// (UnitPopup.xml) greys on `not UnitIsConnected(unit)`, so Whisper sat greyed in the
// right-click menu of every player you targeted. What stays out of reach is real link-death
// — the only wire that carries it is the group roster's status byte, which the party feed
// already reads for its own tokens; a non-group player logging out lingers here as
// connected until that lands.
s.is_connected = true;
// Identity + the raid-target board mark (decision 0434 §5's popup gating; §6's board).
s.guid = guid.0;
s.raid_target = group.raid_target_index(guid.0);
@@ -965,6 +982,7 @@ fn feed_units(
) + 1;
let mut s = snapshot(store, name, reaction);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
// The byte-confirmed CanAttack 0x606980 (decision 0172) — the same predicate TAB and the
@@ -990,6 +1008,53 @@ fn feed_units(
Some(s)
});
// `"targettarget"` — the target's own target, one hop off its `UNIT_FIELD_TARGET`. It is the
// same read `/assist` runs (`target::by_name`), and the same shape 1.12's own token resolver
// walks for the `target` SUFFIX: resolve the base unit, then follow the guid its target field
// carries. Only a STREAMED guid resolves — an unstreamed one leaves `UnitExists("targettarget")`
// false, exactly as an out-of-range party member is false.
//
// No `guild` leg, deliberately: `unit_guild` is the LAZY cache whose miss SENDS a
// `CMSG_GUILD_QUERY` (1257), and nothing asks a target-of-target for its guild — the party and
// raid tokens leave it unfilled for the same reason. Everything else here is a pure read of a
// descriptor we already hold.
let tot = selection
.target
.and_then(|e| stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|guid| *guid != 0)
.and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid)))
.and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
&reputations,
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.can_attack = crate::target::can_attack(
Some(store),
factions.as_deref(),
&reputations,
self_pair.map(|(s, _)| s),
);
enrich_unit(
&mut s,
guid,
&names,
store,
factions.as_deref(),
self_pair.map(|(s, _)| s),
);
Some(s)
});
// `"player"` is pushed only while the descriptor EXISTS: its absence is "no data source",
// never "the player stopped existing". The two absent windows are pre-arrival at login —
// where a `None` push would erase the roster seat (`seat_from_roster`) that addon file scopes
@@ -1015,6 +1080,17 @@ fn feed_units(
gate.audit("feed_units", "the target snapshot");
script.set_unit("target", target.clone());
}
// The same "absence IS data" push as the target's above: the target dropping ITS target is a
// transition the frame has to hear about, and clearing the token is how it hears it.
let tot_dirty = match (&tot, memo.last.get("targettarget")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if tot_dirty {
gate.audit("feed_units", "the target-of-target snapshot");
script.set_unit("targettarget", tot.clone());
}
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
@@ -1168,7 +1244,11 @@ fn feed_units(
memo.action_bar_toggles = None;
}
for (token, snap) in [("player", &player), ("target", &target)] {
for (token, snap) in [
("player", &player),
("target", &target),
("targettarget", &tot),
] {
match snap {
Some(cur) => {
let prev = memo.last.get(token);
+1 -1
View File
@@ -21,12 +21,12 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// `BenillaMoneyInput_*` money-entry helpers.
const FILES: [&str; 9] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"MoneyFrame.xml",
"MerchantFrame.xml",
"AuctionFrame.xml",
];
+2 -1
View File
@@ -22,9 +22,10 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The character window's load prefix, in `assets/ui/benilla.toc` order. The .toc's own entry says
/// it "depends only on Fonts/UiPanels/GameTooltip"; `ItemButtonTemplate.xml` joins them because the
/// doll's equipment slots are `SetItemButton*` buttons.
const FILES: [&str; 5] = [
const FILES: [&str; 6] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
@@ -20,8 +20,9 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The prefix of `benilla.toc`'s load order these templates need: the font registry, the panel kit,
/// UIParent (the parent every addon passes), and the faux-scroll kit.
const FILES: [&str; 4] = [
const FILES: [&str; 5] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"ScrollTemplates.xml",
+2 -1
View File
@@ -11,8 +11,9 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The reader's dependency prefix, in the manifest's own order. `ScrollTemplates.xml` and
/// `UIPanelTemplates.xml` joined it with decisions 1337/1338: the page sits in a real ScrollFrame
/// now, whose template is in the second and whose `ScrollFrame_OnLoad` is in the first.
const FILES: [&str; 5] = [
const FILES: [&str; 6] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
+1 -1
View File
@@ -14,9 +14,9 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// helpers (postage display), so a load error in either fails here.
const FILES: [&str; 6] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"MoneyFrame.xml",
"MerchantFrame.xml",
"MailFrame.xml",
];
+2 -1
View File
@@ -20,9 +20,10 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// `ItemButtonTemplate.xml` is the `SetItemButton*` family the talent buttons grey through (the
/// reference's own verb; see TalentFrame.xml's header) — it sits at .toc line 32, above every
/// other entry here bar `Fonts.xml`.
const FILES: [&str; 6] = [
const FILES: [&str; 7] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
+2 -1
View File
@@ -13,8 +13,9 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The trade window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// MerchantFrame.xml rides along because TradeFrame.xml reuses its global `BenillaMoney_*` coin
/// helpers (the two gold displays), so a load error in either fails here.
const FILES: [&str; 5] = [
const FILES: [&str; 6] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"MerchantFrame.xml",
+2 -1
View File
@@ -19,8 +19,9 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The tradeskill window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// CraftFrame.xml rides along (it loads right after TradeSkillFrame.xml in the app and shares
/// its guarded-global utilities) so a load error in EITHER window fails here.
const FILES: [&str; 7] = [
const FILES: [&str; 8] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
@@ -1247,7 +1247,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> {
/// (each is a [`benilla_formats::ParticleEmitterDef`] predicate), plus any bit the loader maps
/// to nothing — an unmapped bit on a model that looks wrong is a lead, not noise.
fn part_flags(flags: u32) -> String {
const NAMED: [(u32, &str); 13] = [
const NAMED: [(u32, &str); 14] = [
// The reference has NO "lit" bit — 0x1 is the UNLIT flag, the inverse of the wiki lore
// (wow-re `part-scene-multipliers.md` §1). Naming it the way the binary reads it is the
// point: an emitter WITHOUT this bit is the one that takes the scene's light, and that
@@ -1259,7 +1259,17 @@ fn part_flags(flags: u32) -> String {
// order for every emitter; harmless where a cloud is one flat colour (alpha coverage is
// order-independent), visible where it is not.
(0x0002, "depthSortParticles(TODO)"),
(0x0010, "modelSpace"),
// NOT unmapped, and printing it as a lead sent one session hunting it as the ride switch
// (1578): file `0x8` feeds the SECOND runtime flag word, `rt+0x194` bit 1 = NOT(file 0x8)
// (loader `0x70fd01`), the vertex-format/blend word — wow-re `part-simspace-fields.md` §A2
// calls it "orthogonal to simulation space". Bit 1's own reader is unread there; bit 0's
// (from file `0x1`) picks a 4- vs 8-word vertex stride. Half the corpus authors it.
(0x0008, "vertexFormat(rt+0x194 b1, reader unread)"),
// **The ride-vs-trail switch** (wow-re `part-emitter-motion.md` §2c, decision 1578): SET
// stores emitter-LOCAL and re-applies the live emitter matrix at draw (a rigid ride);
// CLEAR bakes the birth into WORLD and never re-applies it, so a moving host lays a trail
// `speed × lifetime` long. 30.4% of the corpus, 71% of `Item\ObjectComponents`.
(0x0010, "modelSpace(ride)"),
(0x0020, "sizeByInstanceScale"),
(0x0040, "inheritEmitterMotion"),
(0x0080, "killOutbound(sphere)"),
+12 -5
View File
@@ -451,11 +451,18 @@ pub struct ParticleEmitterDef {
impl ParticleEmitterDef {
/// File flag `0x10` (→ runtime `0x100` — the loader's flag word is a **non-identity remap**,
/// wow-re `part-simspace-fields.md` corrections `1f40db0b`, byte block `0x70faf8–0x70fc44`):
/// the whole cloud renders through the emitter's **live bone matrix** each frame (rotation and
/// all — the chandelier's candle flames rigidly ride the swing). Clear ⇒ bone/model rotation is
/// baked at birth instead. Either way the cloud is **re-anchored to the emitter's current
/// position every frame** (the draw matrix rebuilds with `translate(−emitterPos)` per frame) —
/// a moving model carries its flame; there is NO world-frozen trail mode.
/// **the storage-space choice, and so the ride-vs-trail switch** (wow-re
/// `part-emitter-motion.md` §2c, byte-settled). SET ⇒ the spawn stores raw emitter-LOCAL
/// pos/vel and the draw folds the live emitter matrix back in every frame: the whole cloud
/// rides the emitter rigidly, rotation and all (the chandelier's candle flames ride the swing;
/// a carried torch is flagged for it). CLEAR ⇒ the spawn bakes pos/vel into WORLD and the draw
/// never re-applies the emitter matrix: each particle **hangs where it was born**, so a moving
/// host lays a trail `host speed × particle lifetime` long.
///
/// This doc said the opposite until 1578 — "a moving model carries its flame; there is NO
/// world-frozen trail mode" was the reading two earlier RE rounds built on a null test (a
/// kobold's 0.2 s candle smear, too small to see), and `part-emitter-motion.md` §2c refuted it
/// with the spawn/draw pairing plus a 7860-emitter corpus census.
pub fn model_space(&self) -> bool {
self.flags & 0x10 != 0
}
+5
View File
@@ -344,6 +344,11 @@ pub enum SessionEvent {
/// question the `CONFIRM_BINDER` dialog puts on screen. `binder` is the innkeeper's guid, and
/// it must be echoed in `CMSG_BINDER_ACTIVATE` for the bind to happen at all (decision 1331).
BinderConfirm { binder: u64 },
/// A class trainer is asking whether to unlearn every talent (`MSG_TALENT_WIPE_CONFIRM`,
/// inbound) — the question the `CONFIRM_TALENT_WIPE` dialog puts on screen, carrying the
/// `trainer` to answer with and the `cost` in copper its money frame shows. Answering means
/// sending the SAME opcode back with the guid; declining sends nothing (decision 1580).
TalentWipeConfirm { trainer: u64, cost: u32 },
/// The bind took (`SMSG_PLAYERBOUND`): `area` is the AreaTable id we are now bound in, the
/// same one [`Self::BindPoint`] carries in the packet beside it.
PlayerBound { binder: u64, area: u32 },
@@ -611,6 +611,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
}
ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }],
ServerPacket::BinderConfirm { binder } => vec![SessionEvent::BinderConfirm { binder }],
ServerPacket::TalentWipeConfirm { trainer, cost } => {
vec![SessionEvent::TalentWipeConfirm { trainer, cost }]
}
ServerPacket::PlayerBound { binder, area } => {
vec![SessionEvent::PlayerBound { binder, area }]
}
+3 -1
View File
@@ -167,7 +167,9 @@ pub use pet::{
PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
pub use pose::{set_sheathed, stand_state_change};
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
pub use progression::{
learn_talent, talent_wipe_confirm, ExplorationXp, LevelUpInfo, TalentWipeConfirm, XpGain,
};
pub use pvp::{inspect_honor_stats, InspectHonorStats, PvpCredit};
pub use quest::{
dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward,
@@ -738,6 +738,20 @@ pub const CMSG_AUTOBANK_ITEM: u16 = 0x0283; // 643
/// (`SMSG_LEARNED_SPELL` etc.) and the refreshed `PLAYER_CHARACTER_POINTS1`. Decision 0304.
pub const CMSG_LEARN_TALENT: u16 = 0x0251; // 593
/// The respec question and its answer — **one opcode, both directions** (VERIFIED vmangos
/// `Opcodes_1_12_1.h`: 682 + `Opcodes.cpp`'s `HandleTalentWipeConfirmOpcode` registration; decision
/// 1580). Selecting a class trainer's "I wish to unlearn my talents" line
/// (`GOSSIP_OPTION_UNLEARNTALENTS`, `Player.cpp:12330`) makes the server close the gossip menu and
/// send this **inbound** carrying the trainer's guid and the current cost — a QUESTION, the talent
/// twin of [`SMSG_BINDER_CONFIRM`]. Nothing is unlearned until the client sends the same opcode
/// **outbound** with that guid, which is the `CONFIRM_TALENT_WIPE` dialog's Accept; the server then
/// runs `Player::ResetTalents` and has the trainer cast 14867 "Untalent Visual Effect".
///
/// Byte-verified client-side too (wow-re `system/ui/scratch/talent-api.md` §ConfirmTalentWipe,
/// `0x48dc40` → `0x5df980`): both directions run through one range-gated function, whose outbound
/// leg puts the *latched* trainer guid on the wire. Bodies in [`super::progression`].
pub const MSG_TALENT_WIPE_CONFIRM: u16 = 0x02AA; // 682
/// Unlearn (abandon) a whole skill line — the skills pane's red circle-slash (VERIFIED vmangos
/// `Opcodes.cpp` handler registration + `Opcodes_1_9_4.h`: 514). Body in
/// [`super::skills::unlearn_skill`]; no ack — the removal returns as a `PLAYER_SKILL_INFO`
@@ -214,6 +214,14 @@ pub enum ServerPacket {
BinderConfirm {
binder: u64,
},
/// `MSG_TALENT_WIPE_CONFIRM` (inbound) — a class trainer is *asking* whether to unlearn every
/// talent, and what it will cost (decision 1580). The twin of [`Self::BinderConfirm`] in every
/// respect: nothing is unlearned until the same opcode goes back out carrying `trainer`.
/// `trainer == 0` is vmangos's "you have no talents to reset" refusal, not a question.
TalentWipeConfirm {
trainer: u64,
cost: u32,
},
/// `SMSG_PLAYERBOUND` — the bind took: who bound us and the AreaTable id we are now bound in.
/// Arrives beside [`Self::BindPoint`], which carries the same area id plus the position; this
/// one exists for the "X is now your home" acknowledgement.
@@ -1261,6 +1269,7 @@ impl ServerPacket {
ServerPacket::BindPoint { .. } => "SMSG_BINDPOINTUPDATE".into(),
ServerPacket::BinderConfirm { .. } => "SMSG_BINDER_CONFIRM".into(),
ServerPacket::PlayerBound { .. } => "SMSG_PLAYERBOUND".into(),
ServerPacket::TalentWipeConfirm { .. } => "MSG_TALENT_WIPE_CONFIRM".into(),
ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(),
ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(),
ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(),
@@ -290,6 +290,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_BINDER_CONFIRM => ServerPacket::BinderConfirm {
binder: binder::read_binder_confirm(&mut r)?,
},
// The talent twin of the confirm above, on a two-way `MSG_` opcode: this direction is the
// question (guid + cost); the answer we send back carries the guid alone (decision 1580).
opcode::MSG_TALENT_WIPE_CONFIRM => {
let ask = progression::read_talent_wipe_confirm(&mut r)?;
ServerPacket::TalentWipeConfirm {
trainer: ask.trainer,
cost: ask.cost,
}
}
opcode::SMSG_PLAYERBOUND => {
let bound = binder::read_player_bound(&mut r)?;
ServerPacket::PlayerBound {
@@ -77,6 +77,41 @@ pub fn learn_talent(talent_id: u32, requested_rank: u32) -> Vec<u8> {
body
}
/// One decoded **inbound** `MSG_TALENT_WIPE_CONFIRM` — a class trainer asking whether to unlearn
/// every talent (vmangos `WorldPackets::Skill::TalentWipeConfirmResponse`,
/// `Server/Packets/Skill.h:39-52` + `Skill.cpp:19-23`, filled by `Player::SendTalentWipeConfirm`,
/// `Player.cpp:8338`). Decision 1580.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TalentWipeConfirm {
/// The trainer that asked — echoed back in the outbound answer, where it is load-bearing
/// (`HandleTalentWipeConfirmOpcode` resolves it to a live `UNIT_NPC_FLAG_TRAINER` in range and
/// drops the request otherwise), exactly like the innkeeper guid in [`super::binder`].
///
/// **Zero means "you have no talents to reset"** — vmangos's own comment on the field, and the
/// value its refusal path sends (`SkillHandler.cpp:52`, after a failed `ResetTalents`). There
/// is no trainer to ask about, so there is nothing to put on screen.
pub trainer: u64,
/// What the reset costs, in copper — `Player::GetResetTalentsCost()`, which climbs with every
/// reset (the dialog's money frame, and the line "The cost will increase each time you do it").
pub cost: u32,
}
/// Read the inbound `MSG_TALENT_WIPE_CONFIRM`: trainer guid u64 · cost u32 (`Skill.cpp:19-23`).
pub(super) fn read_talent_wipe_confirm(r: &mut impl Read) -> io::Result<TalentWipeConfirm> {
Ok(TalentWipeConfirm {
trainer: read_u64_le(r)?,
cost: read_u32_le(r)?,
})
}
/// Body of the **outbound** `MSG_TALENT_WIPE_CONFIRM` — the `CONFIRM_TALENT_WIPE` dialog's Accept
/// (vmangos `WorldPackets::Skill::TalentWipeConfirm`, `Skill.cpp:14-17`: one guid, no cost). The
/// guid is the one the question arrived with; the real client sends its own latched copy
/// (`0xc4d7a0`, wow-re `talent-api.md`), which is the same number. Decision 1580.
pub fn talent_wipe_confirm(trainer_guid: u64) -> Vec<u8> {
trainer_guid.to_le_bytes().to_vec()
}
/// One decoded `SMSG_LEVELUP_INFO` — our own ding, self-addressed only (vmangos
/// `WorldPackets::Misc::LevelUpInfo`, `Misc.h:793-802` + `Misc.cpp:524-532`; filled by
/// `Player::GiveLevel`, Player.cpp:3210-3218, and sent straight to the leveling session — no
@@ -116,3 +151,36 @@ pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result<LevelUpInfo> {
stats,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The respec pair, both directions: the question carries guid + cost, the answer carries the
/// guid alone. Asymmetric bodies on ONE opcode is the whole reason this is an `MSG_`.
#[test]
fn the_talent_wipe_pair_round_trips() {
let trainer: u64 = 0xF130_0000_0000_2A01;
let mut body = trainer.to_le_bytes().to_vec();
body.extend_from_slice(&15_000u32.to_le_bytes());
assert_eq!(
read_talent_wipe_confirm(&mut &body[..]).unwrap(),
TalentWipeConfirm {
trainer,
cost: 15_000
}
);
assert_eq!(talent_wipe_confirm(trainer), trainer.to_le_bytes().to_vec());
}
/// vmangos's "you have no talents to reset" reply — guid 0, and a cost that is still filled in.
/// It parses; what to DO with it is the app's call (decision 1580: there is no trainer to ask
/// about, so nothing goes on screen).
#[test]
fn a_zero_trainer_is_a_parseable_refusal() {
let mut body = 0u64.to_le_bytes().to_vec();
body.extend_from_slice(&0u32.to_le_bytes());
let ask = read_talent_wipe_confirm(&mut &body[..]).unwrap();
assert_eq!(ask.trainer, 0);
}
}
@@ -2,8 +2,9 @@
//! [`crate::messages::progression`]. Split out of [`super::spells`] by decision 0640.
//!
//! The rest of that family is inbound only: XP awards and the level-up summary arrive as packets,
//! and the points they grant go back out through this one verb. The skills half of "what I have
//! learned" has its own opcode and its own file, [`super::skills`].
//! and the points they grant go back out through this one verb — plus the one that gives them all
//! back, the respec answer (decision 1580). The skills half of "what I have learned" has its own
//! opcode and its own file, [`super::skills`].
use anyhow::Result;
@@ -22,4 +23,17 @@ impl WorldWriter {
&messages::learn_talent(talent_id, requested_rank),
)
}
/// Answer a class trainer's respec question (`MSG_TALENT_WIPE_CONFIRM` outbound, layout in
/// [`messages::talent_wipe_confirm`]) — the `CONFIRM_TALENT_WIPE` dialog's Accept, and the only
/// packet in the flow that unlearns anything: the question that raised the dialog arrived on
/// this same opcode and changed nothing. The server answers by resetting the talents and having
/// the trainer cast 14867, which lands as the un-learn of every rank spell plus the refreshed
/// `PLAYER_CHARACTER_POINTS1`; declining sends nothing at all (decision 1580).
pub fn talent_wipe_confirm(&mut self, trainer_guid: u64) -> Result<()> {
self.send(
opcode::MSG_TALENT_WIPE_CONFIRM,
&messages::talent_wipe_confirm(trainer_guid),
)
}
}
+12
View File
@@ -627,6 +627,16 @@ pub(crate) struct Model {
/// `LearnTalent(tab, index)` clicks queued since the app's last
/// [`super::UiScript::take_talent_learns`] drain.
pub(crate) talent_learns: Vec<(u32, u32)>,
/// `ConfirmTalentWipe()` calls queued since the app last drained them — each is one outbound
/// `MSG_TALENT_WIPE_CONFIRM`. A COUNT for [`Self::binder_confirms`]'s reason: the app holds
/// the trainer's guid, so the intent carries no payload of its own (decision 1580).
pub(crate) talent_wipe_confirms: u32,
/// Is a trainer's respec question still live and in range — the answer
/// `CheckTalentMasterDist()` gives, pushed by the app each frame
/// ([`super::UiScript::set_talent_master_pending`]). The CONFIRM_TALENT_WIPE dialog polls it
/// from OnUpdate and hides itself when it goes false; the binder question's twin, and the same
/// range gate stands behind both (decision 1580).
pub(crate) talent_master_pending: bool,
/// The stance/shapeshift bar's form list (bar order) the app pushes — the
/// `GetNumShapeshiftForms`/`GetShapeshiftFormInfo` family reads it ([`super::shapeshift`]).
@@ -1410,6 +1420,8 @@ impl Model {
spell_stop_targeting: false,
talents: super::talent::TalentUiState::default(),
talent_learns: Vec::new(),
talent_wipe_confirms: 0,
talent_master_pending: false,
shapeshift_forms: Vec::new(),
shapeshift_casts: Vec::new(),
pet_bar: super::pet::PetBarState::default(),
+48
View File
@@ -6,6 +6,12 @@
//! ([`UiScript::take_talent_learns`]) into `CMSG_LEARN_TALENT`. The engine holds no talent
//! KNOWLEDGE — grid seats, ranks, prerequisites, and availability are the app's resolve.
//!
//! The **respec** pair rides here too (decision 1580) and is the binder question's twin, not a
//! talent-window affordance: `ConfirmTalentWipe()` answers a class trainer's
//! `CONFIRM_TALENT_WIPE`, and `CheckTalentMasterDist()` is the range poll that takes the dialog
//! away when you walk off. Both are 1.12 engine bindings (`reference/1.12-globals.tsv`), and
//! neither reads the snapshot above — the question arrives as the event's argument.
//!
//! The Era tuple shapes are the 1.12 addon's own reads (`Blizzard_TalentUI.lua`, extracted from
//! the patch chain — decision 0304's pin §3):
//! `GetTalentTabInfo(i) → name, texture, pointsSpent, fileName`;
@@ -104,6 +110,23 @@ impl super::UiScript {
pub fn take_talent_learns(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().talent_learns)
}
/// Drain the `ConfirmTalentWipe()` calls queued since the last drain — each one is an outbound
/// `MSG_TALENT_WIPE_CONFIRM` ([`Self::take_binder_confirms`]'s shape, and a count for the same
/// reason: the app holds the trainer's guid).
pub fn take_talent_wipe_confirms(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().talent_wipe_confirms)
}
/// Push whether a trainer's respec question is live and still in range — the host's half of
/// `CheckTalentMasterDist()`. Idempotent; `false` covers both "no question pending" and "you
/// walked away", which is all the dialog's OnUpdate needs in order to decide to hide.
pub fn set_talent_master_pending(&mut self, pending: bool) {
let mut model = self.model_mut();
if model.talent_master_pending != pending {
model.talent_master_pending = pending;
}
}
}
/// Look up one talent by the Lua-facing (tab, index) pair (both 1-based).
@@ -213,6 +236,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ConfirmTalentWipe() — the CONFIRM_TALENT_WIPE dialog's Accept, and the one call in the
// client that unlearns talents: the trainer's question changed nothing (decision 1580).
// Zero-arg because the guid it sends is one the client latched from that question — the
// reference's own `0xc4d7a0` (wow-re `talent-api.md` §ConfirmTalentWipe); here the app holds it.
g.set(
"ConfirmTalentWipe",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.talent_wipe_confirms += 1;
Ok(())
})?,
)?;
// CheckTalentMasterDist() — polled from that dialog's OnUpdate; false hides it. The reference
// re-runs its interact-range test against the latched trainer (`0x5df980`, the same
// `d² <= [0xc4c28c]` gate `CheckBinderDist` runs), so walking away takes the question off
// screen with no packet either way.
g.set(
"CheckTalentMasterDist",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.talent_master_pending)
})?,
)?;
// UnitCharacterPoints(unit) -> cp1 (unspent talent points), cp2 (free professions). The
// pair is our own player's (PLAYER_CHARACTER_POINTS1/2 are PRIVATE fields) — any other
// unit token answers the same store, matching the fields only we ever receive.
+9 -22
View File
@@ -189,24 +189,10 @@ pub struct ParticleEmitter {
/// again: an emitter riding an ANIMATED bone — the food sparkle orbits on a global-sequence
/// spin — would otherwise drag every risen star in a circle (the director's swirl).
/// `None` = anchor at the spawn placement (a placed doodad whose emitter rides a joint
/// anchors at the doodad, not the bone). [`Self::world_composed`] is what decides whether the
/// anchor's own motion carries the pool at all.
/// anchors at the doodad, not the bone). How much of the anchor's own motion the live pool
/// keeps is the emitter's file flag `0x10`, not this frame — see
/// [`world_motion_kept`](crate::particles::sim) (decision 1578).
anchor: Option<Entity>,
/// Whether this emitter's world MOTION reaches the particles through its own **emitter
/// matrix** rather than through the model's device-stack transform — the reference's real
/// ride-vs-trail discriminator (wow-re `part-emitter-motion.md` §2b: "`rt+0x1fc` local, Δ≈0 —
/// creature-attached doodads" ⇒ ride, vs "folded into `rt+0x1fc` … a translating missile
/// whose own model IS the emitter" ⇒ the birth bakes world-absolute and the particle is
/// world-FROZEN at draw). Bit 0x100 is NOT that switch and neither is the follow flag: the
/// kobold's candle (file `0x01`) rides while Multi-Shot's FLARE emitters (file `0x0309`,
/// equally unflagged) hang in the air behind the arrow.
///
/// `true` for a FREE world model — a missile, a planted ground burst — whose own transform is
/// its world placement; `false` for everything hung off a model that the scene graph moves (a
/// creature's own emitters, a kit effect on a unit, a held item's glow). It sets the
/// **baseline** the follow-delta term is measured against: 0 (world-frozen) here, 1 (rigid
/// ride) otherwise — see the follow block in [`sim`](crate::particles::sim). Decision 0986.
world_composed: bool,
/// The anchor's last-known world translation (kept when the entity vanishes so the pool
/// drains in place; init = the spawn placement's translation).
anchor_pos: Vec3,
@@ -216,6 +202,11 @@ pub struct ParticleEmitter {
/// refreshed EVERY frame — `0x7b5230` @0x7b5265): the one-frame Δ source for both
/// emitter-motion terms (follow-delta, velocity inherit). `None` until the first frame.
emitter_prev: Option<Vec3>,
/// The **anchor's** last-frame world position, on the same one-frame refresh — what the
/// anchored store's own ride amounts to, and the term the world-motion carry subtracts (see
/// the carry block in [`sim`](crate::particles::sim)). Distinct from `emitter_prev`: an
/// emitter orbiting an animated bone moves while its model — the anchor — stands still.
anchor_prev: Option<Vec3>,
/// Velocity-inherit state (file flag 0x40, wow-re `part-emitter-motion.md` §1): the ~30 Hz
/// trigger accumulator (rt+0x254) and the held inherit velocity (rt+0x258.., world frame) —
/// recomputed only at a trigger, births read the held value between.
@@ -348,10 +339,6 @@ pub struct EmitterFrames {
pub alpha: Option<Entity>,
/// What losing `owner` means ([`OwnerLoss`]).
pub on_owner_loss: OwnerLoss,
/// Whether the model's world motion reaches the particles through the emitter matrix — a FREE
/// world model like a missile ([`ParticleEmitter::world_composed`]). Defaults `false`: every
/// scene-graph-carried lane (creatures, doodads, kit effects, held items) rides.
pub world_composed: bool,
}
/// Spawn an emitter entity for one [`ModelEmitter`] at `placement`. `None` if the emitter has no
@@ -675,11 +662,11 @@ pub fn spawn_emitter(
alpha_src: frames.alpha,
alpha: 1.0,
anchor: frames.anchor,
world_composed: frames.world_composed,
anchor_pos: placement.translation,
particles: Vec::new(),
accumulator: 0.0,
emitter_prev: None,
anchor_prev: None,
inherit_accum: 0.0,
inherit_vel: Vec3::ZERO,
gate_prev: false,
+173 -59
View File
@@ -81,36 +81,75 @@ fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool {
}
/// **How much of the emitter's per-frame world motion a live particle keeps** — the ride-vs-trail
/// law, in one place (decision 0986; `speed` is `|Δ| / dt`, the yd/s the authored follow response
/// is keyed on). Two inputs, and reading the first one off the *flag* is what left every hunter
/// shot a compact blob where the reference draws a 20-yd bead trail (bug B153):
/// law, in one place (`speed` is `|Δ| / dt`, the yd/s the authored follow response is keyed on).
///
/// 1. **The baseline is the host class, not a flag** (wow-re `part-emitter-motion.md` §2b's own
/// discriminator — 0513 read it as follow-vs-no-follow): a cloud whose model the scene graph
/// carries keeps **100%** (the running kobold's candle rides, file flags `0x01`); a FREE world
/// model that composes its own motion into the emitter matrix — §2b's "a translating missile
/// whose own model IS the emitter" — keeps **0%**, its births baking world-absolute so each
/// particle hangs where it was born. Multi-Shot's FLARE emitters are as unflagged as the
/// kobold's candle (`0x0309`) and trail the arrow the length of its flight.
/// 2. **The follow-delta term** (file `0x4000`, §5-resolved decision 0513) then *overrides* the
/// baseline with the authored two-point response
/// ([`benilla_formats::ParticleEmitterDef::follow_line`]). Which is exactly why the corpus
/// authors it on missiles and nothing else: it exists to claw a fast projectile's head glow
/// back onto the tip (ArcaneShot: 0.1 @ 2.5 yd/s → 0.9 @ 16.7) while the unflagged emitters on
/// the same model stay behind. A degenerate response — equal authored speeds, which the
/// reference answers by zeroing both — is no follow term at all, so the baseline stands.
fn world_motion_kept(
def: &benilla_formats::ParticleEmitterDef,
world_composed: bool,
speed: f32,
) -> f32 {
let baseline = if world_composed { 0.0 } else { 1.0 };
/// **The discriminator is the emitter's own FILE FLAG `0x10`, not the host class** (wow-re
/// `part-emitter-motion.md` §2c, byte-settled by a three-worker §5 with objdump arbitration — the
/// round benilla's 0986 dispatched, which *refuted* the host-class reading 0986 shipped). Spawn and
/// draw are the two halves of one storage-space choice:
///
/// - `0x10` **SET** (rt `0x100`): the spawn stores the raw emitter-LOCAL pos/vel (`0x7b8aa5`) and
/// the draw folds the live emitter matrix `rt+0x1fc` back in every frame (`0x7b3efb`) ⇒ a
/// **rigid ride**, keep `1.0`. A carried torch (`Club_1H_Torch_A_01.m2` = `0x0011`) is flagged
/// for exactly this.
/// - `0x10` **CLEAR**: the spawn bakes pos/vel through the emitter matrix into WORLD (`0x7b8acf`/
/// `0x7b8b0f`) and the draw does *not* fold `rt+0x1fc` (`0x7b3f48`) ⇒ **world-frozen**, keep
/// `0.0`: a trail of length `host speed × particle lifetime`. Sprint's `RibbonTrail` (`0x0029`)
/// and Multi-Shot's flares (`0x0309`) are this, and so is the kobold's head candle (`0x0001`) —
/// whose 0.2 s life makes its ~1 yd smear invisible, which is what let two earlier rounds read
/// the null as a refutation and build the host-class law on it.
///
/// The host class is not consulted at all: `rt+0x1fc` is a world matrix for **every** host
/// (`0x719090`–`0x719164`, no per-class branch), because the device matrix it composes is
/// `scene+0x9c` followed by `scene+0xdc = inverse(scene+0x9c)` — it cancels exactly.
///
/// **The follow-delta term** (file `0x4000`, §2) then ADDS the authored two-point response
/// ([`benilla_formats::ParticleEmitterDef::follow_line`]) on top, *independently of `0x10`*:
/// `0x7b5303` builds `rt+0x26c` and `0x7b2744` adds `rt+0x278` whatever `0x100` says. Over the
/// CLEAR baseline that recovers the ride fraction — ArcaneShot's head glow, 0.1 @ 2.5 yd/s → 0.9
/// @ 16.7, riding the arrowhead while its three unflagged siblings hang behind it. Over a SET
/// baseline (12 emitters corpus-wide carry both) it genuinely leads, and we reproduce that
/// literally. A degenerate response — equal authored speeds, which the reference answers by
/// zeroing both — is no follow term at all.
fn world_motion_kept(def: &benilla_formats::ParticleEmitterDef, speed: f32) -> f32 {
let baseline = if def.model_space() { 1.0 } else { 0.0 };
if !def.follow_emitter() {
return baseline;
}
def.follow_line().map_or(baseline, |(slope, intercept)| {
(slope * speed + intercept).clamp(0.0, 1.0)
})
baseline
+ def.follow_line().map_or(0.0, |(slope, intercept)| {
(slope * speed + intercept).clamp(0.0, 1.0)
})
}
/// The **world-frame correction** the live pool needs this frame: the law says a particle's world
/// displacement is `keep · Δemitter` ([`world_motion_kept`]), and each storage frame already
/// supplies part of that for free — this is the remainder.
///
/// Conflating the two "for free" terms is a real bug, not a rounding one:
///
/// - **Anchored** (`0x10` clear): positions are anchor-relative, so the pool rides the MODEL —
/// `Δanchor`. For a standing model with an emitter orbiting an animated bone that is **zero**
/// while `Δemitter` is not: the births already bake the bone pose and the risen stars must then
/// hold still (decision 0396's eat sparkle). Subtracting `Δemitter` here would counter-orbit the
/// whole cloud — 0396's helix, mirrored.
/// - **Model mode** (`0x10` set): positions are emitter-local and the draw re-applies the live
/// emitter matrix, so the pool already rides the full `Δemitter` — exactly the `keep = 1` the
/// flag buys. What is left is only whatever a follow response adds on top.
fn motion_carry(
def: &benilla_formats::ParticleEmitterDef,
anchored: bool,
emitter_delta: Vec3,
anchor_delta: Vec3,
dt: f32,
) -> Vec3 {
let keep = world_motion_kept(def, emitter_delta.length() / dt);
let carried = if anchored {
anchor_delta
} else {
emitter_delta
};
keep * emitter_delta - carried
}
/// The velocity-inherit trigger (wow-re `part-emitter-motion.md` §1, `0x7b5230` bytes
@@ -684,11 +723,11 @@ pub(super) fn simulate_particles(
alpha_src,
alpha,
anchor,
world_composed,
anchor_pos,
particles,
accumulator,
emitter_prev,
anchor_prev,
inherit_accum,
inherit_vel,
gate_prev,
@@ -818,6 +857,12 @@ pub(super) fn simulate_particles(
let emitter_world = placement.transform_point(wow_to_bevy(def.position));
let emitter_delta = emitter_prev.map_or(Vec3::ZERO, |prev| emitter_world - prev);
*emitter_prev = Some(emitter_world);
// …and the ANCHOR's, on the same one-frame refresh. It is what the anchored store carries
// for free (see the carry block below), and it is NOT the emitter's: an emitter orbiting
// an animated bone inside a standing model has `emitter_delta ≠ 0` with the model — and so
// the cloud — perfectly still (0396's eat sparkle).
let anchor_delta = anchor_prev.map_or(Vec3::ZERO, |prev| *anchor_pos - prev);
*anchor_prev = Some(*anchor_pos);
// NOTE on the R(+Z,90°) emitter-frame law (`emit_local`'s tail): we apply R at EMISSION,
// so stored vectors are already post-R and every stored↔world fold here stays R-free —
// unlike the reference, which stores pre-R and folds R inside its draw matrix. The two
@@ -831,18 +876,13 @@ pub(super) fn simulate_particles(
))
}
};
// The per-frame world-motion carry ([`world_motion_kept`]), folded into the stored frame:
// keeping `fraction` of Δ over our anchor-RIDING storage is a `(fraction − 1)·Δ` move on
// every live particle after its first integrate (the fresh-bit skip).
let follow = if emitter_delta == Vec3::ZERO {
Vec3::ZERO
// The per-frame world-motion carry ([`motion_carry`]), folded into the stored frame and
// applied to every live particle after its first integrate (the fresh-bit skip).
let carry = motion_carry(def, anchored, emitter_delta, anchor_delta, dt);
let follow = if carry == Vec3::ZERO {
Vec3::ZERO // nothing moved, or the store already rides exactly right
} else {
let fraction = world_motion_kept(def, *world_composed, emitter_delta.length() / dt);
if fraction >= 1.0 {
Vec3::ZERO // the rigid ride — the overwhelming majority, kept free
} else {
to_stored((fraction - 1.0) * emitter_delta, attach_inv, placement)
}
to_stored(carry, attach_inv, placement)
};
// VELOCITY INHERIT (file 0x40): the ~30 Hz trigger holds the inherit velocity births
// read; the live gate (rt+0x64) zeroes it while nothing is live.
@@ -1265,8 +1305,8 @@ pub(super) fn simulate_particles(
#[cfg(test)]
mod tests {
use super::{
booth_frozen, inherit_trigger, integrate_particle, is_above, world_motion_kept,
ChildEmitter, Particle, StepEnv, Vec3,
booth_frozen, inherit_trigger, integrate_particle, is_above, motion_carry,
world_motion_kept, ChildEmitter, Particle, StepEnv, Vec3,
};
use bevy::prelude::{Quat, Transform};
@@ -1359,23 +1399,31 @@ mod tests {
assert!(!integrate_particle(&mut out, &kill));
}
/// [`world_motion_kept`] — the ride-vs-trail law (0986). The host class sets the baseline;
/// the follow flag overrides it with the authored response; a degenerate response falls back
/// to the baseline.
/// [`world_motion_kept`] — the ride-vs-trail law (wow-re `part-emitter-motion.md` §2c,
/// decision 1578). The emitter's own file flag `0x10` sets the baseline; the follow flag
/// `0x4000` adds the authored response on top; a degenerate response adds nothing. The host
/// class is not an input — the same unflagged def trails whoever carries it.
#[test]
fn world_motion_kept_reads_the_host_class_then_the_follow_response() {
fn world_motion_kept_reads_the_file_flag_then_the_follow_response() {
let plain = crate::particles::tests::plain_def(); // no flags
assert_eq!(
world_motion_kept(&plain, false, 30.0),
1.0,
"scene-graph-carried, unflagged: the kobold's candle rides at any speed"
);
assert_eq!(
world_motion_kept(&plain, true, 30.0),
world_motion_kept(&plain, 30.0),
0.0,
"a free world model, unflagged: Multi-Shot's flares hang where they were born"
"unflagged: world-frozen at any speed — Sprint's sparkles and Multi-Shot's flares \
hang where they were born, and so does the kobold's candle (its 0.2 s life is what \
hides the smear)"
);
// ArcaneShot's authored pair: 0.1 @ 2.5 yd/s, 0.9 @ 16.667 — the head glow catching up.
let riding = benilla_formats::ParticleEmitterDef {
flags: 0x0011, // the carried torch, Club_1H_Torch_A_01.m2
..crate::particles::tests::plain_def()
};
assert_eq!(
world_motion_kept(&riding, 30.0),
1.0,
"file 0x10: the rigid ride, at any speed"
);
// ArcaneShot's authored pair: 0.1 @ 2.5 yd/s, 0.9 @ 16.667 — the head glow catching up
// over the unflagged (world-frozen) baseline its three siblings keep.
let following = benilla_formats::ParticleEmitterDef {
flags: 0x4000,
follow_speed1: 2.5,
@@ -1385,23 +1433,89 @@ mod tests {
..crate::particles::tests::plain_def()
};
assert!(
(world_motion_kept(&following, true, 2.5) - 0.1).abs() < 1e-3,
"the flag overrides the world-frozen baseline with the authored response"
(world_motion_kept(&following, 2.5) - 0.1).abs() < 1e-3,
"the follow response over the world-frozen baseline IS the fraction"
);
assert_eq!(
world_motion_kept(&following, true, 40.0),
world_motion_kept(&following, 40.0),
1.0,
"clamped at a rigid ride on a fast missile — it never leads"
"clamped at a rigid ride on a fast missile — over this baseline it never leads"
);
// Equal authored speeds: the reference zeroes both, so nothing overrides the baseline.
// Both flags (12 emitters corpus-wide): the reference reads them independently, so the
// follow add lands on top of a locally-stored — already riding — position and genuinely
// leads. Reproduced literally (§2c-E).
let both = benilla_formats::ParticleEmitterDef {
flags: 0x4010,
..following.clone()
};
assert!(
(world_motion_kept(&both, 2.5) - 1.1).abs() < 1e-3,
"0x10 + 0x4000: the ride, plus the authored fraction on top"
);
// Equal authored speeds: the reference zeroes both, so nothing is added.
let degenerate = benilla_formats::ParticleEmitterDef {
flags: 0x4000,
follow_speed1: 4.0,
follow_speed2: 4.0,
..crate::particles::tests::plain_def()
};
assert_eq!(world_motion_kept(&degenerate, true, 30.0), 0.0);
assert_eq!(world_motion_kept(&degenerate, false, 30.0), 1.0);
assert_eq!(world_motion_kept(&degenerate, 30.0), 0.0);
let degenerate_riding = benilla_formats::ParticleEmitterDef {
flags: 0x4010,
..degenerate.clone()
};
assert_eq!(world_motion_kept(&degenerate_riding, 30.0), 1.0);
}
/// [`motion_carry`] — the storage-frame algebra the law is served through, and the guard on
/// 0396: the term an ANCHORED store already carries is the MODEL's motion, never the
/// emitter's. An emitter orbiting an animated bone inside a standing model has a live
/// `Δemitter` and a zero `Δanchor`, and its risen stars must not be dragged by either.
#[test]
fn motion_carry_subtracts_what_each_store_already_rides() {
let plain = crate::particles::tests::plain_def(); // 0x10 clear -> anchored, keep 0
let dt = 1.0 / 60.0;
// The eat sparkle: the model stands, the emitter bone orbits. Nothing to correct — the
// pool is already exactly where the world-frozen law wants it.
assert_eq!(
motion_carry(&plain, true, Vec3::X * 0.02, Vec3::ZERO, dt),
Vec3::ZERO,
"an orbiting bone inside a standing model moves no live particle"
);
// The sprinting rogue: model and emitter travel together, and the anchored store rides
// the model — so the carry cancels it exactly, leaving the sparkles in the air.
let step = Vec3::X * 0.175; // ~10.5 yd/s at 60 fps
assert_eq!(
motion_carry(&plain, true, step, step, dt),
-step,
"world-frozen: the store's own ride is subtracted in full"
);
// The carried torch (0x10 set, model mode): the store already re-applies the live emitter
// matrix, which IS the 100% ride the flag buys. Nothing to correct.
let riding = benilla_formats::ParticleEmitterDef {
flags: 0x0011,
..crate::particles::tests::plain_def()
};
assert_eq!(
motion_carry(&riding, false, step, step, dt),
Vec3::ZERO,
"model mode rides for free"
);
// A follow emitter over the world-frozen baseline recovers `fraction` of the motion.
let following = benilla_formats::ParticleEmitterDef {
flags: 0x4000,
follow_speed1: 2.5,
follow_scale1: 0.1,
follow_speed2: 16.667,
follow_scale2: 0.9,
..crate::particles::tests::plain_def()
};
let slow = Vec3::X * (2.5 * dt);
let carry = motion_carry(&following, true, slow, slow, dt);
assert!(
(carry - (0.1 - 1.0) * slow).length() < 1e-6,
"keeps 0.1 of the emitter's motion over a store that rides all of it"
);
}
/// FOLLOW-DELTA (`0x7b2680` @0x7b2744, rt 0x40000): the shared per-frame vector moves every