diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml
index 0971503e..5567011d 100644
--- a/crates/benilla-app/assets/ui/ActionBar.xml
+++ b/crates/benilla-app/assets/ui/ActionBar.xml
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
@@ -816,16 +987,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BenillaExpBarOverlay_OnLoad()
+
+
+ BenillaExpBar_OnLoad(self)BenillaExpBar_OnEvent(self)BenillaExpBar_OnEnter(self)
- BenillaExpBar_OnLeave()
+ BenillaExpBar_OnLeave(self)
+ BenillaExhaustionTick_OnUpdate(arg1)
+
+ if this:IsShown() and TextStatusBar_OnValueChanged then
+ TextStatusBar_OnValueChanged()
+ end
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ this.timeOfDay = 0;
+ this:SetFrameLevel(this:GetFrameLevel() + 2);
+ GameTimeFrame_Update();
+
+
+ GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
+ GameTimeFrame_UpdateTooltip(GetGameTime());
+
+
+ GameTooltip:Hide();
+
+
+ GameTimeFrame_Update();
+
+
+
+
diff --git a/crates/benilla-app/assets/ui/KeyBindingsPage.xml b/crates/benilla-app/assets/ui/KeyBindingsPage.xml
index a6c3a0dc..e9bd72c5 100644
--- a/crates/benilla-app/assets/ui/KeyBindingsPage.xml
+++ b/crates/benilla-app/assets/ui/KeyBindingsPage.xml
@@ -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),
diff --git a/crates/benilla-app/assets/ui/MinimapCluster.xml b/crates/benilla-app/assets/ui/MinimapCluster.xml
index 58248a65..7b2cf3e7 100644
--- a/crates/benilla-app/assets/ui/MinimapCluster.xml
+++ b/crates/benilla-app/assets/ui/MinimapCluster.xml
@@ -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 on the cluster is dropped (the loader
- doesn't model it; only enlarges the mouse-blocking hull).
+ the simplification). The reference's 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).
-->
+
+
+
+
+ TextStatusBar_Initialize()
+ TextStatusBar_OnEvent(arg1, arg2)
+
+ 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
+
+
+ HideTextStatusBarText(this)
+ if GameTooltip then GameTooltip:Hide() end
+
+ TextStatusBar_OnValueChanged()
+
+
+
diff --git a/crates/benilla-app/assets/ui/UnitFrames.xml b/crates/benilla-app/assets/ui/UnitFrames.xml
index a2e09d6e..cfaef29f 100644
--- a/crates/benilla-app/assets/ui/UnitFrames.xml
+++ b/crates/benilla-app/assets/ui/UnitFrames.xml
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -875,6 +1001,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -882,7 +1052,7 @@
-
+
diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs
index 85d56a1b..424434e1 100644
--- a/crates/benilla-app/src/creature_anim.rs
+++ b/crates/benilla-app/src/creature_anim.rs
@@ -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;
diff --git a/crates/benilla-app/src/creature_anim/events.rs b/crates/benilla-app/src/creature_anim/events.rs
index 86864400..55c11f9f 100644
--- a/crates/benilla-app/src/creature_anim/events.rs
+++ b/crates/benilla-app/src/creature_anim/events.rs
@@ -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 {
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()
+ }
+}
diff --git a/crates/benilla-app/src/footprints.rs b/crates/benilla-app/src/footprints.rs
index 57736f72..73780967 100644
--- a/crates/benilla-app/src/footprints.rs
+++ b/crates/benilla-app/src/footprints.rs
@@ -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 {
- (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,
@@ -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.
diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs
index 77d1a77e..d2f1c66f 100644
--- a/crates/benilla-app/src/minimap/mod.rs
+++ b/crates/benilla-app/src/minimap/mod.rs
@@ -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>,
+ time: Res,
+ mut last: Local