the unit frames are the reference's own, and the water has motes in it

- the player, party, target and pet frames are the reference's own files (`BuffFrame`, `UnitFrame`, `CombatFeedback`, `PlayerFrame`, `PartyFrame`, `TargetFrame`, `PetFrame`, in the reference's toc order) — our 2741-line `UnitFrames.xml` and our `PartyFrame.xml` are deleted; four engine verbs built for them (`PartialPlayTime`, `NoPlayTime`, `UnitPlayerControlled`, `UnitIsCivilian`, each answering 1/nil), the whole-UI load bracketed in the counted sound-suppression scope `0x48fbf0` self-brackets (the reference fires a `PlaySound` during load and throws it away), and `GetBillingTimeRested` read off `SMSG_AUTH_RESPONSE`'s third billing field rather than hardcoded; the differences the stock files bring are pinned rather than papered — `PlayerFrame` never hides, an unresolved name is BLANK not "Unknown", the target frame has no numerals at all (a bar hover pops the unit tooltip instead)
- what the swap surfaced, each a hard break under the stock files: `UnitBuff`/`UnitDebuff` returned the Classic Era ten-value tuple, name first, where 1.12 returns two and three values TEXTURE first (`0x519500`/`0x5198f0`) — a raise twice per `UNIT_AURA` on any debuffed target, and all 184 corpus call sites bind the 1.12 shape; the power events are named per resource (`UNIT_MANA`/`RAGE`/`FOCUS`/`ENERGY`/`HAPPINESS` and their MAX twins) and we fired the Era pair, so no mana, rage or energy bar could live-update; `SetRaidTarget` is the engine verb and `SetRaidTargetIcon` the FrameXML toggle wrapper — we had registered the wrapper's name on the engine's body, so every raid mark in the unit popup raised
- the underwater drift cloud: the reference draws a fixed 4000-mote field whenever the camera eye is inside a liquid — not a particle system, nothing spawns, ages or dies; a population scattered once into a 30 yd camera-relative cube, advected by the camera's own motion plus a gust and wrapped, so the field stands still in the world; benilla had the murk and nothing in it; a dry frame commits no draw, and the submerged one is bounded by the reference's own 666-quad cap (633–644 of 4000 measured live, which is the expected survivor count of a uniform cube through the 90° cone — an independent check on population, distribution and cone at once)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:04:32 +07:00
co-authored by Claude Fable 5.1
parent 6bafea0919
commit 7e3a67a02e
51 changed files with 2337 additions and 3467 deletions
+4 -2
View File
@@ -100,9 +100,11 @@
BOTTOMRIGHT_ACTIONBAR_PAGE = 5
BOTTOMLEFT_ACTIONBAR_PAGE = 6
-- Ref ActionButton.lua:4 / UIParent.lua: the flash blink period and the range-check cadence.
-- Ref ActionButton.lua:4: the flash blink period. Its neighbour `TOOLTIP_UPDATE_TIME`
-- (the range-check cadence this file's `rangeTimer` reads) moved to UIParent.xml, which is
-- the reference's own home for it — `UIParent.lua:1`, and the stock `BuffFrame.lua` reads
-- it as well.
ATTACK_BUTTON_FLASH_TIME = 0.4
TOOLTIP_UPDATE_TIME = 0.2
-- "Lock ActionBars" (ref UIOptionsFrame.lua:102-103 — uvar LOCK_ACTIONBAR, default "0").
-- The reference declares it in its options window; ours lives with the bar it locks, which
+11 -8
View File
@@ -19,8 +19,10 @@
strings; region templates are outside the v1 loader's object model (BuffFrame.xml has
the precedent note), so the 20 instances inherit the CombatTextFont Font object and
carry the template's anchor directly.
- UNIT_MANA → UNIT_POWER_UPDATE + the Era Unit* API (our event surface, decision 0068);
same low-mana law. PLAYER_COMBO_POINTS rides the combo wire (0869); its arm reads
- UNIT_MANA, the spec's own event. This used to read UNIT_POWER_UPDATE + the Era Unit* API
(0068's surface); 1819 retired that pair — 1.12 names the resource in the event, and the
Era spelling was a deviation this file's own header already flagged. Same low-mana law,
and now the handler is not woken by rage or energy at all. PLAYER_COMBO_POINTS rides the combo wire (0869); its arm reads
`GetComboPoints()`, so it is rogue/druid-only and per-target for free — the binding
gates, not this file (0875).
- Lua 5.0 `for k,v in t` iteration becomes explicit index loops (5.1 VM), backwards
@@ -284,10 +286,11 @@
if not messageType then
return
end
elseif event == "UNIT_POWER_UPDATE" then
-- Spec: UNIT_MANA + UnitMana/UnitManaMax (header deviation — the Era surface).
-- Power type 0 is mana; the same event also carries rage, focus and energy, none
-- of which has a low warning.
elseif event == "UNIT_MANA" then
-- The powerType == 0 guard still earns its place: `power_token` reports an UNKNOWN
-- power type as MANA (its descriptor default), so this event can arrive for a unit
-- whose track is not really mana. Rage, focus and energy now have their own event
-- names and never reach here at all (1819).
local mana = UnitMana("player")
local powerType = UnitPowerType("player")
if arg1 == "player" then
@@ -567,7 +570,7 @@
if SHOW_COMBAT_TEXT == "0" then
CombatText:UnregisterEvent("COMBAT_TEXT_UPDATE")
CombatText:UnregisterEvent("UNIT_HEALTH")
CombatText:UnregisterEvent("UNIT_POWER_UPDATE")
CombatText:UnregisterEvent("UNIT_MANA")
CombatText:UnregisterEvent("PLAYER_COMBO_POINTS")
CombatText:UnregisterEvent("PLAYER_REGEN_DISABLED")
CombatText:UnregisterEvent("PLAYER_REGEN_ENABLED")
@@ -575,7 +578,7 @@
else
CombatText:RegisterEvent("COMBAT_TEXT_UPDATE")
CombatText:RegisterEvent("UNIT_HEALTH")
CombatText:RegisterEvent("UNIT_POWER_UPDATE")
CombatText:RegisterEvent("UNIT_MANA")
CombatText:RegisterEvent("PLAYER_COMBO_POINTS")
CombatText:RegisterEvent("PLAYER_REGEN_DISABLED")
CombatText:RegisterEvent("PLAYER_REGEN_ENABLED")
@@ -210,6 +210,19 @@
SHOW_BUFF_DURATIONS = "0"
RegisterForSave("SHOW_BUFF_DURATIONS")
-- The target-of-target pair, at the reference's own values and in the reference's own home
-- (`UIOptionsFrame.lua` l.116-119, where both sit in the uvar defaults block and both are
-- RegisterForSave'd). The switch ships OFF and the state ships "5".
--
-- These lived in our `UnitFrames.xml` file scope until the unit-frame kit went to the
-- chain — a squatter, and its own note said so. The reference declares them beside the
-- other party/unit uvars here, and `TargetFrame.lua:504` reads the switch at CALL time
-- (`TargetofTarget_Update`), so this file sitting far below the frames does not bind.
SHOW_TARGET_OF_TARGET = "0"
SHOW_TARGET_OF_TARGET_STATE = "5"
RegisterForSave("SHOW_TARGET_OF_TARGET")
RegisterForSave("SHOW_TARGET_OF_TARGET_STATE")
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
-- name suffixes; the visible label doubles as the page title (the era does the same —
-- Settings.RegisterCategory names both from one string).
-360
View File
@@ -1,360 +0,0 @@
<Ui>
<!--
The party member frames + the group-invite popup (decision 0434 phase 3). Geometry, art
paths and behavior laws are MEASURED against the reference client's own FrameXML, extracted
through the full MPQ patch chain (decision 0246; survey 2026-07-17):
PartyFrame.xml, PartyFrameTemplates.xml, PartyMemberFrame.lua, StaticPopup.lua ("PARTY_INVITE"),
UIParent.lua (the PARTY_INVITE_REQUEST wiring) — the code below is our own. Data flow is
ours too: the app feeds
party1..party4 unit snapshots + the PartyState roster (crates/benilla/src/ui_party.rs) and
fires the PARTY_* events; the popup's Accept/Decline queue PartyRequests the app drains.
THE CASCADE. The ref anchors frames 2-4 to the PREVIOUS frame's PetFrame bottom-left at
(-23,-10) — spacing rides the pet frame, which sits at (23,-27) when hidden (26 tall), so
the no-pet stride is TOPLEFT->BOTTOMLEFT (0,-10). Pet frames land in phase 4; until then
the cascade uses that no-pet stride directly, which is byte-identical to the ref's layout
for petless parties (the only kind phase 3 renders).
DEVIATIONS / DEFERRED (phase 4+, decision 0434):
- Pet frames, the 4 debuff buttons (48,-32 +2px chain), the 16/8 buff hover panel, and the
dispellable-debuff Status ring: they need the party aura/pet feed (phase 4).
(The PVP icon landed with decision 0646 — UnitFactionGroup exists now.)
- HIDE_PARTY_INTERFACE (hide-in-raid uvar): waits for the raid UI slice.
- Portrait: our high-res model bake (decision 0105), same as every unit frame.
- The ref's low-health portrait pulse flips alpha 127/255<->255/255 on 0.5 s half-cycles
(PartyMemberFrame.lua's sign-flip counter); transcribed as the same triangle wave.
DOUBLE-DECLINE, KEPT: the ref's popup calls DeclineGroup() in OnCancel AND again in OnHide
(only OnAccept sets the inviteAccepted guard), so a clicked Decline sends two
CMSG_GROUP_DECLINE. vmangos silently ignores the second (no pending invite -> early
return). Matched rather than "fixed" - the wire tolerates it and the guard's
real job is the timeout/override/escape paths.
-->
<Script><![CDATA[
-- MAX_RAID_MEMBERS / NUM_RAID_GROUPS / MEMBERS_PER_RAID_GROUP moved out. They were
-- declared here on the stated grounds that "we ship no RaidFrame.xml", which stopped being
-- true — RaidFrame.xml declares all three, which is the reference's own home for them
-- (RaidFrame.lua l.2-4). Nothing between the two manifest positions reads them at load.
-- The party's own bound (ref PartyMemberFrame.lua l.1) — four *other* members, so a full
-- party sweep that includes the player runs `1, MAX_PARTY_MEMBERS+1`, which is exactly how
-- GroupLootDropDown_Initialize walks the master-loot candidates (LootFrame.lua l.219).
-- Published for the same reason as its raid siblings: the addon corpus reads it.
MAX_PARTY_MEMBERS = 4
-- The PARTY_INVITE dialog and its PARTY_INVITE_REQUEST/_CANCEL driver MOVED OUT, to
-- UiPanels.xml — the reference's own homes for both (StaticPopup.lua l.526, UIParent.lua).
-- They were here only because this is where they were first needed; 1751 walked them back,
-- so this file is that much smaller when it follows them onto the chain.
-- ref PartyMemberFrame.lua's OnLoad: cache children, register, first paint.
function BenillaPartyFrame_OnLoad(frame)
local id = frame:GetID()
local name = frame:GetName()
local tf = name .. "TextureFrame"
frame.unit = "party" .. id
frame.portrait = getglobal(name .. "Portrait")
frame.healthbar = getglobal(name .. "HealthBar")
frame.manabar = getglobal(name .. "ManaBar")
-- The recessed-bar look, the reference's own mechanism (ref-TargetFrame.lua l.32-34):
-- push the bars one frame level BELOW the art's TextureFrame. Frame level is the only
-- key term above the draw layer (decision 0884), so this is the only thing that can hold
-- the BACKGROUND party art over the ARTWORK bar fills. See the LAYERING note below.
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthbar:SetFrameLevel(textureFrameLevel - 1)
frame.manabar:SetFrameLevel(textureFrameLevel - 1)
frame.nameText = getglobal(tf .. "Name")
frame.leaderIcon = getglobal(tf .. "LeaderIcon")
frame.masterIcon = getglobal(tf .. "MasterIcon")
frame.disconnect = getglobal(tf .. "Disconnect")
frame.pvpIcon = getglobal(tf .. "PVPIcon")
frame.flashTimer = 0
SetPortraitTexture(frame.portrait, frame.unit)
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
frame:RegisterEvent("PARTY_LEADER_CHANGED")
frame:RegisterEvent("PARTY_LOOT_METHOD_CHANGED")
frame:RegisterEvent("UNIT_HEALTH")
frame:RegisterEvent("UNIT_MAXHEALTH")
frame:RegisterEvent("UNIT_POWER_UPDATE")
frame:RegisterEvent("UNIT_MAXPOWER")
frame:RegisterEvent("UNIT_DISPLAYPOWER")
frame:RegisterEvent("UNIT_NAME_UPDATE")
frame:RegisterEvent("UNIT_FACTION")
frame:EnableMouse(true)
BenillaPartyFrame_Update(frame)
end
function BenillaPartyFrame_OnEvent(frame)
-- UNIT_* events name their unit in arg1; other units' updates are not ours.
if string.sub(event, 1, 5) == "UNIT_" and arg1 ~= frame.unit then
return
end
BenillaPartyFrame_Update(frame)
end
-- ref PartyMemberHealthCheck: the portrait recolor law + the disconnect override.
function BenillaPartyFrame_HealthCheck(frame)
local unit = frame.unit
if not UnitIsConnected(unit) then
-- Disconnected: bar forced full and grey, portrait desaturated, icon on.
frame.healthbar:SetMinMaxValues(0, 1)
frame.healthbar:SetValue(1)
frame.healthbar:SetStatusBarColor(0.5, 0.5, 0.5)
SetDesaturation(frame.portrait, 1)
frame.disconnect:Show()
return
end
SetDesaturation(frame.portrait, nil)
frame.disconnect:Hide()
frame.healthbar:SetStatusBarColor(0.0, 1.0, 0.0)
local hp, hpMax = UnitHealth(unit), UnitHealthMax(unit)
if UnitIsDead(unit) then
frame.portrait:SetVertexColor(0.35, 0.35, 0.35, 1.0)
elseif UnitIsGhost(unit) then
frame.portrait:SetVertexColor(0.2, 0.2, 0.75, 1.0)
elseif hpMax > 0 and hp / hpMax <= 0.2 then
frame.portrait:SetVertexColor(1.0, 0.0, 0.0, 1.0)
else
frame.portrait:SetVertexColor(1.0, 1.0, 1.0, 1.0)
end
end
function BenillaPartyFrame_Update(frame)
local unit = frame.unit
if not UnitExists(unit) then
frame:Hide()
return
end
frame:Show()
frame.nameText:SetText(UnitName(unit) or "Unknown")
frame.healthbar:SetMinMaxValues(0, UnitHealthMax(unit))
frame.healthbar:SetValue(UnitHealth(unit))
local maxPower = UnitManaMax(unit)
if maxPower > 0 then
frame.manabar:Show()
local _, token = UnitPowerType(unit)
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(unit))
else
frame.manabar:Hide()
end
-- Leader + master-looter icons (ref: GetPartyLeaderIndex / GetLootMethod vs own id).
local id = frame:GetID()
if GetPartyLeaderIndex() == id then
frame.leaderIcon:Show()
else
frame.leaderIcon:Hide()
end
local lootMethod, masterId = GetLootMethod()
if lootMethod == "master" and masterId == id then
frame.masterIcon:Show()
else
frame.masterIcon:Hide()
end
BenillaPartyFrame_HealthCheck(frame)
BenillaPartyFrame_UpdatePvP(frame)
end
-- ref PartyMemberFrame_UpdatePvPStatus (PartyMemberFrame.lua l.116-130), verbatim: the
-- same three branches the unit frames run, but the faction leg draws the GroupFrame art
-- (UI-Group-PVP-<group>) — only the FFA skull is shared with the big frames, because no
-- UI-Group-PVP-FFA ships. Decision 0646 §4; closes 0434's named deferral (the icon waited
-- on UnitFactionGroup, which the app now feeds).
function BenillaPartyFrame_UpdatePvP(frame)
local unit = frame.unit
local factionGroup = UnitFactionGroup(unit)
if UnitIsPVPFreeForAll(unit) then
frame.pvpIcon:SetTexture("Interface\\TargetingFrame\\UI-PVP-FFA")
frame.pvpIcon:Show()
elseif factionGroup and UnitIsPVP(unit) then
frame.pvpIcon:SetTexture("Interface\\GroupFrame\\UI-Group-PVP-" .. factionGroup)
frame.pvpIcon:Show()
else
frame.pvpIcon:Hide()
end
end
-- The ref's <=20% portrait alpha pulse: a 0.5 s-per-leg triangle between 127/255 and 1.
function BenillaPartyFrame_OnUpdate(frame, elapsed)
local unit = frame.unit
if not UnitExists(unit) or not UnitIsConnected(unit) then
return
end
local hp, hpMax = UnitHealth(unit), UnitHealthMax(unit)
if hpMax > 0 and hp > 0 and hp / hpMax <= 0.2 then
local t = mod(frame.flashTimer + elapsed, 1.0)
frame.flashTimer = t
local a
if t < 0.5 then
a = 1.0 - t * (1.0 - 127 / 255) * 2
else
a = 127 / 255 + (t - 0.5) * (1.0 - 127 / 255) * 2
end
frame.portrait:SetAlpha(a)
elseif frame.flashTimer ~= 0 then
frame.flashTimer = 0
frame.portrait:SetAlpha(1.0)
end
end
-- The hover tooltip: same law as the unit frames (default corner anchor + SetUnit).
function BenillaPartyFrame_OnEnter(frame)
if not UnitExists(frame.unit) then return end
GameTooltip_SetDefaultAnchor(GameTooltip, frame)
if GameTooltip:SetUnit(frame.unit) then
getglobal("GameTooltipTextLeft1"):SetTextColor(GameTooltip_UnitColor(frame.unit))
end
end
-- The right-click dropdown (decision 0434 phase 5; ref PartyMemberFrame.lua l.369-381)
-- + the ref's click law (l.226-237): left targets the member, right opens the PARTY
-- menu seated at the frame's (47, 15).
function PartyFrameDropDown_OnLoad()
UIDropDownMenu_Initialize(this, PartyFrameDropDown_Initialize, "MENU")
end
function PartyFrameDropDown_Initialize()
local dropdown
if UIDROPDOWNMENU_OPEN_MENU then
dropdown = getglobal(UIDROPDOWNMENU_OPEN_MENU)
else
dropdown = this
end
UnitPopup_ShowMenu(dropdown, "PARTY", "party" .. dropdown:GetParent():GetID())
end
function BenillaPartyFrame_OnClick(frame, button)
if button == "LeftButton" then
TargetUnit(frame.unit)
elseif button == "RightButton" then
ToggleDropDownMenu(1, nil, getglobal(frame:GetName() .. "DropDown"), frame:GetName(), 47, 15)
end
end
]]></Script>
<!-- The shared member-frame template — ref PartyFrameTemplates.xml, 128x53 over the 128x64
UI-PartyFrame art at (0,-2) (the art hangs 13 px below the frame rect, as authored).
LAYERING (the ref's law): the ref nests the frame art two anonymous child frames deep so it
draws OVER the status bars — the bars show through the art's semi-transparent tube windows,
inset, their ends clipped by the rails. Ours is the UnitFrames.xml idiom, which reaches the
same place by the ref's OTHER route (its TargetFrame's): a $parentTextureFrame child, with
OnLoad pushing the bars one frame level BELOW it. Both spend a frame LEVEL, which is the
point — level is the one key term above the draw layer (decision 0884). Until then this file
instead leaned on declaring the TextureFrame after the bars, and the old draw key's
insertion-order tie-break; 0884 pinned the layer as bucket-wide and above the frame, which
put the bars' ARTWORK fills straight over the BACKGROUND art. Do not go back to declaration
order. The name and the leader/master/disconnect icons ride in the TextureFrame too (the ref
keeps them in that nested frame; left below the art, the ring would swallow the leader
crown). -->
<!-- frameStrata LOW is the ref's own (`PartyFrameTemplates.xml` l.194) and it is load-bearing,
not decoration. The panel windows (loot, character, merchant, quest log, …) are MEDIUM, and
the party frames share the top-left corner with the left-slot panel. Left in the default
MEDIUM, a party frame's nested HealthBar/ManaBar/TextureFrame children land one frame LEVEL
above a panel's background region — and level outranks insertion order in the draw key, so
the party frame draws straight *through* the loot window no matter which was shown last.
Nothing about raising could have saved it: a raise only reorders within one stratum (wow-re
`system/ui/scratch/toplevel-raise.md`, dispatched for exactly this). Decision 0597. -->
<Frame name="BenillaPartyMemberTemplate" virtual="true" hidden="true" frameStrata="LOW">
<Size><AbsDimension x="128" y="53"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- Portrait at the bottom of the stack; the art's round window shows it through. -->
<Texture name="$parentPortrait">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<StatusBar name="$parentHealthBar">
<Size><AbsDimension x="70" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-12"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="1.0" b="0"/>
</StatusBar>
<StatusBar name="$parentManaBar">
<Size><AbsDimension x="70" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-21"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0" g="0" b="1.0"/>
</StatusBar>
<!-- The PARTY-menu dropdown host (ref $parentDropDown): hidden state-holder only —
the list anchors to the member frame by name. -->
<Frame name="$parentDropDown" inherits="UIDropDownMenuTemplate" hidden="true">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="-20" y="-10"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>PartyFrameDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
<Frame name="$parentTextureFrame">
<Size><AbsDimension x="128" y="53"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\TargetingFrame\UI-PartyFrame">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentName" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="50" y="43"/></Offset></Anchor></Anchors>
</FontString>
<Texture name="$parentLeaderIcon" file="Interface\GroupFrame\UI-Group-LeaderIcon" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture name="$parentMasterIcon" file="Interface\GroupFrame\UI-Group-MasterLooter" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="32" y="0"/></Offset></Anchor></Anchors>
</Texture>
<!-- The group PvP icon, 32x32 TOPLEFT (-9,-15) (ref PartyFrameTemplates.xml
l.298-309). Texture chosen at runtime — decision 0646 §4. -->
<Texture name="$parentPVPIcon" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-9" y="-15"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentDisconnect" file="Interface\CharacterFrame\Disconnect-Icon" hidden="true">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-7" y="-1"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
</Frame>
</Frames>
<Scripts>
<OnLoad>BenillaPartyFrame_OnLoad(this)</OnLoad>
<OnEvent>BenillaPartyFrame_OnEvent(this)</OnEvent>
<OnUpdate>BenillaPartyFrame_OnUpdate(this, arg1)</OnUpdate>
<OnEnter>BenillaPartyFrame_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:FadeOut()</OnLeave>
<OnClick>BenillaPartyFrame_OnClick(this, arg1)</OnClick>
</Scripts>
</Frame>
<!-- The four instances — frame 1 at the ref's (10,-128); 2-4 cascade at the no-pet stride
(see the header note; the pet-frame anchor law returns with phase 4). -->
<Frame name="PartyMemberFrame1" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-128"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="PartyMemberFrame2" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="PartyMemberFrame3" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="PartyMemberFrame4" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</Frame>
</Ui>
@@ -126,6 +126,13 @@
-- the custom block at the end of the pass recomputes it, for the reason recorded there.
PETACTIONBAR_XPOS = 36
-- ref UIParent.lua:1 — the tooltip/range re-check cadence. It lived in our ActionBar.xml,
-- beside the range timer that reads it; the reference's own `BuffFrame.lua:32` reads it too
-- and, now that BuffFrame is the reference's own file (1751), a kit can want the buff row
-- without wanting the action bars. So it moves to the reference's home, one line above the
-- constant below it.
TOOLTIP_UPDATE_TIME = 0.2
-- ref UIParent.lua:2 — the model panes' held-button spin rate (half a turn per second),
-- read by every `Model_OnUpdate` transcription (the character window's + inspect's).
ROTATIONS_PER_SECOND = .5
File diff suppressed because it is too large Load Diff
+42 -52
View File
@@ -216,75 +216,65 @@ Interface\FrameXML\ItemRef.xml
# and the numerals simply never appear. The `.lua` comes first, as it does there.
Interface\FrameXML\TextStatusBar.lua
Interface\FrameXML\TextStatusBar.xml
# ══ THE PLAYER AURA BAR — the reference's own, off the player's chain (1751) ══
# The HUD row of buff/debuff icons under the minimap, driven by the UnitAura feed
# (`crate::ui_aura`; decisions 0255/0257).
#
# FIRST OF THE KIT, which is the reference's own order (its toc: BuffFrame 40, CombatFeedback 41,
# UnitFrame 43, PlayerFrame 44, PartyFrame 45, TargetFrame 46, PetFrame 47). It has to precede
# PartyFrame because stock `PartyMemberFrame.lua:60` calls `RefreshBuffs` from each row's OnLoad
# and `BuffFrame.lua` is where that lives. Ours had BuffFrame below the unit frames; that
# inversion is gone.
Interface\FrameXML\BuffFrame.xml
# ══ THE UNIT-FRAME KIT — the reference's own, off the player's chain (1751, window 22) ══
# `UnitFrame.xml` declares NO frames — it is a `<Script file="UnitFrame.lua"/>` and nothing else —
# so this is a pure addition of thirteen globals: `UnitFrame_Initialize`, `_Update`, `_OnEvent`,
# so it is a pure addition of thirteen globals: `UnitFrame_Initialize`, `_Update`, `_OnEvent`,
# `_OnEnter`, `_OnLeave`, `_OnUpdate`, `_UpdateManaType`, the `UnitFrameHealthBar_*` /
# `UnitFrameManaBar_*` pairs, and `GetUnitName`.
#
# ABOVE OURS ON PURPOSE, the `PaperDollFrame.lua` shape (see the top of this file): four of the
# thirteen — `UnitFrame_Update`/`_OnEvent`/`_OnEnter`/`_OnLeave` — collide with bodies our own
# UnitFrames.xml owns and drives, and the later line wins, so ours stay live. The other nine arrive
# fresh, and `UnitFrame_Initialize` is the one that matters most: it is what stock
# `UnitFrameManaBar_*` pairs, and `GetUnitName`. `UnitFrame_Initialize` is what stock
# `PlayerFrame.xml`, `TargetFrame.xml`, `PartyFrame.xml` and `PetFrame.xml` each call in their
# OnLoad, and its absence is the single blocker the readiness probe reports for all four.
# OnLoad.
#
# So this is groundwork rather than a window: those four are the most visible thing on the screen
# and their swap is a look the director has to see. This is the part that can land without one.
# Until the four windows below arrived, our own UnitFrames.xml loaded underneath this and
# redefined four of the thirteen — `_Update`, `_OnEvent`, `_OnEnter`, `_OnLeave` — so the
# reference's bodies were dead the whole time and stock party rows would have called OUR update,
# which indexed a `nameText` the reference's rows do not carry. That file is gone; these are live.
Interface\FrameXML\UnitFrame.xml
# …and its sibling, the same shape and the same reason. `CombatFeedback.xml` declares no frames
# either: three timing constants plus `CombatFeedback_Initialize` / `_OnCombatEvent` / `_OnUpdate`,
# the floating hit/miss text a unit frame's portrait carries. Two of the three collide with bodies
# our UnitFrames.xml owns, so ours win from below; `CombatFeedback_Initialize` is the one that is
# new, and it is the second of the two blockers the probe reports for stock `PlayerFrame.xml` and
# `PetFrame.xml` (`UnitFrame_Initialize` above being the first). With both here, all four of the
# reference's unit windows load clean.
# The same shape and the same reason. `CombatFeedback.xml` declares no frames either: three timing
# constants plus `CombatFeedback_Initialize` / `_OnCombatEvent` / `_OnUpdate`, the floating
# hit/miss text a unit frame's portrait carries. Two of the three used to collide with bodies our
# UnitFrames.xml owned; with that file gone the reference's three are what run.
Interface\FrameXML\CombatFeedback.xml
UnitFrames.xml
# ── The four unit windows themselves (1751, window 22) ─────────────────────────────────────────
# `PlayerFrame` + `PlayerStatusGlow` + `PlayerPlayTime` + `PlayerPVPIconHitArea` +
# `PlayerFrameGroupIndicator` + the dropdown, out of `PlayerFrame.lua`.
Interface\FrameXML\PlayerFrame.xml
# `PartyMemberFrame1..4`, `PartyMemberBuffTooltip` and `PartyMemberBackground`, out of
# `PartyMemberFrame.lua` (decision 0434 phase 2). Its `<Script>` list names `TextStatusBar.lua`
# a second time, which is the reference's own doing and harmless — the file is idempotent.
#
# What moved out of our copy before it went: the PARTY_INVITE dialog and its driver, to
# UiPanels.xml (StaticPopup.lua l.526, UIParent.lua), and the three raid constants, which
# RaidFrame.xml already declared — that is the reference's home for them (RaidFrame.lua l.2-4).
Interface\FrameXML\PartyFrame.xml
# `TargetFrame`, its texture frame and dropdown, the sixteen debuff and five buff buttons with
# their three templates, and the whole `TargetofTarget*` family — out of `TargetFrame.lua`, which
# is also where `MAX_COMBO_POINTS` (l.1) and `SetRaidTargetIconTexture` live. `TargetofTarget_Update`
# is what OptionsFrame.xml's two ToT settings drive.
Interface\FrameXML\TargetFrame.xml
# `PetFrame`, its health/mana bars, `PetFrameHappiness` and the dropdown, out of `PetFrame.lua`.
Interface\FrameXML\PetFrame.xml
# ══ THE COMBO-POINT DOTS — the reference's own, off the player's chain (1751, window 10) ══
# `ComboFrame`, `ComboPoint1..5`, `ComboPointTemplate` and the `ComboPointsFrame_OnEvent` /
# `ComboPointShineFade*` chain with its five duration constants, out of `ComboFrame.lua`.
#
# THE POSITION IS THE REQUIREMENT: `relativeTo="TargetFrame"` is resolved at LOAD, so this sits
# below UnitFrames.xml. Its fade chain needs UiPanels.xml's `UIFrameFade` family, far above, at
# call time.
#
# Our copy's frames were `BenillaComboPointTemplate` and kin; the reference's are
# `ComboPointTemplate` / `ComboPoint<N>Highlight` / `ComboPoint<N>Shine`, which is what an addon
# skinning the dots reaches for. `MAX_COMBO_POINTS` moved to UnitFrames.xml with this window —
# 1.12 declares it on `TargetFrame.lua:1`, and our copy's own note said so while explaining that
# it had nowhere else to go.
# below TargetFrame.xml. Its fade chain needs UiPanels.xml's `UIFrameFade` family, far above, at
# call time. `MAX_COMBO_POINTS` comes from TargetFrame.lua now, which is where 1.12 declares it.
Interface\FrameXML\ComboFrame.xml
# The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
# transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
# only Fonts.xml (CombatTextFont) + UIParent before it.
CombatText.xml
# The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of aura
# icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`).
#
# MOVED UP HERE from below the world-state frame when PartyFrame went to the chain (1751). It
# has to precede PartyFrame, because stock `PartyMemberFrame.lua:60` calls `RefreshBuffs` from
# each row's OnLoad and `BuffFrame.lua` is where that lives — which is exactly the reference's
# own order (its toc: BuffFrame 40, UnitFrame 43, PartyFrame 45; ours had them inverted).
#
# The note this entry used to carry — "needs ActionBar's `BENILLA_FALLBACK_ICON`" — was stale:
# that was true of OUR BuffFrame.xml, and the chain file does not mention the name. The only
# readers left are MerchantFrame, StanceBar and TradeSkillFrame, all at runtime.
Interface\FrameXML\BuffFrame.xml
# The party member frames (decision 0434 phase 2).
#
# NOT yet on the chain, and the blocker is ours, not a missing verb: **`UnitFrames.xml` below
# redefines `UnitFrame_OnEvent` and `UnitFrame_Update`**, two names stock `UnitFrame.lua` (l.232
# above) has already defined. Ours win, so stock party rows — built by stock
# `PartyMemberFrame.lua` and initialized by the reference's own `UnitFrame_Initialize` — end up
# calling OUR update, which indexes a `nameText` the reference's rows do not carry. The swap
# loads and then raises on the first PARTY_MEMBERS_CHANGED. So PartyFrame waits on the
# player/target/pet frames, not on anything of its own.
#
# What HAS moved out: the PARTY_INVITE dialog and its driver, to UiPanels.xml (StaticPopup.lua
# l.526, UIParent.lua), and the three raid constants, which RaidFrame.xml already declared —
# that is the reference's home for them (RaidFrame.lua l.2-4).
PartyFrame.xml
# The death arc's dialogs + event driver (decision 0308): registry entries on the shared
# StaticPopup engine, so after UiPanels.xml.
DeathFrame.xml
+8 -7
View File
@@ -4497,13 +4497,14 @@ mod dependency_tests {
/// asserted to EXIST before the silence is asserted, and the test gates on client data like
/// every other reader of the chain.
///
/// What is deliberately NOT asserted here is `load_default_ui`'s own failure list. It is not
/// empty on a seated session today — `TargetFrame`'s OnLoad reaches
/// `BenillaTargetAuras_Update` (UnitFrames.xml:956), which indexes `TargetofTargetFrame`
/// ~1150 lines before that frame is declared (UnitFrames.xml:2103), so a seated TARGET raises
/// there at load. That is a real bug in that file and a separate one from anything this test
/// measures; pinning it here would make the probe's gate hostage to it. Recorded rather than
/// worked around.
/// What is deliberately NOT asserted here is `load_default_ui`'s own failure list, and the
/// reason is a policy rather than a symptom: it is a different measurement from this one, and
/// pinning it here would make the probe's gate hostage to whatever else the chain is mid-way
/// through. (The concrete case that prompted the note has since gone with its file: our
/// `UnitFrames.xml` had `TargetFrame`'s OnLoad reach a `BenillaTargetAuras_Update` that indexed
/// `TargetofTargetFrame` ~1150 lines before the frame was declared, so a seated TARGET raised
/// at load. `TargetFrame.xml` is the reference's own now, 1751, and neither the caller nor the
/// ordering survives.)
#[test]
fn the_ui_probe_is_silent_on_a_clean_vm() {
let _data = benilla_formats::wow_data_or_skip!();
+6 -2
View File
@@ -1066,7 +1066,9 @@ mod tests {
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
// One frame carrying both halves of the race, in the order the drain produces them.
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
@@ -1105,7 +1107,9 @@ mod tests {
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, (enter_on_connected, back_on_disconnect).chain());
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "logged out".into(),
+6 -1
View File
@@ -2173,7 +2173,12 @@ pub(crate) struct CharActionResultMessage {
/// We entered the world (the IO thread's `Connected`, bridged from the Net drain): flips
/// [`crate::char_select::ClientState`] to `InWorld`.
#[derive(Message)]
pub(crate) struct EnteredWorldMessage;
pub(crate) struct EnteredWorldMessage {
/// The account's accumulated **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that
/// admitted the session — pushed into the script here because this is the only moment it ever
/// arrives (decision 1820).
pub(crate) billing_time_rested: u32,
}
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
/// id. Read by [`crate::cinematic`], which owns the playback *and* the ack (decision 0196: the ack
+2
View File
@@ -540,9 +540,11 @@ pub(super) fn apply_net_updates(
SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
} => session::connected(
guid,
name,
billing_time_rested,
&mut self_guid,
&mut status,
&mut names,
+4 -1
View File
@@ -104,6 +104,7 @@ pub(super) fn cinematic_triggered(
pub(super) fn connected(
guid: u64,
name: String,
billing_time_rested: u32,
self_guid: &mut SelfGuid,
status: &mut NetStatus,
names: &mut NameCache,
@@ -115,7 +116,9 @@ pub(super) fn connected(
info!("net: in world as {name} (guid {guid})");
// Our own name came with the login — seed the cache so "player" never queries.
names.insert_player(guid, name, None);
entered_world.write(EnteredWorldMessage);
entered_world.write(EnteredWorldMessage {
billing_time_rested,
});
}
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — back to character select.
+2
View File
@@ -588,11 +588,13 @@ fn run(
session.player_login(guid)?;
session.set_active_mover(guid)?;
let billing_time_rested = session.billing_time_rested();
let (mut reader, writer) = session.into_split()?;
if events_tx
.send(SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
})
.is_err()
{
@@ -40,6 +40,7 @@ fn player_unit() -> UnitState {
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}
}
@@ -12,8 +12,9 @@ const MOB_B: u64 = 0xF130_0000_0000_0002;
use super::test_ui::load_ui as load_xml;
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`,
/// `UnitFrames.xml` for the `TargetFrame` the dots anchor to.
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`, and the
/// reference's own `TargetFrame.xml` for the `TargetFrame` the dots anchor to — `relativeTo` is
/// resolved at LOAD, so that file has to precede this one (1751).
fn load_combo_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -24,7 +25,15 @@ fn load_combo_frame() -> UiScript {
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(&s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ComboFrame.xml");
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -271,20 +271,11 @@ const KNOWN: &[Known] = &[
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "TargetofTargetHealthBar",
flag: Flag::Mouse,
why: "a plain StatusBar where the reference inherits TextStatusBar, whose OnEnter/OnLeave \
show the value text. Wants the status-text arc, not the flag. (The pet page's XP bar \
was this entry's twin until its window became the reference's own.)",
},
Known {
frame: "TargetofTargetManaBar",
flag: Flag::Mouse,
why: "a plain StatusBar where the reference inherits TextStatusBar, whose OnEnter/OnLeave \
show the value text. Wants the status-text arc, not the flag. (The pet page's XP bar \
was this entry's twin until its window became the reference's own.)",
},
// The two `TargetofTarget*Bar` entries RETIRED here (the unit-frame migration), for the same
// reason their twin the pet page's XP bar went: they said our bars were plain StatusBars where
// the reference inherits `TextStatusBar` — true of our transcription, and no longer true of
// anything. `TargetFrame.xml` is the reference's own now and its ToT bars inherit
// `TextStatusBar` like every other unit bar. The gate found them itself, which is its job.
Known {
frame: "TradePlayerItem7",
flag: Flag::Mouse,
@@ -268,7 +268,11 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
// what it needs and why, and PartyFrame's `MAX_PARTY_MEMBERS` is needed at LOAD.
"UIDropDownMenu.xml",
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
],
@@ -598,7 +602,11 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
]);
@@ -51,6 +51,7 @@ fn target_unit() -> UnitState {
class_file: Some("PALADIN".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}
}
@@ -89,7 +89,15 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"UnitFrames.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"RaidFrame.xml",
] {
load_xml(&s, file);
@@ -381,6 +381,7 @@ pub(crate) fn seat_from_roster(
_ => 0,
},
is_player: true,
player_controlled: true,
// Nil here is not "no faction", it is a state a player character cannot be in, and
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
@@ -154,8 +154,20 @@ end
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
// `0x48f681` and `/reloadui` `0x495669`) load without a sound. The only reader of that depth
// is `PlaySoundByName 0x458030`, which drops the call outright.
//
// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
// really does fire at load (the frame ships shown and its OnLoad hides it) and really does
// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033
// reached the right rule from the director's ear; this is the binary agreeing.
script.push_sound_suppression();
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
script.pop_sound_suppression();
failures
}
+4 -2
View File
@@ -9,8 +9,9 @@
//! units tall every frame, and [`seam_scale`] carries quads ×s out / mouse ÷s in. The quad pass
//! wants y-down window px, so extraction also flips through the window height.
//!
//! Benilla's own **unit frames** (`assets/ui/UnitFrames.xml` — a template + the player and target
//! instances) load at startup by default through [`benilla_ui::loader`] — the decision 0068 slice-1
//! The **unit frames** (the reference's own `Interface\FrameXML\{PlayerFrame,PartyFrame,
//! TargetFrame,PetFrame}.xml`, off the player's chain since 1751 — our `UnitFrames.xml`
//! transcription is retired) load at startup through [`benilla_ui::loader`] — the decision 0068 slice-1
//! game-shell: real FrameXML+Lua rendering unit health/power/level/name through the whole chain
//! (snapshot → `Unit*` bindings → Lua → event → StatusBars+text → quad pass). Since captures run
//! server-less (no real game state), `WOW_CAPTURE_UI=1` also feeds synthetic `"player"`/`"target"`
@@ -550,6 +551,7 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}),
);
@@ -730,11 +730,23 @@ fn interface_harness() -> UiScript {
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UnitFrames.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
// `PartyMemberBackground`'s OnEvent sets `OpacityFrameSlider` on VARIABLES_LOADED,
// and that slider is declared in ColorPickerFrame.xml. The reference loads them in
// this same order (its toc: PartyFrame 45, ColorPickerFrame 84) — it works there
// because the reader is an EVENT handler, not a load-time one, so by the time
// VARIABLES_LOADED fires the slider exists. A test that fires the event has to have
// loaded it too.
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"QuestFrame.xml",
"QuestLogFrame.xml",
@@ -31,7 +31,15 @@ fn load_pet_frame() -> UiScript {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(&s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
s.set_unit(
"player",
Some(UnitState {
@@ -61,6 +69,12 @@ fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> U
power_type,
power,
max_power,
// Every token the app's feed pushes is connected (`ui_unit.rs`, "the stated
// `is_connected` gap, closed"). It matters to the reference's own bar: stock
// `UnitFrameManaBar_Update` (UnitFrame.lua:213) greys a DISCONNECTED unit's bar to
// (0.5,0.5,0.5) and only reaches `UnitFrame_UpdateManaType`'s power colour otherwise, so a
// fixture that leaves this false paints every bar grey and tests nothing about power type.
is_connected: true,
..UnitState::default()
}
}
@@ -92,7 +106,9 @@ fn draws(s: &mut UiScript, path: &str) -> bool {
drawn(s).iter().any(|(p, _)| p == path)
}
fn debuff(spell_id: u32, name: &str, count: u8) -> AuraState {
/// One aura on the pet's row. **Helpful**, because the row the pet frame fills is a BUFF row —
/// see [`the_debuff_row_fills_from_the_pets_own_auras`] for the reference's own `showBuffs = 1`.
fn pet_buff(spell_id: u32, name: &str, count: u8) -> AuraState {
AuraState {
spell_id,
name: Some(name.into()),
@@ -102,7 +118,7 @@ fn debuff(spell_id: u32, name: &str, count: u8) -> AuraState {
// No unit but yourself carries a duration on the 1.12 wire (decision 0257 B6).
duration: 0.0,
expiration_time: 0.0,
helpful: false,
helpful: true,
cancelable: false,
until_cancelled: false,
channeled: false,
@@ -183,10 +199,28 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
assert!(!s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
}
/// The reference's art swap (`PetFrame.lua`'s Update): a pet with no power wears the `-NoMana`
/// The reference's art swap (`PetFrame.lua:29-33`): a pet with no power wears the `-NoMana`
/// plate, which has no mana rail to leave empty — and the bar hides with it. A hunter's FOCUS pet
/// takes the plain plate and the focus colour, which is the case the mana-coloured default would
/// get wrong.
///
/// **Two things about the reference's wiring that our transcription did not have.**
///
/// *The plate's only wire is `UNIT_PET`.* `PetFrame_Update` — the sole caller of the
/// `UnitManaMax("pet") == 0` fork — runs from `PetFrame_OnEvent`'s `UNIT_PET`/`arg1 == "player"`
/// arm and from the frame's `OnShow` (`PetFrame.lua:46-49`, `PetFrame.xml:284-287`), and from
/// nothing else. This fixture used to poke `UNIT_MAXPOWER`, which the stock frame does not
/// register at all — nor could it, since the bar's own registrations are 1.12's
/// `UNIT_MANA`/`UNIT_MAXMANA`/… (`UnitFrame.lua:190-200`) rather than our Era pair. Swapping one
/// pet for a differently-powered one is a `UNIT_PET` on the wire, which is the honest edge here.
///
/// *Nothing in `PetFrame.lua` hides the bar.* The hide comes out of `TextStatusBar`: the bar
/// inherits that template's `<OnValueChanged>` (`TextStatusBar.xml:32-34` — `PetFrame.xml:161-169`
/// overrides only OnLoad/OnEvent), so `UnitFrameManaBar_Update`'s `SetMinMaxValues(0, 0)`
/// (`UnitFrame.lua:211-212`) reaches `TextStatusBar_UpdateTextString`, whose `valueMax > 0` else
/// branch is `textStatusBar:Hide()` (`TextStatusBar.lua:34/55-57`). Same visible answer as our
/// transcription's explicit `Hide()`, by a different and much longer road — which is worth knowing,
/// because it means the bar comes back only through a value change, never through `PetFrame.lua`.
#[test]
fn a_powerless_pet_wears_the_no_mana_plate() {
let mut s = load_pet_frame();
@@ -208,9 +242,10 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
assert!(draws(&mut s, PLAIN), "the plate with a mana rail");
assert!(!draws(&mut s, NO_MANA));
// A skeleton: no power at all.
// A skeleton: no power at all. `UNIT_PET` names the OWNER — see the doc above for why it, and
// not a power event, is what re-cuts the plate.
s.set_unit("pet", Some(pet("Skeleton", 100, 0, 0, 0)));
s.fire_event("UNIT_MAXPOWER", vec![ScriptValue::Str("pet".into())]);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
!s.eval::<bool>("return PetFrameManaBar:IsVisible()")
.unwrap(),
@@ -259,8 +294,25 @@ fn the_attack_overlay_follows_its_own_two_events() {
.unwrap());
}
/// The four debuff buttons fill from `UnitDebuff("pet", i)` in order, show a stack count only above
/// 1, and the unused ones hide. `UNIT_AURA("pet")` is the wire.
/// The four `$parentDebuff` buttons fill from the pet's own auras in order and the unused ones
/// hide. `UNIT_AURA("pet")` is the wire (`PetFrame.lua:54-57`).
///
/// **Two things the reference does that our deleted transcription did not**, both worth stating
/// because the frames' NAMES argue for the opposite on both counts:
///
/// 1. **The row shows BUFFS, not debuffs**, despite every frame in it being called
/// `PetFrameDebuffN`. `PetFrame.lua:37`/`:56` are the only two `RefreshBuffs` calls in the whole
/// of 1.12's FrameXML that pass `showBuffs = 1`, and that argument selects
/// `UnitBuff(unit, i, SHOW_CASTABLE_BUFFS)` at `BuffFrame.lua:277`. (Every other caller —
/// `PartyMemberFrame.lua:60`/`:176`/`:259`, `TargetFrame.lua:538` — passes `0`, which is the
/// `UnitDebuff` arm at `:280`.) Our `BenillaPetFrame_UpdateDebuffs` read `UnitDebuff("pet", i)`,
/// so a hunter whose pet was buffed saw an empty row and a poisoned pet saw icons the reference
/// would not show.
/// 2. **There is no stack count.** `RefreshBuffs` sets an icon and a border and nothing else
/// (`BuffFrame.lua:287-301`), and `PartyBuffButtonTemplate` — the template all four inherit
/// (`PetFrame.xml:173`/`:188`/`:203`/`:218`) — declares only `$parentIcon` and `$parentBorder`
/// (`PartyFrameTemplates.xml:3-36`). `PetFrameDebuff2Count` is not a frame in the reference's
/// house at all; the old assertion was reading a FontString our transcription invented.
#[test]
fn the_debuff_row_fills_from_the_pets_own_auras() {
let mut s = load_pet_frame();
@@ -269,7 +321,7 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
s.set_auras(
"pet",
Some(vec![debuff(1000, "Rend", 1), debuff(1001, "Sunder", 3)]),
Some(vec![pet_buff(1000, "Rend", 1), pet_buff(1001, "Sunder", 3)]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
@@ -280,15 +332,23 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
and PetFrameDebuff2:IsVisible()
and not PetFrameDebuff3:IsVisible()
and not PetFrameDebuff4:IsVisible()
and not PetFrameDebuff1Count:IsVisible()
and PetFrameDebuff2Count:IsVisible()
and PetFrameDebuff2Count:GetText() == "3"
-- The reference has no count region on this template at all — not a hidden one.
and getglobal("PetFrameDebuff1Count") == nil
and getglobal("PetFrameDebuff2Count") == nil
"#,
)
.unwrap();
assert!(ok, "two debuffs draw, a stack count shows only above 1");
// …and the icons are the pet's own auras, in the pet's own order.
assert!(draws(&mut s, "Interface\\Icons\\Spell_1000"));
assert!(ok, "two auras draw, the other two rows stay down");
// …and the icons are the pet's own auras, in the pet's own order. `RefreshBuffs` paints them
// with `debuffIcon:SetTexture(debuff)` where `debuff` is `UnitBuff`'s FIRST return
// (`BuffFrame.lua:277`/`:288`) — which on the 1.12 wire is the texture path
// (`TargetFrame.lua:269-272` uses it the same way, and `:287-303` reads the debuff triple as
// `texture, applications, dispelType`).
assert!(
draws(&mut s, "Interface\\Icons\\Spell_1000"),
"the first buff's ICON draws — if this fails with the aura's NAME on the row instead, \
`UnitBuff`'s first return is the Era signature's `name`, not 1.12's texture"
);
assert!(draws(&mut s, "Interface\\Icons\\Spell_1001"));
// They clear when the auras do.
@@ -523,12 +583,24 @@ fn unit_happiness_repaints_only_the_icon() {
/// The layering law (decision 0884), the pet frame's copy of the player/target/party tests: the
/// frame art must draw OVER the bar fills, which only the frame LEVEL can hold — the draw layer is
/// bucket-wide. A "simplification" back to declaration order puts the bars on top as pasted slabs.
///
/// **The art frame has no name in the reference, so it is reached through its texture.** Our
/// deleted transcription gave it one (`PetFrameTextureFrame`); stock `PetFrame.xml:52-124` builds
/// it as two ANONYMOUS `<Frame setAllPoints="true">` wrappers, one inside the other, with
/// `PetFrameTexture` in the inner one's BACKGROUND layer. That nesting is how the reference buys
/// the level: `PetFrame` is 2, its direct children (both bars, `PetFrame.xml:125`/`:150`) are 3,
/// and the doubly-nested art frame is 4. The law is unchanged — the art is one level above the
/// bars — only the way to name the frame is, so this asks the texture for its parent.
///
/// It is also *why* the reference needs no `PetFrameManaBar:Hide()` of its own for the
/// [`a_powerless_pet_wears_the_no_mana_plate`] case: the `-NoMana` plate is painted a level ABOVE
/// the mana bar and simply covers the rail.
#[test]
fn the_pet_art_paints_over_the_bars() {
let s = load_pet_frame();
let level: (i64, i64, i64) = s
.eval(
"return PetFrameTextureFrame:GetFrameLevel(), \
"return PetFrameTexture:GetParent():GetFrameLevel(), \
PetFrameHealthBar:GetFrameLevel(), \
PetFrameManaBar:GetFrameLevel()",
)
@@ -47,6 +47,7 @@ fn player_unit() -> UnitState {
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
player_controlled: true,
..UnitState::default()
}
}
@@ -142,6 +142,7 @@ fn seat_a_hunter(root: &Path) -> UiScript {
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
player_controlled: true,
faction_group: Some("Alliance".into()),
..Default::default()
}),
@@ -1044,10 +1044,11 @@ mod tests {
/// A window that reads unblocked in [`chain_gap_report`] can still fail to swap, and the third
/// reason found (after a missing widget type and a missing widget method) is this one: our
/// `assets/ui` file defines a global the reference defines too, our manifest loads a CHAIN file
/// that also defines it, and whichever lands second wins. `PartyFrame.xml` is the worked
/// example — `UnitFrames.xml` redefines `UnitFrame_OnEvent`/`UnitFrame_Update` nine manifest
/// lines after stock `UnitFrame.lua` defines them, so a stock party row built by the
/// reference's own `UnitFrame_Initialize` calls OUR update and indexes a field its rows do not
/// that also defines it, and whichever lands second wins. `PartyFrame.xml` was the worked
/// example, and it is worth keeping in the past tense because it is what this check was built
/// to catch: our `UnitFrames.xml` redefined `UnitFrame_OnEvent`/`UnitFrame_Update` nine
/// manifest lines after stock `UnitFrame.lua` defined them, so a stock party row built by the
/// reference's own `UnitFrame_Initialize` called OUR update and indexed a field its rows do not
/// carry. It loads clean and raises on the first event.
///
/// Shadowing is not automatically wrong — where we ship a file the reference would have
@@ -1062,10 +1063,11 @@ mod tests {
/// both would declare the same frames.
///
/// That half doubles as the map nothing else holds: `ActionBarFrame.xml` is our
/// `ActionBar.xml`, `FloatingChatFrame.xml` is our `ChatFrame.xml`, `PlayerFrame.xml` and
/// `TargetFrame.xml` and `PetFrame.xml` are all our one `UnitFrames.xml`,
/// `ActionBar.xml`, `FloatingChatFrame.xml` is our `ChatFrame.xml`,
/// `MainMenuBarMicroButtons.xml` is our `MicroMenu.xml`, `StaticPopup.xml` is our
/// `UiPanels.xml`. `chain_gap_report` calls several of those unblocked, and they are —
/// `UiPanels.xml`. (The row this map used to lead with — `PlayerFrame.xml`/`TargetFrame.xml`/
/// `PetFrame.xml` all being our one `UnitFrames.xml` — is retired: those four are the
/// reference's own files now, 1751.) `chain_gap_report` calls several of those unblocked, and they are —
/// individually. They just cannot load beside the file of ours already holding their names.
///
/// Run it before attempting a swap. It predicts which ones will fail without attempting them.
@@ -1083,8 +1085,10 @@ mod tests {
// and missed the case it was built for), because a reference can live in any file. A
// declaration cannot.
//
// What it catches: our `UnitFrames.xml` declares `PetFrame`, and so does the stock
// `PetFrame.xml`. Adding that stock file alongside ours would declare the name twice.
// What it catches, in the case it was built for: our `UnitFrames.xml` declared `PetFrame`,
// and so does the stock `PetFrame.xml`, so adding that stock file alongside ours would have
// declared the name twice. (That pair is resolved — ours is deleted — but the check is not
// about those two files; every window still ahead of 1751 has the same collision waiting.)
// Several stock windows we do not ship under their own name have an equivalent of ours
// under a different one, and `chain_gap_report` calls every one of them unblocked —
// truthfully, because the stock file WOULD load; it just cannot load *beside* ours.
@@ -1442,17 +1446,6 @@ mod tests {
"the twin of the line above, `QuestLogFrame.lua:107`, called on the next line \
(`QuestTimerFrame.lua:44`).",
),
(
"UnitFrame.xml",
"UnitPlayerControlled",
"an engine binding, and the most reachable gap here: `UnitFrame_OnEnter` \
(`UnitFrame.lua:62`) calls it while HOVERING any unit frame, behind \
`SHOW_NEWBIE_TIPS == \"1\"` — which is the shipped default. wow-re has it carved \
(`0x516410`, VERIFIED, `scratch/unit-verbs-controlled-charmed-creaturetype.md`): \
one return, `UNIT_FIELD_FLAGS` bit 3, the DOUBLE 1.0 when set and nil when clear, \
raising on an unrecognised token. `UnitState` carries no such flag yet, so it is \
a field + a push + a binding — the unit frames' own slice.",
),
];
let toc = &super::super::addons::Addon::builtin().toc.files;
@@ -736,9 +736,10 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
/// otherwise lift an ARTWORK bar fill straight over BACKGROUND art.
///
/// Decision 0884 pinned the layer as bucket-wide and above the frame. Two files had been getting
/// this right for the wrong reason — `UnitFrames.xml` and `PartyFrame.xml` both *declared* the
/// this right for the wrong reason — our `UnitFrames.xml` and `PartyFrame.xml` both *declared* the
/// TextureFrame after the bars and leaned on the retired key's insertion-order tie-break — so both
/// inverted the instant the key was corrected, and both reached the director's screen. The
/// inverted the instant the key was corrected, and both reached the director's screen. (Both files
/// are retired now, 1751; the lesson is kept because it is about the KEY, not about them.) The
/// reference spends a real frame level on this in both of its own spellings (`TargetFrame.lua`
/// l.32-34's explicit `SetFrameLevel(textureFrame-1)`, `PlayerFrame.xml` l.50-52's two anonymous
/// nesting frames); benilla now uses the first, everywhere.
@@ -760,7 +761,15 @@ fn every_texture_frame_outranks_its_status_bars() {
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// Every unit-frame family painting at once: the frames hide themselves without a unit, and a
// hidden frame emits no quads to read a level from.
for unit in ["player", "target", "party1", "party2", "party3", "party4"] {
for unit in [
"player",
"target",
"targettarget",
"party1",
"party2",
"party3",
"party4",
] {
s.set_unit(
unit,
Some(benilla_ui::script::UnitState {
@@ -778,6 +787,13 @@ fn every_texture_frame_outranks_its_status_bars() {
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
// The target-of-target frame ships OFF (`SHOW_TARGET_OF_TARGET = "0"`, the reference's own
// default, declared in OptionsFrame.xml which the manifest above has just loaded). Turn it on
// here: it is the SECOND of the two named TextureFrames the reference spells this way, and a
// gate that only ever sees the first covers half of what it claims to.
s.run(r#"SHOW_TARGET_OF_TARGET = "1""#).unwrap();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.unwrap();
s.resolve();
// owner frame name → its (strata, level), read off the packed draw key the renderer sorts by.
@@ -812,18 +828,22 @@ fn every_texture_frame_outranks_its_status_bars() {
checked += 1;
}
}
// Player + target (health/power) and four party members (health/mana) — never let this pass by
// matching nothing, which is exactly how a renamed frame would silently retire the check.
// **The predicted reduction, arrived.** This asserted 12 while the unit frames were ours,
// because our transcription spelled the idiom with a named `$parentTextureFrame` on EVERY
// family. The reference does not: it has exactly TWO named ones, `TargetFrameTextureFrame` and
// `TargetofTargetTextureFrame`, both in TargetFrame.xml. `PlayerFrame`, `PetFrame` and the
// party rows reach the same order the other way — the art inside ANONYMOUS nested
// `<Frame setAllPoints="true">` wrappers, outranking the bars by nesting depth rather than by
// a level on a name. There is nothing for a name sweep to look up, so those families move from
// this assertion's cover to the loader's nesting rule.
//
// **This number drops to 6 the day `PartyFrame.xml` goes to the chain, and that will be a real
// reduction rather than a rename.** Stock `PartyFrameTemplates.xml` has no
// `$parentTextureFrame` at all: it gets the same result a different way, with the art inside a
// pair of ANONYMOUS nested `<Frame setAllPoints="true">` wrappers, so it outranks the bars by
// nesting depth rather than by a level on a named frame. There is nothing to look up, so the
// party rows will move from this assertion's cover to the loader's nesting rule. Written down
// now, while the reason is in front of somebody, rather than discovered as a number to lower.
// The note above this line predicted the drop when PartyFrame went to the chain and called it
// "a real reduction rather than a rename". That was right, and it under-counted: the whole kit
// went at once, so player and pet left by the same door. Two families × (HealthBar, ManaBar,
// ManaBar) = 6 is the reference's own coverage, and it is the floor now. Still never zero —
// matching nothing is exactly how a renamed frame would silently retire the check.
assert!(
checked >= 12,
checked >= 6,
"only {checked} texture-frame/bar pairs checked — the name sweep found nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1,13 +1,16 @@
//! The target frame's aura rows (`assets/ui/UnitFrames.xml`, decision 0255's deferred slice)
//! against the reference's `TargetDebuffButton_Update` law (ref-TargetFrame.lua l.263-387). The
//! XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`'s target half) is stubbed
//! by pushing an [`AuraState`] list through [`UiScript::set_auras`] and firing the events the feed
//! fires — `PLAYER_TARGET_CHANGED` on a switch, `UNIT_AURA "target"` on a list change.
//! The target frame's aura rows — the **reference's own** `Interface\FrameXML\TargetFrame.xml`
//! off the player's patch chain (decision 1751, which retired our `assets/ui/UnitFrames.xml`
//! transcription) — against its `TargetDebuffButton_Update` law (ref TargetFrame.lua l.263-387).
//! The stock XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`'s target half) is
//! stubbed by pushing an [`AuraState`] list through [`UiScript::set_auras`] and firing the events
//! the feed fires — `PLAYER_TARGET_CHANGED` on a switch, `UNIT_AURA "target"` on a list change.
//!
//! Under test: the friend/hostile row swap (buffs first vs debuffs first), the 21→17 shrink when
//! the debuff count reaches the wrap (6, no target-of-target frame), the dispel-tinted border, the
//! stack count, and the hide-on-empty lifecycle. Files load in the app's own order (UnitFrames
//! before BuffFrame), which also pins the `DebuffTypeColor` late-definition contract.
//! stack count, and the hide-on-empty lifecycle. Files load in `benilla.toc`'s order, which is the
//! reference's own (BuffFrame 40 → CombatFeedback 41 → UnitFrame 43 → PlayerFrame 44 → PartyFrame
//! 45 → TargetFrame 46 → PetFrame 47), so `DebuffTypeColor` is defined ahead of the row that
//! indexes it and `RefreshBuffs` ahead of the party rows that call it from their own OnLoad.
use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitState};
@@ -18,12 +21,31 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
load_xml(&s, "UIDropDownMenu.xml"); // the unit popups' kit (UnitFrames' DropDown children)
load_xml(&s, "UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON
load_xml(&s, "UnitFrames.xml"); // loads BEFORE BuffFrame, as in the app
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml"); // DebuffTypeColor
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(&s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
// **Settle the target-of-target frame before any of this measures a row.** Stock
// `TargetofTargetFrame` carries no `hidden=` (ref TargetFrame.xml l.515), so it loads SHOWN,
// and the only thing that takes it down is `TargetofTarget_Update` — which
// `TargetFrame_OnEvent` runs *after* `TargetDebuffButton_Update` (ref TargetFrame.lua l.63-66,
// the update sitting inside `TargetFrame_Update` at l.51). So the very first target a freshly
// loaded client acquires lays its aura rows out for the 5-wide wrap, and only the next
// `TargetDebuffButton_Update` corrects them. One no-target `PLAYER_TARGET_CHANGED` runs that
// ladder to its end — exactly what deselecting once does — and leaves the frame in the state
// it holds for every target after the first, which is the state these tests are about.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s
}
+32 -6
View File
@@ -141,7 +141,19 @@ pub(super) const LOOT_UI: &[&str] = &[
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
"PartyFrame.xml",
// …and what its rows' OnLoad calls: every `PartyMemberFrame<N>` and its pet frame runs
// `UnitFrame_Initialize`, which lives in UnitFrame.lua and itself calls
// `SetTextStatusBarText` out of TextStatusBar.lua. Naming PartyFrame without these loads
// four rows that each raise on their own OnLoad — the loader reports it, but only because
// `load_ui_strict` looks; a plain load would have gone quiet.
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
// …and `RefreshBuffs`, which `PartyMemberFrame.lua:60` calls from each row's OnLoad. Its home
// is BuffFrame.lua, which the chain's BuffFrame.xml sources — the reference's own toc has
// BuffFrame at 40 and PartyFrame at 45 for exactly this reason.
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
];
/// What the **character window** needs before `Interface\FrameXML\CharacterFrame.xml`,
@@ -156,10 +168,13 @@ pub(super) const LOOT_UI: &[&str] = &[
/// three times before doing anything else. Its `.xml` twin comes too, because the pet page's XP
/// bar inherits the `TextStatusBar` template it declares and a missing template is a loader
/// *warning* — the bar would load with no art and no text region and read as a pass.
/// * **`UnitFrames.xml`** for `PlayerFrameHealthBar` / `PlayerFrameManaBar`, the frames those three
/// calls name, plus `PetFrameHealthBar` / `PetFrameManaBar` which `CharacterFrame_OnShow` shows
/// the text on. (Ours: the unit frames are not migrated. When they are, this entry becomes the
/// stock `PlayerFrame.xml` / `PetFrame.xml` pair.)
/// * **`Interface\FrameXML\PlayerFrame.xml`** for `PlayerFrameHealthBar` / `PlayerFrameManaBar`,
/// the frames those three calls name, and **`PetFrame.xml`** for `PetFrameHealthBar` /
/// `PetFrameManaBar`, which `CharacterFrame_OnShow` shows the text on. Both call
/// `UnitFrame_Initialize` in their OnLoad and `PlayerFrame` also calls `CombatFeedback_Initialize`,
/// so `UnitFrame.xml` and `CombatFeedback.xml` come first — the reference's own toc order.
/// (This entry used to be our one `UnitFrames.xml`, and this note used to say "when they are
/// migrated, this becomes the stock pair". They are; it did.)
/// * **`ActionBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// `ShowWatchedReputationBarText` / `HideWatchedReputationBarText`, which the window's
/// show/hide pair calls.
@@ -199,7 +214,18 @@ pub(super) const CHARACTER_UI: &[&str] = &[
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UnitFrames.xml",
// `UnitFrame_Initialize` and `CombatFeedback_Initialize`, which the two windows below call in
// their OnLoad. Neither file declares a frame; both are pure script.
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
// `PetFrame.xml`'s four `PetFrameBuff*` inherit `PartyBuffButtonTemplate`, which lives here.
// The manifest never names this file: it arrives through `PartyFrame.xml`'s
// `<Include file="PartyFrameTemplates.xml"/>`, and the reference's own toc puts PartyFrame (45)
// ahead of TargetFrame (46) and PetFrame (47) for exactly that reason. This kit wants the
// template and not four party member frames, so it takes the included file directly.
"Interface\\FrameXML\\PartyFrameTemplates.xml",
"Interface\\FrameXML\\PetFrame.xml",
"ActionBar.xml",
"MicroMenu.xml",
// The two page files these three need before the window can be OPENED, which is not the same
@@ -89,9 +89,31 @@ fn world_hover_seats_the_default_corner() {
#[test]
fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
// The kit + popups precede the unit frames (their DropDown children's OnLoad), app order.
let mut s = harness(&["UIDropDownMenu.xml", "UnitPopup.xml", "UnitFrames.xml"]);
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
// `UnitFrame.lua` l.1-6) and `UnitFrame_OnEnter` passes `PARTY_OPTIONS_LABEL` /
// `PLAYER_OPTIONS_LABEL` (l.60/63) straight into `GameTooltip:SetText`, which raises on
// nil. The app loads this file ahead of the whole manifest; so does the fixture.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
]);
s.set_unit("target", Some(wolf()));
s.run("BenillaUnitFrame_OnEnter(TargetFrame)").unwrap();
// The reference's handler takes no arguments and reads `this` (ref UnitFrame.lua l.45), which
// is exactly how the stock frames wire it: `<OnEnter>UnitFrame_OnEnter();</OnEnter>`
// (ref TargetFrame.xml l.505, PlayerFrame.xml l.435). Our deleted transcription's
// `BenillaUnitFrame_OnEnter(frame)` adapter went with the file.
s.run("this = TargetFrame UnitFrame_OnEnter() this = nil")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
s.resolve();
let ok: bool = s
@@ -113,7 +135,8 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
"a non-player target still gets the unit lines"
);
// Leave: gone on the spot, no ramp to wait out.
s.run("BenillaUnitFrame_OnLeave(TargetFrame)").unwrap();
s.run("this = TargetFrame UnitFrame_OnLeave() this = nil")
.unwrap();
let hidden: bool = s.eval("return not GameTooltip:IsShown()").unwrap();
assert!(
hidden,
@@ -132,10 +155,28 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
/// (the sibling test above).
#[test]
fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
let mut s = harness(&["UIDropDownMenu.xml", "UnitPopup.xml", "UnitFrames.xml"]);
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
// `UnitFrame.lua` l.1-6) and `UnitFrame_OnEnter` passes `PARTY_OPTIONS_LABEL` /
// `PLAYER_OPTIONS_LABEL` (l.60/63) straight into `GameTooltip:SetText`, which raises on
// nil. The app loads this file ahead of the whole manifest; so does the fixture.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
]);
s.set_unit("player", Some(wolf()));
s.run("BenillaUnitFrame_OnEnter(PlayerFrame)").unwrap();
s.run("this = PlayerFrame UnitFrame_OnEnter() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -160,11 +201,13 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
"target",
Some(UnitState {
is_player: true,
player_controlled: true,
name: Some("Someone".into()),
..wolf()
}),
);
s.run("BenillaUnitFrame_OnEnter(TargetFrame)").unwrap();
s.run("this = TargetFrame UnitFrame_OnEnter() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -313,6 +356,8 @@ fn buff_hover_hangs_below_left_of_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
]);
s.set_auras(
@@ -1,14 +1,26 @@
//! 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.
//! The target-of-target frame, driven end to end (decision 1576) — the **reference's own**
//! `TargetofTargetFrame`, declared in `Interface\FrameXML\TargetFrame.xml` (l.515-680) and
//! driven by `TargetFrame.lua`, off the player's patch chain. Decision 1751 retired our
//! `assets/ui/UnitFrames.xml` transcription, so what is under test here is the stock file over
//! synthetic `"targettarget"` snapshots.
//!
//! Two things here are not ordinary unit-frame plumbing and are what most of these test:
//! Three 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.
//! there or never there. The mode leg leaks by construction: its solo arm calls neither `Show`
//! nor `Hide` when you are in a raid (ref TargetFrame.lua l.513-520), so the frame keeps
//! whatever state it had. Our transcription closed that; the stock file is the end state, so the
//! leak is now the behaviour under test.
//! * **Nothing on this frame is event-driven.** Its bars carry no `OnEvent` at all (ref
//! TargetFrame.xml l.593-625 — the health bar has only `OnValueChanged`, the mana bar no
//! `<Scripts>` block), so the `UNIT_HEALTH`/`UNIT_MANA` registrations
//! `UnitFrameHealthBar_Initialize`/`UnitFrameManaBar_Initialize` make (ref UnitFrame.lua
//! l.150-151, l.190-200) are dead, and so is the frame's own `UNIT_AURA` registration (ref
//! TargetFrame.xml l.668) — `UnitFrame_OnEvent` handles three events and that is not one of
//! them. `TargetofTarget_OnUpdate` → `TargetofTarget_Update` is the only driver of the name, the
//! bars, the dead word, the portrait tint and the aura row. **That is why nearly every step in
//! this file is a `tick`, not a `fire_event`.**
//! * **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.
@@ -41,10 +53,10 @@ fn unit(name: &str, guid: u64, health: u32) -> UnitState {
/// 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.
/// The tail is the target-aura tests' set and is 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 almost every test in this file needs, because the
/// stock frame is driven from its OnUpdate and nowhere else (see the module doc).
fn load_tot() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -55,8 +67,21 @@ fn load_tot() -> UiScript {
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(&s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
// The target-of-target pair's home is `OptionsFrame.xml` (the reference keeps them in
// `UIOptionsFrame.lua`'s uvar defaults block, l.116-119). That file sits at the BOTTOM of our
// manifest where the reference's sits near the top, so a test that loads the unit kit alone
// has to state them itself — the same thing this harness already does for `DEAD`.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" SHOW_TARGET_OF_TARGET_STATE = "5""#)
.unwrap();
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)));
@@ -66,7 +91,7 @@ fn load_tot() -> UiScript {
/// 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()"#)
s.run(r#"SHOW_TARGET_OF_TARGET = "1" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
.unwrap();
}
@@ -163,37 +188,47 @@ fn the_four_unit_gates_each_take_the_frame_down() {
// No target of target.
s.set_unit("targettarget", None);
s.run("TargetofTarget_Update()").unwrap();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.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();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.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();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.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();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.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();
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.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.
/// The five display modes, each against solo / party / raid.
///
/// Mode 3 in a raid is not a rule the ladder states — it is the hole in it. The
/// `SHOW_TARGET_OF_TARGET_STATE == "3"` arm acts only while `GetNumRaidMembers() == 0` (ref
/// TargetFrame.lua l.513-520), so in a raid it calls neither Show nor Hide and the frame keeps
/// whatever it had. It reads `false` here because the preceding `party` step left it hidden, and
/// that sequencing is the only reason. The leak itself is
/// [`the_solo_mode_keeps_the_frame_when_a_raid_forms`], which shows the frame first.
#[test]
fn the_five_modes_answer_solo_party_and_raid() {
let mut s = load_tot();
@@ -216,7 +251,7 @@ fn the_five_modes_answer_solo_party_and_raid() {
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()"#
r#"SHOW_TARGET_OF_TARGET_STATE = "{mode}" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#
))
.unwrap();
assert_eq!(
@@ -230,28 +265,60 @@ fn the_five_modes_answer_solo_party_and_raid() {
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.
/// Mode 3 in a raid, as a STATE question rather than a fresh evaluation — the leaked branch, which
/// can only be seen by showing the frame first.
///
/// **This assertion flipped with 1751.** Our deleted `UnitFrames.xml` answered the question the
/// option asks and took the frame down; the stock file does not. `TargetofTarget_Update`'s
/// `SHOW_TARGET_OF_TARGET_STATE == "3"` arm is `if ( GetNumRaidMembers() == 0 ) then … end` with
/// no `else` (ref TargetFrame.lua l.513-520), so in a raid it reaches neither `Show` nor `Hide`
/// and the frame keeps whatever it had. The stock file is the end state, so the reference's answer
/// is the assertion.
///
/// Driven by a `tick`, which is the strong form: `TargetofTarget_OnUpdate` re-runs
/// `TargetofTarget_Update` on **every** frame (ref TargetFrame.xml l.673-675 → TargetFrame.lua
/// l.500), so this is not one stale evaluation left standing — the reference re-asks the question
/// sixty times a second and still never takes the frame down.
#[test]
fn the_solo_mode_comes_down_when_a_raid_forms() {
fn the_solo_mode_keeps_the_frame_when_a_raid_forms() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"SHOW_TARGET_OF_TARGET_STATE = "3" TargetofTarget_Update()"#)
s.run(r#"SHOW_TARGET_OF_TARGET_STATE = "3" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
.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");
s.tick(0.016);
assert!(
shown(&mut s),
"the raid forms and the reference's solo arm never fires — the frame stays"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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).
/// The paint: name, health, and the powerless unit's mana rail — which the reference **never
/// hides**.
///
/// **Two things flipped with 1751**, and both are the stock file being the end state:
///
/// * **The name arrives on a tick, not on the switch.** `TargetofTarget_Update` (ref
/// TargetFrame.lua l.503-540) touches the bars, the dead word and the aura rows and never the
/// name; the `SetText` lives in `TargetofTarget_OnUpdate`'s `CURRENT_TARGETTARGET` compare
/// (l.494-501), off the frame's own OnUpdate. Turning the switch on alone leaves it nil.
/// * **A powerless unit still gets a rail.** `UnitFrameManaBar_Update` (ref UnitFrame.lua
/// l.203-224) sets 0..0 and calls neither `Show` nor `Hide` — there is no hide leg anywhere in
/// the reference's mana path. Our deleted `UnitFrames.xml` hid the bar and this asserted that;
/// the stock answer is an empty 0/0 rail, still drawn, so that is what is asserted now.
///
/// The powerless step is a `tick` for the same reason as everything else here: 1.12 has no
/// `UNIT_MAXPOWER` event at all (the reference registers `UNIT_MAXMANA`/`RAGE`/`FOCUS`/`ENERGY`/
/// `HAPPINESS`, ref UnitFrame.lua l.195-199), and this frame's mana bar carries no `OnEvent` to
/// answer one with if it did (ref TargetFrame.xml l.612-625).
#[test]
fn the_frame_paints_its_unit() {
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
assert_eq!(
s.eval::<String>("return TargetofTargetName:GetText()")
@@ -274,24 +341,38 @@ fn the_frame_paints_its_unit() {
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())],
);
s.tick(0.016);
assert!(
!s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
.unwrap(),
"no power, no rail"
"no power, and the rail is STILL drawn — the reference has no hide leg"
);
assert_eq!(
s.eval::<(f64, f64)>(
"local _, m = TargetofTargetManaBar:GetMinMaxValues() \
return TargetofTargetManaBar:GetValue(), m",
)
.unwrap(),
(0.0, 0.0),
"it reads 0/0"
);
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).
/// from a linkdead player (both read zero health). Ref `TargetofTarget_CheckDead`, TargetFrame.lua
/// l.559-567.
///
/// Every step is a `tick` rather than a `UNIT_HEALTH`: `TargetofTargetHealthBar` registers that
/// event through `UnitFrameHealthBar_Initialize` (ref UnitFrame.lua l.150) but declares **no**
/// `OnEvent` to answer it with (ref TargetFrame.xml l.593-611 — `OnValueChanged` and nothing
/// else), so the registration is dead and `TargetofTarget_Update` off the frame's OnUpdate is the
/// only thing that ever calls `TargetofTarget_CheckDead`.
#[test]
fn the_dead_word_needs_a_connected_corpse() {
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
@@ -301,7 +382,7 @@ fn the_dead_word_needs_a_connected_corpse() {
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())]);
s.tick(0.016);
assert!(
s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
@@ -311,7 +392,7 @@ fn the_dead_word_needs_a_connected_corpse() {
let mut linkdead = corpse;
linkdead.is_connected = false;
s.set_unit("targettarget", Some(linkdead));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
s.tick(0.016);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
@@ -320,18 +401,24 @@ fn the_dead_word_needs_a_connected_corpse() {
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.
/// The portrait tint (ref `TargetofTargetHealthCheck`, TargetFrame.lua l.569-585), 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.
///
/// Ticks, not `UNIT_HEALTH`, and here the chain is one link longer than the dead word's: the check
/// hangs off the health bar's `OnValueChanged` (ref TargetFrame.xml l.604-608), and the only thing
/// that moves that value is `UnitFrameHealthBar_Update` inside `TargetofTarget_Update` — which is
/// reached from the frame's OnUpdate and nowhere else.
#[test]
fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
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())]);
s.tick(0.016);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
@@ -340,7 +427,7 @@ fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
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())]);
s.tick(0.016);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
@@ -348,7 +435,7 @@ fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
// 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())]);
s.tick(0.016);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
@@ -374,7 +461,15 @@ fn the_left_click_targets_the_unit() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four debuff buttons, dispel-tinted (ref RefreshBuffs over MAX_PARTY_DEBUFFS).
/// The four debuff buttons, dispel-tinted (ref `RefreshBuffs` over `MAX_PARTY_DEBUFFS`,
/// BuffFrame.lua l.262-313, called from `TargetofTarget_Update` l.538).
///
/// The `UNIT_AURA` fire is what the feed really does and is kept for that reason, but it is inert
/// here: the frame registers the event in its own OnLoad (ref TargetFrame.xml l.668) and answers
/// it with `UnitFrame_OnEvent`, which handles `UNIT_NAME_UPDATE`, `UNIT_PORTRAIT_UPDATE` and
/// `UNIT_DISPLAYPOWER` and nothing else (ref UnitFrame.lua l.31-45). A dead registration in the
/// stock file — the row is redrawn by `RefreshBuffs` off the OnUpdate, so the `tick` is what makes
/// it appear.
#[test]
fn the_debuff_row_draws_what_the_unit_carries() {
let mut s = load_tot();
@@ -387,6 +482,7 @@ fn the_debuff_row_draws_what_the_unit_carries() {
]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]);
s.tick(0.016);
let paths = drawn(&mut s);
assert!(
@@ -406,8 +502,13 @@ fn the_debuff_row_draws_what_the_unit_carries() {
}
/// 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.
/// open under the second debuff row instead of the seventh icon (ref `TargetDebuffButton_Update`,
/// TargetFrame.lua l.312-338 for the wrap and l.370-385 for the row anchors).
///
/// Both directions, and they are not symmetric: the reference re-runs the rows on the way **in**
/// only. `TargetDebuffButton_Update` is called from inside `TargetofTarget_Update`'s
/// `if ( TargetofTargetFrame:IsShown() )` (l.532-537), so turning the frame off leaves the rows
/// wrapped short behind it.
#[test]
fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
let mut s = load_tot();
@@ -440,13 +541,18 @@ fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
"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()"#)
// Back off — and the rows STAY wrapped short. **This assertion flipped with 1751.**
// `TargetofTarget_Update` calls `TargetDebuffButton_Update` from inside
// `if ( TargetofTargetFrame:IsShown() )` (ref TargetFrame.lua l.532-537), so the pass that
// hides the frame is exactly the pass that does not re-lay the target's rows. They keep the
// 5-wide wrap until something else runs the update — the next target change or `UNIT_AURA`.
// Our deleted `UnitFrames.xml` re-laid them on the way out; the stock file is the end state.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
.unwrap();
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff7",
"and back to six"
"TargetFrameDebuff6",
"the frame goes and the rows are left wrapped short behind it"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -2,17 +2,23 @@ use benilla_ui::script::{
QuadContent, ScriptValue, SelectionRequest, SoundRequest, UiScript, UnitState,
};
use super::test_ui::load_ui as load_xml;
use super::test_ui::{hover, load_ui as load_xml, unhover};
/// The unit frames' production load prefix (ui_script/mod.rs order): fonts + UIParent +
/// tooltip, then the dropdown kit + unit popups the frames' DropDown children initialize into.
fn load_unit_frames(s: &UiScript) {
// The app runs the real `Interface\FrameXML\GlobalStrings.lua` off the player's chain
// BEFORE any XML (`load_global_strings`); this stands in for it, with the one key these
// frames resolve at load time — `DEAD` l.898, which both dead-text FontStrings name in
// their `text=` attribute. Without it the loader's literal fallback would leave the KEY on
// screen, which is the bug this file guards below.
s.run(r#"DEAD = "Dead""#).unwrap();
// The app runs the real `Interface\FrameXML\GlobalStrings.lua` off the player's chain BEFORE
// any XML (`load_global_strings`), so the fixture names it too. This used to be a single
// hand-set `DEAD = "Dead"`, which was enough while the frames were OUR transcription: ours
// carried `X = X or "…"` fallbacks and hard literals for everything else. The reference's own
// files carry none, and they resolve GlobalStrings at LOAD in three separate places —
// `CombatFeedback.lua` l.7-17 builds the whole `CombatFeedbackText` table out of them
// (`TEXT(ABSORB)`, `TEXT(MISS)`, …), `UnitFrame.lua` l.1-6 builds `ManaBarColor`'s prefixes
// the same way, and `UnitFrame_OnEnter` l.60/63 passes `PARTY_OPTIONS_LABEL` /
// `PLAYER_OPTIONS_LABEL` straight into `GameTooltip:SetText`, which raises on nil rather than
// drawing an empty plate. Hand-setting the union of those is a second copy of the reference's
// own file; naming the file is the only version that cannot drift. (`DEAD` is l.898 of it.)
load_xml(s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(s, "Fonts.xml");
load_xml(s, "UIParent.xml");
// The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
@@ -22,7 +28,13 @@ fn load_unit_frames(s: &UiScript) {
load_xml(s, "GameTooltip.xml");
load_xml(s, "UIDropDownMenu.xml");
load_xml(s, "UnitPopup.xml");
load_xml(s, "UnitFrames.xml");
load_xml(s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(s, "Interface\\FrameXML\\PetFrame.xml");
}
/// Load the real `assets/ui/UnitFrames.xml` (the shipped default UI) into a bare engine and
@@ -36,13 +48,31 @@ fn shipped_unit_frames_drive_end_to_end() {
load_unit_frames(&s);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// No units yet: both frames hid themselves on their OnLoad's first update.
let hidden: bool = s
.eval("return not PlayerFrame:IsVisible() and not TargetFrame:IsVisible()")
// **Only the TARGET frame hides.** Our deleted transcription hid both; the reference hides
// exactly one. `PlayerFrame` is a plain top-level `<Button>` with no `hidden=` attribute
// (ref PlayerFrame.xml l.4) and `PlayerFrame_Update` (ref PlayerFrame.lua l.29-37) wraps its
// whole body in `if UnitExists("player")` without an else — nothing in the file ever calls
// `PlayerFrame:Hide()`. That is the real client's behaviour: the player plate is up from the
// moment the UI loads, empty until the player object arrives. `TargetFrame_Update`
// (ref TargetFrame.lua l.38-55) is the one with the `else this:Hide()`.
let shape: bool = s
.eval("return PlayerFrame:IsVisible() and not TargetFrame:IsVisible()")
.unwrap();
assert!(hidden, "frames hide while their units don't exist");
assert!(
shape,
"the player plate is always up; only the target frame hides while its unit is absent"
);
// The player appears (name still unresolved), at 72/100 health, 45/80 mana.
//
// `PLAYER_ENTERING_WORLD` is the event, not `UNIT_HEALTH`, and the reference is why: the
// stock bars each register their OWN events and repaint only themselves
// (`UnitFrameHealthBar_Initialize` takes UNIT_HEALTH/UNIT_MAXHEALTH, ref UnitFrame.lua
// l.150-151; `UnitFrameManaBar_Initialize` takes the ten UNIT_MANA/RAGE/… ones, l.189-199),
// so UNIT_HEALTH alone would leave the mana bar at its load-time 0/0. The one handler that
// repaints name + portrait + both bars together is `PlayerFrame_OnEvent`'s
// PLAYER_ENTERING_WORLD arm → `UnitFrame_Update()` (ref PlayerFrame.lua l.96-100), which is
// also what really fires when the player object arrives.
s.set_unit(
"player",
Some(UnitState {
@@ -56,10 +86,14 @@ fn shipped_unit_frames_drive_end_to_end() {
max_power: 80,
dead: false,
reaction: 0,
// Not decoration: `UnitFrameManaBar_Update`'s disconnect leg (ref UnitFrame.lua
// l.209-212) pins a disconnected unit's power bar to MAX and greys it, and
// `UnitState::default()` leaves this `false`.
is_connected: true,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert!(s.eval::<bool>("return PlayerFrame:IsVisible()").unwrap());
let ok: bool = s
.eval(
@@ -71,12 +105,24 @@ fn shipped_unit_frames_drive_end_to_end() {
return hb:GetValue() == 72 and hmax == 100
and pb:GetValue() == 45 and pmax == 80 and pb:IsVisible()
and b == 1 and r == 0 -- mana blue
and PlayerName:GetText() == "Unknown"
"#,
)
.unwrap();
assert!(ok, "player frame painted from the snapshot");
// …and the name plate is BLANK while the name is unresolved, not the word "Unknown". That
// word was our transcription's, twice over — a `text="Unknown"` literal on the FontString
// (deleted UnitFrames.xml l.1364) and a `UnitName(unit) or "Unknown"` fallback (l.803). The
// reference's `PlayerName` carries no `text=` at all (ref PlayerFrame.xml l.58) and
// `GetUnitName` returns `UnitName`'s result unchanged (ref UnitFrame.lua l.226-236), so what
// shows is whatever the engine's `UnitName` returns for a nameless unit — nil here.
assert_eq!(
s.eval::<Option<String>>("return PlayerName:GetText()")
.unwrap(),
None,
"no name yet: the stock file has no \"Unknown\" literal to fall back on"
);
// The name-query answer lands: UNIT_NAME_UPDATE repaints the name.
s.set_unit(
"player",
@@ -91,6 +137,7 @@ fn shipped_unit_frames_drive_end_to_end() {
max_power: 80,
dead: false,
reaction: 0,
is_connected: true,
..UnitState::default()
}),
);
@@ -100,7 +147,21 @@ fn shipped_unit_frames_drive_end_to_end() {
"Benilla"
);
// A powerless wolf gets targeted: frame shows, power bar hides, health fills 30/50.
// A powerless wolf gets targeted: frame shows, power bar runs EMPTY, health fills 30/50.
//
// "Empty", not hidden — and the reason is a reference quirk worth knowing. The only thing in
// 1.12 that hides a StatusBar for having no track is `TextStatusBar_UpdateTextString`'s
// `else textStatusBar:Hide()` (ref TextStatusBar.lua l.55-57), and that whole body sits
// inside `if(string)` — `string` being `bar.TextString`. The reference **never declares**
// `TargetFrameHealthBarText` / `TargetFrameManaBarText`: TargetFrame.xml l.486-487 passes
// both names into `UnitFrame_Initialize` and neither exists anywhere in FrameXML (only the
// player's and the pet's do — PlayerFrame.xml l.79/88, PetFrame.xml l.87/96). So
// `SetTextStatusBarText` early-returns on the nil (ref TextStatusBar.lua l.7-10), the
// target's bars carry no `TextString`, and the hide can never fire. It doesn't show: these
// bars have a `<BarTexture>` and no background, so a 0/0 bar draws nothing either way.
//
// Our transcription hid it, because 1146 §3 *declared* the two text regions the reference
// leaves dangling. Those are gone with the file.
s.set_unit(
"target",
Some(UnitState {
@@ -114,6 +175,7 @@ fn shipped_unit_frames_drive_end_to_end() {
max_power: 0,
dead: false,
reaction: 4, // neutral
is_connected: true,
..UnitState::default()
}),
);
@@ -122,8 +184,10 @@ fn shipped_unit_frames_drive_end_to_end() {
let ok: bool = s
.eval(
r#"
local _, pmax = TargetFrameManaBar:GetMinMaxValues()
return TargetFrame:IsVisible()
and not TargetFrameManaBar:IsVisible()
and TargetFrameManaBar:GetValue() == 0 and pmax == 0
and TargetFrameManaBar.TextString == nil -- the reference declares no text region
and TargetName:GetText() == "Young Wolf"
and TargetLevelText:GetText() == "3"
and not TargetDeadText:IsShown() -- living target: no dead word
@@ -132,7 +196,7 @@ fn shipped_unit_frames_drive_end_to_end() {
.unwrap();
assert!(
ok,
"target frame painted; powerless unit hides its power bar"
"target frame painted; a powerless unit's power bar runs empty over an empty track"
);
// The select sound rides the frame's OnShow (ref TargetFrame_OnShow): a neutral (4) wolf is
// neither UnitIsEnemy (≤2) nor UnitIsFriend (≥5) → the neutral kit.
@@ -341,20 +405,9 @@ fn unit_combat_drives_the_player_hit_indicator() {
fn left_clicking_the_player_frame_targets_self() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The SELF-menu strings the popup rows bake at UnitPopup.xml load (no GlobalStrings in a
// bare engine — production reads the real file).
s.run(
r#"
LOOT_METHOD = "Loot Method"
LOOT_THRESHOLD = "Loot Threshold"
LOOT_GROUP_LOOT = "Group Loot"
PARTY_LEAVE = "Leave Party"
RAID_TARGET_ICON = "Raid Target Icon"
CANCEL = "Cancel"
ITEM_QUALITY2_DESC = "Uncommon"
"#,
)
.unwrap();
// The SELF-menu strings the popup rows bake at UnitPopup.xml load arrive with
// `load_unit_frames`' `GlobalStrings.lua`; they used to be hand-set here, and one of the
// hand-set values was wrong (see the row assertion below).
load_unit_frames(&s);
// The player must exist for the frame to be shown and mouse-hittable.
@@ -427,10 +480,14 @@ fn left_clicking_the_player_frame_targets_self() {
6,
"title + Loot Method + Loot Threshold + Leave Party + Raid Target Icon + Cancel"
);
// `PARTY_LEAVE`, verbatim. The reference's own GlobalStrings.lua l.2991 is
// `PARTY_LEAVE = "Leave party"` — lower-case "party". The hand-set fixture this test used to
// open with title-cased it, so the row read back the fixture's own typo rather than the
// client's string; naming the real file is what exposed it.
assert_eq!(
s.eval::<String>("return DropDownList1Button4:GetText()")
.unwrap(),
"Leave Party"
"Leave party"
);
// The nested rows carry the expand arrow (the level-2 gate for a leader).
assert!(
@@ -467,21 +524,9 @@ fn left_clicking_the_player_frame_targets_self() {
fn raid_mark_clicks_through_the_nested_level() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
LOOT_METHOD = "Loot Method"
LOOT_THRESHOLD = "Loot Threshold"
LOOT_GROUP_LOOT = "Group Loot"
PARTY_LEAVE = "Leave Party"
RAID_TARGET_ICON = "Raid Target Icon"
CANCEL = "Cancel"
ITEM_QUALITY2_DESC = "Uncommon"
RAID_TARGET_1 = "Star"; RAID_TARGET_2 = "Circle"; RAID_TARGET_3 = "Diamond";
RAID_TARGET_4 = "Triangle"; RAID_TARGET_5 = "Moon"; RAID_TARGET_6 = "Square";
RAID_TARGET_7 = "Cross"; RAID_TARGET_8 = "Skull"; NONE = "None"
"#,
)
.unwrap();
// The menu strings arrive with `load_unit_frames`' own `GlobalStrings.lua`
// (`RAID_TARGET_ICON` l.3288, `RAID_TARGET_1..8` l.3280-3287, `NONE` l.2795), which is where
// production reads them; they used to be hand-set here.
load_unit_frames(&s);
s.set_unit(
@@ -551,6 +596,13 @@ fn raid_mark_clicks_through_the_nested_level() {
.unwrap();
s.mouse_button(sx as f32, sy as f32, "LeftButton", true);
s.mouse_button(sx as f32, sy as f32, "LeftButton", false);
// RED while the `SetRaidTarget` engine binding is absent, and deliberately left that way.
// `UnitPopup.xml`'s row calls `SetRaidTargetIcon(menu.unit, mark)`; the definition of that
// used to be ours, and since the migration it is the reference's own — `TargetFrame.lua`
// l.486-492 — whose whole body is `SetRaidTarget(unit, 0 or index)`. `SetRaidTarget` is an
// engine verb this house does not have yet, so the click raises
// "attempt to call global 'SetRaidTarget' (a nil value)" and no intent is queued. 1203: it
// gets built, never stubbed.
assert_eq!(
s.take_party_requests(),
vec![benilla_ui::script::PartyRequest::SetRaidTarget {
@@ -608,6 +660,7 @@ fn shipped_target_frame_runs_the_level_law() {
exists: true,
level: 3,
is_player: true,
player_controlled: true,
health: 50,
max_health: 50,
..UnitState::default()
@@ -738,6 +791,7 @@ fn shipped_target_frame_runs_the_level_law() {
exists: true,
level: 30,
is_player: true,
player_controlled: true,
health: 50,
max_health: 50,
..UnitState::default()
@@ -789,6 +843,7 @@ fn pvp_icon_follows_the_three_branch_law() {
max_health: 50,
level: 10,
is_player: true,
player_controlled: true,
faction_group: Some("Alliance".into()),
pvp,
is_pvp_ffa: ffa,
@@ -896,6 +951,7 @@ fn flagged_friendly_player_plate_is_green() {
max_health: 50,
level: 20,
is_player: true,
player_controlled: true,
reaction: 5,
can_attack: false,
faction_group: Some("Alliance".into()),
@@ -1075,10 +1131,16 @@ fn the_ring_art_paints_over_the_bars() {
power_type: 0,
power: 60,
max_power: 100,
is_connected: true, // else the power bar takes the disconnect leg's grey max fill
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
// PLAYER_ENTERING_WORLD, not UNIT_HEALTH: the reference repaints the NAME only from
// `UnitFrame_Update` (ref UnitFrame.lua l.23-28) and on UNIT_NAME_UPDATE (l.31-34) — the
// health bar's own event touches its bar and nothing else. `PlayerFrame_OnEvent`'s
// PLAYER_ENTERING_WORLD arm (ref PlayerFrame.lua l.96-100) is the one that runs the full
// repaint, and the name quad is half of what this test measures.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let quads = s.extract();
@@ -1131,16 +1193,28 @@ fn the_party_art_paints_over_the_bars() {
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
// GlobalStrings first, for the same reason `load_unit_frames` names it — the stock unit-frame
// files resolve it at LOAD (`CombatFeedback.lua` l.7-17, `UnitFrame.lua` l.1-6).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
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");
load_xml(&s, "UnitPopup.xml");
// BENILLA_POWER_COLORS (the shared power-colour table) lives in UnitFrames.xml; the party
// frames' Update reads it. The player/target frames it also defines stay hidden here — no unit.
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "PartyFrame.xml");
// The reference's own kit, in the manifest's order. `UIParent.xml` is not decoration here:
// `RaiseFrameLevel`/`LowerFrameLevel` live in it (ref UIParent.lua l.1890-1896) and stock
// `TargetofTargetTextureFrame`'s OnLoad calls one of them.
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CombatFeedback.xml");
load_xml(&s, "Interface\\FrameXML\\PlayerFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
s.set_unit(
"party1",
@@ -1153,9 +1227,27 @@ fn the_party_art_paints_over_the_bars() {
power_type: 0,
power: 60,
max_power: 100,
is_connected: true,
..UnitState::default()
}),
);
// **The ROSTER, not the unit snapshot, is what shows a party row.** Stock
// `PartyMemberFrame_UpdateMember` gates on `GetPartyMember(id)` (ref PartyMemberFrame.lua
// l.41-57) — `UnitExists("party1")` is never consulted — and its else arm is `this:Hide()`.
// Our deleted `PartyFrame.xml` keyed the row off the unit token, so setting `party1` alone
// used to be enough; against the reference's file it paints nothing at all.
s.set_party(benilla_ui::script::PartyState {
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Onepriest".into(),
guid: 0x0_0B12,
}],
leader_index: 0,
leader_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
});
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
let quads = s.extract();
@@ -1166,14 +1258,39 @@ fn the_party_art_paints_over_the_bars() {
s.quad_owner_name(q.target)
.is_some_and(|n| n.starts_with("PartyMemberFrame1"))
};
let art = quads
// The ART cannot be scoped that way, and the reason is the reference's own declaration:
// `PartyMemberFrameTemplate` hangs `$parentTexture` two levels down inside a pair of
// **anonymous** `<Frame setAllPoints="true">` wrappers (ref PartyFrameTemplates.xml
// l.230-240). The region itself still resolves to `PartyMemberFrame1Texture` — `$parent`
// walks to the nearest named ancestor — but the frame that OWNS the quad has no name at all,
// so an owner-name filter can never see it. (Our deleted PartyFrame.xml gave that wrapper a
// name, `$parentTextureFrame`, which is why this used to work.) Scoped by geometry instead:
// rows 2-4 are hidden with no member, so exactly one UI-PartyFrame quad is drawn, and it is
// asserted to sit on member 1's rect.
let art_quads: Vec<_> = quads
.iter()
.find(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-PartyFrame"))
})
.expect("the party member frame art");
.collect();
assert_eq!(
art_quads.len(),
1,
"one member in the party, one row of art drawn"
);
let art = art_quads[0];
let row: Vec<f32> = s
.eval(
"return { PartyMemberFrame1:GetLeft(), PartyMemberFrame1:GetBottom(), \
PartyMemberFrame1:GetRight(), PartyMemberFrame1:GetTop() }",
)
.unwrap();
let r = art.rect.expect("the art quad has a resolved rect");
assert!(
r.left >= row[0] - 1.0 && r.right <= row[2] + 1.0 && r.top <= row[3] + 3.0,
"the art quad sits on PartyMemberFrame1 (art {r:?}, row {row:?})"
);
let fill = quads
.iter()
.filter(|q| {
@@ -1234,8 +1351,16 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
assert!(alive, "the control: a live hunter reads live");
// He feigns. Only the flag moved on the wire — the snapshot turns it into these three.
//
// BOTH events, because the reference's bars are independent listeners: the health bar takes
// UNIT_HEALTH/UNIT_MAXHEALTH (ref UnitFrame.lua l.150-151) and the mana bar takes the ten
// UNIT_MANA/RAGE/FOCUS/… ones (l.189-199) — there is no shared "repaint the frame" event, so
// UNIT_HEALTH alone leaves the power bar showing the pre-feign number. Our deleted
// transcription drove both bars off one update, which is why this test used to fire one event.
// The server sends both when the dynflag lands.
s.set_unit("target", hunter(0, 0, true));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
s.fire_event("UNIT_MANA", vec![ScriptValue::Str("target".into())]);
let (hp, hmax, mana, mmax): (f64, f64, f64, f64) = (
s.eval("return TargetFrameHealthBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFrameHealthBar:GetMinMaxValues() return m")
@@ -1270,6 +1395,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
// He stands back up: the flag clears, and nothing about the body needed restoring.
s.set_unit("target", hunter(1200, 300, false));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
s.fire_event("UNIT_MANA", vec![ScriptValue::Str("target".into())]);
let up: bool = s
.eval(
r#"
@@ -1444,10 +1570,21 @@ fn the_rest_badge_covers_the_level_number() {
/// frames people actually watch (director report). Now the player's health and power bars carry
/// "value / max", and the switch pins them.
///
/// Which bars it pins is the REFERENCE's own split, transcribed rather than chosen: 1.12 sets
/// `textLockable` on the player's two bars, the pet's two and the XP bar, and on nothing else — so
/// the target frame's numerals are hover-only there and stay blank here. The power bar is whatever
/// the unit runs on; a rage unit reads "45 / 80" the same as a mana one.
/// Which bars it reaches is the REFERENCE's own split, and running the reference's own files made
/// it narrower than 1146 believed. `textLockable` is set on the player's two bars, the pet's two
/// and the XP bar and nothing else — but the TARGET frame goes further than "not lockable": it has
/// **no text regions at all**. `TargetFrame.xml` l.486-487 hands `TargetFrameHealthBarText` /
/// `TargetFrameManaBarText` to `UnitFrame_Initialize` and neither name is declared anywhere in
/// FrameXML (only `PlayerFrame.xml` l.79/88 and `PetFrame.xml` l.87/96 declare theirs), so
/// `SetTextStatusBarText` early-returns on the nil (ref TextStatusBar.lua l.7-10) and those bars
/// never get a `TextString`. 1146 §3 *added* the two regions to our transcription so a hover could
/// reveal them; they went with the file. What 1.12 actually gives you when you hover a target bar
/// is the unit TOOLTIP (ref TextStatusBar.xml l.16-26), asserted below.
///
/// The power bar's numerals also carry the resource's LABEL again — "Rage 45 / 80" — because
/// `UnitFrame_UpdateManaType` sets the prefix on every update (ref UnitFrame.lua l.129) and that
/// call is inside the stock file. 1147 §1 cut ours on the director's look call; the stock file has
/// no such cut.
#[test]
fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
let mut s = UiScript::new().unwrap();
@@ -1465,6 +1602,10 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
max_power: 80,
dead: false,
reaction: 4,
// Without this `UnitFrameManaBar_Update` takes its disconnect leg (ref UnitFrame.lua
// l.209-212) — the power bar pins to MAX and never reaches `UnitFrame_UpdateManaType`,
// so it would read "80 / 80" with no prefix.
is_connected: true,
..UnitState::default()
};
s.set_unit("player", Some(alive(72, 45, 1))); // power_type 1 = RAGE
@@ -1493,59 +1634,95 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
shown(&s, "PlayerFrameHealthBarText"),
"your own health numerals pin on"
);
// Bare here, and only because this fixture stops short of the character window: the
// reference's "Health" prefix is set by `CharacterFrame_OnLoad` (ref CharacterFrame.lua l.55),
// not by the unit frames, and the manifest loads `Interface\FrameXML\CharacterFrame.xml` far
// below these. In a full run the player's health bar reads "Health 72 / 100".
assert_eq!(
text(&s, "PlayerFrameHealthBarText"),
"72 / 100",
"bare numbers: the reference's \"Health\"/\"Rage\" labels were cut on the \
director's call (1147)"
"no prefix without the character window, which is what sets HEALTH"
);
// The power bar's LABEL is back, and it is the unit frames' own:
// `UnitFrame_UpdateManaType` re-sets the prefix from `ManaBarColor[UnitPowerType(unit)].prefix`
// on every mana-bar update (ref UnitFrame.lua l.122-129), and
// `TextStatusBar_UpdateTextString` renders `prefix .. " " .. value .. " / " .. max`
// (ref TextStatusBar.lua l.42-46). 1147 §1 cut the three prefix calls out of OUR files on the
// director's look call; this one lives inside the stock file, so it came back with it.
assert_eq!(
s.eval::<String>("return PlayerFrameManaBar.prefix")
.unwrap(),
"Rage",
"the prefix follows the resource — this player runs on rage"
);
// The STRING, though, is still the one rendered before the switch existed. That is the
// reference's own ordering, not a gap: `TextStatusBar_OnEvent`'s CVAR_UPDATE arm only calls
// `TextString:Show()` (ref TextStatusBar.lua l.14-24) — it never re-renders — and the last
// render ran from `SetValue`'s OnValueChanged during PLAYER_ENTERING_WORLD, before
// `UnitFrame_UpdateManaType` had set the prefix (ref UnitFrame.lua l.208-215 sets the value
// first). Its own re-render is gated on `GetCVar("statusBarText") == "1"` (l.130-132), which
// was not yet true. So the label lands on the next repaint — asserted at the end of this test.
assert_eq!(
text(&s, "PlayerFrameManaBarText"),
"45 / 80",
"the power bar shows the number alone, whatever resource it is"
"flipping the switch shows the string, it does not re-render it"
);
// The target's stay dark with the switch ON: 1.12 never makes its bars textLockable, so the
// option does not reach them.
// The target's bars carry NO text region, so there is nothing for the switch to pin and
// nothing for a hover to reveal. Asserted on the globals themselves: the reference leaves both
// names undeclared (ref TargetFrame.xml l.486-487 passes them anyway).
assert!(
!shown(&s, "TargetFrameHealthBarText"),
"the switch pins your own numbers, never the target's"
s.eval::<bool>(
"return TargetFrameHealthBarText == nil and TargetFrameManaBarText == nil \
and TargetFrameHealthBar.TextString == nil and TargetFrameManaBar.TextString == nil"
)
.unwrap(),
"the reference declares no numerals on the target frame, at any switch setting"
);
// ...but the HOVER reveals them, which is how you read a target's health in 1.12 (1146).
s.run("BenillaUnitFrameBar_OnEnter(TargetFrameHealthBar)")
.unwrap();
assert!(shown(&s, "TargetFrameHealthBarText"));
assert_eq!(text(&s, "TargetFrameHealthBarText"), "50 / 100");
// `this` bound, as the engine binds it for a bar's own `<OnLeave>`: the stock
// `HideTextStatusBarText` reads `this.isZero` on one line where every other reads `bar`
// (TextStatusBar.lua:95 — the reference's own slip; a nil index only from bare Lua).
s.run(
"this = TargetFrameHealthBar BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar) this = nil",
)
.unwrap();
// Hovering a target bar pops the unit TOOLTIP instead — the `elseif this:GetParent() ==
// TargetFrame` arm of the template's own OnEnter (ref TextStatusBar.xml l.16-26). Driven
// through the real pointer path, because that arm reads `this`.
hover(&mut s, "TargetFrameHealthBar");
assert!(
!shown(&s, "TargetFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
s.eval::<bool>(
"return GameTooltip:IsShown() and GameTooltip:IsOwned(TargetFrameHealthBar)"
)
.unwrap(),
"the target's bar hover is a tooltip, not numerals ({:?})",
s.errors()
);
assert!(
s.eval::<String>("return tostring(GameTooltipTextLeft1:GetText())")
.unwrap()
.contains("Somebody"),
"and it is the unit's own plate"
);
unhover(&mut s);
assert!(
s.eval::<bool>("return not GameTooltip:IsShown()").unwrap(),
"the template's OnLeave hides it (ref TextStatusBar.xml l.28-31)"
);
// The same hover works with the switch OFF, which is the point of it on YOUR frame too: the
// numbers are there when you go looking, without living on the bar.
// The hover on YOUR bar with the switch OFF is the reveal that still exists: the numbers are
// there when you go looking, without living on the bar. `ShowTextStatusBarText` /
// `HideTextStatusBarText` and their `lockShow` refcount (ref TextStatusBar.lua l.77-102) are
// the mechanism, reached through the template's OnEnter/OnLeave.
s.run("SetCVar(\"statusBarText\", \"0\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
assert!(!shown(&s, "PlayerFrameHealthBarText"));
s.run("BenillaUnitFrameBar_OnEnter(PlayerFrameHealthBar)")
.unwrap();
hover(&mut s, "PlayerFrameHealthBar");
assert!(
shown(&s, "PlayerFrameHealthBarText"),
"hover shows them even with the option off"
"hover shows them even with the option off ({:?})",
s.errors()
);
unhover(&mut s);
assert!(
!shown(&s, "PlayerFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
);
s.run(
"this = PlayerFrameHealthBar BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar) this = nil",
)
.unwrap();
assert!(!shown(&s, "PlayerFrameHealthBarText"));
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
@@ -1555,10 +1732,11 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(text(&s, "PlayerFrameHealthBarText"), "31 / 100");
// A power-type change repaints the same way (it used to re-LABEL the bar too — 1147 cut that).
// A power-type change repaints AND re-labels — `UnitFrame_UpdateManaType` runs off
// UNIT_DISPLAYPOWER (ref UnitFrame.lua l.36-40) and the prefix follows the new resource.
s.set_unit("player", Some(alive(31, 60, 3)));
s.fire_event("UNIT_DISPLAYPOWER", vec![ScriptValue::Str("player".into())]);
assert_eq!(text(&s, "PlayerFrameManaBarText"), "60 / 80");
assert_eq!(text(&s, "PlayerFrameManaBarText"), "Energy 60 / 80");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -26,7 +26,15 @@ fn load_popup_frames(s: &UiScript) {
"UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\ItemRef.xml",
"UnitFrames.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"FriendsFrame.xml",
@@ -54,6 +62,17 @@ fn bake_strings(s: &UiScript) {
FOLLOW = "Follow"
CANCEL = "Cancel"
RAID_TARGET_ICON = "Raid Target Icon"
-- The newbie tooltip the stock unit frame raises on a HOVER, which every test in this file
-- takes on its way to a right-click. `UnitFrame_OnEnter` (ref `UnitFrame.lua:58-65`) runs
-- the detailed-tip branch whenever `SHOW_NEWBIE_TIPS == "1"` — 1.12's own default
-- (`UIOptionsFrame.lua:100`), which our `GameTooltip.xml:46` sets at load — and for a
-- player-controlled target that is not us it calls
-- `GameTooltip_AddNewbieTip(PLAYER_OPTIONS_LABEL, 1, 1, 1, NEWBIE_TOOLTIP_PLAYEROPTIONS)`.
-- Both are nil in a bare harness, and `GameTooltip:SetText(nil)` raises. Verbatim from the
-- real `Interface\FrameXML\GlobalStrings.lua` off the 1.12.1 chain (l.3081 and l.2755).
-- Our deleted `UnitFrames.xml` never reached this arm; the stock file does.
PLAYER_OPTIONS_LABEL = "Player Options"
NEWBIE_TOOLTIP_PLAYEROPTIONS = "Right-click to bring up special commands for interacting with another player. You can inspect their equipment, issue a party invite, initiate a trade, or challenge a player to a duel. A group leader can promote or remove that player from the group."
"#,
)
.unwrap();
@@ -146,6 +165,7 @@ fn solo_target_right_click_invites_a_player() {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -158,6 +178,7 @@ fn solo_target_right_click_invites_a_player() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
@@ -246,6 +267,7 @@ fn solo_target_trade_click_queues_an_initiate() {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -257,6 +279,7 @@ fn solo_target_trade_click_queues_an_initiate() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
@@ -314,6 +337,7 @@ fn solo_target_follow_click_queues_an_exact_by_name_follow() {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -325,6 +349,7 @@ fn solo_target_follow_click_queues_an_exact_by_name_follow() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
@@ -383,6 +408,7 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -394,6 +420,7 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
@@ -442,7 +469,15 @@ fn load_pet_menu_frames(s: &UiScript) {
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"UnitFrames.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
@@ -495,6 +530,7 @@ fn a_pet(s: &mut UiScript, name: &str) {
max_health: 100,
level: 60,
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -521,9 +557,28 @@ fn a_pet(s: &mut UiScript, name: &str) {
/// Open the pet menu through the real hit path, and return nothing — the assertions read
/// `DropDownList1` afterwards.
///
/// **The click lands in the frame's own HIT RECT, not at its centre** — and on the reference's
/// file those are two different places. Stock `PetFrame.xml:15-17` ships
/// `<HitRectInsets><AbsInset left="7" right="66" top="6" bottom="7"/></HitRectInsets>` on a
/// 128×53 button (`PetFrame.xml:4-7`), so the clickable
/// band is the PORTRAIT half (x = left+7 … left+62) and the geometric centre (x = left+64) is two
/// pixels outside it. Worse, the centre sits *inside* `PetFrameHealthBar` (`PetFrame.xml:125-135`:
/// TOPLEFT 47,-22, 70×8), which is mouse-enabled by the `TextStatusBar` template's OnEnter
/// (ref `TextStatusBar.xml:11-27`) and sits at `PetFrame.level + 1` — so under the hit law it
/// takes the point and the click is consumed there. Unlike stock `PlayerFrame`, stock `PetFrame`
/// ships **no** `<OnMouseUp>` forwarder on its bars, so that click is genuinely eaten in the real
/// client too; a right-click on a pet's health bar opens nothing. Our deleted transcription had no
/// hit-rect insets on the pet frame, which is the only reason `GetCenter()` ever worked here.
fn right_click_the_pet_frame(s: &mut UiScript) {
s.resolve();
let (cx, cy) = s.eval::<(f64, f64)>("return PetFrame:GetCenter()").unwrap();
let (cx, cy) = s
.eval::<(f64, f64)>(
"local il, ir, it, ib = PetFrame:GetHitRectInsets() \
return (PetFrame:GetLeft() + il + PetFrame:GetRight() - ir) / 2, \
(PetFrame:GetBottom() + ib + PetFrame:GetTop() - it) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
@@ -759,6 +814,7 @@ fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
@@ -772,6 +828,7 @@ fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
@@ -849,6 +906,7 @@ fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
is_connected: true,
..UnitState::default()
+61 -6
View File
@@ -15,9 +15,12 @@
//! query-cache seam): the feed resolves each token's guid, which asks the server once on a miss and
//! fills in a later frame; the transition fires `UNIT_NAME_UPDATE` so frames repaint.
//!
//! The event surface is the Era set, fired per field on transitions: `UNIT_HEALTH`/`UNIT_MAXHEALTH`/
//! `UNIT_LEVEL` (arg1 = token), `UNIT_POWER_UPDATE`/`UNIT_MAXPOWER` (token, power token e.g.
//! `"MANA"`), `UNIT_DISPLAYPOWER` (power *type* changed), `UNIT_NAME_UPDATE`, plus
//! The event surface is fired per field on transitions: `UNIT_HEALTH`/`UNIT_MAXHEALTH`/
//! `UNIT_LEVEL` (arg1 = token), the **1.12** per-resource power pair `UNIT_MANA`/`UNIT_RAGE`/
//! `UNIT_FOCUS`/`UNIT_ENERGY`/`UNIT_HAPPINESS` and their five `UNIT_MAX*` twins (arg1 = token; the
//! resource is in the NAME, not an argument — decision 1819, which retired the Era
//! `UNIT_POWER_UPDATE`/`UNIT_MAXPOWER` pair that no 1.12 frame listens for),
//! `UNIT_DISPLAYPOWER` (power *type* changed), `UNIT_NAME_UPDATE`, plus
//! `PLAYER_ENTERING_WORLD` once and `PLAYER_TARGET_CHANGED` on selection change. A token appearing
//! counts as a transition of every present field (frames also pull on target change, so either path
//! populates).
@@ -788,6 +791,11 @@ pub(crate) fn snapshot(
// UNIT_FIELD_FLAGS (vmangos UnitDefines.h: 0x1000 / 0x04000000, VERIFIED).
pvp: store.0.unit_flags() & 0x1000 != 0,
skinnable: store.0.unit_flags() & 0x0400_0000 != 0,
// `UnitPlayerControlled` — the same word, bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, which is
// behaviourally "player-controlled"; `target::relations` already carved it for the duel
// leg of the selection ring). Wider than `is_player` above: a pet or a charmed creature
// sets it without being a player.
player_controlled: store.0.unit_flags() & 0x8 != 0,
// `UnitAffectingCombat 0x517e10` — the SAME `UNIT_FIELD_FLAGS` word, bit 19
// (`shr ecx,0x13; test cl,1`). One flag for every token: wow-re's whole-image census of
// that idiom found the local-player readers reading this identical bit, so there is no
@@ -930,6 +938,14 @@ const PLAYER_FLAGS_PVP_DESIRED: u32 = 0x200;
/// `IsResting 0x516ea0` tests (`shr 5; test 1` — wow-re rested-xp-bindings.md §3).
const PLAYER_FLAGS_RESTING: u32 = 0x20;
/// `PLAYER_FLAGS` bit 12 / bit 13 — the two **play-time** regimes an anti-addiction realm puts an
/// account into, read by `PartialPlayTime` (`0x48eb70`) and `NoPlayTime` (`0x48ebe0`). Decision
/// 1746 carved both, and settled that `0x1000` is PARTIAL_PLAY_TIME on 5875 rather than the
/// pre-1.6.1 `CAN_SELF_RESURRECT` it had been read as. Stock `PlayerFrame_UpdatePlaytime` tests
/// them at LOAD, so their absence is a raise on the first frame, not a cosmetic gap.
const PLAYER_FLAGS_PARTIAL_PLAY_TIME: u32 = 0x1000;
const PLAYER_FLAGS_NO_PLAY_TIME: u32 = 0x2000;
/// The PvP-preference announcement rule (decision 0652): `(toast, verbose)` on a real change of the
/// bit, `None` otherwise.
///
@@ -1027,7 +1043,6 @@ pub(crate) fn fire_transitions(
cur: &UnitState,
) {
let tok = || ScriptValue::Str(token.to_string());
let ptok = || ScriptValue::Str(power_token(cur.power_type).to_string());
let changed = |f: fn(&UnitState) -> u64| prev.is_none_or(|p| f(p) != f(cur));
if changed(|u| u64::from(u.health)) {
@@ -1042,11 +1057,28 @@ pub(crate) fn fire_transitions(
if changed(|u| u64::from(u.power_type)) {
script.fire_event("UNIT_DISPLAYPOWER", vec![tok()]);
}
// The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's
// `UnitFrameManaBar_Initialize` registers `UNIT_MANA`/`UNIT_RAGE`/`UNIT_FOCUS`/`UNIT_ENERGY`/
// `UNIT_HAPPINESS` and the five `UNIT_MAX*` twins (`UnitFrame.lua:190-199`), and nothing in
// 1.12 FrameXML has ever heard of `UNIT_POWER_UPDATE`. `power_token` already yields exactly the
// suffix, so the name is the token. Health is unaffected — `UNIT_HEALTH`/`UNIT_MAXHEALTH` are
// spelled the same in both eras — and so is `UNIT_DISPLAYPOWER` above.
//
// This was live breakage, not tidiness: while we fired only the Era names, the stock unit frames
// registered only the 1.12 ones, so a mana/rage/energy bar never moved except when something
// else happened to call `UnitFrame_Update` (a target change, a pet summon, a frame show).
// Decision 1819.
if changed(|u| u64::from(u.power)) {
script.fire_event("UNIT_POWER_UPDATE", vec![tok(), ptok()]);
script.fire_event(
&format!("UNIT_{}", power_token(cur.power_type)),
vec![tok()],
);
}
if changed(|u| u64::from(u.max_power)) {
script.fire_event("UNIT_MAXPOWER", vec![tok(), ptok()]);
script.fire_event(
&format!("UNIT_MAX{}", power_token(cur.power_type)),
vec![tok()],
);
}
if prev.is_none_or(|p| p.name != cur.name) {
script.fire_event("UNIT_NAME_UPDATE", vec![tok()]);
@@ -1119,10 +1151,26 @@ fn feed_units(
// The NPC the player is interacting with, for the `"npc"` token below. `Option` for the same
// reason `index` and `factions` are: a UI-only harness runs this feed with no session plugin.
interact: Option<Res<crate::ui_session::InteractNpc>>,
// The account's rested billing minutes ride the world-enter message, which is the only moment
// they ever arrive (decision 1820): the value comes off `SMSG_AUTH_RESPONSE` and the reference
// parks it in a process-lifetime global. `MessageReader` rather than a resource because it is
// an EDGE, and it is read here rather than in a system of its own because this feed already
// holds the script.
// `Option` for the same reason `index`, `factions` and `interact` above are: the message is
// registered by `NetPlugin`, and a UI-only harness runs this feed with no net stack at all —
// a bare reader turns that into a system-validation panic.
entered_world: Option<MessageReader<crate::net::EnteredWorldMessage>>,
) {
let Some(mut script) = script else {
return;
};
// Ahead of the gate below, which is about descriptor deltas: this is a login edge and has no
// snapshot to diff.
if let Some(entered) =
entered_world.and_then(|mut r| r.read().last().map(|m| m.billing_time_rested))
{
script.set_billing_time_rested(entered);
}
let chr = classes.as_deref().map(|t| &t.0);
// One reborrow so the memo (`feed.vm`) and `feed.warned_sideless` can be borrowed as the
// disjoint fields they are — through the `ResMut` deref they would alias.
@@ -1449,6 +1497,13 @@ fn feed_units(
let prev = memo.last_rest;
memo.last_rest = Some(rest);
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
// The same descriptor word carries the two play-time bits, and they ride the same
// memo: `0x5ee990` fires PLAYER_FLAGS_CHANGED on ANY PLAYER_FLAGS delta without
// testing which bit moved, so one snapshot is the faithful granularity for all three.
script.set_play_time(
rest.2 & PLAYER_FLAGS_PARTIAL_PLAY_TIME != 0,
rest.2 & PLAYER_FLAGS_NO_PLAY_TIME != 0,
);
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
script.fire_event("UPDATE_EXHAUSTION", vec![]);
}
+9 -1
View File
@@ -195,7 +195,15 @@ pub enum SessionEvent {
CharActionResult { action: CharAction, code: u8 },
/// We are in the world: `self_guid` is our player, `name` its character name. The IO thread emits
/// this first, before any object updates.
Connected { self_guid: u64, name: String },
Connected {
self_guid: u64,
name: String,
/// The account's accumulated **rested billing minutes**, from the `SMSG_AUTH_RESPONSE`
/// that admitted this session — what `GetBillingTimeRested()` reports (decision 1820).
/// Rides the connect event because that is the only moment it ever arrives: the reference
/// keeps it in a process-lifetime global written once by the auth parser.
billing_time_rested: u32,
},
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select.
/// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows.
LoggedOut,
@@ -85,6 +85,15 @@ pub enum ServerPacket {
/// Our place in the login queue, when `result` is [`super::AUTH_WAIT_QUEUE`] — `None` for
/// every other result, and also for a queue packet whose body was too short to carry one.
queue_position: Option<u32>,
/// The account's accumulated **rested billing minutes**, the third field of the billing
/// group — what `GetBillingTimeRested()` reports (decision 1820). `None` when the body was
/// too short to carry the group, which is the same length branch `queue_position` takes.
///
/// The unit is the server's convention, not the engine's: the client applies no conversion
/// at any point (the binding `0x48ec50` does a bare unsigned u32→double, and the parser
/// `0x418eb0` a bare `mov`). Minutes is what the reference's own FrameXML assumes —
/// `PlayerFrame.lua:246` divides by 60 to get hours against `REQUIRED_REST_HOURS = 5`.
billing_time_rested: Option<u32>,
},
CharEnum {
characters: Vec<Character>,
@@ -191,14 +191,21 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
// position as current is a bug we decline to reproduce.
let result = read_u8(&mut r)?;
let queued = result == super::AUTH_WAIT_QUEUE;
let mut billing_time_rested = None;
if (result == super::AUTH_OK || queued) && r.len() >= 5 {
let _billing_time_remaining = read_u32_le(&mut r)?;
let _billing_plan_flags = read_u8(&mut r)?;
let _billing_time_rested = read_u32_le(&mut r)?;
// Kept rather than dropped since 1820: this is the only place the value ever
// arrives, and `GetBillingTimeRested()` is a plain read of it. The client parks it
// in a process-lifetime global (`[ClientServices+0x1af8]`, sole writer `0x5b421a`);
// ours rides the session, which has the same lifetime and cannot go stale across a
// reconnect the way that global does.
billing_time_rested = Some(read_u32_le(&mut r)?);
}
ServerPacket::AuthResponse {
result,
queue_position: queued.then(|| read_u32_le(&mut r).ok()).flatten(),
billing_time_rested,
}
}
opcode::SMSG_CHAR_ENUM => {
+28 -1
View File
@@ -90,6 +90,10 @@ pub struct WorldSession {
/// vmangos drops any chat whose language the character doesn't know (dot-commands included) —
/// hardcoded Common silently ate every Horde character's chat and commands once.
chat_language: u32,
/// The rested billing minutes the admitting `SMSG_AUTH_RESPONSE` carried, or `0` when its body
/// was too short for the billing group. Read by [`Self::billing_time_rested`]; see decision
/// 1820 for why `0` rather than the client's own uninitialised-global behaviour.
billing_time_rested: u32,
}
impl WorldSession {
@@ -178,6 +182,7 @@ impl WorldSession {
crypto,
roster_races: Default::default(),
chat_language: messages::LANGUAGE_COMMON,
billing_time_rested: 0,
};
// 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first
@@ -191,7 +196,17 @@ impl WorldSession {
// honest message at the login screen.
loop {
match session.recv()? {
ServerPacket::AuthResponse { result, .. } if result == messages::AUTH_OK => break,
ServerPacket::AuthResponse {
result,
billing_time_rested,
..
} if result == messages::AUTH_OK => {
// The admitting packet is the only one that counts: a queue packet carries the
// group too, but the client re-reads it on every AUTH_RESPONSE and the last one
// wins, which is this one.
session.billing_time_rested = billing_time_rested.unwrap_or(0);
break;
}
// **Queued, not refused** — the realm is full and we keep our place in line. Report
// the position and go back to reading; the server re-sends as we move up and ends
// the wait with an `AUTH_OK`. Treating this as an ending (which it was until now)
@@ -199,6 +214,7 @@ impl WorldSession {
ServerPacket::AuthResponse {
result,
queue_position,
..
} if result == messages::AUTH_WAIT_QUEUE => {
if !queued {
queued = true;
@@ -230,6 +246,17 @@ impl WorldSession {
Ok(session)
}
/// The account's accumulated **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that
/// admitted this session — what `GetBillingTimeRested()` reports (decision 1820).
///
/// Against vmangos this is always `0`: the server hardcodes `uint32(0)` for the field
/// (`World.cpp:331`, and `:380` for the queue packet), guarded on a build newer than 1.7.1,
/// which 1.12 is. It is read from the wire rather than assumed so that a server which does
/// populate it is reported honestly.
pub fn billing_time_rested(&self) -> u32 {
self.billing_time_rested
}
/// Set a read timeout on the underlying socket (e.g. so a debug read-loop can stop when the
/// world goes quiet). `None` clears it (blocking reads).
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> Result<()> {
+138 -20
View File
@@ -200,6 +200,38 @@ pub(super) fn cancel_authorized(a: &AuraState) -> bool {
/// nameplateShowPersonal, spellId`. The three we cannot know on this wire (`source`, `isStealable`,
/// `nameplateShowPersonal`) return nil — an addon that reads them sees "unknown", which is honest,
/// rather than a fabricated value.
/// The **1.12** return tuple, which is a different shape and not a prefix of the Era one: the first
/// value is the TEXTURE, not the name. `UnitBuff` returns `(texture, applications)` (`0x519500`) and
/// `UnitDebuff` returns `(texture, applications, dispelType)` (`0x5198f0`) — both `verified` in
/// wow-re's ledger, and both are what the reference's own FrameXML reads
/// (`TargetFrame.lua:287-290` binds `debuff, debuffStack, debuffType` and then
/// `SetTexture(debuff)`). Decision 1818, which also records why the Era shape was serving nobody.
fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Result<MultiValue> {
let icon = match &a.icon {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
let mut out = vec![icon, Value::Integer(i64::from(a.count))];
if with_dispel_type {
out.push(match &a.debuff_type {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
});
}
Ok(MultiValue::from_vec(out))
}
/// Which return shape a getter wants — the two are not compatible, see [`returns_1121`].
#[derive(Clone, Copy)]
enum Shape {
/// `UnitAura`'s ten values. Era-only: the name does not exist in 1.12 FrameXML at all.
Era,
/// `UnitBuff`'s two.
Buff,
/// `UnitDebuff`'s three.
Debuff,
}
fn returns(lua: &Lua, a: &AuraState) -> mlua::Result<MultiValue> {
let s = |v: &Option<String>| -> mlua::Result<Value> {
Ok(match v {
@@ -229,6 +261,7 @@ fn nth_aura(
token: &Option<String>,
index: i64,
filter: &Filter,
shape: Shape,
) -> mlua::Result<MultiValue> {
if index < 1 {
return Ok(MultiValue::new());
@@ -246,7 +279,11 @@ fn nth_aura(
})
};
match hit {
Some(a) => returns(lua, &a),
Some(a) => match shape {
Shape::Era => returns(lua, &a),
Shape::Buff => returns_1121(lua, &a, false),
Shape::Debuff => returns_1121(lua, &a, true),
},
None => Ok(MultiValue::new()),
}
}
@@ -285,39 +322,62 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitAura(unit, index [, filter]) — filter defaults to HELPFUL, like the live API.
// UnitAura(unit, index [, filter]) — filter defaults to HELPFUL, like the live API. This one
// keeps the Era tuple: it is not a 1.12 verb at all (zero occurrences in the reference's
// FrameXML) and has zero call sites in the 110-addon corpus, so it costs nothing and stays the
// Era-compat surface 0068 asked for.
g.set(
"UnitAura",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
nth_aura(lua, &token, index, &Filter::parse(filter.as_deref()))
nth_aura(
lua,
&token,
index,
&Filter::parse(filter.as_deref()),
Shape::Era,
)
},
)?,
)?;
// UnitBuff/UnitDebuff(unit, index [, filter]) — the live API pins the sign and folds any extra
// filter tokens (CANCELABLE, …) in alongside it.
// UnitBuff(unit, index [, raidFilter]) / UnitDebuff(unit, index [, raidFilter]) — the 1.12
// signature and the 1.12 RETURN SHAPE (decision 1818). The sign is fixed by the verb, not by a
// filter word: `0x519500` reads aura slots 0..31 and `0x5198f0` reads 32..47.
//
// **The third argument is a `raidFilter` flag, not Era's filter string.** Non-zero enables ONE
// extra per-slot predicate — castable-by-me for buffs (`0x4b3870`), dispellable-by-me for
// debuffs (`0x4b3920`). We accept it and DO NOT apply it, because [`AuraState`] holds neither
// fact and neither is derivable from the aura feed: castable-by-me needs the player's spellbook,
// dispellable-by-me needs a class→dispel-type table joined to `debuff_type`. Inventing an answer
// is the guess 1203 exists to prevent, so the flag is parked here in the open. Visible
// consequence, so nobody has to rediscover it: the pet frame's buff row and the
// dispellable-debuff rows show everything rather than only what the player can cast or dispel.
g.set(
"UnitBuff",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
let spec = match filter {
Some(f) => format!("HELPFUL|{f}"),
None => "HELPFUL".to_string(),
};
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HELPFUL")),
Shape::Buff,
)
},
)?,
)?;
g.set(
"UnitDebuff",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
let spec = match filter {
Some(f) => format!("HARMFUL|{f}"),
None => "HARMFUL".to_string(),
};
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HARMFUL")),
Shape::Debuff,
)
},
)?,
)?;
@@ -429,16 +489,18 @@ mod tests {
.unwrap(),
"Battle Stance"
);
// `UnitBuff`/`UnitDebuff` enumerate the same list, but their first return is the ICON, not
// the name (1.12's shape — decision 1818), so they are addressed by it here.
assert_eq!(
s.eval::<String>(r#"return (UnitBuff("player", 2))"#)
.unwrap(),
"Mark of the Wild"
"Interface\\Icons\\Spell_1126"
);
// The debuff is index 1 of its own filter, not index 3.
assert_eq!(
s.eval::<String>(r#"return (UnitDebuff("player", 1))"#)
.unwrap(),
"Shadow Word: Pain"
"Interface\\Icons\\Spell_589"
);
// Past the end → nil, the loop terminator.
assert!(s
@@ -512,7 +574,7 @@ mod tests {
let (name, icon, count, dtype, dur, expiry, spell) = s
.eval::<(String, String, i64, String, f64, f64, i64)>(
r#"local n, i, c, d, du, e, src, st, np, sid = UnitDebuff("target", 1)
r#"local n, i, c, d, du, e, src, st, np, sid = UnitAura("target", 1, "HARMFUL")
assert(src == nil and st == nil and np == nil, "unknowable fields must be nil")
return n, i, c, d, du, e, sid"#,
)
@@ -524,6 +586,62 @@ mod tests {
assert_eq!(spell, 589);
}
/// The **1.12** shape, which is not a prefix of the Era one: `UnitBuff` → `(texture,
/// applications)`, `UnitDebuff` → `(texture, applications, dispelType)`, and the FIRST value is
/// the texture. Decision 1818; `0x519500`/`0x5198f0`, both `verified` in wow-re's ledger. The
/// reference's own FrameXML reads exactly this (`TargetFrame.lua:287-290`), and so does every
/// one of the 184 call sites in the addon corpus.
#[test]
fn unit_buff_and_unit_debuff_return_the_1121_tuple_not_the_era_one() {
let mut s = UiScript::new().unwrap();
let mut buff = aura(1126, "Mark of the Wild", true, true);
buff.count = 1;
let mut debuff = aura(589, "Shadow Word: Pain", false, false);
debuff.count = 3;
debuff.debuff_type = Some("Magic".into());
s.set_auras("target", Some(vec![buff, debuff]));
// UnitBuff returns exactly TWO values, texture first.
let (icon, count, third) = s
.eval::<(String, i64, Option<String>)>(
r#"local a, b, c = UnitBuff("target", 1) return a, b, c"#,
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\Spell_1126");
assert_eq!(count, 1);
assert_eq!(third, None, "UnitBuff returns two values, never a third");
// UnitDebuff returns THREE, the dispel type last.
let (icon, count, dispel, fourth) = s
.eval::<(String, i64, String, Option<String>)>(
r#"local a, b, c, d = UnitDebuff("target", 1) return a, b, c, d"#,
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\Spell_589");
assert_eq!((count, dispel.as_str()), (3, "Magic"));
assert_eq!(
fourth, None,
"UnitDebuff returns three values, never a fourth"
);
// The reference's own read, verbatim from TargetFrame.lua:287-297 — the comparison that
// raised `attempt to compare number with string` while we returned the Era tuple.
assert!(s
.eval::<bool>(
r#"local d, stack, dtype = UnitDebuff("target", 1)
return stack > 1 and dtype == "Magic""#
)
.unwrap());
// The raidFilter argument is ACCEPTED and its predicate is not applied (1818): passing it
// must not error, and must not change the answer.
assert_eq!(
s.eval::<String>(r#"return (UnitDebuff("target", 1, 1))"#)
.unwrap(),
"Interface\\Icons\\Spell_589"
);
}
#[test]
fn cancel_unit_buff_queues_the_spell_id_and_refuses_a_non_cancelable_aura() {
let mut s = UiScript::new().unwrap();
+9 -7
View File
@@ -412,15 +412,17 @@ pub const SCREEN: crate::layout::Handle = 0;
///
/// **This list is FLAT; the reference's set is per widget type** (RF-0028's script-name→slot
/// resolvers: base map `0x76a0d0` + the type's own additions — a `<Frame>` has no `OnClick`). That
/// divergence is deliberate and measured: our own transcribed FrameXML relies on it in 9 places
/// (`PlayerFrame`/`TargetFrame`/`PetFrame`/`PartyMemberFrame1-4` are mouse-enabled `<Frame>`s
/// carrying `OnClick` — see the note at `assets/ui/UnitFrames.xml`'s `PlayerFrame_OnClick` — plus
/// `ChatFrame1`/`ChatFrame2`), and the corpus in 21 more (20 `EditBox` + 1 `<Frame>`
/// divergence is deliberate and measured, and the 1751 migration has been shrinking the debt it
/// covers. Our own FrameXML used to rely on it in 9 places; seven of them were
/// `PlayerFrame`/`TargetFrame`/`PetFrame`/`PartyMemberFrame1-4` declared as mouse-enabled
/// `<Frame>`s carrying `OnClick` in our transcribed `UnitFrames.xml`, and that file is gone — the
/// reference declares all seven as `<Button>`, which carries `OnClick` by type. What is left of
/// ours is `ChatFrame1`/`ChatFrame2`, plus the corpus's 21 (20 `EditBox` + 1 `<Frame>`
/// `OnEscapePressed`), all of which fire today ([`button::click_button`]'s "plain frames can carry
/// one too" arm). Going per-type is the faithful shape and is what would give us `HasScript` (948
/// corpus call sites across 91 addons), but it removes working behaviour from those 30 sites, so it
/// is a change to make deliberately with FrameXML fixed first — not a side effect of widening this
/// list.
/// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain,
/// so it is still a change to make deliberately rather than as a side effect of widening this list
/// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now.
const SCRIPT_KINDS: [&str; 35] = [
"OnLoad",
"OnEvent",
+41
View File
@@ -592,6 +592,26 @@ pub(crate) struct Model {
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
pub(crate) sound_queue: Vec<SoundRequest>,
/// **The UI-load sound-suppression depth** — `[0xb05fa0]` in the reference, a counted scope
/// with exactly three references image-wide: `0x458f50` (`inc`), `0x458f60` (`dec`), and one
/// reader at `0x458046`, inside `PlaySoundByName 0x458030`. The whole-UI load `0x48fbf0`
/// brackets ITSELF in it — `0x48fbfa` on entry, `0x49016d` on exit, spanning the TOC walk,
/// `Bindings.xml` and the AddOns — and both of its callers, login (`0x48f681`) and `/reloadui`
/// (`0x495669`), therefore load silently.
///
/// The reader bails at `0x45804d` **before** the `MasterSoundEffects` CVar and the kit-hash
/// lookup, so a suppressed call is **dropped**: not muted, not queued, no Lua error, and the
/// binding still answers its usual `(willPlay, handle)`. Scoped to the NAME-keyed entry only —
/// id-keyed `PlaySound(kitId)` (`0x457fb0`/`0x457ff0`), `PlaySoundFile`, `PlayMusic` and
/// `StopMusic` all run normally inside the scope.
///
/// This is what makes stock `TargetFrame_OnHide`'s `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`
/// inaudible at every login: the frame really is shown when its `OnLoad` hides it, the OnHide
/// really does fire, the call really is made — and the engine throws it away. Decision 1033's
/// "no sound during the load" is the client's own law, enforced here rather than by hoping no
/// handler rings.
pub(crate) sound_suppression: u32,
/// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered
/// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
@@ -1235,6 +1255,23 @@ pub(crate) struct Model {
pub(crate) rest_state: u8,
pub(crate) rest_pool: u32,
pub(crate) resting: bool,
/// `PLAYER_FLAGS` bit 12 — **PARTIAL_PLAY_TIME**, what `PartialPlayTime()` answers
/// (`0x48eb70`, `shr eax,0xc` / `and al,1`; decision 1746 §"`PLAYER_FLAGS` bit `0x1000` is
/// read after all"). The play-time regime a realm with an anti-addiction policy puts an
/// account into; on a western realm it never sets.
pub(crate) partial_play_time: bool,
/// `PLAYER_FLAGS` bit 13 — what `NoPlayTime()` answers (`0x48ebe0`, the neighbour of the
/// above and the harsher half of the same regime).
pub(crate) no_play_time: bool,
/// The account's accumulated **rested billing minutes**, from `SMSG_AUTH_RESPONSE` — what
/// `GetBillingTimeRested()` returns (decision 1820, binding `0x48ec50`). Minutes is the
/// server's convention and the engine converts nothing; stock `PlayerFrame.lua:246` divides by
/// 60 for hours. Reached only from inside the two play-time bits above, so with those clear
/// nothing in stock FrameXML ever reads it.
pub(crate) billing_time_rested: u32,
/// Is a cinematic playing? What `InCinematic()` reports, pushed by the cinematic plugin.
///
/// Read by more than the letterbox: `StaticPopup_Show` refuses any dialog whose entry lacks
@@ -1600,6 +1637,7 @@ impl Model {
action_bar_toggles: None,
action_bar_toggle_sends: Vec::new(),
sound_queue: Vec::new(),
sound_suppression: 0,
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
cvar_changes: Vec::new(),
@@ -1804,6 +1842,9 @@ impl Model {
rest_state: 2,
rest_pool: 0,
resting: false,
partial_play_time: false,
no_play_time: false,
billing_time_rested: 0,
in_cinematic: false,
exhaustion: [
(1, ("Rested".to_string(), 2.0)),
+11 -4
View File
@@ -205,7 +205,7 @@ pub enum PartyRequest {
},
/// `SetLootThreshold(n)` — the new quality floor.
LootThreshold(u32),
/// `SetRaidTargetIcon(unit, index)` — mark (1..=8) or clear (0) the raid-target icon on a
/// `SetRaidTarget(unit, index)` — mark (1..=8) or clear (0) the raid-target icon on a
/// unit, addressed by token; the app resolves the token to a guid for the
/// `MSG_RAID_TARGET_UPDATE` send (decision 0434 §5's submenu, §6's board law).
SetRaidTarget { unit: String, index: u8 },
@@ -561,8 +561,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// SetRaidTarget(unit, index) — the ENGINE verb. `SetRaidTargetIcon` is NOT one: it is FrameXML,
// `function SetRaidTargetIcon(unit, index)` at `TargetFrame.lua:486`, a toggle wrapper that
// calls this with 0 when the unit already wears `index`. We had registered the wrapper's name on
// this body, which behaved correctly and so never showed — until 1751 put `TargetFrame.lua` on
// the chain and the wrapper started calling a `SetRaidTarget` that did not exist. Proof both
// ways: no `function SetRaidTarget(` anywhere in the reference's FrameXML, and both
// `TargetFrame.lua:488`/`:490` and `Bindings.xml:1160` call it bare.
g.set(
"SetRaidTargetIcon",
"SetRaidTarget",
lua.create_function(|lua, (unit, index): (String, u8)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
@@ -1157,8 +1164,8 @@ mod tests {
#[test]
fn set_raid_target_icon_queues_the_token_and_index() {
let mut s = UiScript::new().unwrap();
s.run(r#"SetRaidTargetIcon("target", 8)"#).unwrap();
s.run(r#"SetRaidTargetIcon("party2", 0)"#).unwrap();
s.run(r#"SetRaidTarget("target", 8)"#).unwrap();
s.run(r#"SetRaidTarget("party2", 0)"#).unwrap();
assert_eq!(
s.take_party_requests(),
vec![
+10 -1
View File
@@ -85,7 +85,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
));
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.sound_queue.push(req);
// `PlaySoundByName 0x458030`'s first act: read the UI-load suppression depth
// (`0x458046`) and bail at `0x45804d` if it is non-zero — BEFORE the
// `MasterSoundEffects` CVar and before the kit-hash lookup. So the call is dropped
// rather than muted or queued, and the binding still answers as if it had played.
// NAME-keyed only: the id-keyed entry (`0x457fb0`) has no such gate, which is why the
// arm above pushes unconditionally.
let suppressed = matches!(req, SoundRequest::KitName(_)) && model.sound_suppression > 0;
if !suppressed {
model.sound_queue.push(req);
}
drop(model);
// willPlay (queued), soundHandle (nil — module docs).
Ok((true, Value::Nil))
@@ -323,6 +323,42 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// red/blue player legs, an NPC the reaction swatch. (The live gate is `UnitPlayerControlled`,
// which also covers pets/charmed creatures; we resolve only the player half of it — the extra
// reach needs a player-controlled flag we don't carry, and a player's own alt is the case here.)
// UnitIsCivilian(unit) → 1 if killing this unit would be a DISHONORABLE kill, else nil. This is
// `0x612550` itself handed to Lua — the same four-term predicate (`pvp` bit AND creature-query
// civilian flag AND hostile AND the kill would be grey) that the unit tooltip's green CIVILIAN
// line and `UnitPVPName`'s civilian arm already run through `is_civilian_kill`. That function's
// doc called itself "ONE home, two callers"; the stock target frame is the third, and it is the
// one that names the predicate — `TargetFrame_CheckDishonorableKill` (TargetFrame.lua:230) is
// its only FrameXML caller and the comment inside it reads "Is a dishonorable kill".
//
// Sharing the function is the point: a second copy would let the tooltip, the name and the
// target plate disagree about the same mob.
g.set(
"UnitIsCivilian",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let player_level = model.player_req.level;
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if super::is_civilian_kill(u, player_level) => Value::Integer(1),
_ => Value::Nil,
})
})?,
)?;
// UnitPlayerControlled(unit) → 1 if a PLAYER is driving this unit, else nil. `UNIT_FIELD_FLAGS`
// bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`), which is wider than `UnitIsPlayer` below: a player's
// pet and a charmed creature answer 1 here and nil there. Stock `UnitFrame_OnEnter`
// (UnitFrame.lua:58) is the caller that made this a blocker rather than a nicety — it gates the
// player-options newbie tip on it, so a missing binding raised on the first unit-frame hover.
g.set(
"UnitPlayerControlled",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if u.player_controlled => Value::Integer(1),
_ => Value::Nil,
})
})?,
)?;
g.set(
"UnitIsPlayer",
lua.create_function(|lua, token: Option<String>| {
@@ -902,6 +938,54 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
// PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read
// straight off `PLAYER_FLAGS` — `0x48eb70` is `shr eax,0xc; and al,1` (bit 12) and `0x48ebe0`
// its bit-13 neighbour (decision 1746, which carved both while establishing that bit 0x1000 is
// PARTIAL_PLAY_TIME on 5875 rather than the pre-1.6.1 CAN_SELF_RESURRECT). Same Lua-vanilla
// predicate shape as `IsResting` above: the NUMBER 1 or nil, never a boolean. Stock
// `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) is the only FrameXML caller of either.
g.set(
"PartialPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.partial_play_time {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
g.set(
"NoPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.no_play_time {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always
// (decision 1820; binding `0x48ec50`, byte-VERIFIED). The body has no `jcc` at all and ends in
// an unconditional `mov eax,1`, and both callees are branchless and call-free — so it can never
// return nil and never return zero values, which is why this is not the `Value::Nil` shape its
// neighbours use. The engine applies NO conversion: `0x48ec5e` is a bare unsigned u32→double
// push, so "minutes" is the server's convention, corroborated by stock `PlayerFrame.lua:246`
// dividing by 60 against `REQUIRED_REST_HOURS = 5`.
//
// Against vmangos this reads 0, because the server hardcodes `uint32(0)` for the field
// (`World.cpp:331`). It is fed from the wire rather than pinned to a constant so a server that
// populates it is reported honestly. Note the reference only ever reaches this call from
// INSIDE `if PartialPlayTime() ... elseif NoPlayTime()`, so with those bits clear stock
// FrameXML never calls it — it has to exist for the file to load, and is off the live path.
g.set(
"GetBillingTimeRested",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(Value::Number(f64::from(model.billing_time_rested)))
})?,
)?;
// GetTimeToWellRested() → nil, always — `0x48d4b0`, byte-VERIFIED: the whole binding is 11
// bytes, `pushnil; return 1`. FrameXML's EXHAUST_TOOLTIP4 countdown branch is dead in 5875.
g.set(
+51 -6
View File
@@ -182,6 +182,15 @@ pub struct UnitState {
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
pub is_player: bool,
/// This token is **player-CONTROLLED** — `UNIT_FIELD_FLAGS` bit 3
/// (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, behaviourally "player-controlled"; the same bit
/// `target::relations::ring_reaction`'s duel leg selects on). Wider than
/// [`Self::is_player`]: a player's pet and a charmed creature are player-controlled without
/// being players, which is exactly the distinction stock `UnitFrame_OnEnter`
/// (UnitFrame.lua:58) needs when it decides whether the hovered plate gets the
/// player-options newbie tip. `UnitIsPlayer`'s note used to call this reach a gap we did not
/// carry; the unit-frame migration is what closed it.
pub player_controlled: bool,
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see
@@ -221,12 +230,16 @@ pub struct UnitState {
pub faction_name: Option<String>,
/// Connection to the server is live (`UnitIsConnected`) — an offline party member's portrait
/// desaturates (decision 0434 §2/§3: the `GROUP_LIST` status byte's `0x01` online bit).
/// **v1 default gap, stated not hidden:** the zero value is `false`, so a snapshot built via
/// `..Default::default()` reports a unit disconnected unless the app sets this explicitly; the
/// per-frame feed must set it `true` for every *live* unit token it pushes (mirroring
/// [`Self::exists`]) — a pending app-side change, not yet wired as of this field's addition. A
/// token absent from the store (`UnitExists` false) still reports nil regardless, via the same
/// "no snapshot" path every other predicate here uses.
/// **The zero value is `false`**, so a snapshot built via `..Default::default()` reports a unit
/// DISCONNECTED — which matters more than it reads: stock `UnitFrameManaBar_Update`
/// (UnitFrame.lua:213) greys a disconnected unit's bar and never reaches the power colour at
/// all. A synthetic unit that means to be live must say so.
///
/// The app's own feed does (`ui_unit::snapshot`, "the stated `is_connected` gap, closed"), and
/// the party feed reads the roster status byte's `0x01` for its tokens. This doc used to call
/// that "a pending app-side change, not yet wired"; it has been wired since, and the note
/// outlived it. A token absent from the store (`UnitExists` false) still reports nil
/// regardless, via the same "no snapshot" path every other predicate here uses.
pub is_connected: bool,
/// Away-from-keyboard (`UnitIsAFK` — decision 0434 §2's status-byte `0x40` bit). The party
/// frame's name line and chat both key off it. App-resolved; `false` until the feed lands.
@@ -451,6 +464,38 @@ impl super::UiScript {
model.resting = resting;
}
/// Push the two **play-time** bits of `PLAYER_FLAGS` — 12 (`PartialPlayTime`) and 13
/// (`NoPlayTime`), decision 1746. Taken together for the same reason the rest trio is:
/// stock `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) tests them as an if/elseif pair
/// and a half-updated pair would paint the wrong one of the two icons.
pub fn set_play_time(&mut self, partial: bool, none: bool) {
let mut model = self.model_mut();
model.partial_play_time = partial;
model.no_play_time = none;
}
/// Push the account's **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that admitted
/// the session — what `GetBillingTimeRested()` returns (decision 1820). Set once at login: the
/// client parks it in a process-lifetime global and nothing else on the wire ever writes it.
pub fn set_billing_time_rested(&mut self, minutes: u32) {
self.model_mut().billing_time_rested = minutes;
}
/// Enter the **UI-load sound-suppression scope** — the reference's `0x458f50`, an `inc` on the
/// counted depth every name-keyed `PlaySound` reads. Counted, not a flag, exactly as the
/// client has it: nesting is legal and only the outermost exit re-enables sound.
pub fn push_sound_suppression(&self) {
let mut model = self.model_mut();
model.sound_suppression = model.sound_suppression.saturating_add(1);
}
/// Leave it — `0x458f60`'s `dec`. Saturating on the way down too: an unbalanced pop is a bug
/// in the caller, and silently wrapping to a permanently-muted UI would be the worse failure.
pub fn pop_sound_suppression(&self) {
let mut model = self.model_mut();
model.sound_suppression = model.sound_suppression.saturating_sub(1);
}
/// Push whether a cinematic is playing — what `InCinematic()` answers.
pub fn set_in_cinematic(&mut self, playing: bool) {
self.model_mut().in_cinematic = playing;
+930
View File
@@ -0,0 +1,930 @@
//! The underwater **drift cloud** — the 4000-mote field the reference draws while the camera eye
//! is inside a liquid (decision 1814).
//!
//! Ground truth: wow-re `system/lighting/scratch/drift-cloud-emission-law.md`, a §5 six-worker
//! round commissioned for this build, plus the difftested kernel transcription in wow-re
//! `crates/lighting/src/drift.rs`. The object is `World.cpp`'s own — `0xfa40` bytes from
//! `0x66f971`, ctor `0x68e5a0`, held by the pointer `[0xc63180]` — and it is **not** part of the
//! weather manager `[0xc6326c]`: the two share no state and no code (verified by complete
//! enumeration; 12 references to the cloud, none in the CMapWeather band).
//!
//! ## The shape
//!
//! 4000 records of 16 bytes — `xyz` **camera-relative**, plus a per-particle billboard edge. They
//! sit in a 30 yd cube centred on the eye and are advected by `p += (last_cam − cam) + gust`, each
//! axis wrapped back into `±15`: the cloud is therefore **world-fixed** apart from the gust, and
//! the wrap recycles a mote the moment it leaves the box. Nothing is spawned or killed — there is
//! no lifetime, no emitter, and no per-particle recycle; the whole field is re-scattered only on a
//! liquid-type change or a teleport.
//!
//! Both drive sites — the advect `0x66fee0` and the draw `0x6701e0` — carry the same two
//! conjuncts: the render-flag bit `[0xc7b2a4] & 0x2000000` and `[0xc7f288] != 0xf`, the
//! **camera-eye** liquid type. That second one is [`Underwater`], which benilla's murk already
//! gates on, so the motes appear exactly when the murk does.
//!
//! ## Where it draws
//!
//! `0x483731`, between the ground-target reticle's liquid pass and the glare dispatch — after the
//! water surface **and** both M2 transparent passes, i.e. the last world content in the frame.
//! [`Rung::DRIFT_CLOUD`](crate::sky_order::Rung::DRIFT_CLOUD) is that slot on our ladder.
//!
//! Render state is unusually plain: alpha blend, depth test on, **depth write off**, lighting off,
//! immediate `0xFFFFFFFF` white, and fog **off** for water/ocean (on only for magma). No
//! LightParams value reaches a mote — the draw contains zero indirect calls and the colour is a
//! compile-time immediate — so the motes are full-brightness texels over the fogged scene and take
//! **no** underwater tint. That is worth stating because it is the opposite of the obvious guess.
//!
//! ## Where we knowingly differ from the bytes
//!
//! Four places, each a deliberate call rather than a gap — the reasoning is in decision 1814:
//!
//! 1. **[`GUST_REF_HZ`]** — the reference's water gust is a per-*frame* displacement with no `dt`
//! term, so its drift speed scales with frame rate. We normalise it to 60 fps.
//! 2. **Atlas cell 12 is completed.** The reference's UV initialiser stops two floats early and
//! one magma mote in four draws a sprite smeared across the atlas diagonal — a shipped defect
//! ([`ATLAS`]).
//! 3. **The cell walk has no off-by-one.** The reference reads its cell index at the *bottom* of
//! the draw loop, so the value lands on the next mote and slot 0 takes a hardcoded cell 8.
//! 4. **The cull cone is floored at the frustum** ([`cull_limits`]) — its hardcoded 90° contains
//! the view at every aspect the reference shipped for, and stops doing so around 21:9.
use bevy::prelude::*;
use benilla_formats::Submersion;
use crate::particles::buffer::{
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectLighting, EffectQuads,
EffectVertex,
};
use crate::particles::emit::rand01;
use crate::sky_order::Rung;
use crate::view::WorldCamera;
use super::Underwater;
/// Pool size — `count = __ftol(1.0 * [0x81038c])`, `[0x81038c] = 4000.0f`, and `0x68e650` has
/// exactly **one** caller (the ctor, with that literal). No CVar, console command or quality tier
/// scales it: 4000 is not a capacity, it is the population.
const COUNT: usize = 4000;
/// The wrap box's edge (`+0xfa1c`, ctor literal `30.0f`; `0x68e680` has one caller). The motes
/// live in `[−15, +15)` per axis, camera-relative.
const BOX_EDGE: f32 = 30.0;
const BOX_HALF: f32 = BOX_EDGE * 0.5;
/// A camera jump this big in ONE frame re-scatters the field (`0x68e99a` loads the *extent*, not
/// the half — and `last_cam` is rewritten every frame, so this is a per-frame delta). It is a
/// teleport detector, not a box re-centre: the box re-centres for free via the `+= delta` term.
const TELEPORT: f32 = BOX_EDGE;
/// The per-particle scale base (`+0xfa18`), re-set per liquid class by `0x68e670`: `1/36` for
/// water and ocean (`0x680b1d`), `1/9` for magma (`0x680b5c`). A mote's edge is drawn uniformly
/// from `[base·0.5, base·1.5)`, so water motes are **1.4–4.2 cm** across and magma's 5.6–16.7 cm.
const SCALE_WATER: f32 = 1.0 / 36.0;
const SCALE_MAGMA: f32 = 1.0 / 9.0;
/// Gust frequency (`[0x807a4c]`) and amplitude (`[0x807a3c]`) unit scales; each is drawn as
/// `m · scale` with `m ∈ [1,2)`, giving freq `[0.0125, 0.025)` and amp `[0.005, 0.01)`.
const GUST_FREQ_UNIT: f32 = 0.0125;
const GUST_AMP_UNIT: f32 = 0.005;
/// The vertical squash on a freshly rolled gust direction (`[0x8029b0]`), applied **before**
/// normalising.
///
/// It is a bias, **not a bound** — a correction to the RE note's first reading, checked at the
/// difftested transcription and by Monte Carlo. `|z|/|xy| = 0.25·|cot a|` with `a` uniform on
/// `[−π, π)`, so `atan(0.25) = 14.04°` is the **median** elevation (quartiles 5.9°/31.1°): about
/// 15.6% of rolls are steeper than 45° and the limit as `a → 0` is straight up. The one hard
/// constraint is the `fchs` at `0x68e27d`, which forces `z ≥ 0` — the gust never blows downward.
const GUST_RISE: f32 = 0.25;
/// Magma's motion is not a gust at all: a true velocity of `0.02` yd/s **downward**
/// (`[0x86a098]`), and the one mode that is `dt`-scaled in the reference.
const MAGMA_SINK: f32 = -0.02;
/// The frame rate the water gust is denominated in — **our deviation, and the whole of it.**
///
/// `0x68e4f0`'s `mode <= 1` leg writes `out = sin(term·2π)·amp·dir` and the advect adds it to the
/// position **with no `dt` factor** (the RE round flagged this as load-bearing; only magma's leg
/// multiplies by `dt`). So the reference's mote drift is frame-rate dependent: at **peak** of the
/// half-sine, ~0.3 yd/s at 30 fps, ~0.6 at 60, and ~1.2 on the 120 Hz panel this is being built on
/// — a gust's *mean* is `2/π` of that, so ~0.19–0.38 yd/s at 60. (Peak and mean are labelled here
/// because the RE's own table shipped one as the other for a day; the ratio, which is what this
/// constant is about, is the same either way.) There is no single faithful speed to port — the
/// binary's own answer spans 2× across the era's hardware — so reproducing the literal per-frame
/// step would not be "the reference's speed", it would be *this machine's*.
///
/// We take the top of the era's range, 60 Hz, and scale by `dt·60`. This is the same move as the
/// snow flake's [`SNOW_PX_REF_HEIGHT`](crate::weather::precip): denominate the reference's
/// frame-quantised number in the era's own units so the *behaviour* carries over to hardware it
/// never ran on, rather than the literal number carrying over and the behaviour changing.
const GUST_REF_HZ: f32 = 60.0;
/// The draw's own cap: `0x68f2cd` stops the fill at `0xa68` bytes = 2664 verts = **666 quads**.
/// It is not arbitrary — a uniform 4000-particle cube contributes ~1/6 of itself to a 90° cone, so
/// the cap is sized to the expected survivor count and almost never actually bites.
const SUBMIT_CAP: usize = 666;
/// The atlas cell pitch, `[0x810334] = 51/256`.
const CELL: f32 = 51.0 / 256.0;
/// The `Textures\WaterPoop02.blp` atlas as `(column, row)` cells — the lattice the CRT initialiser
/// `[0x68ebf0, 0x68efae)` fills, recovered by emulating it. Four columns per row, plus cell 8
/// alone out at column 4 of row 0.
///
/// **Cell 12 is completed here and is not in the binary.** The initialiser's last store is
/// `0x68efa5` and its `ret` is `0x68efae`, so cell 12 gets 6 of its 8 floats and its bottom-right
/// corner keeps BSS zero — sampling UV `(0,0)` and smearing the sprite across the atlas diagonal.
/// The magma row uses cell 12 for one mote in four, so a faithful port would ship a visible defect
/// on a quarter of the motes in every lava pool. Writing it as the lattice plainly intends is the
/// §3 call (implement the mechanism, not the quirk); 1814 records it.
const ATLAS: [(f32, f32); 13] = [
(0.0, 0.0),
(1.0, 0.0),
(2.0, 0.0),
(3.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(2.0, 1.0),
(3.0, 1.0),
(4.0, 0.0),
(0.0, 2.0),
(1.0, 2.0),
(2.0, 2.0),
(3.0, 2.0),
];
/// Which atlas cells a mode draws from (`[0x86a0a0]`): water and ocean cycle cells 0–7, magma
/// cycles 9–12.
const CELLS_WATER: [usize; 8] = [0, 1, 2, 3, 4, 5, 6, 7];
const CELLS_MAGMA: [usize; 8] = [9, 10, 11, 12, 9, 10, 11, 12];
/// The cull's per-axis tangent limits — the reference's cone, floored at the frustum.
///
/// `0x68f1c9` culls to `vz > 0 && |vx| < vz && |vy| < vz`: a fixed **90° cone** about the view
/// axis, not the real frustum, and it is what sizes [`SUBMIT_CAP`]. At every aspect the reference
/// shipped for, that cone comfortably contains the view — 4:3 with its ~44.1° vertical fov puts
/// the horizontal half-angle at 28.4°, a 16.6° margin — so the two never disagreed.
///
/// They do on a modern window. The horizontal half-fov reaches the cone's 45° at about **21:9**
/// and passes it beyond: taken literally, a 32:9 monitor would show motes winking out in two
/// vertical bands down the left and right edges of the screen. That is not a defect the reference
/// has and it is not one worth importing, so the limit is the **wider** of the two. On 4:3 and
/// 16:9 this is exactly the reference's cone and nothing changes; only an aspect the reference
/// never ran at sees a difference, which is the same shape of call as [`GUST_REF_HZ`].
///
/// Vertically the cone always wins (`tan 22.5° = 0.414`), so the `max` there is a statement of
/// that fact rather than a live term.
fn cull_limits(fov_y: f32, aspect: f32) -> (f32, f32) {
let ty = (fov_y * 0.5).tan();
((ty * aspect).max(1.0), ty.max(1.0))
}
/// One mote — the reference's 16-byte record exactly: a camera-relative position and the
/// billboard's world edge length.
#[derive(Clone, Copy, Default)]
struct Mote {
pos: Vec3,
edge: f32,
}
/// Which liquid class the field is configured for. Slime is absent on purpose: its arm of the
/// dispatch (`0x680b6f`) clears the enable byte and does **not** re-scatter — there are no motes
/// in slime.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum DriftMode {
/// Liquid types 0 and 1 — water and ocean.
Water,
/// Liquid type 2.
Magma,
}
impl DriftMode {
fn scale_base(self) -> f32 {
match self {
DriftMode::Water => SCALE_WATER,
DriftMode::Magma => SCALE_MAGMA,
}
}
fn cells(self) -> &'static [usize; 8] {
match self {
DriftMode::Water => &CELLS_WATER,
DriftMode::Magma => &CELLS_MAGMA,
}
}
/// Fog is enabled for magma alone (`0x68f36e` sets gx id `0x0f` to `mode == 2`). Water motes
/// burn at full brightness over the murk.
fn fog(self) -> EffectFog {
match self {
DriftMode::Water => EffectFog::Off,
DriftMode::Magma => EffectFog::Scene,
}
}
}
/// The mote field. One resource, allocated once (64 KB) and never resized — the reference builds
/// its object at process init and tears it down at exit.
#[derive(Resource)]
pub(super) struct DriftCloud {
motes: Vec<Mote>,
/// The camera position at the previous advect (`+0xfa04`).
last_cam: Vec3,
/// `None` = the enable byte is clear: never configured, or slime.
mode: Option<DriftMode>,
/// What [`Underwater`] said last frame — the reference compares the new liquid type against
/// `[0xc7f288]` and skips the whole reconfiguration when it is unchanged.
was: Submersion,
gust_dir: Vec3,
gust_freq: f32,
gust_phase: f32,
gust_amp: f32,
rng: u32,
/// `$WOW_NO_PARTICLES` — the family's perf-bisect kill switch (`particles.rs`,
/// `ribbons.rs`): one switch, whole family, and a mote field is squarely in it. Cached at
/// construction rather than read per frame.
off: bool,
/// Instrument state — see [`dump`].
dump: bool,
dump_at: f64,
submitted: usize,
wrapped: usize,
}
impl Default for DriftCloud {
fn default() -> Self {
Self {
motes: vec![Mote::default(); COUNT],
last_cam: Vec3::ZERO,
mode: None,
was: Submersion::Dry,
gust_dir: Vec3::X,
gust_freq: GUST_FREQ_UNIT,
gust_phase: 0.0,
gust_amp: GUST_AMP_UNIT,
// Any odd seed: the reference's lagged-table generator is byte-known, but nothing here
// needs its exact stream — only uniformity (the `particles::emit` idiom, and the same
// ruling `weather::precip::rand01` carries).
rng: 0x9e37_79b9,
off: std::env::var_os("WOW_NO_PARTICLES").is_some(),
dump: std::env::var_os("WOW_DRIFT_DUMP").is_some(),
dump_at: 0.0,
submitted: 0,
wrapped: 0,
}
}
}
impl DriftCloud {
/// Scatter every mote uniformly through the box and redraw its edge — `0x68e720`. Called on a
/// liquid-type change and on a teleport, never per frame and never per mote at the wrap.
fn scatter(&mut self, mode: DriftMode) {
let base = mode.scale_base();
let lo = base * 0.5;
let span = base * 1.5 - lo;
let mut rng = self.rng;
for m in &mut self.motes {
// Uniform in `[−15, +15)` per axis: the reference's `(m − 1)·extent − half`.
m.pos = Vec3::new(
rand01(&mut rng) * BOX_EDGE - BOX_HALF,
rand01(&mut rng) * BOX_EDGE - BOX_HALF,
rand01(&mut rng) * BOX_EDGE - BOX_HALF,
);
m.edge = rand01(&mut rng) * span + lo;
}
self.rng = rng;
self.mode = Some(mode);
}
/// Roll a fresh gust — `0x68e1c0`. Two uniform angles on `[−π, π)`; the vertical term is
/// squashed by [`GUST_RISE`] and forced non-negative before the direction is normalised, then
/// a fresh period and amplitude.
fn roll_gust(&mut self) {
let mut rng = self.rng;
let angle = |r: &mut u32| (rand01(r) * 2.0 - 1.0) * std::f32::consts::PI;
let (sa, ca) = angle(&mut rng).sin_cos();
let (se, ce) = angle(&mut rng).sin_cos();
// WoW z is up, Bevy y is up: the reference's (x, y, z) is our (x, z, y).
let dir = Vec3::new(ce * sa, (ca * GUST_RISE).abs(), se * sa);
self.gust_dir = dir.normalize_or(Vec3::Y);
self.gust_freq = (1.0 + rand01(&mut rng)) * GUST_FREQ_UNIT;
self.gust_amp = (1.0 + rand01(&mut rng)) * GUST_AMP_UNIT;
self.gust_phase = 0.0;
self.rng = rng;
}
/// This frame's whole-field displacement — `0x68e4f0`.
fn gust(&mut self, mode: DriftMode, dt: f32) -> Vec3 {
match mode {
DriftMode::Magma => Vec3::new(0.0, MAGMA_SINK * dt, 0.0),
DriftMode::Water => {
self.gust_phase += dt;
let mut term = self.gust_phase * self.gust_freq;
// Strictly greater (`0x68e542 test ah,0x41; jne`): the period runs 20 s at the
// fastest frequency to 40 s at the slowest, and `sin(term·2π)` over `[0, 0.5]` is a
// non-negative half-sine — the gust swells from nothing to a peak and back, then
// takes a new direction.
if term > 0.5 {
self.roll_gust();
term = 0.0;
}
let speed = (term * std::f32::consts::TAU).sin() * self.gust_amp;
// The one deviation: `· dt · 60` where the reference has no `dt` at all.
self.gust_dir * (speed * dt * GUST_REF_HZ)
}
}
}
/// Advance the field — `0x68e930`. Because the records are camera-relative and `delta` is the
/// camera's *backwards* step, the cloud stands still in the world; only the gust moves it.
fn advect(&mut self, mode: DriftMode, eye: Vec3, dt: f32) {
let mut delta = self.last_cam - eye;
self.last_cam = eye;
if delta.length_squared() > TELEPORT * TELEPORT {
self.scatter(mode);
delta = Vec3::ZERO;
}
let add = delta + self.gust(mode, dt);
let wrap = |v: f32| {
if v > BOX_HALF {
v - BOX_EDGE
} else if v < -BOX_HALF {
v + BOX_EDGE
} else {
v
}
};
let mut wrapped = 0usize;
for m in &mut self.motes {
let moved = m.pos + add;
m.pos = Vec3::new(wrap(moved.x), wrap(moved.y), wrap(moved.z));
if m.pos != moved {
wrapped += 1;
}
}
self.wrapped = wrapped;
}
}
/// The mote texture. One BLP, loaded once with the world's first camera.
#[derive(Resource)]
struct DriftAssets {
motes: Handle<Image>,
}
fn setup_drift(
mut commands: Commands,
asset_server: Res<AssetServer>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
existing: Option<Res<DriftAssets>>,
) {
if existing.is_some() || cam.single().is_err() {
return;
}
// `PointSprite` — clamp, the gamma lane, and the BLP's **authored mip pyramid**. A mote is
// 1.4–4.2 cm wide and lives out to 26 yd, which on this projection is ~87 px at arm's length
// down to ~3 px at the box corner: a 17× minification of a 51 px atlas cell. Mip-0-only there
// is the snow flake's flickering speckle (`BlpVariant::Effect`'s docs), not crispness. The
// cost is that the deep mips blend neighbouring cells — which the reference pays too, having
// shipped the same atlas with the same 8 mips and bound it with the same filtering.
let point_sprite = |s: &mut benilla_assets::BlpLoaderSettings| {
s.variant = benilla_assets::BlpVariant::PointSprite;
};
commands.insert_resource(DriftAssets {
motes: asset_server.load_with_settings("mpq://textures/waterpoop02.blp", point_sprite),
});
}
/// Reconfigure on a liquid-class change, then advect — the reference's update-pass leg
/// (`0x66fee0` → `0x68e930`), with `0x6809c0`'s type dispatch folded in ahead of it.
fn simulate_drift(
mut cloud: ResMut<DriftCloud>,
underwater: Res<Underwater>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
time: Res<Time>,
) {
let Ok(cam_tf) = cam.single() else {
return;
};
let eye = cam_tf.translation();
let now = underwater.0;
if now != cloud.was {
cloud.was = now;
match now {
// Going dry does NOT clear the enable byte — `0x680abe` intercepts `0xf` before the
// type dispatch, so the field keeps its configuration and is suppressed by the frame
// gate alone. Diving back into the same liquid therefore finds it exactly as it was.
Submersion::Dry => {}
// Slime disables outright, with no re-scatter (`0x680b6f`).
Submersion::Slime => cloud.mode = None,
Submersion::Water => {
cloud.scatter(DriftMode::Water);
cloud.last_cam = eye;
}
Submersion::Magma => {
cloud.scatter(DriftMode::Magma);
cloud.last_cam = eye;
}
}
}
// The frame gate's second conjunct. (The first, the render-flag bit `0x2000000`, is the
// `waterParticulates` console command's latch — default ON, a plain on/off that scales
// nothing, and not a CVar at all: it is registered through the developer-console table
// `0x63f9e0`, has no `CVar::Register` site and no `Config.wtf` persistence. Nothing here
// wires it, exactly as nothing wires `showfootprints`.)
if !now.any() || cloud.off {
return;
}
let Some(mode) = cloud.mode else {
return;
};
cloud.advect(mode, eye, time.delta_secs());
}
/// Emit the surviving motes into the shared effect stream — the reference's render-pass leg
/// (`0x6701e0` → `0x68efe0`).
fn push_drift(
mut cloud: ResMut<DriftCloud>,
underwater: Res<Underwater>,
assets: Option<Res<DriftAssets>>,
cam: Query<(Entity, &GlobalTransform, &Projection), With<WorldCamera>>,
mut quads: ResMut<EffectQuads>,
time: Res<Time>,
) {
cloud.submitted = 0;
// Every exit yields a REASON rather than returning, so the instrument can say why nothing drew.
// A census that goes silent when the thing is off cannot tell "off" from "broken" — which is
// exactly the hole the first live probe of this module fell into.
let status: &'static str = 'draw: {
// The same two conjuncts as the advect — the draw is independently gated, not a consequence of
// the sim having run.
if !underwater.0.any() {
break 'draw "dry (the eye is not in a liquid)";
}
if cloud.off {
break 'draw "off ($WOW_NO_PARTICLES)";
}
let Some(mode) = cloud.mode else {
break 'draw "disabled (slime, or never configured)";
};
let Some(assets) = assets else {
break 'draw "no texture resource yet";
};
let Ok((cam_entity, cam_tf, proj)) = cam.single() else {
break 'draw "no world camera";
};
let (tan_x, tan_y) = match proj {
Projection::Perspective(p) => cull_limits(p.fov, p.aspect_ratio),
// No perspective divide, so the cone means nothing; the reference has no such mode.
_ => (1.0, 1.0),
};
let eye = cam_tf.translation();
let fwd = cam_tf.forward().as_vec3();
let right = cam_tf.right().as_vec3();
let up = cam_tf.up().as_vec3();
let cells = mode.cells();
let start = quads.begin();
let mut submitted = 0usize;
for (i, m) in cloud.motes.iter().enumerate() {
if submitted == SUBMIT_CAP {
break;
}
// The reference's cone, floored at the frustum — see [`cull_limits`].
let rel = m.pos;
let vz = rel.dot(fwd);
if vz <= 0.0 {
continue;
}
if rel.dot(right).abs() >= vz * tan_x || rel.dot(up).abs() >= vz * tan_y {
continue;
}
let (col, row) = ATLAS[cells[i & 7]];
let (u0, v0) = (col * CELL, row * CELL);
let half = m.edge * 0.5;
let r = right * half;
let u = up * half;
// Perimeter order (bl, br, tr, tl) — the stream's quad-index pattern closes it. The
// vertices are camera-RELATIVE (`cam_relative` below), which is what the record already
// holds; the reference does the same thing by handing the device only the projection.
for (pos, uv) in [
(rel - r - u, [u0, v0 + CELL]),
(rel + r - u, [u0 + CELL, v0 + CELL]),
(rel + r + u, [u0 + CELL, v0]),
(rel - r + u, [u0, v0]),
] {
quads.verts.push(EffectVertex {
pos: pos.to_array(),
uv,
// The colour is an immediate `0xFFFFFFFF` in the binary (`0x68f27b`): not
// per-particle, not a global, and with no distance or depth fade.
color: [1.0, 1.0, 1.0, 1.0],
});
}
submitted += 1;
}
quads.commit_quads(
start,
EffectDrawSpec {
cam: cam_entity,
texture: assets.motes.id(),
// `SRC_ALPHA / ONE_MINUS_SRC_ALPHA` (gx id 7 = 2). The cascading alpha test at
// `GEQUAL 1/255` needs no lane of its own — under this blend a zero-alpha fragment
// already contributes nothing.
blend: EffectBlend::Alpha,
fog: mode.fog(),
lighting: EffectLighting::None,
anchor: eye,
bias: Rung::DRIFT_CLOUD,
raster_bias: 0,
raster_slope: 0.0,
cam_relative: true,
main_entity: Entity::PLACEHOLDER,
light: None,
},
);
cloud.submitted = submitted;
if submitted == 0 {
break 'draw "submerged, but every mote culled";
}
"drawing"
};
dump(&mut cloud, status, &time);
}
/// `$WOW_DRIFT_DUMP` — a 1 Hz line, because every question this field raises is numeric and none
/// of them is answerable from a screenshot. `submitted` against [`SUBMIT_CAP`] says whether the cap
/// is biting; `wrapped` is the recycle rate, which is the drift speed made visible; the gust block
/// is the only state with a 20–40 s clock on it, so a stuck gust shows here and nowhere else.
///
/// **It reports when nothing draws, too, and that is the point.** The first live probe of this
/// module printed no lines at all, which was indistinguishable between "the eye never went under"
/// and "the draw is broken" — so `status` names the gate that stopped it.
fn dump(cloud: &mut DriftCloud, status: &'static str, time: &Time) {
if !cloud.dump {
return;
}
let now = time.elapsed_secs_f64();
if now - cloud.dump_at < 1.0 {
return;
}
cloud.dump_at = now;
let d = cloud.gust_dir;
let elev = d.y.clamp(-1.0, 1.0).asin().to_degrees();
info!(
"drift: {status} — mode {:?} submitted {}/{} (cap {}) wrapped {} | gust dir \
[{:.3} {:.3} {:.3}] elev {:.1}° freq {:.4} amp {:.4} phase {:.1}/{:.1}s",
cloud.mode,
cloud.submitted,
COUNT,
SUBMIT_CAP,
cloud.wrapped,
d.x,
d.y,
d.z,
elev,
cloud.gust_freq,
cloud.gust_amp,
cloud.gust_phase,
0.5 / cloud.gust_freq,
);
}
pub(super) fn register(app: &mut App) {
app.init_resource::<DriftCloud>()
.add_systems(
Update,
(
setup_drift,
// `CameraPoseSet` as well as the verdict: the advect's whole input is
// `last_cam − cam`, and for the length of `Update` a camera's `GlobalTransform` is
// last frame's pose unless you order after the set that refreshes it (1503). Read
// stale, a teleport's jump would land in `delta` a frame late — as a 30 yd shove
// through the field instead of the re-scatter it is.
simulate_drift
.after(super::SubmersionVerdict)
.after(crate::view::CameraPoseSet)
.after(setup_drift),
),
)
.add_systems(PostUpdate, push_drift.after(begin_effect_frame));
}
#[cfg(test)]
mod tests {
use super::*;
fn cloud(mode: DriftMode) -> DriftCloud {
let mut c = DriftCloud::default();
c.scatter(mode);
c
}
fn in_box(v: Vec3) -> bool {
[v.x, v.y, v.z]
.iter()
.all(|c| *c >= -BOX_HALF && *c < BOX_HALF)
}
#[test]
fn scatter_fills_the_box_and_the_edge_lane() {
for (mode, base) in [
(DriftMode::Water, SCALE_WATER),
(DriftMode::Magma, SCALE_MAGMA),
] {
let c = cloud(mode);
assert_eq!(c.motes.len(), COUNT);
for m in &c.motes {
assert!(in_box(m.pos), "{:?} outside the 30 yd box", m.pos);
assert!(
m.edge >= base * 0.5 && m.edge < base * 1.5,
"edge {} outside [{}, {})",
m.edge,
base * 0.5,
base * 1.5
);
}
// A scatter that filled the box would still be wrong if it filled it in a corner: the
// mean of a uniform cube is the centre, and 4000 samples put it well inside 1 yd.
let mean: Vec3 = c.motes.iter().map(|m| m.pos).sum::<Vec3>() / COUNT as f32;
assert!(mean.length() < 1.0, "scatter is not centred: mean {mean:?}");
}
}
#[test]
fn the_wrap_keeps_every_mote_inside_the_box() {
let mut c = cloud(DriftMode::Water);
let mut eye = Vec3::ZERO;
// Walk the camera a long way in small steps — every step pushes motes out of the far face,
// and the wrap is the only thing bringing them back.
for _ in 0..500 {
eye += Vec3::new(0.4, 0.05, -0.3);
c.advect(DriftMode::Water, eye, 1.0 / 60.0);
assert!(c.motes.iter().all(|m| in_box(m.pos)));
}
assert!(c.wrapped > 0, "500 yards of walking wrapped nothing");
}
#[test]
fn the_cloud_is_world_fixed_apart_from_the_gust() {
let mut c = cloud(DriftMode::Water);
// Silence the gust so the camera term is the only motion left.
c.gust_amp = 0.0;
c.gust_freq = 0.0;
let world_before: Vec<Vec3> = c.motes.iter().map(|m| m.pos).collect();
let eye = Vec3::new(3.0, -1.0, 2.0);
c.advect(DriftMode::Water, eye, 1.0 / 60.0);
// `pos` is camera-relative, so a mote that stood still in the world must now read
// `old − eye` — unless it wrapped, which is a teleport in world space by design.
let mut checked = 0;
for (m, was) in c.motes.iter().zip(&world_before) {
let expect = *was - eye;
if in_box(expect) {
assert!(
(m.pos - expect).length() < 1e-3,
"mote moved in world space: {:?} vs {:?}",
m.pos,
expect
);
checked += 1;
}
}
assert!(checked > COUNT / 2, "too few unwrapped motes to prove it");
}
#[test]
fn a_teleport_rescatters_rather_than_wrapping() {
let mut c = cloud(DriftMode::Water);
c.gust_amp = 0.0;
let before: Vec<Vec3> = c.motes.iter().map(|m| m.pos).collect();
// One frame, further than the box is wide.
c.advect(
DriftMode::Water,
Vec3::new(0.0, 0.0, TELEPORT + 1.0),
1.0 / 60.0,
);
assert!(c.motes.iter().all(|m| in_box(m.pos)));
let moved = c
.motes
.iter()
.zip(&before)
.filter(|(m, b)| (m.pos - **b).length() > 1e-3)
.count();
// A wrap would have translated the field rigidly; a re-scatter moves essentially all of it
// to independent places.
assert!(
moved > COUNT * 9 / 10,
"only {moved} motes were re-scattered"
);
}
#[test]
fn the_gust_never_blows_downward_and_is_biased_horizontal_without_being_bounded() {
let mut c = DriftCloud::default();
let mut elev = Vec::new();
for _ in 0..20_000 {
c.roll_gust();
let d = c.gust_dir;
assert!(
(d.length() - 1.0).abs() < 1e-4,
"gust dir is not a unit vector"
);
// The `fchs` at `0x68e27d` — the one hard constraint on the direction.
assert!(d.y >= 0.0, "the gust blew downward: {d:?}");
elev.push(d.y.clamp(-1.0, 1.0).asin().to_degrees());
}
elev.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = elev[elev.len() / 2];
let steep = elev.iter().filter(|e| **e > 45.0).count() as f32 / elev.len() as f32;
// `atan(0.25)` is the MEDIAN elevation, not a bound — the RE note's first reading said
// "always within 14.04° of horizontal", which the formula does not support and this test
// exists to keep anyone from re-introducing as a clamp. Closed forms: median
// `atan(0.25) = 14.036°`, and `P(elev > 45°) = (2/π)·atan(0.25) = 0.156`.
assert!(
(median - 0.25f32.atan().to_degrees()).abs() < 1.0,
"median elevation {median} is not atan(0.25)"
);
assert!(
(steep - 0.156).abs() < 0.02,
"{steep} of rolls steeper than 45° — expected ~0.156; a clamp would read ~0"
);
assert!(
*elev.last().unwrap() > 80.0,
"the distribution is bounded — it should reach straight up"
);
}
#[test]
fn the_gust_period_runs_twenty_to_forty_seconds() {
let mut c = DriftCloud::default();
for _ in 0..2_000 {
c.roll_gust();
let period = 0.5 / c.gust_freq;
assert!(
(20.0..=40.0).contains(&period),
"gust period {period}s outside 20–40s"
);
assert!((0.005..0.01).contains(&c.gust_amp), "amp {}", c.gust_amp);
}
}
#[test]
fn the_water_gust_is_frame_rate_independent() {
// The deviation `GUST_REF_HZ` buys exactly this: the same wall-clock second of drift,
// whatever the frame rate. (The reference's own answer differs by 2× between 30 and 60 fps
// — see the constant's docs.)
let travel = |steps: u32, dt: f32| {
let mut c = DriftCloud::default();
c.roll_gust();
c.mode = Some(DriftMode::Water);
let dir = c.gust_dir;
let mut sum = 0.0;
for _ in 0..steps {
sum += c.gust(DriftMode::Water, dt).dot(dir);
}
sum
};
let at30 = travel(30, 1.0 / 30.0);
let at120 = travel(120, 1.0 / 120.0);
// 5%, not 0: the gust's half-sine envelope is being sampled, and a 30-step Riemann sum of
// a rising function differs from a 120-step one by O(dt). That residual is the envelope's
// shape, not the frame rate leaking into the speed.
assert!(
(at30 - at120).abs() / at30.abs().max(1e-6) < 0.05,
"one second of drift: {at30} at 30 fps vs {at120} at 120 fps"
);
// And this is what the normalisation is FOR: strip the `dt·60` back out — which is the
// reference's literal per-frame law — and the same second of wall clock moves the field
// four times further at 120 fps than at 30.
let literal = |steps: u32, dt: f32| travel(steps, dt) / (dt * GUST_REF_HZ);
let ratio = literal(120, 1.0 / 120.0) / literal(30, 1.0 / 30.0);
assert!(
(ratio - 4.0).abs() < 0.4,
"the literal per-frame law should scale 4× from 30 to 120 fps, read {ratio}"
);
}
#[test]
fn magma_sinks_at_a_true_velocity() {
let mut c = DriftCloud::default();
let a = c.gust(DriftMode::Magma, 1.0 / 30.0) * 30.0;
let b = c.gust(DriftMode::Magma, 1.0 / 120.0) * 120.0;
assert!((a.y - MAGMA_SINK).abs() < 1e-5 && (b.y - MAGMA_SINK).abs() < 1e-5);
assert!(a.x == 0.0 && a.z == 0.0, "magma drift is vertical only");
}
#[test]
fn the_atlas_is_a_complete_lattice_including_the_reference_s_truncated_cell() {
// Every cell is a distinct 51/256 tile, and no two cells overlap.
let mut seen = std::collections::HashSet::new();
for (col, row) in ATLAS {
assert!(col >= 0.0 && row >= 0.0);
assert!((col + 1.0) * CELL <= 1.0 + 1e-6, "cell runs off the atlas");
assert!((row + 1.0) * CELL <= 1.0 + 1e-6, "cell runs off the atlas");
assert!(
seen.insert((col as u32, row as u32)),
"duplicate atlas cell"
);
}
// Cell 12 is the one the reference leaves half-written (its bottom-right corner stays BSS
// zero and samples UV (0,0)). Ours is a real tile at column 3, row 2 — if this ever reads
// (0,0) again we have re-imported a shipped defect.
assert_eq!(ATLAS[12], (3.0, 2.0));
// And it is reachable: magma draws it for one mote in four.
assert!(CELLS_MAGMA.contains(&12));
}
#[test]
fn the_cull_never_clips_a_mote_that_is_on_screen() {
// The invariant, at every aspect a window can have: the cull limit is at least the
// frustum's, so nothing visible is dropped.
for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0, 21.0 / 9.0, 32.0 / 9.0] {
let (tx, ty) = cull_limits(crate::view::CAM_FOVY, aspect);
let frustum_x = (crate::view::CAM_FOVY * 0.5).tan() * aspect;
let frustum_y = (crate::view::CAM_FOVY * 0.5).tan();
assert!(
tx >= frustum_x,
"aspect {aspect}: cull {tx} < frustum {frustum_x}"
);
assert!(
ty >= frustum_y,
"aspect {aspect}: cull {ty} < frustum {frustum_y}"
);
}
// ...and on the aspects the reference actually ran at, the limit IS its 90° cone — the
// widening is inert there, so `SUBMIT_CAP`'s sizing still holds where it was derived.
assert_eq!(cull_limits(crate::view::CAM_FOVY, 4.0 / 3.0), (1.0, 1.0));
assert_eq!(cull_limits(crate::view::CAM_FOVY, 16.0 / 9.0), (1.0, 1.0));
// Ultrawide is where it bites, and it bites horizontally only.
let (tx, ty) = cull_limits(crate::view::CAM_FOVY, 32.0 / 9.0);
assert!(tx > 1.0 && ty == 1.0);
}
/// The perf gate, and it is structural rather than measured: a dry frame must commit **no
/// draw and no vertex**. The effect lane charges per submitted vertex, so "costs nothing when
/// you are not underwater" is a property of this assertion, not of a frame-rate reading on
/// whatever the machine was doing that afternoon (the 0353 law).
#[test]
fn a_dry_frame_commits_nothing() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
world.insert_resource(EffectQuads::default());
world.insert_resource(Time::<()>::default());
world.insert_resource(DriftAssets {
motes: Handle::default(),
});
let mut c = DriftCloud::default();
c.scatter(DriftMode::Water);
world.insert_resource(c);
world.spawn((
WorldCamera,
GlobalTransform::default(),
Projection::default(),
));
// Dry: the field is configured and the texture is present, so the ONLY thing stopping the
// draw is the gate.
world.insert_resource(Underwater(Submersion::Dry));
world.run_system_once(push_drift).unwrap();
{
let q = world.resource::<EffectQuads>();
assert!(q.draws.is_empty(), "a dry frame committed a draw");
assert!(q.verts.is_empty(), "a dry frame pushed vertices");
}
// Submerged: the same world, one resource different, and now it draws — so the assertion
// above is about the gate and not about a fixture that could never draw at all.
world.insert_resource(Underwater(Submersion::Water));
world.run_system_once(push_drift).unwrap();
{
let q = world.resource::<EffectQuads>();
assert_eq!(q.draws.len(), 1, "submerged, the cloud did not draw");
assert!(!q.verts.is_empty());
assert_eq!(q.verts.len() % 4, 0, "whole quads only");
assert!(
q.verts.len() / 4 <= SUBMIT_CAP,
"{} quads exceeds the reference's cap",
q.verts.len() / 4
);
assert_eq!(q.draws[0].bias, Rung::DRIFT_CLOUD);
}
}
#[test]
fn slime_has_no_motes_and_going_dry_keeps_the_configuration() {
let mut c = cloud(DriftMode::Water);
assert!(c.mode.is_some());
// Slime's arm clears the enable byte outright.
c.mode = None;
assert!(c.mode.is_none());
// ...while going dry leaves it alone: the reference intercepts `0xf` ahead of the type
// dispatch, so surfacing and diving again finds the same field.
let mut c = cloud(DriftMode::Water);
let before: Vec<Vec3> = c.motes.iter().map(|m| m.pos).collect();
c.was = Submersion::Dry;
assert_eq!(c.mode, Some(DriftMode::Water));
assert!(c.motes.iter().zip(&before).all(|(m, b)| m.pos == *b));
}
}
+8
View File
@@ -67,6 +67,10 @@
//! through. The once-a-frame askers keep the plain walk.
//! * [`surface`] — **the Bevy render glue.** The per-kind animated materials, the two spawn paths
//! (MCLQ and WMO MLIQ), the flat mesh build, and the 24 fps frame cycler.
//! * [`drift`] — **the underwater drift cloud**: the 4000-mote field the reference draws while the
//! camera eye is inside a liquid (decision 1814). It is here rather than under `weather` because
//! the reference keeps it that way too — the pool is CWorld's, not the weather manager's, and
//! the two share no state and no code — and because [`Underwater`] is its whole trigger.
//!
//! The one cross-feed runs query → lighting: `detect_submersion` publishes WHICH liquid the eye is
//! in, and `lighting::update_time_lighting` selects the whole submerged atmosphere from it.
@@ -77,6 +81,7 @@ use bevy::prelude::*;
use benilla_assets::materials::LiquidMaterial;
use benilla_assets::AssetSet;
mod drift;
mod query;
#[cfg(test)]
mod real_data;
@@ -155,5 +160,8 @@ impl Plugin for LiquidPlugin {
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate)
.run_if(|| std::env::var_os("WOW_NO_LIQUID").is_some()),
);
// The underwater drift cloud — the one thing in this subsystem that RENDERS because the
// eye is submerged, rather than answering where the liquid is (see the layout note above).
drift::register(app);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub(super) fn next_u32(state: &mut u32) -> u32 {
}
/// A uniform random `f32` in `[0, 1)`.
pub(super) fn rand01(state: &mut u32) -> f32 {
pub(crate) fn rand01(state: &mut u32) -> f32 {
(next_u32(state) >> 8) as f32 / (1u32 << 24) as f32
}
@@ -384,6 +384,7 @@ fn decal_lane(bias: f32) -> Option<&'static str> {
b if b == Rung::FOOTPRINT => Some("footprint"),
b if b == Rung::RETICLE => Some("reticle"),
b if b == Rung::GROUND_FX => Some("ground-fx"),
b if b == Rung::DRIFT_CLOUD => Some("drift-cloud"),
_ => None,
}
}
+21
View File
@@ -334,6 +334,21 @@ impl Rung {
/// eye spends that budget: at `4.0` the wake visibly washed onto the beach, which is what the
/// director reported twice.
pub const FOAM_RASTER_SLOPE: f32 = 0.0;
/// **The underwater drift cloud** — the mote field, at the reference's own frame slot
/// `0x483731`: after the water surface AND both M2 transparent passes, immediately before the
/// glare dispatch `0x483740`. The last world content in the frame (decision 1814).
///
/// The window is narrow and this rung is checked against its actual NEIGHBOURS rather than the
/// blanket 1e4, exactly as the decal band is: it has to clear
/// [`GROUND_FX`](Rung::GROUND_FX) below and [`GLARE_BIAS`] above, and those are only 1.18e4
/// apart. Centred, that leaves ~5.8e3 each side — comfortably more than the
/// [`WORLD_VIEW_Z_FLOOR`] spread that is the most two anchors here can differ by, since the
/// cloud's anchor is the eye itself and a ground-fx decal's is at most a far plane away.
///
/// One batch for the whole field, so there is no intra-cloud sort — which is right rather than
/// merely cheap: the reference submits all its survivors as **one indexed triangle list**
/// (`0x68f3c9`) in slot order, with depth WRITE off and no per-mote ordering anywhere.
pub const DRIFT_CLOUD: f32 = 1.4e4;
/// **World text** — above the celestial glare, so a flare never washes a nameplate, and above
/// every sky rung. The reference draws its world text late in the frame (decision 0519).
/// Small on purpose: 6× the far plane is all the ordering needs, and this same field doubles
@@ -368,6 +383,12 @@ const _: () = {
assert!(Rung::GROUND_FX - CLOUDS_BIAS > 1.0e4);
assert!(GLARE_BIAS - Rung::GROUND_FX > 1.0e4);
assert!(Rung::NAMEPLATE - GLARE_BIAS > 1.0e4);
// The drift cloud sits between the ground-fx decals and the glare — the reference's `0x483731`,
// the frame's last world draw. Its window is 1.18e4 wide, so like the decal band it is measured
// against its neighbours rather than the blanket: the margin only has to beat the view-z spread
// two anchors here can differ by, and the cloud's anchor IS the eye.
assert!(Rung::DRIFT_CLOUD - Rung::GROUND_FX > -WORLD_VIEW_Z_FLOOR);
assert!(GLARE_BIAS - Rung::DRIFT_CLOUD > -WORLD_VIEW_Z_FLOOR);
// The raster margins are their own axis (B131) — not comparable to the sort rungs above, only
// to zero and to each other. One number for every ground decal (1817); the foam is not one of
// them, and takes far less.