rested XP arrives: the zzz badge, the blue bar and the tick

- resting flashes the zzz on your portrait, the XP bar fills its rested blue, and the exhaustion tick rides the bar with hover numerals
- a dual-wielded pair keeps both hands in the dressing room
- a footfall plays one sound, not two
- the minimap wears the time-of-day clock, and the keybinding capsules go back to 1.12's red

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:58 +07:00
co-authored by Claude Fable 5
parent cd58f90bec
commit f35c3cb0f6
32 changed files with 1756 additions and 196 deletions
+241 -18
View File
@@ -10,8 +10,11 @@
Structure mirrors the real hierarchy so the draw order falls out of the frame tree:
BenillaActionBar (1024x53, BOTTOM of screen)
├─ BenillaExpBar (StatusBar, TOP) — drawn first ⇒ lowest
└─ BenillaActionBarArtFrame (fills the bar) — dwarf strips + end caps over the XP bar
└─ 12 buttons + page up/down — child frames ⇒ over the art
│ ├─ BenillaExhaustionLevelFillBar (BORDER) — the pale rested wash up to the tick
│ └─ BenillaExpBarOverlayFrame (DIALOG) — the hover "curr / max" numerals
├─ BenillaActionBarArtFrame (fills the bar) — dwarf strips + end caps over the XP bar
│ └─ 12 buttons + page up/down — child frames ⇒ over the art
└─ BenillaExhaustionTick (DIALOG) — the rested-boundary marker on the strip
(The real client gets the same order via RaiseFrameLevel(MainMenuBarArtFrame); our loader's
later-sibling-is-higher + child-over-parent rules reproduce it without the explicit raise.)
@@ -594,37 +597,192 @@
function BenillaExpBar_OnLoad(bar)
bar:RegisterEvent("PLAYER_ENTERING_WORLD")
bar:RegisterEvent("PLAYER_XP_UPDATE")
-- The numerals seam (ref-MainMenuBar.xml l.117-120: TextStatusBar_Initialize() +
-- textLockable in the bar's own OnLoad; TextStatusBar.xml). textLockable is what lets
-- the statusBarText cvar pin the numerals on permanently.
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize()
bar.textLockable = 1
end
BenillaExpBar_Update(bar)
end
-- ref-MainMenuBar.xml l.121-126: repaint, then hand arg1/arg2 to the CVAR_UPDATE watcher.
function BenillaExpBar_OnEvent(bar)
BenillaExpBar_Update(bar)
if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end
end
-- The XP bar's hover (ref-MainMenuBar.xml l.136-147, MainMenuExpBar's own OnEnter/OnLeave).
-- ref GlobalStrings.lua l.5461 / l.2789 (enUS), in the `X = X or` FALLBACK form: the app
-- runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
-- only fill in for the test harnesses, which load no patch chain. CombatText.xml's shape.
-- ref GlobalStrings.lua l.5461 / l.2789 / l.1929-1932 (enUS), in the `X = X or` FALLBACK
-- form: the app runs the real localized GlobalStrings.lua off the patch chain BEFORE this
-- file (ui_script/mod.rs load_global_strings), so the installed locale wins and these
-- copies only fill in for the test harnesses, which load no patch chain.
-- CombatText.xml's shape.
XPBAR_LABEL = XPBAR_LABEL or "XP Bar"
NEWBIE_TOOLTIP_XPBAR = NEWBIE_TOOLTIP_XPBAR or "The amount of experience (XP) you have earned. The color of the XP bar indicates your rest state: light blue for Rested, and purple for Normal. Rested players earn twice the experience they would normally gain from slaying a monster. Characters become less rested when they kill monsters, and more rested by spending time at or logging out at an inn or city."
EXHAUST_TOOLTIP1 = EXHAUST_TOOLTIP1 or "|cffffd200%s|r\n|cffffffff%d%% of normal experience\ngained from monsters.|r"
EXHAUST_TOOLTIP2 = EXHAUST_TOOLTIP2 or "\n|cffff0000You should rest at an Inn.|r"
EXHAUST_TOOLTIP4 = EXHAUST_TOOLTIP4 or "\n\n|cffffd200Resting|r\n|cffffffffYou must rest for %d additional\nminutes to become|r |cffffd200Fully Rested|r|cffffffff.|r"
-- The ref's OnEnter does three more things, all of them machinery we don't have yet, and
-- each one its own slice rather than a gap in this one:
-- TextStatusBar_UpdateTextString() + ShowTextStatusBarText(this) — the "1234/5678" numerals
-- that appear ON the bar while hovered. No TextStatusBar text layer exists here at all.
-- ExhaustionTick.timer = 1 and GameTooltip.canAddRestStateLine = 1 — the rest-state line
-- ("You should rest at an Inn", EXHAUST_TOOLTIP1-4) the exhaustion tick appends. There is
-- no rest-state feed, so there is nothing to append.
-- The trailing 1 is the ref's noNormalText: with detailed tips OFF this hover shows NOTHING
-- rather than a bare "XP Bar". Transcribed, though 1.12's default (0661) never takes it.
-- The ref OnEnter's full body (ref-MainMenuBar.xml l.136-141), now that its machinery
-- exists (decision 1082): the on-bar numerals (TextStatusBar.xml), the 1 s armed timer
-- that appends the rest-state line to the held-open tooltip, and canAddRestStateLine —
-- the handshake BenillaExhaustionToolTipText's newbie-tips branch reads. The trailing 1
-- is the ref's noNormalText: with detailed tips OFF this hover shows NOTHING rather than
-- a bare "XP Bar". Transcribed, though 1.12's default (0661) never takes it.
function BenillaExpBar_OnEnter(bar)
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
if TextStatusBar_UpdateTextString then -- engine tests load file subsets
TextStatusBar_UpdateTextString(bar)
ShowTextStatusBarText(bar)
end
BenillaExhaustionTick.timer = 1
if not GameTooltip_AddNewbieTip then return end
GameTooltip_AddNewbieTip(bar, XPBAR_LABEL, 1.0, 1.0, 1.0, NEWBIE_TOOLTIP_XPBAR, 1)
GameTooltip.canAddRestStateLine = 1
end
function BenillaExpBar_OnLeave()
function BenillaExpBar_OnLeave(bar)
if HideTextStatusBarText then HideTextStatusBarText(bar) end
if GameTooltip then GameTooltip:Hide() end
BenillaExhaustionTick.timer = nil
end
-- ── The exhaustion tick (ref-MainMenuBar.lua ExhaustionTick_*) ────────────────────────
-- The round marker riding the XP bar at the point where rested XP runs out, plus the
-- pale fill between the bar's value and that point. Two transcription cuts, named:
-- the ref's MainMenuBarMaxLevelBar / ReputationWatchBar guards reference max-level bar
-- art and the faction watch bar, neither built — each is its own slice, and until then
-- the guards' frames can't be shown, so the cuts are unreachable-code cuts, not behavior
-- cuts. (A level-60 body simply keeps its inert XP bar for now.)
function BenillaExhaustionTick_OnLoad(tick)
tick:RegisterEvent("PLAYER_ENTERING_WORLD")
tick:RegisterEvent("PLAYER_XP_UPDATE")
tick:RegisterEvent("UPDATE_EXHAUSTION")
tick:RegisterEvent("PLAYER_LEVEL_UP")
tick:RegisterEvent("PLAYER_UPDATE_RESTING")
end
function BenillaExhaustionTick_Update(tick)
if event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_XP_UPDATE"
or event == "UPDATE_EXHAUSTION" or event == "PLAYER_LEVEL_UP" then
local playerCurrXP = UnitXP("player")
local playerMaxXP = UnitXPMax("player")
local exhaustionThreshold = GetXPExhaustion()
local exhaustionStateID = GetRestState()
-- The ref's beta-eras leftover: the tired/exhausted tiers (id >= 3) park the tick
-- at the bar's RIGHT end. The live server never produces them (GetRestState's
-- binding note) — kept because the ref keeps it.
if exhaustionStateID >= 3 then
tick:SetPoint("CENTER", "BenillaExpBar", "RIGHT", 0, 0)
end
if not exhaustionThreshold then
tick:Hide()
BenillaExhaustionLevelFillBar:Hide()
else
local exhaustionTickSet = max(((playerCurrXP + exhaustionThreshold)
/ playerMaxXP) * BenillaExpBar:GetWidth(), 0)
tick:ClearAllPoints()
if exhaustionTickSet > BenillaExpBar:GetWidth() then
tick:Hide()
BenillaExhaustionLevelFillBar:Hide()
else
tick:Show()
tick:SetPoint("CENTER", "BenillaExpBar", "LEFT", exhaustionTickSet, 0)
BenillaExhaustionLevelFillBar:Show()
BenillaExhaustionLevelFillBar:SetPoint("TOPRIGHT", "BenillaExpBar",
"TOPLEFT", exhaustionTickSet, 0)
end
end
end
-- The rest-state paint (only these two events repaint it, the ref's own split):
-- rested = light blue bar + pale blue fill-to-tick, normal = the purple default.
if event == "PLAYER_ENTERING_WORLD" or event == "UPDATE_EXHAUSTION" then
local exhaustionStateID = GetRestState()
if exhaustionStateID == 1 then
BenillaExpBar:SetStatusBarColor(0.0, 0.39, 0.88, 1.0)
BenillaExhaustionLevelFillBar:SetVertexColor(0.0, 0.39, 0.88, 0.15)
BenillaExhaustionTickHighlight:SetVertexColor(0.0, 0.39, 0.88)
elseif exhaustionStateID == 2 then
BenillaExpBar:SetStatusBarColor(0.58, 0.0, 0.55, 1.0)
BenillaExhaustionLevelFillBar:SetVertexColor(0.58, 0.0, 0.55, 0.15)
BenillaExhaustionTickHighlight:SetVertexColor(0.58, 0.0, 0.55)
end
end
end
-- The tick's tooltip (ref-MainMenuBar.lua ExhaustionToolTipText, the live branches only —
-- its own dead commented-out code is not carried). Two callers, two postures: hovering
-- the TICK anchors the plate beside it (flipped to whichever side has room); the armed
-- 1 s timer from the BAR hover instead APPENDS the line to the already-open newbie tip,
-- gated on canAddRestStateLine. EXHAUST_TOOLTIP4 (time to fully rested) needs
-- GetTimeToWellRested, which is nil on this wire — the line simply never renders,
-- faithfully (the binding's note).
function BenillaExhaustionToolTipText()
if SHOW_NEWBIE_TIPS ~= "1" then
local x, y = BenillaExhaustionTick:GetCenter()
if BenillaExhaustionTick:IsVisible() then
if x >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(BenillaExhaustionTick, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(BenillaExhaustionTick, "ANCHOR_RIGHT")
end
else
GameTooltip_SetDefaultAnchor(GameTooltip, UIParent)
end
end
local exhaustionStateID, exhaustionStateName, exhaustionStateMultiplier = GetRestState()
local exhaustionThreshold = GetXPExhaustion()
exhaustionStateMultiplier = exhaustionStateMultiplier * 100
local exhaustionCountdown = nil
if GetTimeToWellRested() then
exhaustionCountdown = GetTimeToWellRested() / 60
end
local tooltipText = format(EXHAUST_TOOLTIP1, exhaustionStateName,
exhaustionStateMultiplier)
local append = nil
if IsResting() then
if exhaustionThreshold and exhaustionCountdown then
append = format(EXHAUST_TOOLTIP4, exhaustionCountdown)
end
elseif exhaustionStateID == 4 or exhaustionStateID == 5 then
append = EXHAUST_TOOLTIP2
end
if append then
tooltipText = tooltipText .. append
end
if SHOW_NEWBIE_TIPS ~= "1" then
GameTooltip:SetText(tooltipText)
else
if GameTooltip.canAddRestStateLine then
GameTooltip:AddLine("\n" .. tooltipText)
GameTooltip:Show()
GameTooltip.canAddRestStateLine = nil
end
end
end
-- Driven from the XP BAR's OnUpdate (the ref wires it there, not on the tick — the timer
-- only ever counts while the mouse is on the bar, which is what arms it).
function BenillaExhaustionTick_OnUpdate(elapsed)
if BenillaExhaustionTick.timer then
if BenillaExhaustionTick.timer < 0 then
BenillaExhaustionToolTipText()
BenillaExhaustionTick.timer = nil
else
BenillaExhaustionTick.timer = BenillaExhaustionTick.timer - elapsed
end
end
end
-- ref-MainMenuBar.xml l.104-110 (the overlay child's OnLoad): the numerals wiring lives
-- in the OVERLAY frame's own OnLoad because there its FontString is guaranteed
-- materialized; the bar's lockShow starts at the not-hovered refcount.
function BenillaExpBarOverlay_OnLoad()
if SetTextStatusBarText then -- engine tests load file subsets
BenillaExpBar.lockShow = 0
SetTextStatusBarText(BenillaExpBar, BenillaExpBarText)
end
end
-- ── The performance ("ping") meter ────────────────────────────────────────────────────
@@ -793,6 +951,19 @@
<Color r="0.0" g="0.0" b="0.0" a="0.5"/>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- The rested fill (ref-MainMenuBar.xml l.20-33 ExhaustionLevelFillBar):
the pale wash from the bar's LEFT edge to the exhaustion tick. 0-wide
until BenillaExhaustionTick_Update stretches its TOPRIGHT to the tick
and tints it (SetVertexColor a=0.15 over this authored white). -->
<Texture name="BenillaExhaustionLevelFillBar">
<Size><AbsDimension x="0" y="13"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaExpBar"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Color r="1.0" g="1.0" b="1.0" a="1.0"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<Texture name="BenillaXPBarTexture0" file="Interface\MainMenuBar\UI-MainMenuBar-Dwarf">
<Size><AbsDimension x="256" y="10"/></Size>
@@ -816,16 +987,68 @@
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The numerals overlay (ref-MainMenuBar.xml l.96-113 MainMenuBarOverlayFrame):
a DIALOG-strata child so the "1234 / 5678" text draws over the dwarf-notch
art and the buttons; its own OnLoad wires the FontString to the bar
(BenillaExpBarOverlay_OnLoad's note). -->
<Frame name="BenillaExpBarOverlayFrame" frameStrata="DIALOG">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaActionBar" relativePoint="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="BenillaActionBar" relativePoint="BOTTOMRIGHT"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<FontString name="BenillaExpBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativeTo="BenillaExpBar"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaExpBarOverlay_OnLoad()</OnLoad>
</Scripts>
</Frame>
</Frames>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.58" g="0.0" b="0.55"/>
<Scripts>
<OnLoad>BenillaExpBar_OnLoad(self)</OnLoad>
<OnEvent>BenillaExpBar_OnEvent(self)</OnEvent>
<OnEnter>BenillaExpBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaExpBar_OnLeave()</OnLeave>
<OnLeave>BenillaExpBar_OnLeave(self)</OnLeave>
<OnUpdate>BenillaExhaustionTick_OnUpdate(arg1)</OnUpdate>
<OnValueChanged>
if this:IsShown() and TextStatusBar_OnValueChanged then
TextStatusBar_OnValueChanged()
end
</OnValueChanged>
</Scripts>
</StatusBar>
<!--
The exhaustion tick (ref-MainMenuBar.xml l.415-442): a 32×32 button riding the XP
bar at the rested boundary, DIALOG strata so it draws over the bar art. Normal =
the brass marker, Highlight = the ADD glow BenillaExhaustionTick_Update tints to
the rest state. Starts at the bar's CENTER exactly like the ref (its own first
Update re-seats or hides it before anything is seen).
-->
<Button name="BenillaExhaustionTick" frameStrata="DIALOG">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="BenillaExpBar"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaExhaustionTick_OnLoad(self)</OnLoad>
<OnEvent>BenillaExhaustionTick_Update(self)</OnEvent>
<OnEnter>BenillaExhaustionToolTipText()</OnEnter>
<OnLeave>if GameTooltip then GameTooltip:Hide() end</OnLeave>
</Scripts>
<NormalTexture name="BenillaExhaustionTickNormal" file="Interface\MainMenuBar\UI-ExhaustionTickNormal"/>
<HighlightTexture name="BenillaExhaustionTickHighlight" file="Interface\MainMenuBar\UI-ExhaustionTickHighlight" alphaMode="ADD"/>
</Button>
<!--
The art frame: fills the bar (ref-MainMenuBar.xml l.225-229). Carries the 4 dwarf
strips (ARTWORK) + the 2 end caps and page number (OVERLAY); the buttons are its
+141
View File
@@ -0,0 +1,141 @@
<!--
The minimap's time-of-day indicator (GameTimeFrame) — transcribed from the 1.12 FrameXML
GameTime.xml + GameTime.lua (the reference's own file split; its TOC loads GameTime.xml right
after Minimap.xml, and the manifest mirrors that beside MinimapCluster.xml). This closes the
"day/night indicator" line in MinimapCluster.xml's OUT list.
The 50×50 frame overhanging the cluster's top-right corner shows
Interface\Minimap\UI-TOD-Indicator (128×64: the sun disc on the left half, the moon on the
right — verified by extraction); GameTimeFrame_Update re-reads GetGameTime() every OnUpdate,
and when the game minute changed it re-seats the 50-px texcoord window — +0.5 across the
dawn/dusk thresholds (before 5:30 AM / from 9:00 PM game time = night) — and refreshes the
tooltip if it is showing. Hover formats the same clock into the tooltip. GetGameTime() is the
app's SMSG_LOGIN_SETTIMESPEED clock, pushed on the minute (`crate::minimap::feed_game_time`).
The frame declares no enableMouse yet its tooltip hovers: the real XML `<Scripts>` walker
auto-enables the mouse kind when it attaches a mouse handler (wow-re
`ui/scratch/scripts-auto-enable.md`, resolved 2026-08-07) — benilla's loader models the same
law (`loader/scripts.rs`), so this file stays attribute-free like the reference.
Deltas from the reference, each deliberate:
- TEXT() is dropped (this file set's convention: the ref's build-time no-op localization
macro; CharacterFrame.xml's note).
- TwentyFourHourTime = 1 is SET here: the reference sets it in Localization.lua's
LocalizeFrames() — this install is enGB, and enGB is a 24-hour locale — called at
VARIABLES_LOADED, which benilla doesn't fire yet (no saved-variables arc; BagFrame.xml's
precedent). So the tooltip reads "21:07" exactly as the reference client on this install
does; the 12-hour AM/PM branches stay verbatim below for the day a locale/CVar drives it.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- ref-GameTime.lua, verbatim (minus TEXT(); TwentyFourHourTime per the header).
TwentyFourHourTime = 1;
GAMETIME_DAWN = ( 5 * 60) + 30; -- 5:30 AM
GAMETIME_DUSK = (21 * 60) + 0; -- 9:00 PM
function GameTimeFrame_Update()
local hour, minute = GetGameTime();
local time = (hour * 60) + minute;
if(time ~= this.timeOfDay) then
this.timeOfDay = time;
local minx = 0;
local maxx = 50/128;
local miny = 0;
local maxy = 50/64;
if(time < GAMETIME_DAWN or time >= GAMETIME_DUSK) then
minx = minx + 0.5;
maxx = maxx + 0.5;
end
GameTimeTexture:SetTexCoord(minx, maxx, miny, maxy);
if(GameTooltip:IsOwned(this)) then
GameTimeFrame_UpdateTooltip(hour, minute);
end
end
end
function GameTimeFrame_UpdateTooltip(hours, minutes)
if(TwentyFourHourTime) then
GameTooltip:SetText(format(TIME_TWENTYFOURHOURS, hours, minutes));
else
local pm = 0;
if(hours >= 12) then
pm = 1;
end
if(hours > 12) then
hours = hours - 12;
end
if(hours == 0) then
hours = 12;
end
if(pm == 0) then
GameTooltip:SetText(format(TIME_TWELVEHOURAM, hours, minutes));
else
GameTooltip:SetText(format(TIME_TWELVEHOURPM, hours, minutes));
end
end
end
function GameTime_GetTime()
local hour, minute = GetGameTime();
if(TwentyFourHourTime) then
return format(TIME_TWENTYFOURHOURS, hour, minute);
else
local pm = 0;
if(hour >= 12) then
pm = 1;
end
if(hour > 12) then
hour = hour - 12;
end
if(hour == 0) then
hour = 12;
end
if(pm == 0) then
return format(TIME_TWELVEHOURAM, hour, minute);
else
return format(TIME_TWELVEHOURPM, hour, minute);
end
end
end
]]></Script>
<Frame name="GameTimeFrame" parent="MinimapCluster">
<Size>
<AbsDimension x="50" y="50"/>
</Size>
<Anchors>
<Anchor point="TOPRIGHT">
<Offset>
<AbsDimension x="4" y="-19"/>
</Offset>
</Anchor>
</Anchors>
<HitRectInsets>
<AbsInset left="6" right="0" top="5" bottom="10"/>
</HitRectInsets>
<Layers>
<Layer level="ARTWORK">
<Texture name="GameTimeTexture" file="Interface\Minimap\UI-TOD-Indicator"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>
this.timeOfDay = 0;
this:SetFrameLevel(this:GetFrameLevel() + 2);
GameTimeFrame_Update();
</OnLoad>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTimeFrame_UpdateTooltip(GetGameTime());
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
<OnUpdate>
GameTimeFrame_Update();
</OnUpdate>
</Scripts>
</Frame>
</Ui>
@@ -37,9 +37,12 @@
Key button is the clearing affordance). ESC with nothing armed never reaches any of
this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press.
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the capsules wear 1.12's own
UIPanelButtonTemplate2 panel-button art (era silver VERIFIED ABSENT — 0997), selection
is 1.12's LockHighlight glow; section headers wear the 1.12 collapsible-list dress —
the QuestLog's own UI-PlusButton/UI-MinusButton square and gold label.
UIPanelButtonTemplate2 panel-button art (the Classic client's silver stretch set —
UI-Silver-Button-Up/-Down/-Highlight/-Select, a 9-slice — is VERIFIED ABSENT from
the 1.12 chain: 0997, re-pinned and reverted-to-red in 1088 after 1085's gray-dress
detour), selection is 1.12's LockHighlight glow; section headers wear the 1.12
collapsible-list dress — the QuestLog's own UI-PlusButton/UI-MinusButton square
and gold label.
COMMIT LAW (1008, the page's one big behavior change from 0997): binds are LIVE-COMMIT.
The options window's whole grammar writes live (SetCVar on click, no Okay/Cancel pair),
@@ -14,10 +14,12 @@
PLAYER_AURAS_CHANGED + GetTrackingTexture() (the aura rebuild's tracking-global law, `ui_aura`).
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons;
the ping model + MINIMAP_PING; the day/night indicator; the rotate-minimap CVar. The
the ping model + MINIMAP_PING; the rotate-minimap CVar. The day/night indicator now lives in
its own GameTime.xml (the reference's own file split), loaded right after this file. The
reference's minimapPlayerModel/.mdx arrow renders app-side from its .blp for now (0203 flags
the simplification). The reference's <HitRectInsets> on the cluster is dropped (the loader
doesn't model it; only enlarges the mouse-blocking hull).
the simplification). The reference's <HitRectInsets> on the cluster is dropped (it only
enlarges the mouse-blocking hull; the loader models the tag now, so restore it if it ever
matters).
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
@@ -0,0 +1,150 @@
<!--
The shared status-bar numerals machinery (ref-TextStatusBar.lua + ref-TextStatusBar.xml,
transcribed whole — decision 1082). A bar opts in by calling TextStatusBar_Initialize() in its
OnLoad (setting `textLockable`), wiring a FontString via SetTextStatusBarText(bar, text), and
routing its OnEnter/OnLeave/OnValueChanged through the Show/Hide/Update functions here. The
text is "value / max", shown while hovered (a lockShow refcount) or always when the
`statusBarText` cvar is "1" ("Status Text" in the options; our GetCVar returns nil for a cvar
the engine doesn't carry, which reads as OFF — the 1.12 default).
First (and so far only) customer: the XP bar (ActionBar.xml). The unit-frame health/mana
numerals are the ref's other users of this file — UnitFrames.xml omits them deliberately (its
own header note) — so this is the seam they inherit when their slice lands.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
function TextStatusBar_Initialize()
this:RegisterEvent("CVAR_UPDATE")
this.lockShow = 0
end
function SetTextStatusBarText(bar, text)
if not bar or not text then
return
end
bar.TextString = text
end
function TextStatusBar_OnEvent(cvar, value)
if event == "CVAR_UPDATE" and cvar == "STATUS_BAR_TEXT" then
if this.TextString then
if value == "1" and this.textLockable then
this.TextString:Show()
elseif this.lockShow == 0 then
this.TextString:Hide()
end
end
end
end
function TextStatusBar_UpdateTextString(textStatusBar)
if not textStatusBar then
textStatusBar = this
end
local string = textStatusBar.TextString
if string then
local value = textStatusBar:GetValue()
local valueMin, valueMax = textStatusBar:GetMinMaxValues()
if valueMax > 0 then
textStatusBar:Show()
if value == 0 and textStatusBar.zeroText then
string:SetText(textStatusBar.zeroText)
textStatusBar.isZero = 1
string:Show()
else
textStatusBar.isZero = nil
if textStatusBar.prefix then
string:SetText(textStatusBar.prefix .. " " .. value .. " / " .. valueMax)
else
string:SetText(value .. " / " .. valueMax)
end
if GetCVar("statusBarText") == "1" and textStatusBar.textLockable then
string:Show()
elseif textStatusBar.lockShow > 0 then
string:Show()
else
string:Hide()
end
end
else
textStatusBar:Hide()
end
end
end
function TextStatusBar_OnValueChanged()
TextStatusBar_UpdateTextString()
end
function SetTextStatusBarTextPrefix(bar, prefix)
if bar and bar.TextString then
bar.prefix = prefix
end
end
function SetTextStatusBarTextZeroText(bar, zeroText)
if bar and bar.TextString then
bar.zeroText = zeroText
end
end
function ShowTextStatusBarText(bar)
if bar and bar.TextString then
if not bar.lockShow then
bar.lockShow = 0
end
bar.TextString:Show()
bar.lockShow = bar.lockShow + 1
end
end
function HideTextStatusBarText(bar)
if bar and bar.TextString then
if not bar.lockShow then
bar.lockShow = 0
end
if bar.lockShow > 0 then
bar.lockShow = bar.lockShow - 1
end
-- The ref reads `this.isZero` here — a leak of the handler's own frame into a
-- helper that otherwise takes `bar`. Equivalent at every call site (each caller
-- passes its handler's own frame), so it is written as `bar` — which also keeps
-- the function callable outside a handler, where `this` is nil.
if bar.lockShow > 0 or bar.isZero == 1 then
bar.TextString:Show()
elseif GetCVar("statusBarText") == "1" and bar.textLockable then
bar.TextString:Show()
else
bar.TextString:Hide()
end
end
end
]]></Script>
<!--
The opt-in template (ref-TextStatusBar.xml): every script routed through the functions
above. One transcription cut, named: the ref OnEnter's TargetFrame arm (re-anchor the unit
tooltip under the target frame's bars) references a TextStatusBar-inheriting TargetFrame
bar we don't build yet — it is dead until that slice, so it is left out rather than
shipped unreachable. A frame that overrides all five scripts (the XP bar does) takes only
the StatusBar widget type from inheriting this.
-->
<StatusBar name="TextStatusBar" virtual="true">
<Scripts>
<OnLoad>TextStatusBar_Initialize()</OnLoad>
<OnEvent>TextStatusBar_OnEvent(arg1, arg2)</OnEvent>
<OnEnter>
TextStatusBar_UpdateTextString()
ShowTextStatusBarText(this)
if this.tooltipTitle and GameTooltip_AddNewbieTip then
GameTooltip_AddNewbieTip(this, this.tooltipTitle, 1.0, 1.0, 1.0, this.tooltipText, 1)
end
</OnEnter>
<OnLeave>
HideTextStatusBarText(this)
if GameTooltip then GameTooltip:Hide() end
</OnLeave>
<OnValueChanged>TextStatusBar_OnValueChanged()</OnValueChanged>
</Scripts>
</StatusBar>
</Ui>
+171 -1
View File
@@ -195,6 +195,29 @@
frame.feedbackText = getglobal(tf .. "HitIndicator")
frame.feedbackFontHeight = 30
frame:RegisterEvent("UNIT_COMBAT")
-- The status flash (ref PlayerFrame_OnLoad, decision 1082): the resting zzz /
-- auto-attack swords under the portrait, the gold/red ring, and the pulse
-- counters. Handles cached like pvpIcon above; their presence is what gates
-- BenillaPlayerFrame_UpdateStatus in the shared Update.
frame.statusTexture = getglobal(tf .. "StatusTexture")
frame.attackBackground = getglobal(tf .. "AttackBackground")
frame.restIcon = getglobal(tf .. "RestIcon")
frame.attackIcon = getglobal(tf .. "AttackIcon")
frame.statusGlow = getglobal(name .. "StatusGlow")
frame.restGlow = getglobal(name .. "StatusGlowRestGlow")
frame.attackGlow = getglobal(name .. "StatusGlowAttackGlow")
frame.statusCounter = 0
frame.statusSign = -1
frame.attackBackground:SetVertexColor(0.8, 0.1, 0.1)
frame.attackBackground:SetAlpha(0.4)
-- Its four wires (ref PlayerFrame_OnLoad's registrations): auto-attack toggling
-- (ENTER/LEAVE_COMBAT), the aggro list (REGEN_DISABLED/ENABLED), and the rest
-- flag (UPDATE_RESTING); PLAYER_ENTERING_WORLD is registered above for all units.
frame:RegisterEvent("PLAYER_ENTER_COMBAT")
frame:RegisterEvent("PLAYER_LEAVE_COMBAT")
frame:RegisterEvent("PLAYER_REGEN_DISABLED")
frame:RegisterEvent("PLAYER_REGEN_ENABLED")
frame:RegisterEvent("PLAYER_UPDATE_RESTING")
end
-- A plain Frame isn't mouse-enabled by construction (only Button/EditBox are); enable it so
-- the hover tooltip fires (the real client's unit frames take the mouse to target/tooltip).
@@ -282,6 +305,22 @@
CombatFeedback_OnCombatEvent(frame, arg2, arg3, arg4, arg5)
return
end
-- The status flash's state arms (ref PlayerFrame_OnEvent l.100-115, decision 1082):
-- inCombat = auto-attack active, onHateList = on something's aggro list; a world
-- entry resets both (the ref's own PLAYER_ENTERING_WORLD arm). The repaint itself
-- rides the shared Update below — this file's posture (the PvP sound note).
if event == "PLAYER_ENTERING_WORLD" then
frame.inCombat = nil
frame.onHateList = nil
elseif event == "PLAYER_ENTER_COMBAT" then
frame.inCombat = 1
elseif event == "PLAYER_LEAVE_COMBAT" then
frame.inCombat = nil
elseif event == "PLAYER_REGEN_DISABLED" then
frame.onHateList = 1
elseif event == "PLAYER_REGEN_ENABLED" then
frame.onHateList = nil
end
BenillaUnitFrame_Update(frame)
-- The player's flag-change sound (decision 0646 §2/§4). The reference plays
-- igPVPUpdate inside PlayerFrame_UpdatePvPStatus's two show branches, which it only
@@ -570,6 +609,11 @@
-- The PvP flag icon, on both frames (decision 0646 §4).
BenillaUnitFrame_UpdatePvP(frame)
-- The status flash, player frame only (handle-gated like pvpIcon; decision 1082).
if frame.statusTexture then
BenillaPlayerFrame_UpdateStatus(frame)
end
-- The aura rows (target only) — the ref's TargetFrame_Update →
-- TargetDebuffButton_Update: repainted on PLAYER_TARGET_CHANGED and UNIT_AURA alike.
if unit == "target" then
@@ -690,6 +734,69 @@
end
end
-- The status flash's paint (ref PlayerFrame_UpdateStatus, transcribed onto frame-param
-- style; decision 1082). Priority is the ref's: RESTING wins over combat — sitting in an
-- inn mid-auto-attack shows the zzz — then the auto-attack state (gold ring/zzz vs red
-- ring/swords/red disc), then bare swords for on-someone's-aggro-list, then nothing.
function BenillaPlayerFrame_UpdateStatus(frame)
if IsResting() then
frame.statusTexture:SetVertexColor(1.0, 0.88, 0.25, 1.0)
frame.statusTexture:Show()
frame.restIcon:Show()
frame.attackIcon:Hide()
frame.restGlow:Show()
frame.attackGlow:Hide()
frame.statusGlow:Show()
frame.attackBackground:Hide()
elseif frame.inCombat then
frame.statusTexture:SetVertexColor(1.0, 0.0, 0.0, 1.0)
frame.statusTexture:Show()
frame.attackIcon:Show()
frame.restIcon:Hide()
frame.attackGlow:Show()
frame.restGlow:Hide()
frame.statusGlow:Show()
frame.attackBackground:Show()
elseif frame.onHateList then
frame.attackIcon:Show()
frame.restIcon:Hide()
frame.statusGlow:Hide()
frame.attackBackground:Hide()
else
frame.statusTexture:Hide()
frame.restIcon:Hide()
frame.attackIcon:Hide()
frame.statusGlow:Hide()
frame.attackBackground:Hide()
end
end
-- ref PlayerFrame_OnUpdate: the status pulse — a 0.5 s triangle wave between alpha
-- 55/255 and 255/255 on the ADD-blend status ring and the icon glow, run only while the
-- ring is visible (resting or auto-attacking); the hit-indicator fade rides the same
-- tick, exactly as before.
function BenillaPlayerFrame_OnUpdate(frame, elapsed)
if frame.statusTexture:IsVisible() then
local counter = frame.statusCounter + elapsed
local sign = frame.statusSign
if counter > 0.5 then
sign = -sign
frame.statusSign = sign
end
counter = mod(counter, 0.5)
frame.statusCounter = counter
local alpha
if sign == 1 then
alpha = (55 + counter * 400) / 255
else
alpha = (255 - counter * 400) / 255
end
frame.statusTexture:SetAlpha(alpha)
frame.statusGlow:SetAlpha(alpha)
end
CombatFeedback_OnUpdate(frame)
end
-- Left-click the player frame to target yourself (ref PlayerFrame_OnClick, the LeftButton
-- branch: an item on the cursor auto-equips, otherwise TargetUnit("player")); right-click
-- opens the SELF menu (ref's ToggleDropDownMenu at (106, 27), decision 0434 phase 5). The
@@ -851,6 +958,25 @@
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="18" y="-20"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The status ring (ref PlayerStatusTexture, ref-PlayerFrame.xml l.99-111):
the ADD-blend glow ring around the whole frame, tinted gold while
resting / red while auto-attacking, pulsed by the OnUpdate flash
(decision 1082). 190×66 TOPLEFT (35,-8), the ring art's own recess. -->
<Texture name="$parentStatusTexture" file="Interface\CharacterFrame\UI-Player-Status" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="190" y="66"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="35" y="-8"/></Offset></Anchor>
</Anchors>
<TexCoords left="0" right="0.74609375" top="0" bottom="0.53125"/>
</Texture>
<!-- ref PlayerAttackBackground (l.112-123): the dark red disc behind the
attack state icon; OnLoad tints it (0.8,0.1,0.1) at alpha 0.4. -->
<Texture name="$parentAttackBackground" file="Interface\TargetingFrame\UI-TargetingFrame-AttackBackground" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="37" y="-50"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- Name 100×10 CENTER (50,19) (ref l.58-69). -->
@@ -875,6 +1001,50 @@
<Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="73" y="-42"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The state icon pair under the portrait (ref PlayerRestIcon /
PlayerAttackIcon, ref-PlayerFrame.xml l.171-201): the two halves of
UI-StateIcon — zzz left, crossed swords right (decision 1082). -->
<Texture name="$parentRestIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Size><AbsDimension x="31" y="33"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="37" y="-49"/></Offset></Anchor>
</Anchors>
<TexCoords left="0" right="0.5" top="0" bottom="0.421875"/>
</Texture>
<Texture name="$parentAttackIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRestIcon"><Offset><AbsDimension x="1" y="1"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.5" right="1.0" top="0" bottom="0.5"/>
</Texture>
</Layer>
</Layers>
</Frame>
<!-- The state icon's own ADD-blend glow (ref PlayerStatusGlow, ref-PlayerFrame.xml
l.216-248): a sibling FRAME over the icon so the whole thing can pulse as one
SetAlpha group with the status ring (the OnUpdate flash, decision 1082). -->
<Frame name="$parentStatusGlow" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTextureFrameRestIcon"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentRestGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
</Anchors>
<TexCoords left="0" right="0.5" top="0.5" bottom="1.0"/>
</Texture>
<Texture name="$parentAttackGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
</Anchors>
<TexCoords left="0.5" right="1.0" top="0.5" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
</Frame>
@@ -882,7 +1052,7 @@
<Scripts>
<OnLoad><![CDATA[BenillaUnitFrame_OnLoad(this, "player")]]></OnLoad>
<OnEvent><![CDATA[BenillaUnitFrame_OnEvent(this)]]></OnEvent>
<OnUpdate><![CDATA[CombatFeedback_OnUpdate(this)]]></OnUpdate>
<OnUpdate><![CDATA[BenillaPlayerFrame_OnUpdate(this, arg1)]]></OnUpdate>
<OnEnter><![CDATA[BenillaUnitFrame_OnEnter(this)]]></OnEnter>
<OnLeave><![CDATA[BenillaUnitFrame_OnLeave(this)]]></OnLeave>
<OnClick><![CDATA[BenillaPlayerFrame_OnClick(arg1)]]></OnClick>
+1 -1
View File
@@ -676,7 +676,7 @@ use driver::{drive_animations, drive_hand_grip};
/// small concern, separate from the driver ([`driver`]) that advances the clips it scans.
mod events;
use events::fire_anim_events;
pub(crate) use events::{is_footstep, scan_events, AnimSoundEvent};
pub(crate) use events::{footfall_side, is_footstep_sound, scan_events, AnimSoundEvent};
mod impact;
use impact::route_swing_impacts;
+64 -8
View File
@@ -22,16 +22,36 @@ pub(crate) struct AnimSoundEvent {
pub(crate) data: u32,
}
/// Is this tag a **footstep plant**? The per-foot keys (`$FL/$FR/$RL/$RR/$SL/$SR/$BL/$BR`) plus the
/// dispatch tag `$FSD` (the client's `HandleFootfallAnimEvent`; a sequence fires one style or the
/// other). Each match = one foot meeting the ground — the shared trigger for footfall-driven
/// effects: the wading splash **sound** ([`crate::sound::footsteps`]) and the water-surface
/// **ripple** ([`crate::water_fx`]) both gate on it, reading the same [`AnimSoundEvent`] stream.
pub(crate) fn is_footstep(ident: &[u8; 4]) -> bool {
/// A footfall is **two independent channels**, and a tag belongs to exactly one of them — the
/// client's event dispatcher `0x5ffbd0` routes them to two different handlers (wow-re
/// `footprint-decals.md` §1, §5 4-agent round, byte-arbitrated):
///
/// - **`$FSD` → `0x623390`: the footstep SOUND**, and nothing else — it never reaches the decal
/// path ([`is_footstep_sound`]).
/// - **the per-foot side tags → `0x5fbf70`: the VISUAL footfall** — the footprint decal and the
/// spray/splash particle, and no sound at all ([`footfall_side`]).
///
/// So a gait that authors both fires **one** sound per `$FSD` key, not one per key of either
/// family: HumanMale's Walk keys `$FR0 · $FSD · $FL0 · $FSD` over 1 s and the real client plays
/// **two** steps there, while its turn-in-place ShuffleLeft/Right key only `$SL0 $SR0` at
/// `t = 0.000` and are **silent** (decision 1080).
///
/// The **sound** channel: the dispatch tag `$FSD` alone.
pub(crate) fn is_footstep_sound(ident: &[u8; 4]) -> bool {
ident == b"$FSD"
}
/// The **visual** channel: the foot side a per-foot plant tag names (`$FL0` → `L`), or `None` for
/// every other tag — the trigger for footfall-driven *visuals* ([`crate::footprints`]). The ten
/// families the dispatcher tables, each with its `0/1/2/3` variants: `$FL/$FR` (front),
/// `$RL/$RR` (rear), `$SL/$SR` (shuffle), `$BL/$BR` (backwards), `$WL/$WR` — call sites
/// `0x5ffe32` (`push 1`, LEFT) and `0x5ffc82` (`push 0`, RIGHT).
pub(crate) fn footfall_side(ident: &[u8; 4]) -> Option<u8> {
matches!(
&ident[..3],
b"$FL" | b"$FR" | b"$RL" | b"$RR" | b"$SL" | b"$SR" | b"$BL" | b"$BR"
) || ident == b"$FSD"
b"$FL" | b"$FR" | b"$RL" | b"$RR" | b"$SL" | b"$SR" | b"$BL" | b"$BR" | b"$WL" | b"$WR"
)
.then_some(ident[2])
}
/// How far (seconds) into a just-switched clip the playhead may be for [`fire_anim_events`] to
@@ -157,3 +177,39 @@ pub(crate) fn scan_events(
fire(-1.0, cur);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The two channels are disjoint (decision 1080): `$FSD` is the whole sound channel, the
/// per-foot side tags the whole visual one. HumanMale's Walk keys one of each family per
/// footfall — routing both to sound is exactly the doubled step rate.
#[test]
fn the_sound_channel_is_fsd_alone() {
assert!(is_footstep_sound(b"$FSD"));
for t in [
b"$FL0", b"$FR0", b"$RL2", b"$SL0", b"$SR0", b"$BR0", b"$WL1",
] {
assert!(!is_footstep_sound(t), "{} is the visual channel", lossy(t));
assert!(footfall_side(t).is_some(), "{} names a side", lossy(t));
}
assert_eq!(footfall_side(b"$FSD"), None);
}
/// The side letter a per-foot tag names; every other tag is `None`.
#[test]
fn footfall_side_reads_the_side_letter() {
assert_eq!(footfall_side(b"$FL0"), Some(b'L'));
assert_eq!(footfall_side(b"$FR0"), Some(b'R'));
assert_eq!(footfall_side(b"$RL2"), Some(b'L'));
assert_eq!(footfall_side(b"$BR0"), Some(b'R'));
assert_eq!(footfall_side(b"$WR3"), Some(b'R'));
assert_eq!(footfall_side(b"$SND"), None);
assert_eq!(footfall_side(b"$CSL"), None);
}
fn lossy(t: &[u8; 4]) -> String {
String::from_utf8_lossy(t).into_owned()
}
}
+5 -22
View File
@@ -50,7 +50,7 @@ use benilla_assets::AdtTile;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::blob_shadow::SHADOW_RASTER_BIAS;
use crate::collision::GroundDecalSurface;
use crate::creature_anim::{is_footstep, move_flags, AnimSoundEvent, MovementState};
use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState};
use crate::decal::{project_decal, DecalFrame};
use crate::entities::{BoneAttach, Creatures};
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
@@ -152,13 +152,6 @@ fn load_ink(
}
}
/// The foot side a per-foot tag names (`$FL0` → `L`) — `$FSD` (sound-only, byte-verified) and
/// non-footstep tags are `None`. **Right** prints get the mirrored texture: the shipped art is
/// the left foot (the `0x5fc07f` `side==0` mirror, corrected from 1006's guess in 1012).
fn foot_side(ident: &[u8; 4]) -> Option<u8> {
(is_footstep(ident) && ident != b"$FSD").then_some(ident[2])
}
/// The spawner's ROOT-unit reads (see [`spawn_footprints`]'s `roots` param).
type RootState = (
Has<SelfPlayer>,
@@ -199,7 +192,10 @@ fn spawn_footprints(
};
let now = time.elapsed_secs();
for ev in events.read() {
let Some(side) = foot_side(&ev.ident) else {
// The VISUAL footfall channel: the per-foot side tags only (`$FSD` is the sound handler's,
// decision 1080). **Right** prints get the mirrored texture — the shipped art is the left
// foot (the `0x5fc07f` `side==0` mirror, corrected from 1006's guess in 1012).
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach)) = units.get(ev.entity) else {
@@ -390,19 +386,6 @@ fn push_footprints(
mod tests {
use super::*;
/// Per-foot tags carry their side letter; `$FSD` (sound-only, byte-verified) and
/// non-footstep tags spawn nothing.
#[test]
fn foot_side_reads_the_side_letter() {
assert_eq!(foot_side(b"$FL0"), Some(b'L'));
assert_eq!(foot_side(b"$FR0"), Some(b'R'));
assert_eq!(foot_side(b"$RL2"), Some(b'L'));
assert_eq!(foot_side(b"$BR0"), Some(b'R'));
assert_eq!(foot_side(b"$FSD"), None);
assert_eq!(foot_side(b"$SND"), None);
assert_eq!(foot_side(b"$CSL"), None);
}
/// The byte-verified ramp: no fade-in — 127/255 from age 0, held while `255·t ≥ 127`
/// (through age = 6·(1 − 127/255) ≈ 3.012 s), then the raw `⌊255·t⌋/255` line to zero at
/// 6 s, dead after.
+29
View File
@@ -695,6 +695,32 @@ fn feed_minimap_inside(
}
}
/// Push the live game clock into the VM when the game minute ticks — `GetGameTime()`'s backing
/// globals (the zone-text family's shape). The reference's GameTimeFrame re-reads GetGameTime
/// every OnUpdate and compares the packed minute against its cached `timeOfDay`, so a
/// minute-granular push is exactly the API's own resolution (the binding returns no seconds).
/// Before the first `SMSG_LOGIN_SETTIMESPEED` the globals stay at their 0:00 stdlib seed.
fn feed_game_time(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
time: Res<crate::net::ServerTime>,
mut last: Local<Option<u32>>,
) {
let Some(script) = script else { return };
let Some(gt) = time.0 else { return };
let minute = gt.minute_of_day();
if *last == Some(minute) {
return;
}
*last = Some(minute);
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_game_hour", minute / 60)
.and_then(|()| globals.set("__benilla_game_minute", minute % 60));
if let Err(e) = pushed {
warn!("minimap: game-time globals: {e}");
}
}
/// The app half of the `<Minimap>` widget (decision 0203 phase 1) — see the module doc. The zone
/// LABEL feed lives with the rest of the zone-text data plane (`crate::area`, decision 0287's
/// fold-back): the client updates the minimap line and fires `MINIMAP_ZONE_CHANGED` from the same
@@ -715,6 +741,9 @@ impl Plugin for MinimapPlugin {
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
// Before the script tick, so GameTimeFrame's OnUpdate reads this frame's
// minute, not last frame's.
feed_game_time.before(crate::ui_script::UiInput),
// After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip
// transition must end with the blip tooltip shown, not the fade.
blips::drive_blip_tooltip
+10 -4
View File
@@ -849,10 +849,16 @@ fn setup_booths(
booth_view_shape(),
Camera {
order: -100 + (SLOTS.len() + i) as isize,
// A near-black backdrop like the portraits (the pipeline is proven opaque; the pane
// reads as a recessed dark panel, close to the ref's model-frame background). A
// transparent float-over-the-frame-art backdrop is a director's-call follow-up.
clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)),
// **TRANSPARENT** — a `<PlayerModel>` widget has no backdrop of its own (decision
// 1083). The reference's three model frames declare nothing but the widget; what
// fills the pane behind the figure is the PAGE's own art, the same
// `UI-Character-CharacterTab-*` / `UI-Character-General-*` plates our transcription
// already draws, and the model composites over them. Baking an opaque backdrop into
// the target painted those plates out — the director's report is exactly that: "our
// paperdoll is pure black bg instead of having what the ref has, slightly off black
// and textured" (2026-08-07). The glue booth has composited this way since 0807;
// the combine passes scene alpha through untouched.
clear_color: ClearColorConfig::Custom(Color::NONE),
..default()
},
RenderTarget::Image(image.clone().into()),
+37 -8
View File
@@ -1,11 +1,15 @@
//! Footsteps (decision 0070 slice 3): the anim footstep tags resolved through the terrain-type
//! Footsteps (decision 0070 slice 3): the `$FSD` anim tag resolved through the terrain-type
//! chain — `ground effect under the unit → TerrainType → × the unit's footstep class →
//! FootstepTerrainLookup → SoundEntries`.
//!
//! Triggers: the per-foot tags (`$FL0..3`/`$FR`/`$RL`/`$RR`/`$SL`/`$SR`/`$BL`/`$BR`) and the
//! dispatch tag `$FSD` (the client's `HandleFootfallAnimEvent`; sequences use either style —
//! DireWolf's walk plants four paws with `$FL/$FR` keys, its run fires `$FSD`). Each tag = one
//! step.
//! **Trigger: `$FSD` and nothing else** (decision 1080). A footfall is two disjoint channels in
//! the client's event dispatcher `0x5ffbd0` — `$FSD → 0x623390` is the *sound*, the per-foot side
//! tags (`$FL/$FR/$RL/$RR/$SL/$SR/$BL/$BR/$WL/$WR`) `→ 0x5fbf70` are the *visual* footfall (the
//! decal + spray, [`crate::footprints`]) and play nothing (wow-re `footprint-decals.md` §1, §5
//! byte-arbitrated). Reading both as steps rang **every gait at double rate** — HumanMale's Walk
//! keys `$FR0 · $FSD · $FL0 · $FSD`, a horse's gallop four of each per 0.8 s cycle — and made the
//! turn-in-place shuffle, whose *only* keys are `$SL0 $SR0` at `t = 0.000`, clatter where the
//! reference is silent ([`crate::creature_anim::is_footstep_sound`]).
//!
//! The unit's class comes from its voice row (`CreatureSoundData.FootstepID`) through the
//! generic display→sound chain incl. the model fallback (`benilla_formats::creature_sound` —
@@ -14,6 +18,11 @@
//! `benilla-pins.md` B11, byte-confirmed); the lookup's class-0 rows are the Ancient
//! Protector's stomps (kit 661), reached only by that model's own nonzero class.
//!
//! Ahead of the class, `0x623390` opens on the same three state gates the visual handler does,
//! read on the ROOT unit (the rider of a mounted composite): **hover** (`0x6233aa`, move flag
//! `0x4000_0000`), **stealth/CREEP** (`0x62339c`, `BYTES_1` byte 3 bit `0x2` — not death) and
//! **player ghost** (`0x6233d3`, `PLAYER_FLAGS & 0x10`) — a stealthed unit is silent-footed.
//!
//! Wading (feet below a water surface, down to the unit's own swim boundary `0.75·h`) picks the
//! lookup's **splash** slot instead of dry (falling back to dry when the class has no splash kit);
//! deeper the unit swims and footfalls go silent. The flat 2.0-yd stand-in that boundary used to be
@@ -28,10 +37,10 @@ use benilla_assets::AdtTile;
use benilla_formats::FootstepCatalog;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::creature_anim::{is_footstep, AnimSoundEvent};
use crate::creature_anim::{is_footstep_sound, move_flags, AnimSoundEvent, MovementState};
use crate::entities::CollisionHeight;
use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::net::NetEntity;
use crate::net::{NetEntity, ObjectStore};
use crate::player::swim_enter_depth;
use crate::schedule::WorldStage;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
@@ -73,6 +82,12 @@ fn footstep_sounds(
Option<&CollisionHeight>,
Option<&UnitWmoRoom>,
)>,
// The handler's state gates read the ROOT unit — a mount child's `$FSD` is the rider's
// footfall, and it is the RIDER's stealth/hover/ghost the client tests (`0x623390`'s `this`
// is the unit the mount model's event stream is registered against, wow-re
// `footprint-decals.md` §1 "Mounted"). Same walk-to-root the footprint spawner does.
parents: Query<&ChildOf>,
root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>,
footsteps: Option<Res<Footsteps>>,
voices: Option<Res<CreatureVoices>>,
streamer: Res<TerrainStreamer>,
@@ -95,12 +110,26 @@ fn footstep_sounds(
};
let listener = listener.pos;
for ev in events.read() {
if !is_footstep(&ev.ident) {
if !is_footstep_sound(&ev.ident) {
continue;
}
let Ok((net, transform, collision, room)) = units.get(ev.entity) else {
continue;
};
// The three state gates, on the root unit (module docs): hover · stealth · player ghost.
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let Ok((store, movement)) = root_state.get(root) else {
continue;
};
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
continue;
}
if store.is_some_and(|s| s.0.unit_is_stealthed() || s.0.player_is_ghost()) {
continue;
}
// The unit's footstep class (module docs): the voice row's class verbatim; zero or no
// row = silent (the client's class-0 gate — no code default exists, B11).
let class = match net.display_id.and_then(|d| voices.0.for_display(d)) {
+19 -4
View File
@@ -321,6 +321,18 @@ fn skill_pair(store: &ObjectStore, skill_id: u32) -> (u32, i32) {
/// a creature has no PLAYER block, so they read absent and fall to their defaults, which is the
/// right pet answer (no buff decomposition, `damage_percent` the divide-safe 1.0) rather than a
/// second unit-only copy of the same twenty lines.
///
/// **That premise is load-bearing, and it was false until decision 1081.** A created store used to
/// answer `Some(0)` for *any* absent index, PLAYER block or not, so on a live pet every default
/// here was dead code — `damage_percent` came through as `0` and the ref's `damage / percent` made
/// the pet sheet read `inf - inf` / `nan`. The protocol layer now bounds "absent = 0" to the
/// object's own descriptor, which is what makes this shared core honest for a creature.
/// A swing time in ms, with `0` (and absent) read as "no swing time yet" → the vanilla 2000. Never
/// zero, because it is the reference's `damage / speed` divisor.
fn base_swing(streamed: Option<u32>) -> u32 {
streamed.filter(|&ms| ms != 0).unwrap_or(2000)
}
pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
let f = &store.0;
let round = |v: Option<f32>| v.unwrap_or(0.0).round() as i32;
@@ -360,16 +372,19 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
physical_bonus_neg: f.player_mod_damage_done_neg(0).unwrap_or(0),
damage_percent: f.player_mod_damage_done_pct(0).unwrap_or(1.0),
// 2000 ms is the vanilla base swing when the field hasn't streamed — keeps the ref's
// damage/speed division finite on the first frames.
main_attack_time_ms: f.unit_base_attack_time(0).unwrap_or(2000),
offhand_attack_time_ms: f.unit_base_attack_time(1).unwrap_or(2000),
// damage/speed division finite on the first frames. **`0` counts as unstreamed**, not as a
// swing time: on a created store an absent in-block field reads `Some(0)` (the descriptor's
// zero), so a plain `unwrap_or` would never fire and `damage / speed` would go to infinity
// (the shape of the bug decision 1081 is about, one field over).
main_attack_time_ms: base_swing(f.unit_base_attack_time(0)),
offhand_attack_time_ms: base_swing(f.unit_base_attack_time(1)),
attack_power: f.unit_attack_power().unwrap_or(0),
attack_power_pos: i32::from(ap_pos),
attack_power_neg: i32::from(ap_neg),
ranged_attack_power: f.unit_ranged_attack_power().unwrap_or(0),
ranged_attack_power_pos: i32::from(rap_pos),
ranged_attack_power_neg: i32::from(rap_neg),
ranged_attack_time_ms: f.unit_ranged_attack_time().unwrap_or(2000),
ranged_attack_time_ms: base_swing(f.unit_ranged_attack_time()),
ranged_min_damage: f.unit_min_ranged_damage().unwrap_or(0.0),
ranged_max_damage: f.unit_max_ranged_damage().unwrap_or(0.0),
// The equipment- and skill-derived half is the PLAYER feed's ([`combat_stats`]); a unit
+58 -7
View File
@@ -96,16 +96,23 @@ fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) {
*mine = Some(item);
}
// Back into the enum array, each item at the slot its own inventory type names — so a bow that
// won the off lane still renders through the ranged arm (`placement`'s HandLeft), which is what
// the widget installs it as.
// Back into the enum array's held triple — where the index is **which held law `held_wants`
// asks**, not "the slot this item's inventory type names": 15/16 are the melee-drawn main/off
// attach points, and 17 is the ranged-drawn arm, which carries the bow-left / gun-right split
// itself. So a lane writes back at its own lane, and a ranged item writes back at 17 whichever
// lane it won.
//
// Routing this through [`equip_slot`] instead is what collapsed a dual-wielded pair into one
// weapon (decision 1079): an off-hand one-hander is `INVTYPE_WEAPON` 13 just like a main-hand
// one, and that table — which answers "where is this item WORN", the right question everywhere
// else — maps 13 to the main hand, so the off hand overwrote the main.
for slot in HELD_SLOTS {
equipment[slot] = CharEnumItem::default();
}
for item in [main, off].into_iter().flatten() {
if let Some(slot) = equip_slot(item.inventory_type) {
equipment[slot] = item;
}
for (lane, item) in [(15usize, main), (16, off)] {
let Some(item) = item else { continue };
let ranged = matches!(item.inventory_type, 15 | 25 | 26);
equipment[if ranged { 17 } else { lane }] = item;
}
}
@@ -561,4 +568,48 @@ mod tests {
);
assert_eq!(look.equipment[16].display_id, 5600);
}
/// The director's report (2026-08-07): *"there is an issue now where the preview is only
/// showing 1 wep, while I was just previewing some boots"* — decision 1079, a regression from
/// 1076's write-back.
///
/// A dual-wielded pair is two items of the **same** inventory type: an off-hand one-hander is
/// `INVTYPE_WEAPON` 13 exactly like the main-hand one, and [`equip_slot`] answers "where is
/// this WORN", which for 13 is the main hand. Both lanes therefore landed on slot 15 and the
/// off hand overwrote the main. Nothing about it needed a held try-on — the boots are in the
/// test because that is what the director was previewing, and the pair has to survive an
/// unrelated substitution untouched.
///
/// Every off-lane inventory type is looped rather than only 13, so what is pinned is "a lane
/// keeps its own item" and not "13 got a special case".
#[test]
fn a_dual_wielded_pair_keeps_both_hands() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(1500, Some(worn("Main Sword", 5500, 13))); // INVTYPE_WEAPON
items.insert_template(2000, Some(worn("Shiny Boots", 7000, 8))); // FEET — the try-on
for (entry, display, inv, what) in [
(1600u32, 5600u32, 13u32, "a second one-hander"),
(1601, 5601, 22, "an off-hand-only weapon"),
(1602, 5602, 23, "a held-in-off-hand"),
(1603, 5603, 14, "a shield"),
] {
items.insert_template(entry, Some(worn("Off Hand", display, inv)));
let store = player(&[(15, 1500), (16, entry)]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
room.apply(DressUpIntent::TryOn(2000));
room.resolve_pending(&mut items, &cmds);
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
assert_eq!(look.equipment[7].display_id, 7000, "the boots went on");
assert_eq!(
(look.equipment[15].display_id, look.equipment[16].display_id),
(5500, display),
"the main hand keeps its sword beside {what}"
);
}
}
}
+26 -16
View File
@@ -98,7 +98,7 @@ fn feed_pet_doll(
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::ObjectFields;
use benilla_protocol::messages::{ObjectFields, ObjectType};
use crate::net::ObjectStore;
@@ -114,22 +114,32 @@ mod tests {
/// A boar's descriptor: the UNIT half a creature really streams, and nothing else — no PLAYER
/// block at all, which is the whole point of the fixture.
///
/// **CREATED, and that is the load-bearing half** (decision 1081). A live pet arrives as a
/// create block, and a create is a *complete* snapshot — absent means 0, not unknown. Built
/// bare (the fixture default), this boar answered `None` for every PLAYER field and the
/// defaults below looked right while the live client's read `0`; the pet sheet's damage
/// tooltip came out `inf - inf` / `nan` under a green test. Take `.into_created` off and the
/// `damage_percent` assertion is the one that fails.
fn boar() -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(STAT0, 63), // strength
(STAT0 + 1, 45), // agility
(STAT0 + 2, 68), // stamina
(STAT0 + 3, 32), // intellect
(STAT0 + 4, 42), // spirit
(RESISTANCES0, 1810),
(RESISTANCES0 + 2, 15), // fire
(BASEATTACKTIME, 2000),
(MINDAMAGE, 30.5f32.to_bits()),
(MAXDAMAGE, 44.5f32.to_bits()),
(ATTACK_POWER, 178),
// The MODS field is a packed signed pair: pos in the low word, neg in the high.
(ATTACK_POWER_MODS, (((-4i16) as u16 as u32) << 16) | 12),
]))
ObjectStore(
ObjectFields::from_pairs(&[
(STAT0, 63), // strength
(STAT0 + 1, 45), // agility
(STAT0 + 2, 68), // stamina
(STAT0 + 3, 32), // intellect
(STAT0 + 4, 42), // spirit
(RESISTANCES0, 1810),
(RESISTANCES0 + 2, 15), // fire
(BASEATTACKTIME, 2000),
(MINDAMAGE, 30.5f32.to_bits()),
(MAXDAMAGE, 44.5f32.to_bits()),
(ATTACK_POWER, 178),
// The MODS field is a packed signed pair: pos in the low word, neg in the high.
(ATTACK_POWER_MODS, (((-4i16) as u16 as u32) << 16) | 12),
])
.into_created(ObjectType::Unit),
)
}
/// The descriptor-only core over a real creature's field set: the UNIT values come through,
@@ -24,8 +24,8 @@ fn shipped_action_bar_drives_end_to_end() {
);
if file == "ActionBar.xml" {
assert_eq!(
report.frames, 31,
"bar + XP StatusBar + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
report.frames, 33,
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
);
}
}
@@ -824,76 +824,3 @@ fn state_events_leave_empty_wells_untinted() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The XP bar's hover (ref-MainMenuBar.xml l.136-147): the strip takes the mouse, and the plate is
/// the ref's two-line `GameTooltip_AddNewbieTip` — "XP Bar" over the wrapped explanation.
///
/// `enableMouse` is the load-bearing half and the easy thing to lose: without it the strip is
/// transparent and the hover silently never fires, which no tooltip assertion alone would catch.
#[test]
fn the_xp_bar_takes_the_mouse_and_explains_itself() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
s.resolve();
// Mid-height of the strip (the bar's top 13 px), a quarter of the way along — NOT its
// horizontal center, where the page arrows straddle the strip's lower edge and rightly take
// the mouse ahead of it.
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaExpBar:GetLeft() + BenillaExpBar:GetWidth() / 4, \
(BenillaExpBar:GetBottom() + BenillaExpBar:GetTop()) / 2",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some("BenillaExpBar"),
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"XP Bar"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
s.eval::<String>("return NEWBIE_TOOLTIP_XPBAR").unwrap(),
"line 2 is the ref's NEWBIE_TOOLTIP_XPBAR, verbatim"
);
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"the default-corner anchor"
);
s.run("BenillaExpBar_OnLeave()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -0,0 +1,238 @@
//! The XP bar's own tests — the hover plate, the rested/exhaustion-tick system and the
//! on-bar numerals (decision 1082) — split from `action_bar_tests.rs`, which keeps the
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
//! player-progress readout; the buttons are the input surface.
use benilla_ui::script::UiScript;
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
/// then Cooldown + the bar itself (manifest order).
fn exp_bar_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
s
}
/// The XP bar's hover (ref-MainMenuBar.xml l.136-147): the strip takes the mouse, and the plate is
/// the ref's two-line `GameTooltip_AddNewbieTip` — "XP Bar" over the wrapped explanation.
///
/// `enableMouse` is the load-bearing half and the easy thing to lose: without it the strip is
/// transparent and the hover silently never fires, which no tooltip assertion alone would catch.
#[test]
fn the_xp_bar_takes_the_mouse_and_explains_itself() {
let mut s = exp_bar_harness();
s.resolve();
// Mid-height of the strip (the bar's top 13 px), a quarter of the way along — NOT its
// horizontal center, where the page arrows straddle the strip's lower edge and rightly take
// the mouse ahead of it.
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaExpBar:GetLeft() + BenillaExpBar:GetWidth() / 4, \
(BenillaExpBar:GetBottom() + BenillaExpBar:GetTop()) / 2",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some("BenillaExpBar"),
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"XP Bar"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
s.eval::<String>("return NEWBIE_TOOLTIP_XPBAR").unwrap(),
"line 2 is the ref's NEWBIE_TOOLTIP_XPBAR, verbatim"
);
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"the default-corner anchor"
);
s.run("BenillaExpBar_OnLeave()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The exhaustion tick (decision 1082, ref `ExhaustionTick_Update`): with 1000/10000 XP and a
/// rested pool of 700 base-XP, the doubled span is 1400 bar-XP, so the tick parks at
/// (1000+1400)/10000 of the strip's width and the pale fill stretches exactly to it; the bar
/// paints rested blue. Draining the pool (normal state) hides both and returns the purple; a
/// pool whose span runs past the level's end hides the tick but keeps the rested blue.
#[test]
fn the_exhaustion_tick_marks_where_rested_runs_out() {
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// The binding trio underneath (the app-feed shape the XML consumes).
let (id, name, mult) = s
.eval::<(i64, String, f64)>("return GetRestState()")
.unwrap();
assert_eq!((id, name.as_str(), mult), (1, "Rested", 2.0));
assert_eq!(
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
Some(1400),
"the pool is served ×2 — bar-XP, not wire units"
);
assert_eq!(
s.eval::<Option<i64>>("return IsResting()").unwrap(),
Some(1)
);
let ok: bool = s
.eval(
r#"
local tick, fill = BenillaExhaustionTick, BenillaExhaustionLevelFillBar
local expected = (1000 + 1400) / 10000 * BenillaExpBar:GetWidth()
local x = tick:GetCenter()
local r, g, b = BenillaExpBar:GetStatusBarColor()
-- fill's width via its RESOLVED edges: the authored size is 0 x 13, and the engine
-- derives the span from the TOPLEFT + runtime-TOPRIGHT anchor pair (layout.rs's
-- zero-size law), exactly as the real client does.
return tick:IsVisible() and fill:IsShown()
and math.abs((x - BenillaExpBar:GetLeft()) - expected) < 0.5
and math.abs((fill:GetRight() - fill:GetLeft()) - expected) < 0.5
and r == 0.0 and math.abs(g - 0.39) < 0.001 and math.abs(b - 0.88) < 0.001
"#,
)
.unwrap();
assert!(ok, "tick at the rested boundary, fill up to it, bar blue");
// The pool drains dry: state normal, no threshold — tick and fill gone, purple back.
s.set_rest_state(2, 0, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
and GetXPExhaustion() == nil
and math.abs(r - 0.58) < 0.001 and g == 0.0 and math.abs(b - 0.55) < 0.001
"#,
)
.unwrap();
assert!(ok, "a dry pool hides the tick and returns the purple bar");
// A pool past the level's end (6000×2 span from 1000/10000): off the strip → hidden tick,
// still rested blue (the ref's exhaustionTickSet > width branch).
s.set_rest_state(1, 6000, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
and math.abs(g - 0.39) < 0.001
"#,
)
.unwrap();
assert!(
ok,
"a span past the level end hides the tick but keeps the blue"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
#[test]
fn the_xp_bar_numerals_show_on_hover() {
let mut s = exp_bar_harness();
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return BenillaExpBarText:GetText()")
.unwrap(),
"1234 / 5678"
);
assert!(s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.unwrap());
s.run("BenillaExpBar_OnLeave(BenillaExpBar)").unwrap();
assert!(
!s.eval::<bool>("return BenillaExpBarText:IsShown()")
.unwrap(),
"leaving drops the lockShow refcount to 0 and hides the numerals"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rest-state tooltip line (decision 1082, ref `ExhaustionToolTipText`'s newbie-tips branch):
/// hovering the BAR arms a 1 s timer and the canAddRestStateLine handshake; when the timer runs
/// out on the bar's OnUpdate, the "Rested / 200% of normal experience" line is APPENDED to the
/// held-open newbie tip, once (the handshake is consumed).
#[test]
fn the_rest_state_line_joins_the_held_open_tooltip() {
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaExhaustionTick.timer").unwrap(),
1.0,
"the bar hover arms the tick's timer"
);
// Three OnUpdate ticks walk the timer 1 → 0.4 → −0.2 → fire (the ref's `< 0` edge).
for _ in 0..3 {
s.run("BenillaExhaustionTick_OnUpdate(0.6)").unwrap();
}
let appended: String = s.eval("return GameTooltipTextLeft3:GetText()").unwrap();
assert!(
appended.contains("Rested") && appended.contains("200%"),
"the rest-state line rides the open plate: {appended:?}"
);
assert!(
s.eval::<bool>("return GameTooltip.canAddRestStateLine == nil")
.unwrap(),
"the handshake is consumed — the line never doubles"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -44,6 +44,11 @@ const UI_MANIFEST: &[&str] = &[
// (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
// The shared status-bar numerals machinery (decision 1082): TextStatusBar_* + the opt-in
// template — before its customers (the XP bar in ActionBar.xml today; the unit-frame
// health/mana numerals are the ref's other users, when that slice lands). The ref's own TOC
// loads TextStatusBar right before its unit frames, same seat as here.
"TextStatusBar.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
@@ -99,6 +104,10 @@ const UI_MANIFEST: &[&str] = &[
// <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
// hover uses the shared tooltip).
"MinimapCluster.xml",
// The minimap's time-of-day indicator (GameTimeFrame): the reference's own file split — its
// TOC loads GameTime.xml right after Minimap.xml. After MinimapCluster (its parent) and
// GameTooltip (the hover tooltip); driven by the app's game clock (`minimap::feed_game_time`).
"GameTime.xml",
// The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
// FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
// Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
@@ -125,3 +125,108 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(!vis(&s), "no tracking texture hides the frame again");
}
/// A session with the minimap cluster + the time-of-day indicator (`GameTime.xml`) loaded, the
/// game clock parked at `hour:minute` — the shape `crate::minimap::feed_game_time` pushes.
fn game_time_session(hour: u32, minute: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
// Byte-exact 1.12 GlobalStrings (l.4251-4253) — the engine-only session loads no
// GlobalStrings off a chain.
s.run("TIME_TWELVEHOURAM = \"%d:%02d AM\"; TIME_TWELVEHOURPM = \"%d:%02d PM\"; TIME_TWENTYFOURHOURS = \"%d:%02d\"")
.unwrap();
s.run(&format!(
"__benilla_game_hour = {hour}; __benilla_game_minute = {minute}"
))
.unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "GameTime.xml");
s
}
/// `GameTimeTexture`'s current 4-edge texcoord window.
fn tod_window(s: &UiScript) -> (f64, f64, f64, f64) {
s.eval::<(f64, f64, f64, f64)>("return GameTimeTexture:GetTexCoord()")
.unwrap()
}
/// The verbatim `GameTimeFrame_Update` law: the 50-px window over the 128×64 UI-TOD-Indicator
/// sits on the LEFT half (the sun) through the game day and slides +0.5 to the RIGHT half (the
/// moon) outside it — night is before 5:30 AM or from 9:00 PM, boundaries included exactly as
/// the ref compares (`< DAWN or >= DUSK`).
#[test]
fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
let mut s = game_time_session(10, 30);
// OnLoad seeded `timeOfDay = 0` and 10:30 ≠ 0, so the very first update already seated the
// window — no OnUpdate tick needed for the initial state.
let day = (0.0, 50.0 / 128.0, 0.0, 50.0 / 64.0);
assert_eq!(tod_window(&s), day, "mid-morning shows the sun half");
// 21:00 exactly is night (`>= DUSK`): the OnUpdate re-read slides the window +0.5.
s.run("__benilla_game_hour = 21; __benilla_game_minute = 0")
.unwrap();
s.tick(0.016);
assert_eq!(
tod_window(&s),
(0.5, 0.5 + 50.0 / 128.0, 0.0, 50.0 / 64.0),
"9:00 PM sharp is the moon half"
);
// 5:29 is still night; 5:30 exactly is day (`< DAWN`).
s.run("__benilla_game_hour = 5; __benilla_game_minute = 29")
.unwrap();
s.tick(0.016);
assert_eq!(tod_window(&s).0, 0.5, "5:29 AM is still the moon");
s.run("__benilla_game_minute = 30").unwrap();
s.tick(0.016);
assert_eq!(tod_window(&s), day, "5:30 AM sharp flips to the sun");
}
/// Hovering the indicator through the REAL pointer path (hit-test → OnEnter) shows the game-time
/// tooltip, live-updates it while owned (the `IsOwned` refresh branch), and hides it on leave.
/// This also pins the two loader-side pieces the transcription leans on: the `<Scripts>` walker's
/// mouse auto-enable (the frame declares no enableMouse, like the reference — without the law the
/// hit-test never captures) and the `<HitRectInsets>` hull.
#[test]
fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
let mut s = game_time_session(21, 7);
s.resolve();
// Hover the middle of the frame's hit rect: the resolved rect inset by (l=6, r=0, t=5, b=10).
let (l, r, t, b) = (
s.eval::<f32>("return GameTimeFrame:GetLeft()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetRight()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetTop()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetBottom()").unwrap(),
);
let (x, y) = ((l + 6.0 + r) * 0.5, (b + 10.0 + t - 5.0) * 0.5);
s.mouse_move(x, y);
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnEnter owns the tooltip (the Scripts-walker auto-enable capturing at {x},{y})"
);
// TwentyFourHourTime = 1 (enGB LocalizeFrames, the transcription header): 21:07, not 9:07 PM.
let text = |s: &UiScript| {
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap()
};
assert_eq!(text(&s), "21:07");
// The minute ticks while hovered: GameTimeFrame_Update's IsOwned branch refreshes in place.
s.run("__benilla_game_minute = 8").unwrap();
s.tick(0.016);
assert_eq!(text(&s), "21:08", "the owned tooltip follows the clock");
// Leave: a point past the left inset — outside the hit hull but still inside the raw 50×50
// rect — must ALSO leave; the insets are part of the reference geometry.
s.mouse_move(l + 2.0, y);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the 6-px left inset band is not hoverable"
);
}
+3
View File
@@ -624,6 +624,9 @@ mod unit_popup_tests;
#[cfg(test)]
mod action_bar_tests;
#[cfg(test)]
mod exp_bar_tests;
#[cfg(test)]
mod multibar_stance_tests;
@@ -1284,3 +1284,108 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
.unwrap();
assert!(up, "the feign ends and the frame reads live again");
}
/// The resting status flash (decision 1082, ref `PlayerFrame_UpdateStatus` + `_OnUpdate`): while
/// resting the player frame wears the gold status ring, the zzz state icon and its glow, all
/// pulsing on a 0.5 s alpha wave; auto-attack (PLAYER_ENTER_COMBAT) swaps them for the red
/// ring/swords/disc — resting still wins when both hold — and leaving both states clears the lot.
#[test]
fn the_player_frame_flashes_zzz_while_resting() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Prober".into()),
health: 100,
max_health: 100,
level: 12,
power_type: 0,
power: 80,
max_power: 80,
dead: false,
reaction: 0,
..UnitState::default()
}),
);
// Into the inn: the resting flag lands and PLAYER_UPDATE_RESTING repaints.
s.set_rest_state(1, 500, true);
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
let resting: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and BenillaPlayerFrameStatusGlow:IsShown()
and BenillaPlayerFrameStatusGlowRestGlow:IsShown()
and not BenillaPlayerFrameStatusGlowAttackGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
assert!(
resting,
"resting shows the gold ring + zzz + glow, nothing red"
);
// The pulse: two OnUpdate ticks move the ring's alpha (the 0.5 s triangle wave).
let a0: f64 = s
.eval("return BenillaPlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
s.run("BenillaPlayerFrame_OnUpdate(BenillaPlayerFrame, 0.25)")
.unwrap();
let a1: f64 = s
.eval("return BenillaPlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
assert!(
(a0 - a1).abs() > 0.1,
"the flash moves the status alpha ({a0} → {a1})"
);
// Swinging while resting: resting still wins (the ref's branch order).
s.fire_event("PLAYER_ENTER_COMBAT", vec![]);
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameRestIcon:IsShown()")
.unwrap(),
"resting outranks auto-attack"
);
// Out of the inn mid-swing: the red attack set takes over.
s.set_rest_state(2, 0, false);
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
let attacking: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "AttackIcon"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and BenillaPlayerFrameStatusGlowAttackGlow:IsShown()
and getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
assert!(attacking, "auto-attack shows the red ring + swords + disc");
// Swords down: everything clears.
s.fire_event("PLAYER_LEAVE_COMBAT", vec![]);
let clear: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
return not getglobal(tf .. "StatusTexture"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and not BenillaPlayerFrameStatusGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
assert!(clear, "neither state → no status dressing at all");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+29
View File
@@ -97,6 +97,10 @@ struct UnitFeedState {
/// The last `(PLAYER_XP, PLAYER_NEXT_LEVEL_XP)` pair pushed, for the `PLAYER_XP_UPDATE` trigger —
/// the XP bar's feed is a player-global (like coinage), not a per-unit-token field.
last_xp: Option<(u32, u32)>,
/// The last `(restState, restPool, resting)` triple pushed, for the `UPDATE_EXHAUSTION` and
/// `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decision 1082). Pushed
/// as one snapshot so `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
last_rest: Option<(u8, u32, bool)>,
/// The last `(count, banked-target guid)` pair pushed, for the `PLAYER_COMBO_POINTS` trigger —
/// player-globals (`PLAYER_FIELD_BYTES` byte 1 + `PLAYER_FIELD_COMBO_TARGET`), not per-unit-
/// token fields. Diffed as a pair because the server writes them as one (decision 0875).
@@ -741,6 +745,31 @@ fn feed_units(
}
}
// The rest feed (decision 1082): the `PLAYER_BYTES_2` rest-state byte, the
// `PLAYER_REST_STATE_EXPERIENCE` pool and the `PLAYER_FLAGS_RESTING` bit, pushed as one
// snapshot. Two watches, the reference's own grain: `UPDATE_EXHAUSTION` on a state-or-pool
// change (the exhaustion tick and the bar's rested/normal color both register it),
// `PLAYER_UPDATE_RESTING` on the flag toggling (the player frame's zzz). Absent fields read
// 0 / not-resting — a fresh descriptor's zero default, which `GetRestState` renders as Normal.
if let Some((store, _)) = self_q.iter().next() {
let rest = (
store.0.player_rest_state().unwrap_or(0),
store.0.player_rest_state_experience().unwrap_or(0),
store.0.player_is_resting(),
);
if feed.last_rest != Some(rest) {
let prev = feed.last_rest;
feed.last_rest = Some(rest);
script.set_rest_state(rest.0, rest.1, rest.2);
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
script.fire_event("UPDATE_EXHAUSTION", vec![]);
}
if prev.map(|p| p.2) != Some(rest.2) {
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
}
}
}
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
// it is banked against, both PRIVATE, pushed as the pair the server writes (0869, 0875).
//
@@ -337,11 +337,18 @@ const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176;
// UNIT_END + 0x210 / 0x211; its stale hex comments read 0x2C6/0x2C7, the same 6-low drift as COINAGE.)
const FIELD_PLAYER_XP: u16 = 716;
const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717;
// PLAYER_REST_STATE_EXPERIENCE (INT, PRIVATE — self only): the rested-XP pool, in BASE kill-XP
// units — the server drains it 1:1 against a kill's base XP while granting +100%
// (vmangos `Player::GetXPRestBonus`), and caps it at NEXT_LEVEL_XP × 1.5 / 2 (`SetRestBonus`), so
// the on-screen doubled-XP span is 2× this field (the exhaustion tick's math; decision 1082).
// Derived by the same server enum arithmetic as COINAGE above: UNIT_END(0xBC = 188) +
// 0x3DB(987) = 1175 — exactly one below the tested COINAGE anchor (1175 + 1 = 1176 ✓).
const FIELD_PLAYER_REST_STATE_EXPERIENCE: u16 = 1175;
// PLAYER_EXPLORED_ZONES_1 (BYTES ×64, PRIVATE — self only): the discovery bitset, 2048 bits
// indexed by AreaTable's exploreFlag column; the world map's exploration overlays key off it
// (decision 0203 phase 3). Derived by the same server enum arithmetic as COINAGE/XP above:
// UNIT_END(0xBC = 188) + 0x39B(923) = 1111. Cross-checked against COINAGE: 0x3DC − 0x39B = 65 =
// the 64 bitset slots + PLAYER_FIELD_WATCHED_FACTION_INDEX between them (1111 + 64 = 1175, then
// the 64 bitset slots + PLAYER_REST_STATE_EXPERIENCE between them (1111 + 64 = 1175, then
// 1176 ✓); the stale hex comment reads 0x451 — the usual 6-low drift.
// The four avoidance/crit percentages the spell tooltip's chance-to-X line reads (law §3-CHANCE),
// consecutive and immediately below EXPLORED_ZONES_1: UNIT_END(188) + 0x396..0x399. FLOAT, and the
@@ -460,20 +467,50 @@ pub struct PlayerSkillSlot {
/// leaves this crate (decision 0061). The ECS mirrors one of these per object and [`Self::merge`]s each
/// `Values` delta into it; the codec itself stays stateless (decision 0006).
///
/// **Absent-field semantics** hinge on [`Self::created`]: a CREATE block is a *complete* snapshot —
/// the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set iff
/// `value != 0`), and the real client reads it into a zero-initialized descriptor buffer — so on a
/// create-seeded store an absent field reads `Some(0)`, the truth. A bare `Values` delta carries
/// only *changed* fields; there an absent field means "not touched", so it reads `None` (and a
/// merge must not clobber it). Director-caught: an item at durability 0 streams its create with
/// `MAXDURABILITY` but no `DURABILITY` word, and the sparse `None` sent the tooltip to the
/// **Absent-field semantics** hinge on [`Self::descriptor_end`]: a CREATE block is a *complete*
/// snapshot — the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set
/// iff `value != 0`), and the real client reads it into a zero-initialized descriptor buffer — so
/// on a create-seeded store an absent field reads `Some(0)`, the truth. A bare `Values` delta
/// carries only *changed* fields; there an absent field means "not touched", so it reads `None`
/// (and a merge must not clobber it). Director-caught: an item at durability 0 streams its create
/// with `MAXDURABILITY` but no `DURABILITY` word, and the sparse `None` sent the tooltip to the
/// template's max/max — 100% on fully broken gear.
///
/// **…but only within the object's OWN descriptor** (decision 1081). "Absent = 0" is a statement
/// about the *zero-initialized buffer*, and that buffer is exactly [`descriptor_len`] dwords long
/// for this object's type. A creature has no PLAYER block at all, so a PLAYER-block index read off
/// one is not an absent field — it is a question the object cannot answer, and the honest reply is
/// `None`. Conflating the two made every `player_*` accessor answer a confident `0` for any
/// created creature, which is how the pet sheet's damage multiplier came out `0` instead of the
/// unstreamed default `1.0` and the tooltip read `inf - inf` / `nan` (director, 2026-08-07).
#[derive(Debug, Clone, Default)]
pub struct ObjectFields {
fields: BTreeMap<u16, u32>,
/// Seeded by a CREATE block (set by the parser via [`Self::into_created`]): every field is
/// known — absent = `0`.
created: bool,
/// How long this object's descriptor is, in dwords — [`descriptor_len`] of the CREATE block's
/// own `ObjectType` (set by the parser via [`Self::into_created`]). Every index **below** it is
/// known: absent = `0`. `0` marks a bare `Values` delta (or a hand-built fixture), where no
/// index is known and absent = `None`.
descriptor_end: u16,
}
/// The length of an object's descriptor buffer, in dwords — the `*_END` of the innermost block its
/// `ObjectType` names (vmangos `UpdateFields_1_12_1.h`, build 5875, **by enum arithmetic**: the
/// trailing comments in that header's CONTAINER and PLAYER blocks are stale by a constant and are
/// not the numbers here).
///
/// The type hierarchy is inclusive — a Player's buffer spans OBJECT+UNIT+PLAYER, a Container's
/// OBJECT+ITEM+CONTAINER — so each entry is the outermost end, not a block size.
fn descriptor_len(object_type: ObjectType) -> u16 {
match object_type {
ObjectType::Object => 6, // OBJECT_END = 0x6
ObjectType::Item => 48, // ITEM_END = OBJECT_END + 0x2A
ObjectType::Container => 122, // CONTAINER_END = ITEM_END + 0x4A
ObjectType::Unit => 188, // UNIT_END = OBJECT_END + 0xB6
ObjectType::Player => 1282, // PLAYER_END = UNIT_END + 0x446
ObjectType::GameObject => 26, // GAMEOBJECT_END = OBJECT_END + 0x14
ObjectType::DynamicObject => 16, // DYNAMICOBJECT_END = OBJECT_END + 0xA
ObjectType::Corpse => 38, // CORPSE_END = OBJECT_END + 0x20
}
}
impl ObjectFields {
@@ -493,14 +530,16 @@ impl ObjectFields {
}
Ok(Self {
fields,
created: false,
descriptor_end: 0,
})
}
/// Mark this set as a CREATE snapshot (see the struct doc's absent-field semantics) — the
/// parser calls it on a create block's mask; a `Values` delta never gets it.
pub fn into_created(mut self) -> Self {
self.created = true;
/// Mark this set as a CREATE snapshot of `object_type` (see the struct doc's absent-field
/// semantics) — the parser calls it on a create block's mask with the type off the same block's
/// header; a `Values` delta never gets it. The type is what bounds "absent = 0" to the
/// object's own descriptor.
pub fn into_created(mut self, object_type: ObjectType) -> Self {
self.descriptor_end = descriptor_len(object_type);
self
}
@@ -511,7 +550,7 @@ impl ObjectFields {
pub fn from_pairs(pairs: &[(u16, u32)]) -> Self {
Self {
fields: pairs.iter().copied().collect(),
created: false,
descriptor_end: 0,
}
}
@@ -571,7 +610,7 @@ impl ObjectFields {
self.fields
.get(&index)
.copied()
.or(self.created.then_some(0))
.or((index < self.descriptor_end).then_some(0))
}
fn get_f32(&self, index: u16) -> Option<f32> {
self.get_u32(index).map(f32::from_bits)
@@ -609,7 +648,7 @@ impl ObjectFields {
/// dropped to zero since the last stream (an aura that expired out of view, durability that
/// broke) — exactly the absent-vs-zero bug the `created` flag exists to kill.
pub fn merge(&mut self, delta: ObjectFields) {
if delta.created {
if delta.descriptor_end != 0 {
*self = delta;
} else {
self.fields.extend(delta.fields);
@@ -349,10 +349,26 @@ impl ObjectFields {
self.get_u32(FIELD_PLAYER_BYTES)
}
/// `PLAYER_BYTES_2` — byte 0 = facialHair, byte 1 = unk, byte 2 = bankBagSlots (see
/// [`Self::player_bank_bag_slots_purchased`]), byte 3 = restState (unread here).
/// [`Self::player_bank_bag_slots_purchased`]), byte 3 = restState (see
/// [`Self::player_rest_state`]).
pub fn player_bytes_2(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_BYTES_2)
}
/// `PLAYER_BYTES_2` **byte 3** — the rest state the client's `GetRestState()` maps to its
/// (id, name, multiplier) triple: `1` = rested, `2` = normal (vmangos `REST_STATE_*`,
/// `Player.h:673`). The server writes it with hysteresis off the rest pool — rested when the
/// pool exceeds 10, normal when it falls to ≤1 (`SetRestBonus`) — so the byte, not the pool,
/// is the state authority. `None` when never sent; a fresh descriptor reads absent as normal.
pub fn player_rest_state(&self) -> Option<u8> {
self.player_bytes_2().map(|b| (b >> 24) as u8)
}
/// `PLAYER_REST_STATE_EXPERIENCE` (field 1175) — the rested-XP pool, in **base kill-XP
/// units**: the server drains it 1:1 against a kill's base XP while granting +100%
/// (vmangos `GetXPRestBonus`), so the doubled-XP span the exhaustion tick marks is 2× this
/// value. `None` before the field streams (PRIVATE — self only).
pub fn player_rest_state_experience(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_REST_STATE_EXPERIENCE)
}
/// `PLAYER_BYTES_3` **byte 1** — the inebriation byte (0..255) drunkenness renders from. The
/// server packs `gender | (drunk & 0xFFFE)` into the field's low u16 and decays it 256 per
/// 10 s (vmangos `Player::SetDrunkValue` / `HandleDrunkReduce`), so this byte is
@@ -375,6 +391,13 @@ impl ObjectFields {
pub fn player_is_ghost(&self) -> bool {
self.player_flags() & 0x10 != 0
}
/// Whether the player is inside a rest area (inn/city) — `PLAYER_FLAGS_RESTING (0x20)`
/// (vmangos `Player.h:320`), set/cleared by the area-trigger/zone rest checks
/// (`SetRestType`). The real client's `IsResting()` and the player frame's flashing zzz
/// state icon read exactly this bit.
pub fn player_is_resting(&self) -> bool {
self.player_flags() & 0x20 != 0
}
/// `PLAYER_DUEL_ARBITER` (field 188, GUID) — the duel-flag GameObject of the duel this player
/// is in, `0` when they are in none. Set on both duellists by `Spell::EffectDuel`, cleared by
/// `Player::DuelComplete`; **PUBLIC**, so it streams for every player we can see and not just
@@ -30,6 +30,17 @@ fn combat_stat_indices_chain_to_the_tested_anchors() {
assert_eq!(FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER, OBJECT_END + 0xA4);
assert_eq!(FIELD_UNIT_MINRANGEDDAMAGE, OBJECT_END + 0xA5);
assert_eq!(FIELD_UNIT_MAXRANGEDDAMAGE, OBJECT_END + 0xA6);
// The rested-XP pool sits exactly one below the tested COINAGE anchor, and exactly one past
// the explored-zones bitset's 64 slots (decision 1082).
assert_eq!(FIELD_PLAYER_REST_STATE_EXPERIENCE, UNIT_END + 0x3DB);
assert_eq!(
FIELD_PLAYER_REST_STATE_EXPERIENCE + 1,
FIELD_PLAYER_FIELD_COINAGE
);
assert_eq!(
FIELD_PLAYER_EXPLORED_ZONES_1 + PLAYER_EXPLORED_ZONES_SLOTS,
FIELD_PLAYER_REST_STATE_EXPERIENCE
);
// PLAYER stat block: POSSTAT0 sits one past the tested COINAGE, and the run through
// AMMO_ID lands three shy of the tested BUYBACK_PRICE_1 (BYTES 1222, SELF_RES 1224,
// PVP_MEDALS 1225 between).
@@ -388,7 +399,7 @@ fn a_stale_spell_id_with_a_cleared_flags_nibble_is_not_a_live_aura() {
#[test]
fn created_store_reads_absent_fields_as_zero_a_delta_as_none() {
// A broken item's create: MAXDURABILITY present (40), DURABILITY omitted (it is 0).
let create = ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created();
let create = ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created(ObjectType::Item);
assert_eq!(create.item_durability(), Some(0), "created absent = 0");
assert_eq!(create.item_max_durability(), Some(40));
@@ -401,12 +412,54 @@ fn created_store_reads_absent_fields_as_zero_a_delta_as_none() {
assert_eq!(create.corpse_owner(), None);
}
/// …and "absent = 0" stops at the end of the object's OWN descriptor (decision 1081). A creature
/// has no PLAYER block to be absent from, so a PLAYER-block read off one is `None` — a question it
/// cannot answer — not a confident `0`.
///
/// The live bug: `unit_combat_stats` reads `PLAYER_FIELD_MOD_DAMAGE_DONE_PCT` for any unit and
/// defaults an absent answer to the divide-safe `1.0`. With a created pet answering `Some(0.0)`,
/// the default never fired and the ref's `damage / percent` produced the pet sheet's
/// `inf - inf` / `nan` tooltip and its red damage line (director, 2026-08-07).
#[test]
fn a_creature_has_no_player_block_to_be_absent_from() {
// PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[0] = UNIT_END(188) + 0x403 — past a UNIT's descriptor end.
const MOD_DAMAGE_DONE_PCT: u16 = 1215;
// UNIT_FIELD_BASEATTACKTIME — well inside it.
const BASEATTACKTIME: u16 = 126;
let pet = ObjectFields::from_pairs(&[(2, 0x09), (150, 33)]).into_created(ObjectType::Unit);
assert_eq!(
pet.player_mod_damage_done_pct(0),
None,
"a creature cannot answer a PLAYER-block field"
);
assert_eq!(
pet.unit_base_attack_time(0),
Some(0),
"…while a field it DOES have still reads the create's zero"
);
assert_eq!(pet.unit_stat(0), Some(33), "and a present field is itself");
// The same index on a real player: inside the descriptor, so absent is the honest 0 the
// client's own zero-initialized buffer holds.
let player = ObjectFields::from_pairs(&[(2, 0x19), (BASEATTACKTIME, 1800)])
.into_created(ObjectType::Player);
assert_eq!(player.player_mod_damage_done_pct(0), Some(0.0));
assert_eq!(player.unit_base_attack_time(0), Some(1800));
// A bare delta answers nothing either way — it never claimed to be complete.
let delta = ObjectFields::from_pairs(&[(MOD_DAMAGE_DONE_PCT, 1.0f32.to_bits())]);
assert_eq!(delta.player_mod_damage_done_pct(0), Some(1.0));
assert_eq!(delta.unit_base_attack_time(0), None);
}
/// Merging keeps the semantics straight: a delta overlays a created store without unsetting the
/// created mark, and a re-CREATE *replaces* the store — stale non-zero values for fields the
/// fresh snapshot omits (they dropped to zero out of view) must die with it.
#[test]
fn merge_overlays_deltas_and_replaces_on_recreate() {
let mut store = ObjectFields::from_pairs(&[(3, 2264), (46, 40), (47, 40)]).into_created();
let mut store =
ObjectFields::from_pairs(&[(3, 2264), (46, 40), (47, 40)]).into_created(ObjectType::Item);
// A durability-damage delta overlays; the store stays a complete (created) descriptor.
store.merge(ObjectFields::from_pairs(&[(46, 30)]));
@@ -418,7 +471,7 @@ fn merge_overlays_deltas_and_replaces_on_recreate() {
);
// Re-create after the item broke out of view: the fresh snapshot omits DURABILITY (0).
store.merge(ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created());
store.merge(ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created(ObjectType::Item));
assert_eq!(
store.item_durability(),
Some(0),
@@ -445,7 +498,7 @@ fn dynamicobject_fields_read_the_live_blizzard_capture() {
(12, 668.83f32.to_bits()), // POS_Y
(13, 97.81f32.to_bits()), // POS_Z
])
.into_created();
.into_created(ObjectType::DynamicObject);
assert_eq!(f.dynamicobject_caster(), Some(26));
assert_eq!(f.dynamicobject_bytes(), Some(1));
assert_eq!(f.dynamicobject_spell_id(), Some(10));
@@ -62,8 +62,10 @@ impl Object {
let movement = MovementBlock::read(r)?;
// A create's mask is the COMPLETE descriptor — the server omits zero-valued
// fields (vmangos `_SetCreateBits`), so absent must read 0, not unknown
// (`ObjectFields`'s created semantics).
let mask = ObjectFields::read(r)?.into_created();
// (`ObjectFields`'s created semantics). The TYPE rides along because "absent = 0"
// holds only inside this object's OWN descriptor: a creature has no PLAYER block
// to be absent from (decision 1081).
let mask = ObjectFields::read(r)?.into_created(object_type);
Object::Create {
guid,
object_type,
+21
View File
@@ -32,6 +32,27 @@ impl Loader<'_> {
);
continue;
}
// The real `<Scripts>` walker auto-enables the matching input kind after each
// successful SetScript (`0x769ef0` → `0x76af00(kind,-1)` per handler name; wow-re
// `ui/scratch/scripts-auto-enable.md`, §5 cross-checked): the five MOUSE-kind
// handlers arm the same enable as the XML `enableMouse` attribute — `OnDragStart`
// is in the set, `OnDragStop`/`OnReceiveDrag` are NOT. This is XML-load-time
// ONLY: the Lua SetScript binding (`0x7748d0`) never auto-enables, so the law
// lives here and not in SetScript itself (a runtime-created frame still needs an
// explicit `EnableMouse(true)`, like the real client). The keyboard kinds
// (`OnChar` = kind 0, `OnKeyDown`/`OnKeyUp` = kind 1) and the wheel kind
// (`OnMouseWheel` = kind 3) have separate indexes in the real client that this
// engine doesn't model yet — un-modeled beside the SetScript keyboard gap above.
const MOUSE_KIND: [&str; 5] = [
"OnEnter",
"OnLeave",
"OnMouseDown",
"OnMouseUp",
"OnDragStart",
];
if MOUSE_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) {
self.call(wrapper, "EnableMouse", true, dbg);
}
if name.eq_ignore_ascii_case("OnLoad") {
onload = Some(func);
}
+41
View File
@@ -847,6 +847,47 @@ mod loader_tests {
);
assert!(s.hit_test(14.0, 20.0).is_some(), "the art band still hits");
}
/// **The `<Scripts>` walker auto-enables the mouse kind when it attaches a mouse handler**
/// (`0x769ef0` → `0x76af00(2,-1)` per handler name; wow-re `ui/scratch/scripts-auto-enable.md`,
/// §5 cross-checked) — the reference's GameTimeFrame declares `<OnEnter>`/`<OnLeave>` and no
/// `enableMouse`, yet its tooltip hovers in the real client. The kind-2 name set is exactly
/// {OnEnter, OnLeave, OnMouseDown, OnMouseUp, OnDragStart}: `OnDragStop`/`OnReceiveDrag` are
/// NOT in it, and the law is XML-load-time only — the Lua SetScript binding (`0x7748d0`)
/// never auto-enables (verified negative there).
#[test]
fn scripts_block_mouse_handlers_auto_enable_mouse() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Hoverable">
<Scripts><OnEnter>-- hover</OnEnter></Scripts>
</Frame>
<Frame name="DropTarget">
<Scripts><OnDragStop>-- outside the kind-2 set</OnDragStop></Scripts>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
s.eval::<bool>("return Hoverable:IsMouseEnabled()").unwrap(),
"an XML mouse handler arms EnableMouse like the attribute would"
);
assert!(
!s.eval::<bool>("return DropTarget:IsMouseEnabled()")
.unwrap(),
"OnDragStop is outside the kind-2 name set"
);
// Runtime SetScript never auto-enables — the law is the XML walker's, not SetScript's.
s.run("rt = CreateFrame('Frame', 'Rt'); rt:SetScript('OnEnter', function() end)")
.unwrap();
assert!(
!s.eval::<bool>("return Rt:IsMouseEnabled()").unwrap(),
"a runtime-created frame still needs an explicit EnableMouse"
);
}
/// **`text=` is a GLOBAL-STRING LOOKUP, not a literal** (wow-re rf28 l.36/l.115 →
/// `FrameScript_GetText 0x703bf0`). Every arm of [`Loader::resolve_text`] in one document:
/// a `<Button text=>`, a `<ButtonText text=>` and a `<FontString text=>` all resolve through
+15
View File
@@ -699,6 +699,18 @@ pub(crate) struct Model {
pub(crate) combo_points: u8,
pub(crate) combo_target: u64,
/// The player's rest snapshot, pushed together each frame any part moves
/// ([`UiScript::set_rest_state`]) — player-globals like `money`. `rest_state` is the raw
/// `PLAYER_BYTES_2` byte 3 (1 = rested, 2 = normal — the server writes it with hysteresis
/// off the pool; `0` until the feed lands, read as normal), `rest_pool` the raw
/// `PLAYER_REST_STATE_EXPERIENCE` value in **base kill-XP units**, `resting` the
/// `PLAYER_FLAGS_RESTING (0x20)` bit (inside an inn/city). `GetRestState`/`GetXPExhaustion`/
/// `IsResting` read these; the ×2 display scaling of the pool lives in the `GetXPExhaustion`
/// binding, where the real client applies it (decision 1082).
pub(crate) rest_state: u8,
pub(crate) rest_pool: u32,
pub(crate) resting: bool,
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
/// seam ([`char_stats`], decision 0208 §3).
@@ -993,6 +1005,9 @@ impl Model {
player_next_level_xp: 0,
combo_points: 0,
combo_target: 0,
rest_state: 0,
rest_pool: 0,
resting: false,
player_combat_stats: None,
pet_combat_stats: None,
inventory_slots: Default::default(),
+9
View File
@@ -157,6 +157,15 @@ function GetZonePVPInfo()
return t, f, __benilla_pvp_arena
end
-- ── GetGameTime: the server's in-game clock (hour, minute) — the reference reads the
-- SMSG_LOGIN_SETTIMESPEED-seeded clock the client advances by its timescale. The app pushes the
-- host globals when the game minute ticks (`crate::minimap::feed_game_time` — same shape as the
-- zone-text family); minute resolution is the API's own (the binding returns no seconds).
-- 0:00 until the first time packet lands.
__benilla_game_hour = 0
__benilla_game_minute = 0
function GetGameTime() return __benilla_game_hour, __benilla_game_minute end
-- ── wipe / tostringall ─────────────────────────────────────────────────────────────────────────
function wipe(t)
for k in pairs(t) do t[k] = nil end
@@ -540,6 +540,66 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The rest-state trio (decision 1082) — player-level globals over the app's rest feed
// ([`UiScript::set_rest_state`]), the MainMenuBar exhaustion tick's and the player frame's
// whole wire. INTERIM pending the dispatched wow-re verdict on `GetRestState 0x48d350` /
// `GetXPExhaustion 0x48d3f0` / `IsResting 0x516ea0`; each claim below is flagged at its site.
//
// GetRestState() → (stateID, stateName, multiplier). The id IS the wire byte
// (`PLAYER_BYTES_2` byte 3): 1 = Rested (earn 200% of base kill XP), 2 = Normal (100%) — the
// only two the live server ever writes (vmangos `SetRestBonus`; the beta tired/exhausted
// tiers 3..5 survive as dead FrameXML branches). 0-before-feed reads as Normal. The names are
// the live client's own returns (INTERIM: enUS spellings, source string table unverified);
// the multiplier is what `EXHAUST_TOOLTIP1` renders ×100 ("200% of normal experience").
g.set(
"GetRestState",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.rest_state == 1 {
(1i64, "Rested", 2.0f64)
} else {
(2i64, "Normal", 1.0f64)
})
})?,
)?;
// GetXPExhaustion() → the rested span in BAR-XP units, or nil once the pool is dry — the tick
// parks at `currXP + this` and the fill bar spans up to it (`ExhaustionTick_Update`). ×2 of
// the wire pool: the server drains `PLAYER_REST_STATE_EXPERIENCE` 1:1 against BASE kill XP
// while granting +100% (vmangos `GetXPRestBonus`), so the bar advances two for every one the
// pool loses (INTERIM: the ×2-in-client and the nil-when-zero shape are the dispatch's C1).
g.set(
"GetXPExhaustion",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.rest_pool == 0 {
Value::Nil
} else {
Value::Integer(i64::from(model.rest_pool) * 2)
})
})?,
)?;
// IsResting() → 1/nil: inside a rest area (inn/city) right now — `PLAYER_FLAGS_RESTING
// (0x20)`. The player frame's flashing zzz reads exactly this.
g.set(
"IsResting",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.resting {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetTimeToWellRested() → nil, always: nothing on the 1.12 wire carries a time-to-full
// estimate, and `ExhaustionToolTipText` nil-guards the call (its EXHAUST_TOOLTIP4 line simply
// never renders). INTERIM: the dispatch's Q5 — if the binary computes one client-side, this
// grows the real formula.
g.set(
"GetTimeToWellRested",
lua.create_function(|_, ()| Ok(Value::Nil))?,
)?;
// TargetUnit(unit) — select a unit by token (the reference's `TargetUnit` Lua shim → SetSelection;
// the caller here is `PlayerFrame_OnClick`'s left-click branch → `TargetUnit("player")`, and the
// TARGETSELF binding). Queues the raw token; the app resolves it to a streamed entity and commits
+13
View File
@@ -276,6 +276,19 @@ impl super::UiScript {
model.player_next_level_xp = next_level_xp;
}
/// Push the player's rest snapshot — the `PLAYER_BYTES_2` byte 3 rest state, the
/// `PLAYER_REST_STATE_EXPERIENCE` pool (raw wire value, base kill-XP units) and the
/// `PLAYER_FLAGS_RESTING` bit — taken together so the `GetRestState`/`GetXPExhaustion`/
/// `IsResting` trio can never read a half-updated rest picture. Player-level fields, same
/// shape as [`Self::set_money`]; the app calls this each frame any of the three moves
/// (decision 1082).
pub fn set_rest_state(&mut self, state: u8, pool: u32, resting: bool) {
let mut model = self.model_mut();
model.rest_state = state;
model.rest_pool = pool;
model.resting = resting;
}
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as