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