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:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(°enerate, true, 30.0), 0.0);
|
||||
assert_eq!(world_motion_kept(°enerate, false, 30.0), 1.0);
|
||||
assert_eq!(world_motion_kept(°enerate, 30.0), 0.0);
|
||||
let degenerate_riding = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4010,
|
||||
..degenerate.clone()
|
||||
};
|
||||
assert_eq!(world_motion_kept(°enerate_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
|
||||
|
||||
Reference in New Issue
Block a user