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