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>, +) { + 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 `` 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 diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 70b23d35..69b0ac74 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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 `` 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()), diff --git a/crates/benilla-app/src/sound/footsteps.rs b/crates/benilla-app/src/sound/footsteps.rs index 2c6c9121..047d80d4 100644 --- a/crates/benilla-app/src/sound/footsteps.rs +++ b/crates/benilla-app/src/sound/footsteps.rs @@ -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>, voices: Option>, streamer: Res, @@ -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)) { diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index aecba21f..d669cec9 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -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 { + 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| 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 diff --git a/crates/benilla-app/src/ui_dressup.rs b/crates/benilla-app/src/ui_dressup.rs index 9a988daa..1e20a970 100644 --- a/crates/benilla-app/src/ui_dressup.rs +++ b/crates/benilla-app/src/ui_dressup.rs @@ -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}" + ); + } + } } diff --git a/crates/benilla-app/src/ui_pet_doll.rs b/crates/benilla-app/src/ui_pet_doll.rs index cccff824..ffcae213 100644 --- a/crates/benilla-app/src/ui_pet_doll.rs +++ b/crates/benilla-app/src/ui_pet_doll.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index ed0ef6dd..a1a33c90 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -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::("return GameTooltipTextLeft1:GetText()") - .unwrap(), - "XP Bar" - ); - assert_eq!( - s.eval::("return GameTooltipTextLeft2:GetText()") - .unwrap(), - s.eval::("return NEWBIE_TOOLTIP_XPBAR").unwrap(), - "line 2 is the ref's NEWBIE_TOOLTIP_XPBAR, verbatim" - ); - assert_eq!( - s.eval::("return GameTooltip.default").unwrap(), - 1, - "the default-corner anchor" - ); - - s.run("BenillaExpBar_OnLeave()").unwrap(); - assert!( - !s.eval::("return GameTooltip:IsVisible()").unwrap(), - "leaving hides the plate" - ); - assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); -} diff --git a/crates/benilla-app/src/ui_script/exp_bar_tests.rs b/crates/benilla-app/src/ui_script/exp_bar_tests.rs new file mode 100644 index 00000000..e632887c --- /dev/null +++ b/crates/benilla-app/src/ui_script/exp_bar_tests.rs @@ -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::("return GameTooltipTextLeft1:GetText()") + .unwrap(), + "XP Bar" + ); + assert_eq!( + s.eval::("return GameTooltipTextLeft2:GetText()") + .unwrap(), + s.eval::("return NEWBIE_TOOLTIP_XPBAR").unwrap(), + "line 2 is the ref's NEWBIE_TOOLTIP_XPBAR, verbatim" + ); + assert_eq!( + s.eval::("return GameTooltip.default").unwrap(), + 1, + "the default-corner anchor" + ); + + s.run("BenillaExpBar_OnLeave()").unwrap(); + assert!( + !s.eval::("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::>("return GetXPExhaustion()").unwrap(), + Some(1400), + "the pool is served ×2 — bar-XP, not wire units" + ); + assert_eq!( + s.eval::>("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::("return BenillaExpBarText:GetText()") + .unwrap(), + "1234 / 5678" + ); + assert!(s + .eval::("return BenillaExpBarText:IsShown()") + .unwrap()); + + s.run("BenillaExpBar_OnLeave(BenillaExpBar)").unwrap(); + assert!( + !s.eval::("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::("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::("return GameTooltip.canAddRestStateLine == nil") + .unwrap(), + "the handshake is consumed — the line never doubles" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 27032ad0..ecc20379 100644 --- a/crates/benilla-app/src/ui_script/manifest.rs +++ b/crates/benilla-app/src/ui_script/manifest.rs @@ -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] = &[ // 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 diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index ed0f80c5..97207a60 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -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 `` walker's +/// mouse auto-enable (the frame declares no enableMouse, like the reference — without the law the +/// hit-test never captures) and the `` 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::("return GameTimeFrame:GetLeft()").unwrap(), + s.eval::("return GameTimeFrame:GetRight()").unwrap(), + s.eval::("return GameTimeFrame:GetTop()").unwrap(), + s.eval::("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::("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::("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::("return GameTooltip:IsVisible()").unwrap(), + "the 6-px left inset band is not hoverable" + ); +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index ca0ebd08..3e844c22 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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; diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 99c54497..f9654b5c 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -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::("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()); +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 5f8eebf4..b7f84038 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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). // diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 0267d60a..1efa9c5e 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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, - /// 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 { 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); diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index ce666ffc..2aeff98d 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -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 { 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 { + 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 { + 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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 4dfc8c11..5dd394ee 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -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)); diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index 79034b12..d479d01a 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/loader/scripts.rs b/crates/benilla-ui/src/loader/scripts.rs index d74baf27..69635617 100644 --- a/crates/benilla-ui/src/loader/scripts.rs +++ b/crates/benilla-ui/src/loader/scripts.rs @@ -32,6 +32,27 @@ impl Loader<'_> { ); continue; } + // The real `` 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); } diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index ae46085b..c60cd8a7 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -847,6 +847,47 @@ mod loader_tests { ); assert!(s.hit_test(14.0, 20.0).is_some(), "the art band still hits"); } + + /// **The `` 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 ``/`` 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#" + + -- hover + + + -- outside the kind-2 set + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + assert!( + s.eval::("return Hoverable:IsMouseEnabled()").unwrap(), + "an XML mouse handler arms EnableMouse like the attribute would" + ); + assert!( + !s.eval::("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::("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 `