Interface and Social pages arrive, and slopes refuse the jump-climb

- the options window gains Interface, Action Bars and Social pages — buff durations, the action-bar lock and the social switches are live settings
- a saved switch is applied when it loads, not just restored to its row
- jumping at an unwalkable slope costs the jump instead of banking height up the hillside
- the airborne animation starts at the launch, not at the falling flag — and the saved camera pitch is verified against the reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-08 17:13:45 +07:00
co-authored by Claude Fable 5
parent 0accc6ccbb
commit dba123b60d
23 changed files with 1597 additions and 183 deletions
+19 -2
View File
@@ -62,6 +62,16 @@
ATTACK_BUTTON_FLASH_TIME = 0.4
TOOLTIP_UPDATE_TIME = 0.2
-- "Lock ActionBars" (ref UIOptionsFrame.lua:102-103 — uvar LOCK_ACTIONBAR, default "0").
-- The reference declares it in its options window; ours lives with the bar it locks, which
-- is this codebase's rule for every stock global (1128/1134): the assignment sits beside its
-- consumers, and the options row CAPTURES it rather than restating a default of its own.
-- Read by the drag guards below and by the pet bar's (PetActionBar.xml, the ref's own second
-- consumer at PetActionBarFrame.lua:270/278); driven by the Action Bars page and by the
-- TOGGLEACTIONBARLOCK binding the option's tooltip promises (decision 1136).
LOCK_ACTIONBAR = "0"
RegisterForSave("LOCK_ACTIONBAR")
-- CooldownFrame_SetTimer moved to Cooldown.xml (the ref's own file split) — the bag
-- slots share it, so it loads before both consumers.
@@ -253,8 +263,8 @@
-- by construction (OnClick/OnDragStart both call PickupAction; OnReceiveDrag calls
-- PlaceAction — ref ActionBarFrame.xml:12-38). Both buttons drag, ref-verbatim too
-- (l.108) — a started drag suppresses the click, so right-press-and-move relocates the
-- action while right-click-in-place still uses it. LOCK_ACTIONBAR stays the vanilla
-- default (unlocked), so unlike the ref's cvar guard, drag is always live here.
-- action while right-click-in-place still uses it. Both drag ends honour LOCK_ACTIONBAR
-- since 1136 — see the guards themselves for what the lock does and does not stop.
button:RegisterForDrag("LeftButton", "RightButton")
BenillaActionButton_Update(button)
end
@@ -271,12 +281,19 @@
BenillaActionButton_Update(button)
end
-- The lock (ref ActionBarFrame.xml:23-38, both handlers wrapped in `if LOCK_ACTIONBAR ~= "1"
-- then … end`). Note what the reference does NOT guard, and neither do we: the shift-click
-- pick-up in OnClick above (its l.12-22 fork runs PickupAction unconditionally). So the lock
-- stops the *drag*, and shift-click stays the deliberate way to move an action with the bar
-- locked — which is what makes the setting usable rather than a wall.
function BenillaActionButton_OnDragStart(button)
if LOCK_ACTIONBAR == "1" then return end
PickupAction(BenillaActionButton_Action(button))
BenillaActionButton_Update(button)
end
function BenillaActionButton_OnReceiveDrag(button)
if LOCK_ACTIONBAR == "1" then return end
PlaceAction(BenillaActionButton_Action(button))
BenillaActionButton_Update(button)
end
+72 -22
View File
@@ -8,14 +8,21 @@
engine exposes the one Era aura function instead (decision 0068), so the handlers here read
UnitBuff/UnitDebuff and the countdown rides GetTime(), exactly as the cast bar does.
SCOPE (decision 0255): the LOCAL PLAYER only, durations always visible. The reference's
temporary-weapon-enchant frame (TemporaryEnchantFrame / TempEnchant1-2) and the
SHOW_BUFF_DURATIONS CVar toggle are deliberately out — there is no weapon-enchant wire yet,
and the director's call is "durations visible, no setting". So the bar is hard-anchored to the
reference's *durations-shown, no-enchant* steady state: 16 buff buttons (two rows of 8) above 8
debuff buttons, rows 45px apart (30px icon + a 15px duration gutter), right-aligned under the
minimap at UIParent TOPRIGHT (-175,-13) — the point the reference's BuffFrame snaps to when no
temp enchant is present (BuffFrame_Enchant_OnUpdate's no-enchant branch).
SCOPE (decision 0255): the LOCAL PLAYER only. The reference's temporary-weapon-enchant frame
(TemporaryEnchantFrame / TempEnchant1-2) is deliberately out — there is no weapon-enchant wire
yet — so the bar is anchored to the reference's *no-enchant* steady state: 16 buff buttons
(two rows of 8) above 8 debuff buttons, right-aligned under the minimap at UIParent TOPRIGHT
(-175,-13), the point the reference's BuffFrame snaps to when no temp enchant is present
(BuffFrame_Enchant_OnUpdate's no-enchant branch).
DURATIONS ARE A SETTING NOW (decision 1139). 0255 shipped the durations-shown geometry
unconditionally — the director's call, and there was no panel to hang a switch on — and said
in as many words that "if a CVar system later arrives, the geometry is already the branch it
would select". It arrived, as the saved-variable store rather than a CVar (1134): the store
below is SHOW_BUFF_DURATIONS, the Interface page's Buff Durations row drives it, and BOTH of
the reference's geometries are live again — rows at y = 0/−45/−90 with timers, 0/−35/−70
without (BenillaBuffButtons_UpdatePositions). Our default is "1", the reference's "0": the
director's look is what the bar comes up wearing, and what Defaults walks it back to.
ORDER IS STATE. The list UnitBuff/UnitDebuff enumerate is the app's insertion-ordered display
cache (crate::ui_aura, decision 0257): a new aura appends at the end, a dropped one repacks —
@@ -40,11 +47,20 @@
wire's cancelable bit didn't allow, so a debuff right-click is a silent no-op, as in 1.12.
DEVIATIONS from the reference .lua (noted, not bugs):
- The count and duration FontStrings are BLANKED ("") rather than hidden — an empty string
draws nothing, same look. A site that predates region Show/Hide (region.rs has the real
pair now, and the loader honours hidden= on regions); equivalent either way.
- The count FontString is BLANKED ("") rather than hidden — an empty string draws nothing,
same look. A site that predates region Show/Hide; equivalent either way. (The DURATION
strings used the same shortcut until 1139 and no longer do: a hidden setting has to hide
a region, not write an empty string into a visible one.)
-->
<Script><![CDATA[
-- The timer switch (ref uvar, UIOptionsFrame.lua l.21/104 — its `default = "0"`). This
-- assignment is the ONLY statement of our default: the saved chunk executes over it at load
-- (1128) and the Interface page's row captures it as its Defaults value at OnLoad, which
-- runs first. "1" because the director asked for visible timers (0255) — the divergence is
-- written down once, here, and Defaults honours it rather than the reference's table.
SHOW_BUFF_DURATIONS = "1"
RegisterForSave("SHOW_BUFF_DURATIONS")
-- The reference's flash cadence + thresholds (BuffFrame.lua l.1-14). An aura in its last 31s
-- pulses between full and BUFF_MIN_ALPHA; its duration text turns highlight-white under 60s.
BUFF_FLASH_TIME_ON = 0.75
@@ -93,6 +109,26 @@
for i = 0, 23 do
getglobal("BuffButton" .. i .. "Duration"):SetPoint("TOP", "BuffButton" .. i, "BOTTOM", 0, 0)
end
BenillaBuffButtons_UpdatePositions()
end
-- BuffButtons_UpdatePositions (BuffFrame.lua l.152-160): the ROW PITCH depends on whether a
-- timer sits under each icon. Shown, the three rows land at y = 0/−45/−90 (a 30px icon + a
-- 15px gutter); hidden, at 0/−35/−70 — the icons close up to the same 5px the columns use.
-- The reference reaches those two geometries by re-anchoring through its TempEnchant chain,
-- which we do not have; same two numbers, our own anchors, and SetPoint on a point already
-- held overwrites it (layout.rs — the client's own SetPoint law).
--
-- This is the Buff Durations row's `applyFunc`, and the ONLY thing the setting needs applied:
-- the timer text itself is re-decided every frame by the poll below, so a click changes what
-- the next frame draws without anyone telling it. Geometry is the part that is stated once.
function BenillaBuffButtons_UpdatePositions()
local gutter = -5
if SHOW_BUFF_DURATIONS == "1" then
gutter = -15
end
BuffButton8:SetPoint("TOP", "BuffButton0", "BOTTOM", 0, gutter)
BuffButton16:SetPoint("TOPRIGHT", "BuffButton8", "BOTTOMRIGHT", 0, gutter)
end
function BenillaBuffFrame_OnUpdate(elapsed)
@@ -149,13 +185,31 @@
-- tint. Those are what the aura-change event exists for. The countdown is NOT cached here —
-- see BenillaBuffButton_OnUpdate. No aura ⇒ hide the button (which hides its icon/border
-- with it) and blank its duration string.
-- BuffFrame_UpdateDuration (BuffFrame.lua l.247-259), gate and all: the timer draws only when
-- the setting is on AND there is a live one. `timeLeft` nil covers both of the other cases —
-- no aura in this slot, and a permanent aura (no duration on the wire). Hidden, not blanked:
-- see the header's deviation note.
function BenillaBuffButton_SetDuration(button, timeLeft)
local durationText = getglobal(button:GetName() .. "Duration")
if SHOW_BUFF_DURATIONS ~= "1" or not timeLeft then
durationText:Hide()
return
end
durationText:SetText(SecondsToTimeAbbrev(timeLeft))
if timeLeft < BUFF_DURATION_WARNING_TIME then
durationText:SetVertexColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
else
durationText:SetVertexColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
end
durationText:Show()
end
function BenillaBuffButton_Update(button)
local name, icon, count, debuffType = BenillaBuffButton_Aura(button)
local durationText = getglobal(button:GetName() .. "Duration")
if not name then
button:Hide()
durationText:SetText("")
BenillaBuffButton_SetDuration(button, nil)
return
end
button:SetAlpha(1.0)
@@ -195,11 +249,10 @@
-- its icon did. Both were live defects; the poll is what makes them unrepresentable
-- (decision 0846).
function BenillaBuffButton_OnUpdate(button)
local durationText = getglobal(button:GetName() .. "Duration")
local name, _, _, _, _, expirationTime = BenillaBuffButton_Aura(button)
if not name or not expirationTime or expirationTime <= 0 then
button:SetAlpha(1.0)
durationText:SetText("")
BenillaBuffButton_SetDuration(button, nil)
return
end
@@ -207,18 +260,15 @@
if timeLeft < 0 then
timeLeft = 0
end
-- The flash is NOT gated on the setting: the reference pulses a button in its last 31s
-- from BuffButton_OnUpdate, whether or not a timer is drawn under it (l.128-138), and
-- with the timers off it is the only thing left saying "about to drop".
if timeLeft < BUFF_WARNING_TIME then
button:SetAlpha(BUFF_ALPHA_VALUE)
else
button:SetAlpha(1.0)
end
durationText:SetText(SecondsToTimeAbbrev(timeLeft))
if timeLeft < BUFF_DURATION_WARNING_TIME then
durationText:SetVertexColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
else
durationText:SetVertexColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
end
BenillaBuffButton_SetDuration(button, timeLeft)
end
-- Right-click cancels (BuffButton_OnClick → CancelPlayerBuff). Resolve within the button's own
+5 -3
View File
@@ -32,10 +32,12 @@
-- SHOW_NEWBIE_TIPS = "1"; RegisterForSave("SHOW_NEWBIE_TIPS");
-- It is a FrameXML *uvar*, not a CVar, and 1.12 ships it ON — so the detailed second line
-- IS the default reference tooltip, not an opt-in. (An earlier note in this corpus had this
-- backwards; decision 0661.) We ship no UIOptionsFrame and no saved variables, so the ref's
-- initial value is the whole story; the options panel takes ownership of this line the day
-- it lands.
-- backwards; decision 0661.) The options window's Interface page drives it now
-- (decision 1136), and the assignment below is the ONLY statement of the default: the saved
-- chunk executes over it at load (1128) and the row captures it as its Defaults value at
-- OnLoad — which runs first — so "1" is written down in exactly one place, here.
SHOW_NEWBIE_TIPS = "1"
RegisterForSave("SHOW_NEWBIE_TIPS")
-- The bracketed name out of an item hyperlink "|cff…|Hitem:117|h[Tough Jerky]|h|r"; if the
-- string has no bracket it is already a plain name (the merchant/loot rows hand plain names).
@@ -614,6 +614,21 @@
},
Graphics = { "RowUiScale", "RowWorldDetail" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
-- The stock globals that were already defined AND consumed when 1134 opened the store
-- (1136; Buff Durations 1139), in the reference's own on-screen order — its Basic
-- Options anchor chain, Display block (CheckButton42 → 39 → 66) before Help (29).
Interface = {
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch", "RowNewbieTips",
},
-- The two chat-bubble switches (decision 1139), in the reference's relative order —
-- its Advanced Options chat chain runs CheckButton37 → 46 (Loot Spam, no knob of ours)
-- → 38, so ours are adjacent with that gap named rather than silently closed.
Social = { "RowChatBubbles", "RowPartyChatBubbles" },
-- 1.12's Advanced Options "Action Bars" group is six rows; five of them are the
-- multibar show/hide toggles, which read `func = MultiBarN_IsVisible` — not a store at
-- all, and our two bottom multibars are always on (MultiBars.xml). So the honest page
-- is the sixth (decision 1136).
ActionBars = { "RowLockActionBar" },
-- The floating-combat-text family (decision 1134), in the reference's own on-screen
-- order — its AdvancedOptionsCombatText box's anchor chain, master → scroll mode →
-- the twelve, NOT its check-button index order (which jumps 58→60→63…59→69).
@@ -697,6 +712,35 @@
row.udefault = getglobal(uvar)
end
-- The other end of `applyFunc`, and the reason a saved "off" used to come back on
-- (decision 1139). A CVar row needs nothing here — the host reads its own table at boot.
-- A GLOBAL row does: the whole UI's XML runs BEFORE the saved chunk executes over it
-- (1128's load order), so every file-scope consumer already ran against the SHIPPED
-- default — CombatText_OnLoad armed six event registrations from SHOW_COMBAT_TEXT = "1",
-- BuffFrame seated its rows from the durations-shown geometry — and the saved value
-- landing afterwards changed the variable while leaving the world it configured alone.
--
-- 1.12 closes this with a hand-written ladder in UIOptionsFrame's VARIABLES_LOADED arm
-- (l.204-220, "Option specific function calls": BuffButtons_UpdatePositions, the
-- SIMPLE_CHAT/CHAT_LOCKED arms, MultiActionBar_ShowAllGrids). We already hold each side
-- effect on its own row, so the ladder is a walk: run every distinct applyFunc once, in
-- no particular order (they are independent by construction — each one re-derives one
-- feature's state from one global). A row whose defining file was not loaded has a nil
-- applyFunc and is skipped, which is what keeps a partial test harness honest.
function OptionsFrame_ApplySavedSettings()
local ran = {}
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
for _, rkey in ipairs(rows) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
local apply = row and row.applyFunc
if apply and not ran[apply] then
ran[apply] = 1
apply()
end
end
end
end
-- A slider row's own range and readout, when the template's 0..1 / 5% / percent (the
-- volume shape) isn't it — called after OptionsRow_OnLoad in the row's OnLoad. The
-- refreshing guard: SetMinMaxValues re-clamps the held value and fires OnValueChanged,
@@ -2183,6 +2227,147 @@
</Frame>
</Frames>
</Frame>
<!-- The ACTION BARS page (decision 1136). One row, because the
reference's group is the lock plus five multibar show/hide
toggles whose store is a `func`, not a uvar or a CVar — and our
two bottom multibars are unconditionally on. The lock itself
had to be BUILT before it could have a row: ActionBar.xml and
PetActionBar.xml now guard their drag ends on it, the way the
reference does. -->
<Frame name="$parentActionBars" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowLockActionBar" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "LOCK_ACTIONBAR",
"Lock ActionBars", "OPTION_TOOLTIP_LOCK_ACTIONBAR")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The INTERFACE page (decision 1136; the Buff Durations row is
1139) — the second store's second page, and the one 1134 §3
named as ready: stock globals already defined AND consumed,
needing only a row. Rows in the reference's own on-screen
order — Basic Options' anchor chain runs CheckButton42 → 39 →
62 → 66 in Display, then 29 in Help, and we hold all of those
but 62 (HIDE_OUTDOOR_WORLD_STATE: there is no zone-objective
tracker to hide). Labels and OPTION_TOOLTIP_* keys are its
GlobalStrings verbatim.
Only Buff Durations carries an `applyFunc`, and the split is
the honest one rather than an omission: the other three are
read at the moment their consumer acts — the tooltip as it is
built, the quest panel as it shows, the auto-watch as progress
arrives — so the write IS the apply. A setting is owed an
applyFunc exactly when something DERIVED from it is stated
once and kept: the buff bar's row pitch, the combat-text
family's event registrations. -->
<Frame name="$parentInterface" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowInstantQuestText" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "QUEST_FADING_DISABLE",
"Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING")
</OnLoad></Scripts>
</Frame>
<!-- The reference's own next link in that chain
(CheckButton39, anchored under 42), and the one row on
this page that DOES carry an applyFunc: the timer text
is re-decided by the bar's per-frame poll, but the row
PITCH is stated once and has to be re-stated when the
switch moves (decision 1139). -->
<Frame name="$parentRowBuffDurations" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowInstantQuestText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowInstantQuestText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_BUFF_DURATIONS",
"Buff Durations", "OPTION_TOOLTIP_SHOW_BUFF_DURATION")
self.applyFunc = BenillaBuffButtons_UpdatePositions
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAutoQuestWatch" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowBuffDurations" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowBuffDurations" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "AUTO_QUEST_WATCH",
"Automatic Quest Tracking", "OPTION_TOOLTIP_AUTO_QUEST_WATCH")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNewbieTips" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoQuestWatch" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoQuestWatch" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_NEWBIE_TIPS",
"Show Detailed Tooltips", "OPTION_TOOLTIP_SHOW_NEWBIE_TIPS")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The SOCIAL page (decision 1139) — the last category in this
window that opened onto nothing. Both rows are CVar rows, not
globals: `ChatBubbles`/`ChatBubblesParty` are 1.12's own
registrar CVars, and chat_bubble.rs held them as a pair of
`const bool` from 0598 — a gate transcribed faithfully with no
way to reach it, which is the same shape as the action-bar lock
and fixed the same way (the knob first, then the row).
1.12's own order, with one gap named: its Advanced Options chat
chain runs CheckButton37 (Chat Bubbles) → 46 (Loot Spam) → 38
(Party Chat Bubbles), and 46 is not ours to ship — no
`showLootSpam` knob exists. So these two are adjacent here and
in the reference's relative order. Neither is a dependency child:
the client gates party lines on their own CVar, so party bubbles
survive turning say/yell bubbles off. -->
<Frame name="$parentSocial" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "ChatBubbles",
"Show Chat Bubbles", "OPTION_TOOLTIP_CHAT_BUBBLES")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowPartyChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowChatBubbles" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowChatBubbles" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "ChatBubblesParty",
"Show Party Chat Bubbles", "OPTION_TOOLTIP_PARTY_CHAT_BUBBLES")
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The Nameplates page (0992 — the page 0952 promised when the
category row joined): the three 1.12 UnitName* checkboxes over
the overhead-name gates (nameplates.rs NameConfig), rows in
@@ -2482,6 +2667,24 @@
getglobal(self:GetName() .. "Label"):SetText("Combat")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadActionBars" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "ActionBars"
getglobal(self:GetName() .. "Label"):SetText("Action Bars")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadInterface" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Interface"
getglobal(self:GetName() .. "Label"):SetText("Interface")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadSocial" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Social"
getglobal(self:GetName() .. "Label"):SetText("Social")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadNameplates" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Nameplates"
@@ -2658,6 +2861,17 @@
</Button>
</Frames>
<Scripts>
<!-- The window is where the saved globals get applied, not just edited: the chunk
lands after every file-scope consumer has already run (decision 1139). This is
1.12's own seat for it — UIOptionsFrame registers the same event for the same
reason — and the window is hidden at the time, which is the point: the event is
the UI's, not the panel's. -->
<OnLoad>this:RegisterEvent("VARIABLES_LOADED")</OnLoad>
<OnEvent>
if event == "VARIABLES_LOADED" then
OptionsFrame_ApplySavedSettings()
end
</OnEvent>
<!-- Controls is the default page; a re-opened window keeps its last selection (the
era remembers too — Settings.OpenToCategory's default seat). The scale re-fits
on every show — the era panel manager re-runs checkFit the same way. -->
@@ -207,8 +207,9 @@
-- (PetActionButton_OnLoad l.234) — and unlike the action bar's, ALL THREE gestures here
-- reach the same verb: the reference's OnClick-under-shift, OnDragStart and
-- OnReceiveDrag all call PickupPetAction, which forks on whether the cursor is already
-- carrying. LOCK_ACTIONBAR stays the vanilla default (unlocked), so — as on ActionBar
-- — the ref's cvar guard is not reproduced and the drag is always live.
-- carrying. Both drag ends honour LOCK_ACTIONBAR since 1136 — the same uvar the main bar
-- declares (ActionBar.xml), which is the ref's own arrangement too: PetActionBarFrame.lua
-- reads a global UIOptionsFrame owns.
button:RegisterForDrag("LeftButton", "RightButton")
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
@@ -273,12 +274,18 @@
-- empty cursor it picks the slot up, with a pet payload already held it drops onto this
-- slot. There is no separate "place" binding on this bar, which is also why a spell or an
-- item can never land here — nothing but a pet slot produces the payload it accepts.
--
-- Both also honour the lock (the ref wraps each body in `if LOCK_ACTIONBAR ~= "1" then …`).
-- The shift-click in OnClick above is unguarded on this bar too, exactly as the reference
-- leaves it (l.253-255): the lock stops the drag, shift-click stays the way through.
function BenillaPetActionButton_OnDragStart(button)
if LOCK_ACTIONBAR == "1" then return end
PickupPetAction(button.index)
BenillaPetActionBar_Update()
end
function BenillaPetActionButton_OnReceiveDrag(button)
if LOCK_ACTIONBAR == "1" then return end
PickupPetAction(button.index)
BenillaPetActionBar_Update()
end
+14 -5
View File
@@ -62,9 +62,10 @@
window opening/closing does.
Deviations (each cited again at its own widget below):
- `QUEST_FADING_DISABLE` (the ref's "Instant Quest Text" option) is pinned "1" — quest text
lands whole and the panel fade-ins skip; see the uvar's own comment below. The write-on
machinery itself (0132) is ref-verbatim and live behind the flag.
- `QUEST_FADING_DISABLE` (the ref's "Instant Quest Text" option) DEFAULTS "1" where the ref
defaults "0" — quest text lands whole and the panel fade-ins skip; see the uvar's own
comment below. The write-on machinery itself (0132) is ref-verbatim and live behind the
flag, and since decision 1136 the player can flip the flag on the Interface page.
- No "Parchment"/"Book" material swap (`QuestFrame_SetMaterial`/`GetQuestBackgroundMaterial`)
— this engine has no such Lua surface; the corner art below is the ref's Parchment skin only
(already true of every window in this codebase).
@@ -93,9 +94,17 @@
-- what the ref's instant mode actually does (see the OnShow arm below): it jams the
-- reveal edge past the text rather than skipping the write-on, so the first OnUpdate
-- tick plays one WriteQuest before the gradient runs off. Instant is one scribble, not
-- silence. The ref home for this uvar is the options window; it moves to a real options
-- surface when we build one.
-- silence.
--
-- The ref home for this uvar is the options window, and that is where it lives now: the
-- Interface page's "Instant Quest Text" row drives it (decision 1136), and this assignment
-- is the only place the default is written down. Note what that buys — our default is "1"
-- where 1.12's table says "0", and because the row CAPTURES its default here rather than
-- restating one, the Defaults button walks the page back to the director's pin, not to the
-- reference's. Registered for save on the line below, so a player who prefers the fade-in
-- keeps it (1128's load order: this assignment, then the saved chunk, then consumers).
QUEST_FADING_DISABLE = "1"
RegisterForSave("QUEST_FADING_DISABLE")
-- One quest-item row (kind = "choice"/"reward"/"required", 1-based `index`) — unchanged from
-- before this pass; every panel's rows share it via `GetQuestItemInfo`.
+16 -2
View File
@@ -906,7 +906,21 @@
-- We keep the advertised behavior — the feature the option tooltip documents — off the
-- benilla event that carries what the Lua needed; a deliberate intent-over-letter
-- divergence (the fold-back decision record).
BENILLA_AUTO_QUEST_WATCH = true
--
-- The FLAG is the ref's own uvar, under the ref's own name since decision 1136 — it was
-- BENILLA_AUTO_QUEST_WATCH (a boolean) while nothing could change it. It is a "1"/"0"
-- string now because that is what an options row writes and what the reference's own
-- consumers compare against; the Interface page's "Automatic Quest Tracking" row drives it.
--
-- Which is the SECOND break in this feature, independent of the event seam above and just
-- as fatal: 1.12 assigns `AUTO_QUEST_WATCH = 1` — the NUMBER — at UIOptionsFrame.lua:122,
-- while every consumer tests `AUTO_QUEST_WATCH == "1"` (QuestLogFrame.lua:64/68) and the
-- checkbox loads on the same string compare. In Lua 5.0 `1 ~= "1"`, so on a fresh install
-- the option paints UNCHECKED and the auto-watch is dead, despite the table declaring
-- `default = "1"`; it only comes alive once the player clicks the box, which writes the
-- string. We ship the string, so the advertised default is the real one.
AUTO_QUEST_WATCH = "1"
RegisterForSave("AUTO_QUEST_WATCH")
BENILLA_QUEST_WATCH_TIMER = 300
BENILLA_QUEST_WATCH_TIMERS = {}
@@ -921,7 +935,7 @@
end
function BenillaAutoQuestWatch_Update(questIndex)
if not BENILLA_AUTO_QUEST_WATCH or not questIndex then return end
if AUTO_QUEST_WATCH ~= "1" or not questIndex then return end
local title = GetQuestLogTitle(questIndex)
if not title then return end
if IsQuestWatched(questIndex) then
+28 -11
View File
@@ -85,18 +85,19 @@ pub(crate) mod cmd {
pub(crate) const OPEN_CHAT: Cmd = Cmd(12);
pub(crate) const OPEN_CHAT_SLASH: Cmd = Cmd(13);
pub(crate) const REPLY: Cmd = Cmd(17);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(50);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(51);
pub(crate) const NAMEPLATES: Cmd = Cmd(62);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(63);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(64);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(65);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(51);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(52);
pub(crate) const NAMEPLATES: Cmd = Cmd(63);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(64);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(65);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(66);
// These three shift whenever a row lands ahead of them in SPECS (1057's `TOGGLECHARACTER3`
// moved them 86/87/88 → 87/88/89). `the_cmd_handles_index_their_rows` is what makes that
// loud and cheap instead of silent: it names the handle and the row it actually points at.
pub(crate) const TOGGLE_UI: Cmd = Cmd(87);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(88);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(89);
// moved them 86/87/88 → 87/88/89; 1136's `TOGGLEACTIONBARLOCK` → 88/89/90).
// `the_cmd_handles_index_their_rows` is what makes that loud and cheap instead of silent: it
// names the handle and the row it actually points at.
pub(crate) const TOGGLE_UI: Cmd = Cmd(88);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(89);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(90);
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
@@ -445,6 +446,22 @@ pub(crate) static SPECS: &[Spec] = &[
Some("CTRL-0"),
None
),
// The action-bar lock (decision 1136), the ref's own binding body verbatim (Bindings.xml:433-
// 439) — it flips the `LOCK_ACTIONBAR` uvar `ActionBar.xml` declares, the same global the
// Options window's Action Bars row writes. It sits here because the reference files it under
// this header (l.433 carries no `header=`, so it inherits l.121's ACTIONBAR), and it ships
// **unbound**: no `TOGGLEACTIONBARLOCK` line in any of the install's three
// `bindings-cache.wtf` files, which is also what the option's own tooltip implies ("can be
// bound to a function key in the keybindings interface").
spec!(
"TOGGLEACTIONBARLOCK",
ACTIONBAR,
Kind::Edge(
r#"if LOCK_ACTIONBAR == "1" then LOCK_ACTIONBAR = "0" else LOCK_ACTIONBAR = "1" end"#
),
None,
None
),
// ── Targeting (BINDING_HEADER_TARGETING) ────────────────────────────────────────────
spec!(
"TARGETNEARESTENEMY",
+67 -15
View File
@@ -69,11 +69,28 @@ use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates};
/// The two CVars (registrar `0x603280`): `ChatBubbles` ships "1"; `ChatBubblesParty` ships
/// "0" — flipped ON here (the director's ask: `/p` bubbles), a deliberate deviation like
/// [`crate::vplates::VPlateMode`]'s boot default.
const CHAT_BUBBLES: bool = true;
const CHAT_BUBBLES_PARTY: bool = true;
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
/// they were a pair of `const bool` from 0598 until the options window had a Social page to put
/// them on, which is exactly the shape 1134 calls a row over a frozen gate. The defaults are the
/// values that were frozen: `ChatBubbles` the reference's own "1"; `ChatBubblesParty` flipped ON
/// against the binary's "0" (the director's ask: `/p` bubbles), the same deliberate deviation
/// [`crate::vplates::VPlateMode`]'s boot default carries.
#[derive(Resource)]
pub(crate) struct BubbleConfig {
/// `ChatBubbles` — say/yell and their monster variants.
pub(crate) all: bool,
/// `ChatBubblesParty` — party lines, which the client gates separately.
pub(crate) party: bool,
}
impl Default for BubbleConfig {
fn default() -> Self {
Self {
all: true,
party: true,
}
}
}
/// The bubble art (`0x4b0940` ctor) — the shared tooltip-family textures.
const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
@@ -109,11 +126,11 @@ const Z_TEXT: u64 = 3;
/// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`).
/// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an
/// OPEN remap (module doc) — out until a capture confirms.
fn bubble_cvar(kind: ChatEventKind) -> Option<bool> {
fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
use ChatEventKind as K;
match kind {
K::Party => Some(CHAT_BUBBLES_PARTY),
K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(CHAT_BUBBLES),
K::Party => Some(cfg.party),
K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(cfg.all),
_ => None,
}
}
@@ -202,9 +219,16 @@ pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>);
impl BubbleQueue {
/// Queue a routed wire line for a bubble. Drops non-bubbling kinds, disabled CVars, and
/// senderless lines here; the live-unit/range/plate gates run in the driver.
pub(crate) fn push(&mut self, sender_guid: u64, kind: ChatEventKind, text: &str) {
if sender_guid == 0 || !bubble_cvar(kind).unwrap_or(false) {
/// senderless lines here; the live-unit/range/plate gates run in the driver. The switch is
/// read at PUSH time, so a Social-page click takes the very next line either way.
pub(crate) fn push(
&mut self,
cfg: &BubbleConfig,
sender_guid: u64,
kind: ChatEventKind,
text: &str,
) {
if sender_guid == 0 || !bubble_cvar(kind, cfg).unwrap_or(false) {
return;
}
self.0.push((sender_guid, kind, text.to_string()));
@@ -556,6 +580,7 @@ pub(crate) struct ChatBubblePlugin;
impl Plugin for ChatBubblePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BubbleQueue>()
.init_resource::<BubbleConfig>()
.init_resource::<Bubbles>()
.init_resource::<BubblesActive>()
.add_systems(Startup, load_bubble_art.after(AssetSet::Open))
@@ -613,14 +638,20 @@ mod tests {
/// The v1 kind set: say/yell + monster say/yell on `ChatBubbles`, party on
/// `ChatBubblesParty` (benilla default ON — the 0598 deviation); everything else out
/// pending the OPEN remap capture.
/// pending the OPEN remap capture. And the two switches are genuinely separate: the client
/// gates party lines on their own CVar, so turning one off leaves the other bubbling.
#[test]
fn the_kind_set_is_the_uncontested_v1() {
use ChatEventKind as K;
let on = BubbleConfig::default();
for k in [K::Say, K::Yell, K::MonsterSay, K::MonsterYell] {
assert_eq!(bubble_cvar(k), Some(true));
assert_eq!(bubble_cvar(k, &on), Some(true));
}
assert_eq!(bubble_cvar(K::Party), Some(true), "the director's /p ask");
assert_eq!(
bubble_cvar(K::Party, &on),
Some(true),
"the director's /p ask"
);
for k in [
K::Guild,
K::Officer,
@@ -631,8 +662,29 @@ mod tests {
K::Channel,
K::MonsterEmote,
] {
assert_eq!(bubble_cvar(k), None, "{k:?} must not bubble in v1");
assert_eq!(bubble_cvar(k, &on), None, "{k:?} must not bubble in v1");
}
let no_party = BubbleConfig {
all: true,
party: false,
};
assert_eq!(bubble_cvar(K::Party, &no_party), Some(false));
assert_eq!(
bubble_cvar(K::Say, &no_party),
Some(true),
"say is untouched"
);
let no_say = BubbleConfig {
all: false,
party: true,
};
assert_eq!(bubble_cvar(K::Say, &no_say), Some(false));
assert_eq!(
bubble_cvar(K::Party, &no_say),
Some(true),
"party has its own switch"
);
}
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
+6
View File
@@ -767,6 +767,11 @@ pub(crate) struct AnimDriver {
/// airborne frame — the MSG_MOVE_JUMP-vs-StartFalling distinction the real client carries as
/// an event, read off the launch velocity in our flag-collapsed machine. Stale on the ground.
jump_arc: bool,
/// Vertical speed as of last frame — the **launch** detector behind [`Self::jump_arc`]. A jump
/// out of a one-frame micro-detachment never toggles the FALLING bit (land and relaunch inside
/// one frame), so the bit's edge cannot be the only place the arc is classified; a rise past
/// `JUMP_ARC_MIN_UP` from below is the launch itself, and nothing but a jump produces one.
last_vertical_speed: f32,
/// FALLING was set last frame — marks the arc edges: takeoff (decide [`Self::jump_arc`]) and
/// the landing of a bracket-less step-off fall (which must still run the `0x602c60` land pick
/// even though no Special drove the arc).
@@ -903,6 +908,7 @@ impl Default for AnimDriver {
overlay_fade: None,
wound: None,
jump_arc: false,
last_vertical_speed: 0.0,
was_falling: false,
deferred: None,
loop_window: None,
+16 -1
View File
@@ -538,7 +538,22 @@ pub(super) fn drive_animations(
// unlatched landing is a full no-op: the gait keeps rolling — decision 0187).
let falling = mv.flags & move_flags::FALLING != 0;
let was_falling = std::mem::replace(&mut drv.was_falling, falling);
if falling && !was_falling {
// **The edge is the LAUNCH, not the FALLING bit** (decision 1137). The bit's rising edge is
// the arc's start only when the body was on the ground the frame before, and on broken
// ground it often is not: a run across a hillside micro-detaches for a frame or two, and a
// jump pressed in that window lands and relaunches inside a single frame, with FALLING set
// continuously across the seam. The bit never toggles, the sample never runs, and the arc
// inherits `jump_arc = false` from the detachment — so a real jump plays the step-off
// freeze and the body sails upward still running. Seven of the nineteen jumps in the
// director's hillside capture took exactly that path (`grounded=0 vy=-0.65`, then
// `vy=+7.96` the next frame).
//
// A launch is visible in the quantity itself: nothing but a jump drives vertical speed
// *upward* past the threshold. The mover's own arc bookkeeping already agrees — a
// same-frame land+relaunch is a new arc there ([`crate::player::Player::advance_airborne_arc`]).
let prev_vertical = std::mem::replace(&mut drv.last_vertical_speed, mv.vertical_speed);
let launched = mv.vertical_speed > JUMP_ARC_MIN_UP && prev_vertical <= JUMP_ARC_MIN_UP;
if falling && (!was_falling || launched) {
drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP;
}
// The airborne-freeze's exact gate (`0x5fd8e8`, §5-arbitrated — decision 0868):
@@ -1331,6 +1331,72 @@ fn jumper(app: &mut App) -> Entity {
.id()
}
/// **The director's hillside jump** (decision 1137): a run across broken ground micro-detaches for
/// a frame, and the jump pressed in that window lands and relaunches inside one frame — FALLING
/// never drops, so the old FALLING-edge sample never re-ran and the arc kept the detachment's
/// `jump_arc = false`. The body sailed upward playing the run gait.
#[test]
fn a_jump_out_of_a_micro_detachment_still_enters_the_jump_bracket() {
let mut app = app();
let unit = jumper(&mut app);
app.update(); // settle: Stand
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
// One frame of detachment while running: FALLING with a *downward* speed is a step-off arc —
// no bracket, the gait freezes, which is correct on its own (decision 0868).
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING | move_flags::FORWARD,
speed: 7.0,
vertical_speed: -0.65,
..Default::default()
});
app.update();
assert_eq!(
mode(&app),
super::super::select::Mode::Gait,
"a downward launch is a step-off: the gait holds"
);
// The jump fires out of that frame: the body lands and relaunches within the frame, so FALLING
// is set on this frame too and the bit never toggled. The launch is still a launch.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING | move_flags::FORWARD,
speed: 7.0,
vertical_speed: 7.96,
..Default::default()
});
app.update();
assert_eq!(
mode(&app),
super::super::select::Mode::Entering(super::super::select::Special::Jump),
"the jump bracket enters even though FALLING never dropped"
);
}
/// The control on the launch rule: a step-off fall must stay a step-off fall for its whole arc.
/// The gait freeze is the §5-verified behaviour (decision 0868) and the new edge must not reach
/// into it — only a rise *past* the threshold is a launch, and a fall only ever accelerates
/// downward.
#[test]
fn a_deepening_step_off_fall_never_becomes_a_jump() {
let mut app = app();
let unit = jumper(&mut app);
app.update();
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
for vz in [-0.65_f32, -3.0, -7.4, -12.0] {
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING | move_flags::FORWARD,
speed: 7.0,
vertical_speed: vz,
..Default::default()
});
app.update();
assert_eq!(
mode(&app),
super::super::select::Mode::Gait,
"a step-off fall keeps its frozen gait at vz={vz}"
);
}
}
/// The ref's jump-in-place cast (decision 0864 — the report this pins): a cast id is CLASS_A
/// but NOT COMBAT, so the airborne route test doesn't mask it — with no move bits it routes
/// FULL-BODY and REPLACES the jump hang on bone 0 (the client's one-slot last-writer-wins; the
+32 -1
View File
@@ -27,6 +27,7 @@ use std::time::Instant;
use bevy::prelude::*;
use crate::chat_bubble::BubbleConfig;
use crate::clutter::ClutterConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
@@ -74,6 +75,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// the clutter-density knob — 0 is the client's bare frillDensity baseline (×1 = 16 visits),
// each step +1×; the "2" default IS ClutterConfig's shipped ×3 (the reference's High).
("WorldDetail", "2"),
// The two chat-bubble switches (1139): 1.12's own registrar CVars over the bubble gate,
// which held them as `const bool` from 0598 until this window had a page for them.
// `ChatBubbles` is the reference's registered "1"; `ChatBubblesParty` is ON where the binary
// registers "0" — the director's `/p` ask, mirrored from BubbleConfig::default().
("ChatBubbles", "1"),
("ChatBubblesParty", "1"),
// The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"`
// (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the
// +/- buttons on the minimap do, through `Minimap:SetZoom`, exactly as in the reference, where
@@ -134,6 +141,7 @@ struct Knobs<'a> {
names: &'a mut NameConfig,
clutter: &'a mut ClutterConfig,
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -160,6 +168,9 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
"unitnameown" => knobs.names.own = v != 0.0,
// The two bubble switches (1139) — flags, like every other pair here.
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
// The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12
// slider's own range (an off-grid hand-edit rides between stops, like every slider).
"worlddetail" => knobs.clutter.density = v.clamp(0.0, 2.0) + 1.0,
@@ -195,6 +206,7 @@ fn load_config(
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -206,6 +218,7 @@ fn load_config(
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
};
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
@@ -275,6 +288,7 @@ fn sync_cvars(
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
) {
let Some(mut script) = script else {
return;
@@ -282,7 +296,7 @@ fn sync_cvars(
if !persist.registered {
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 18] = [
let session: [(&str, String); 20] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -302,6 +316,8 @@ fn sync_cvars(
// multiplier seeds off-grid honestly — the dropdown shows the raw number, checks
// nothing (the 0959 out-of-range posture, dropdown-flavored).
("WorldDetail", (clutter.density - 1.0).to_string()),
("ChatBubbles", flag(bubbles.all)),
("ChatBubblesParty", flag(bubbles.party)),
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
];
@@ -327,6 +343,7 @@ fn sync_cvars(
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
@@ -451,6 +468,11 @@ mod tests {
// ClutterConfig::default() reads $WOW_CLUTTER_DENSITY; the registered default mirrors
// the env-less ×3 literal (clutter.rs: "Default ×3 = High") on the panel's 0..2 scale.
assert_eq!(d["WorldDetail"], 2.0);
// The bubble pair (1139) welds to BubbleConfig's defaults — including the "1" that
// deliberately disagrees with the binary's registered `ChatBubblesParty` "0" (0598).
let bubbles = BubbleConfig::default();
assert_eq!(d["ChatBubbles"] != 0.0, bubbles.all);
assert_eq!(d["ChatBubblesParty"] != 0.0, bubbles.party);
// The minimap pair (1131) welds to the widget's own `MINIMAP_DEFAULT_ZOOM`, which is the
// byte-verified registration default `"3"` — one truth, mirrored in three places.
let zoom = MinimapZoom::default();
@@ -476,6 +498,7 @@ mod tests {
fade_far: 70.0,
};
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -486,6 +509,7 @@ mod tests {
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -511,6 +535,11 @@ mod tests {
assert!(!knobs.names.npc);
assert!(apply_to_knobs("unitnameown", "1", &mut knobs));
assert!(knobs.names.own);
// The bubble pair lands on the spawn gate's own knob (1139).
assert!(apply_to_knobs("ChatBubbles", "0", &mut knobs));
assert!(!knobs.bubbles.all);
assert!(apply_to_knobs("chatbubblesparty", "0", &mut knobs));
assert!(!knobs.bubbles.party);
// WorldDetail: panel 0/1/2 → density ×1/×2/×3, clamped to the 1.12 slider's range.
assert!(apply_to_knobs("WorldDetail", "0", &mut knobs));
assert_eq!(knobs.clutter.density, 1.0);
@@ -586,6 +615,7 @@ mod tests {
.init_resource::<NameConfig>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -652,6 +682,7 @@ mod tests {
.init_resource::<NameConfig>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
+50 -21
View File
@@ -10,8 +10,13 @@
//! cameraPitch 13.449968
//! ```
//!
//! Two keys, LF, six decimals, trailing newline; **no yaw** and none of the `SaveView(2..5)` custom
//! views. benilla writes the same two keys in the same order to
//! Two keys, LF, six decimals, trailing newline — VERIFIED at the writer (`0x50c4d0`) and reader
//! (`0x50c5a0`), decision 1138. **No yaw**: the live heading is never persisted anywhere. (The
//! `SaveView(2..5)` custom views *are* persisted, but as archived `config.wtf` CVars —
//! `cameraYaw`/`cameraYawA..D` and their Distance/Pitch — not in this file. 1131 §3 said otherwise;
//! 1138 corrects it. benilla has no `SaveView`, so nothing is owed here yet.)
//!
//! benilla writes the same two keys in the same order to
//! `benilla/camera/<realm>-<character>.txt` ([`crate::local_state::camera_character_path`]) — a file
//! that stays readable beside its ancestor.
//!
@@ -23,7 +28,11 @@
//! **Written at the session edges** — `OnExit(InWorld)` (a `/logout`, a disconnect) and `AppExit`
//! (quit) — the same two edges the saved variables use ([`crate::ui_saved`]), and the same posture
//! the reference has for its caches: no autosave, no dirty bit. A camera pose is a thing you settle
//! into once and leave; there is nothing an intermediate write would preserve.
//! into once and leave; there is nothing an intermediate write would preserve. The reference's write
//! set is the UI-shutdown root set at `0x490bd0`, which is *wider* than ours — it also fires on
//! `/reload`, `/console reloadUI` and a UI-scale change, each of which then reads the file straight
//! back (1138). benilla has no UI reload, so there is no edge here to miss; when one lands it joins
//! this list.
//!
//! **Read once**, when the roster names the character we are entering the world as — the macro/
//! binding load's own seam ([`crate::ui_macro::identity`]). Absent file = the shipped defaults, which
@@ -54,18 +63,17 @@ pub(super) struct CameraPoseFile {
/// The persisted `cameraPitch` → the live [`FlyCam::pitch`].
///
/// The file's unit is **degrees**, and its sign is the camera's *elevation above the character*:
/// positive = the camera sits high and looks down, which is the ordinary over-the-shoulder view.
/// [`FlyCam::pitch`] is radians on the client's *internal* convention, where **negative = looking
/// down** (wow-re `follow-camera`, VERIFIED at the pitch integrate `0x510120` and the ±89.00° clamp
/// `0x8089d8`) — so the two are negatives of each other.
/// **Both halves VERIFIED** at the bytes (decision 1138; 1131 §2.1 had the right conversion for the
/// wrong reason). The file is **degrees**, and the client's own reader is a pure unit conversion
/// with **no sign flip**: `deg × 0.01745329238474369` at `0x50c6f9` on load, `× 57.295780181884766`
/// at `0x50c54f` on save. The client's internal pitch is itself **positive = looking down** — its
/// forward is `(cos y·cos p, sin y·cos p, −sin p)` with `eye = pivot − dist·forward`, so `p > 0`
/// puts the eye above the pivot.
///
/// **Unit and sign are INFERRED** (decision 1131), from the reference's own 26-character `WTF`
/// tree: its saved pitches run ≈ −4.7 … +27.6, overwhelmingly positive, and the view those players
/// left behind is the normal one — slightly *above* the character, looking *down*. Under the
/// internal sign that distribution would mean two dozen players all left the camera pointing at the
/// sky. A wow-re cross-check of the file's reader/writer is dispatched; if it corrects this, these
/// two functions are the whole of the change.
/// The negation below is therefore ours, not the file's: **[`FlyCam::pitch`] is positive = looking
/// UP.** Our forward is `Quat::from_euler(YXZ, yaw, pitch, 0)` applied to `−Z`, whose Y component is
/// `+sin(pitch)`, and `camera::control` seats the camera at `pivot − forward·distance`. Two opposite
/// conventions meeting, which is exactly what these two functions exist to bridge.
fn pitch_from_file(degrees: f32) -> f32 {
(-degrees.to_radians()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT)
}
@@ -86,8 +94,16 @@ fn render(distance: f32, pitch_radians: f32) -> String {
/// Parse the two keys out of the file, each independently optional — a file with only one line
/// restores only that half, and an unknown key is skipped rather than failing the parse (a later
/// build's third key must not cost this build its zoom). Returns `(distance, pitch_radians)`, both
/// already clamped into the ranges the live camera enforces, so a hand edit lands somewhere legal.
/// build's third key must not cost this build its zoom). Returns `(distance, pitch_radians)`.
///
/// Permissive in the same three ways the reference's reader is (`0x50c5a0`, VERIFIED 1138): keys
/// match **case-insensitively** (`SStrCmpI 0x64a4c0`), either line ending is fine (its tokenizer
/// splits on `"\r\n"`; `str::lines` strips the `\r`), and an unrecognised key is silently skipped.
///
/// One deliberate divergence: **we clamp, the client does not.** Its load path writes the parsed
/// float straight into the camera with no bound, so a hand-edited `cameraPitch 400` survives there
/// until the next mouse-look re-clamps it. Ours lands somewhere legal instead — a file is not a
/// gesture, and there is nothing to be faithful *to* in an unreachable pose.
fn parse(text: &str) -> (Option<f32>, Option<f32>) {
let (mut distance, mut pitch) = (None, None);
for line in text.lines() {
@@ -102,10 +118,10 @@ fn parse(text: &str) -> (Option<f32>, Option<f32>) {
if !v.is_finite() {
continue;
}
match key {
KEY_DISTANCE => distance = Some(v.clamp(CAM_DIST_MIN, CAM_DIST_MAX)),
KEY_PITCH => pitch = Some(pitch_from_file(v)),
_ => {}
if key.eq_ignore_ascii_case(KEY_DISTANCE) {
distance = Some(v.clamp(CAM_DIST_MIN, CAM_DIST_MAX));
} else if key.eq_ignore_ascii_case(KEY_PITCH) {
pitch = Some(pitch_from_file(v));
}
}
(distance, pitch)
@@ -231,10 +247,13 @@ mod tests {
}
/// Round trip: what we write parses back to what we had, through the sign flip and both clamps.
///
/// The first assertion is the one 1138 settled: the file's positive pitch is the client's
/// *looking down*, and OUR pitch is positive-up, so the stored sign must invert on the way in.
#[test]
fn the_pose_round_trips() {
let pitch = pitch_from_file(24.2);
assert!(pitch < 0.0, "a positive saved pitch looks DOWN internally");
assert!(pitch < 0.0, "a positive saved pitch looks DOWN for us");
let (d, p) = parse(&render(17.509_666, pitch));
assert_eq!(d, Some(17.509_666));
assert!((p.unwrap() - pitch).abs() < 1e-4, "{p:?} vs {pitch}");
@@ -259,4 +278,14 @@ mod tests {
let (d, p) = parse("cameraDistance banana\nfutureKey 1\n\ncameraPitch nan\n");
assert_eq!((d, p), (None, None), "junk and unknown keys are skipped");
}
/// The reference's reader is case-insensitive on the key (`SStrCmpI`) and takes either line
/// ending (1138). Only a hand edit can produce either — our own writer is exact — but matching
/// it costs nothing and a file we refuse to read is a pose silently lost.
#[test]
fn a_hand_written_file_may_shout_its_keys_and_use_crlf() {
let (d, p) = parse("CAMERADISTANCE 12.5\r\ncamerapitch 10.0\r\n");
assert_eq!(d, Some(12.5));
assert!((p.unwrap() - pitch_from_file(10.0)).abs() < 1e-6);
}
}
+6 -1
View File
@@ -190,7 +190,12 @@ pub(super) fn frame(f: Frame) {
let left = f
.snap
.and_then(|(_, hit)| hit)
.is_some_and(|(_, ny)| ny < super::GROUND_COS)
// **A face the body is touching is not a face it left.** The steep hit must be at a
// *distance* — `d = 0.000` is the body resting flush against a bank, sliding along it, which
// is the ordinary rub the mover is built to do and which reads identically on every other
// term here (`dy = 0`, forward travel, a steep normal below). Eighteen of those in one
// hillside capture buried the one real signature this marker exists to name.
.is_some_and(|(dist, ny)| ny < super::GROUND_COS && dist > 1.0e-3)
// **Flat is the signature — not "anything that isn't descending".** A dive frame is one that
// travels forward and goes *nowhere* vertically while a steep surface sits below it. Written
// as `dy > -eps` this also swallows every frame that is climbing, where not descending is
+231 -65
View File
@@ -22,16 +22,16 @@
//! foot cone ([`FOOT_CONE_HEIGHT`]) is **ridden** up the cone's 61.6° skirt over the frames the
//! gait needs ([`foot_cone_ride`]) — a kerb takes three at a run; only a rise above the cone is
//! the instant pop, committed whole within the frame. Uncertified, nothing rises at all;
//! - a steep face never *lifts* the mover: when the slide's clip would convert a push into
//! upward motion, the face clips as a vertical wall instead ([`steep_wall_plane`]) — you
//! rub along trunks and steep banks, never up them;
//! - a steep face never lifts the mover on a *push*: the horizontal held into the face is
//! stripped before the clip ([`steep_push_stripped`]), so what the face may do is carry the
//! body's own descent down itself — you rub along trunks and steep banks, never up them,
//! and a jump at a hillside costs the jump instead of banking its height;
//! - airborne → gravity carries the arc, with a one-shot nudge to steer a standstill jump;
//! - a fall whose descent stalls (a capsule wedged between steep faces — the
//! tree-pinch funnel) *lands there*: standing, walking control live, instead of hanging in
//! the falling pose forever with mid-air control locked (decisions 0211/0212).
use avian3d::character_controller::move_and_slide::MoveHitData;
use avian3d::math::Dir;
use avian3d::prelude::*;
use bevy::prelude::*;
@@ -535,9 +535,6 @@ pub(crate) fn grounded_step(
StepVerdict::Commit { up, .. } if ride_to.is_none() => Some(up),
_ => None,
};
if popped.is_some() {
eprintln!("POPFIRE");
}
let start = center + Vec3::Y * popped.unwrap_or(0.0);
let mut rode = false;
let out = crate::collision::one_sided::move_and_slide(
@@ -565,10 +562,8 @@ pub(crate) fn grounded_step(
return MoveAndSlideHitResponse::Accept;
}
}
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
if let Ok(wall) = Dir::new(wall) {
*hit.normal = wall;
}
if let Some(pushed) = steep_push_stripped(**hit.normal, *hit.velocity) {
*hit.velocity = pushed;
}
MoveAndSlideHitResponse::Accept
},
@@ -732,7 +727,7 @@ pub(crate) fn grounded_step(
/// **One airborne step, resolved against the world** — the arc's slide and nothing else. No
/// step-up and no election snap: the arc owns its own height (gravity carries it; the landing is
/// next frame's ground probe to call), so the only thing the world may do here is *stop* it. Steep
/// faces get [`steep_wall_plane`]'s treatment, exactly as they do on the ground.
/// faces get [`steep_push_stripped`]'s treatment, exactly as they do on the ground.
///
/// The airborne twin of [`grounded_step`], and shared for the same reason (0059's one controller,
/// every mover): the local controller ([`step`]) calls it for its held/airborne frames, and a
@@ -757,10 +752,8 @@ pub(crate) fn airborne_step(
&MoveAndSlideConfig::default(),
filter,
|hit| {
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
if let Ok(wall) = Dir::new(wall) {
*hit.normal = wall;
}
if let Some(pushed) = steep_push_stripped(**hit.normal, *hit.velocity) {
*hit.velocity = pushed;
}
MoveAndSlideHitResponse::Accept
},
@@ -798,9 +791,8 @@ fn is_steep_face(ny: f32) -> bool {
/// with the vertical-lift projection: keep the horizontal velocity exactly, set the vertical so
/// the motion rides along the plane (`v'·n = 0` — the plane's own clip then passes it
/// untouched). Unreal's `bMaintainHorizontalGroundVelocity` is the same standard treatment.
/// Steep faces stay with [`steep_wall_plane`], airborne contacts keep the true clip (a landing
/// still slides naturally), and any height the ride manufactures is bounded by the end-of-frame
/// snap, which only ever settles onto a walkable floor.
/// Steep faces stay with [`steep_push_stripped`], and any height the ride manufactures is
/// bounded by the end-of-frame snap, which only ever settles onto a walkable floor.
fn walkable_ride_velocity(n: Vec3, v: Vec3) -> Option<Vec3> {
if n.y < GROUND_COS || v.dot(n) >= 0.0 {
return None;
@@ -845,31 +837,53 @@ fn foot_cone_ride(n: Vec3, v: Vec3) -> Option<Vec3> {
Some(Vec3::new(v.x, into * STEP_SLOPE_RATIO, v.z))
}
/// The steep-face wall rule: a steep (non-walkable, non-overhanging) face must never *lift*
/// the mover. Collide-and-slide clips velocity onto each contact plane, and on a tilted plane
/// that clip manufactures upward motion out of a horizontal push (`v'.y − v.y = −(v·n)·n.y`,
/// positive for every opposing contact) — which walked the capsule straight up 50–80° trunks
/// and hillsides, and, while falling with locked forward momentum, cancelled enough of the
/// descent to trip the wedge rest (decisions 0211/0212 modeled a vertical-only fall) into
/// landing mid-face: together, a climbing ratchet. When the true-plane clip would leave the
/// mover moving *upward* (`v'.y > 0`), return the face's vertical-wall flatten to clip against
/// instead: the push slides along the wall line and only the mover's own vertical motion
/// survives. A descending clip (`v'.y ≤ 0`) keeps the true plane — that IS the natural slide
/// down a steep surface; flattening those stalls real falls against the face (the hover the
/// module note warned about). Walkable floors and overhangs (`n.y < 0`) always keep their
/// plane. This is the standard controller treatment (Unreal `HandleSlopeBoosting`, Godot
/// `floor_block_on_wall`); penetration safety is untouched — the slide's sweeps still stop at
/// the real surface, the plane only shapes the deflection.
fn steep_wall_plane(n: Vec3, v: Vec3) -> Option<Vec3> {
/// The steep-face law: **a steep (non-walkable, non-overhanging) face may convert the mover's
/// own descent into a slide down itself; it may never convert the mover's *push* into lift.**
///
/// Collide-and-slide clips velocity onto each contact plane, and on a tilted plane that clip
/// manufactures upward motion out of a horizontal push. Split the result and the two halves have
/// different owners:
///
/// ```text
/// v'.y = v.y·(1 − n.y²) + into·|n.xz|·n.y
/// \_____________/ \______________/
/// geometry's share the push's lift
/// ```
///
/// The first term is the mover's own vertical, shortened because a body sliding down a face of
/// pitch θ keeps `sin²θ` of its descent — that is the slide, and it must survive untouched. The
/// second is manufactured out of the horizontal the player is holding into the hill, and is
/// exactly what walked the capsule up 50–80° trunks and hillsides.
///
/// So this **removes the into-face horizontal and leaves the true plane to clip what remains**,
/// rather than choosing between two planes. A rise carries the body up on its own jump with the
/// push spent against the face; a fall keeps `sin²θ` of its descent and slides *down* the hill.
///
/// The predecessor rule swapped in the face's vertical-wall flatten whenever the clip would
/// leave the mover moving *upward* (`v'.y > 0`), and kept the true plane otherwise — a test on
/// the **sign** of a quantity whose problem is its **origin**. The director's Elwynn capture is
/// what that misses: at 55° a run into the hill cancels 99% of a 4.9 yd/s descent without ever
/// crossing zero, so the flatten declined, the descent stalled, and the wedge rest (decisions
/// 0211/0212, which model a vertical-only fall) read the stall as a landing and stood the body up
/// mid-face. Jump, bank ~1.2 yd, repeat: a climbing ratchet up terrain far too steep to walk.
///
/// Grounded frames are unchanged in outcome — their velocity is horizontal, so stripping the
/// into-face component leaves motion along the wall line, which is what the flatten produced.
/// Walkable floors ride ([`walkable_ride_velocity`]) and overhangs (`n.y < 0`) are ceilings;
/// neither belongs here. Penetration safety is untouched: the sweeps still stop at the real
/// surface, this only shapes the deflection. (Unreal's `HandleSlopeBoosting` and Godot's
/// `floor_block_on_wall` are the same treatment with the same sign-test blind spot.)
fn steep_push_stripped(n: Vec3, v: Vec3) -> Option<Vec3> {
if !is_steep_face(n.y) {
return None;
}
let vn = v.dot(n);
if vn >= 0.0 || v.y - vn * n.y <= 0.0 {
// Steepness bounds the horizontal part below by sin 50°, so the normalize is safe.
let h = Vec3::new(n.x, 0.0, n.z).normalize();
let into = -(v.x * h.x + v.z * h.z);
if into <= 0.0 {
return None;
}
// Steepness bounds the horizontal part below by sin 50°, so the normalize is safe.
Some(Vec3::new(n.x, 0.0, n.z).normalize())
Some(v + h * into)
}
/// What one atomic step-up attempt decided — the structured form of the `step` trace line.
@@ -1782,51 +1796,203 @@ mod tests {
assert_eq!((ride.x, ride.z), (7.0, 0.0));
assert!(ride.y <= 7.0 * 50.0_f32.to_radians().tan() + 1e-3);
assert!(walkable_ride_velocity(face(50.1), v).is_none());
assert!(steep_wall_plane(face(50.1), v).is_some());
assert!(steep_push_stripped(face(50.1), v).is_some());
}
#[test]
fn walking_into_a_steep_face_clips_as_a_wall() {
let wall = steep_wall_plane(face(60.0), Vec3::new(7.0, 0.0, 0.0)).expect("must flatten");
assert_eq!(wall.y, 0.0);
assert!(wall.x < 0.0 && wall.is_normalized());
fn walking_into_a_steep_face_spends_the_push_along_it() {
// The grounded case, unchanged in outcome by the strip: a run straight at a 60° face
// keeps nothing pointing into it, and manufactures no vertical of its own.
let v = Vec3::new(7.0, 0.0, 0.0);
let out = steep_push_stripped(face(60.0), v).expect("must strip");
assert!(out.x.abs() < 1e-6 && out.y == 0.0);
}
#[test]
fn the_wedge_misfire_window_flattens() {
// Falling slowly with locked forward momentum: the true-plane clip would end
// RISING (v'.y = +2.06) — the descent-cancel that tripped the wedge rest.
assert!(steep_wall_plane(face(60.0), Vec3::new(7.0, -1.3, 0.0)).is_some());
fn the_stall_window_is_stripped_not_judged_by_sign() {
// The window the sign test missed, and the ratchet's engine. Falling at 4.9 yd/s into a
// 55° face with a run held into it: the true-plane clip leaves the body descending — so
// the old `v'.y > 0` flatten declined — but at ~1% of what gravity intended, which the
// wedge rest reads as a landing. After the strip, geometry's share is all that is taken.
let (n, v) = (face(55.0), Vec3::new(7.0, -4.9, 0.0));
let raw = v - v.dot(n) * n;
assert!(
raw.y > -0.2,
"the clip must be the near-cancel it was: {:.3}",
raw.y
);
let stripped = steep_push_stripped(n, v).expect("must strip");
let slid = stripped - stripped.dot(n) * n;
let share = slid.y / v.y;
let sin2 = 55.0_f32.to_radians().sin().powi(2);
assert!(
(share - sin2).abs() < 1e-3,
"a stripped fall keeps exactly sin²θ ({sin2:.3}) of its descent: {share:.3}"
);
}
#[test]
fn a_real_fall_keeps_the_true_plane() {
// The natural slide down a steep surface must survive: descent-dominated clips
// stay on the true plane (flattening them hovers the fall mid-face).
assert!(steep_wall_plane(face(60.0), Vec3::new(0.0, -10.0, 0.0)).is_none());
assert!(steep_wall_plane(face(60.0), Vec3::new(7.0, -20.0, 0.0)).is_none());
fn a_real_fall_keeps_its_slide() {
// Nothing to strip when nothing pushes: a plumb fall against the face, and a fall whose
// horizontal runs *away* from it, both keep the true plane and slide naturally.
assert!(steep_push_stripped(face(60.0), Vec3::new(0.0, -10.0, 0.0)).is_none());
assert!(steep_push_stripped(face(60.0), Vec3::new(-7.0, -20.0, 0.0)).is_none());
}
#[test]
fn rising_contacts_flatten_but_a_wall_keeps_own_lift() {
// A jump rising along the face: the flatten removes the face's manufactured
// boost; the mover's own +vy passes through the vertical wall untouched.
fn a_rising_jump_keeps_its_own_lift() {
// A jump rising along the face: the strip spends the push against the hill and the
// mover's own +vy passes through untouched — the face adds nothing to it.
let v = Vec3::new(7.0, 8.0, 0.0);
let wall = steep_wall_plane(face(60.0), v).expect("boost must flatten");
let clipped = v - v.dot(wall) * wall;
assert!((clipped.y - v.y).abs() < 1e-6);
let out = steep_push_stripped(face(60.0), v).expect("boost must strip");
assert!((out.y - v.y).abs() < 1e-6);
let n = face(60.0);
// …and the true plane no longer opposes what is left, so nothing further is clipped.
assert!(out.dot(n) >= -1e-6);
}
/// **The Elwynn hillside the director jump-climbed** — a 55° face (`ny = +0.574`, the normal
/// the capture read off the real slope at WoW `(-9236.8, -341.9, 101.6)`) rising out of flat
/// ground. Too steep to walk by a wide margin, so nothing here may ever end with the body
/// standing part-way up it.
fn world_with_steep_hillside() -> App {
const P: [(f32, f32); 3] = [(-3.0, 0.0), (0.0, 0.0), (2.5, 3.570)];
world_from_profile(&P)
}
/// One airborne frame's readings: `(feet height, vertical velocity, fraction of the descent
/// gravity intended that the frame actually achieved)`.
type AirRow = (f32, f32, f32);
/// Jump into `world` from `start_feet` and fly the arc exactly as the mover flies it —
/// gravity into `vel_y`, then [`airborne_step`] — holding `dir` at a run the whole way.
/// This is the *airborne* half of the mover's loop, the half no fixture here exercised
/// before: every other walker in this module drives [`grounded_step`].
fn jump_into(mut world: App, start_feet: Vec3, dir: Vec3, frames: usize) -> Vec<AirRow> {
world
.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
let capsule = player_capsule();
let filter = SpatialQueryFilter::default();
let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0);
let secs = dt.as_secs_f32();
let mut center = start_feet + Vec3::Y * (CAPSULE_HEIGHT * 0.5);
let mut vel_y = JUMP_SPEED;
(0..frames)
.map(|_| {
vel_y = (vel_y - GRAVITY * secs).max(-TERMINAL_VELOCITY);
let before = center.y;
center = airborne_step(
&ms,
&capsule,
&filter,
center,
dir * 7.0 + Vec3::Y * vel_y,
dt,
);
let intent = vel_y * secs;
let achieved = center.y - before;
// Only a descent has a stall to measure; a rise reports 1.0.
let frac = if intent < 0.0 { achieved / intent } else { 1.0 };
(center.y - CAPSULE_HEIGHT * 0.5, vel_y, frac)
})
.collect::<Vec<_>>()
})
.unwrap()
}
#[test]
fn walkable_overhanging_and_vertical_faces_are_untouched() {
fn a_jump_into_a_steep_hillside_banks_no_height() {
// **The climbing ratchet the director caught** (their capture: ten jumps up an Elwynn
// hillside, +1.2 yd banked each, 100.0 -> 104.6 in half a minute). Jumping at a face too
// steep to walk must cost the jump and return the body where it started: the face may
// convert the body's own descent into a slide down itself, and nothing else.
let arc = jump_into(
world_with_steep_hillside(),
Vec3::new(-0.6, 0.0, 0.0),
Vec3::X,
150,
);
let peak = arc.iter().map(|r| r.0).fold(f32::MIN, f32::max);
let end = arc.last().unwrap().0;
assert!(
peak > 1.0,
"the jump must actually leave the ground: {peak:.3}"
);
assert!(
end < 0.05,
"the arc must end back at the foot of the slope, not banked part-way up it: \
ended at {end:.3} (peak {peak:.3})"
);
// …and it must never *read* as a landing on the way down, which is how the height gets
// banked on screen: [`WEDGE_STILL_FRAMES`] consecutive frames under [`WEDGE_STALL_RATIO`]
// of gravity's intent is what the mover calls a wedge rest, and it stands the body up
// wherever it is. Above the foot of the slope there is nothing here to rest on.
let (mut run, mut worst_run) = (0u8, 0u8);
for r in arc.iter().filter(|r| r.0 > 0.2) {
run = if r.1 < -WEDGE_MIN_FALL && r.2 < WEDGE_STALL_RATIO {
run + 1
} else {
0
};
worst_run = worst_run.max(run);
}
assert!(
worst_run < WEDGE_STILL_FRAMES,
"the descent must never stall long enough to read as a wedge rest: \
{worst_run} consecutive stalled frames"
);
}
#[test]
fn a_steep_face_never_cancels_a_fall() {
// The ratchet's engine, stated where it lives. Collide-and-slide's true-plane clip turns a
// horizontal push into upward motion (`v'.y - v.y = -(v·n)·n.y`), and against a 55° face a
// run into the hill very nearly cancels gravity: the capture's stalled frames descended
// 1-7% of what gravity intended, three in a row, which is what tripped the wedge rest into
// "landed standing" (decisions 0211/0212) part-way up an open hillside.
//
// What geometry may take is bounded: a body sliding down a face of pitch θ keeps
// `sin²θ` of its descent (0.67 at 55°). Anything under that is the push holding the body
// up, not the slope carrying it down.
let arc = jump_into(
world_with_steep_hillside(),
Vec3::new(-0.6, 0.0, 0.0),
Vec3::X,
150,
);
// Frames clear of the flat ground only: once the body is back at the foot of the slope it
// is *standing*, and a standing frame achieving none of gravity's intent is the floor
// doing its job. (This harness is the bare arc — it has no ground probe to tell it that.)
let worst = arc
.iter()
.filter(|r| r.1 < -WEDGE_MIN_FALL && r.0 > 0.2)
.map(|r| r.2)
.fold(f32::MAX, f32::min);
assert!(
worst > 0.5,
"a falling frame against the face must keep its descent (sin²55° = 0.67 of it is \
geometry's share): worst was {worst:.2} of intent"
);
}
#[test]
fn walkable_and_overhanging_faces_are_untouched() {
let push = Vec3::new(7.0, 0.0, 0.0);
// Walkable floor: the slide's ordinary uphill walk.
assert!(steep_wall_plane(face(40.0), push).is_none());
assert!(steep_push_stripped(face(40.0), push).is_none());
// Overhang: the ceiling clip stands as-is.
assert!(steep_wall_plane(Vec3::new(-0.5, -0.7, 0.0).normalize(), push).is_none());
// A true vertical wall manufactures no lift — nothing to fix.
assert!(steep_wall_plane(face(90.0), push).is_none());
assert!(steep_push_stripped(Vec3::new(-0.5, -0.7, 0.0).normalize(), push).is_none());
// A receding face never opposes the motion.
assert!(steep_wall_plane(face(60.0), -push).is_none());
assert!(steep_push_stripped(face(60.0), -push).is_none());
}
#[test]
fn a_vertical_wall_takes_the_whole_push_and_no_more() {
// A true vertical face manufactures no lift under either rule — the strip removes the
// push, and there is no vertical left for the plane to take.
let v = Vec3::new(7.0, -4.0, 0.0);
let out = steep_push_stripped(face(90.0), v).expect("must strip");
assert!(out.x.abs() < 1e-6 && (out.y - v.y).abs() < 1e-6);
}
}
+2 -1
View File
@@ -278,6 +278,7 @@ pub(super) fn feed_chat(
mut channels: ResMut<ChannelState>,
mut names: ResMut<NameCache>,
mut bubbles: ResMut<crate::chat_bubble::BubbleQueue>,
bubble_cfg: Res<crate::chat_bubble::BubbleConfig>,
commands: Res<NetCommands>,
time: Res<Time>,
// The `$`-macro subject seam: monster/BG lines expand against the guid the line is ADDRESSED to,
@@ -442,7 +443,7 @@ pub(super) fn feed_chat(
// The bubble shows the same expanded line the feed does — the reference's
// bubble spawn sits inside this same SMSG display path, downstream of the
// expander, so a `$n` must never survive into it either.
bubbles.push(msg.sender_guid, kind, &text);
bubbles.push(&bubble_cfg, msg.sender_guid, kind, &text);
}
}
Pending::Notice {
@@ -410,6 +410,84 @@ fn shift_click_picks_up_not_uses() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Lock ActionBars** (decision 1136) — the `LOCK_ACTIONBAR` uvar the Options window's Action Bars
/// row and the `TOGGLEACTIONBARLOCK` binding both write, guarding the two drag ends the way the
/// reference does (ActionBarFrame.xml:23-38).
///
/// The teeth are the second half: the reference leaves the shift-click pick-up in `OnClick`
/// UNGUARDED (l.12-22), so a locked bar still yields to the deliberate gesture. Guarding it too
/// would be a "sensible" tightening that silently diverges — and would leave a locked bar with no
/// way to rearrange it at all.
#[test]
fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert_eq!(
s.eval::<String>("return LOCK_ACTIONBAR").unwrap(),
"0",
"the bar ships unlocked, the reference's own default"
);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked bar does not give the action up to a drag"
);
assert!(
s.eval::<bool>("return HasAction(1)").unwrap(),
"slot intact"
);
assert!(
s.take_action_sets().is_empty(),
"and nothing is sent to the server"
);
// Shift-click still picks up — the reference's unguarded fork, and the way out of a locked bar.
s.set_modifiers(true, false, false);
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.cursor_payload().is_some(),
"shift-click is not what the lock stops"
);
// The receiving end is guarded too: the held action cannot be dropped back by a drag…
s.run("BenillaActionButton_OnReceiveDrag(BenillaActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_some(),
"a locked slot refuses the drop"
);
// …and unlocking makes both ends live again.
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("BenillaActionButton_OnReceiveDrag(BenillaActionButton1)")
.unwrap();
assert!(s.cursor_payload().is_none(), "unlocked, the drop lands");
assert_eq!(
s.take_action_sets(),
vec![(1, 0), (1, 111)],
"the shift-pickup's clear and the unlocked drop's set — and nothing from the two \
refused gestures between them"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A physical drag from button 1 onto OCCUPIED button 2: the byte-verified action-bar hop (0218
/// §4) — the displaced action lands on the cursor, TWO independent `action_sets` entries across
/// the one gesture (0218 §4: "a drag-swap is two sends, never atomic").
+64 -6
View File
@@ -206,11 +206,11 @@ fn a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer() {
);
frame(&mut s, 0.1);
assert_eq!(
text(&s, "BuffButton0Duration"),
"",
assert!(
!shown(&s, "BuffButton0Duration"),
"a permanent aura shows no timer (our 'until cancelled')"
);
assert!(shown(&s, "BuffButton1Duration"));
assert_eq!(
text(&s, "BuffButton1Duration"),
"2 m",
@@ -315,11 +315,13 @@ fn an_emptied_bar_hides_every_button() {
frame(&mut s, 0.1);
assert!(shown(&s, "BuffButton0"), "shown while the aura is up");
// The aura drops: the feed pushes an empty list and re-fires. The button hides, timer blanks.
// The aura drops: the feed pushes an empty list and re-fires. The button hides, and so does its
// timer — a HIDDEN region since 1139, where it used to be blanked, so the text it last drew is
// stale and beside the point; what the bar promises is that nothing paints.
push(&mut s, vec![]);
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0"), "hidden once the aura is gone");
assert_eq!(text(&s, "BuffButton0Duration"), "");
assert!(!shown(&s, "BuffButton0Duration"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -374,7 +376,7 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
// where the duration packet's stamp lands a frame later than the descriptor delta.
s.set_auras("player", Some(vec![mark(0.0)])); // icon up, no duration joined yet
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "", "no timer yet");
assert!(!shown(&s, "BuffButton0Duration"), "no timer yet");
s.set_auras("player", Some(vec![mark(60.0)])); // the stamp joins, still no event
frame(&mut s, 0.1);
assert_eq!(
@@ -384,3 +386,59 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`SHOW_BUFF_DURATIONS`** (decision 1139) — 0255 shipped the durations-shown geometry with no
/// switch because there was no panel to hang one on, and said the other branch was already there
/// waiting. This is it: the global (ours "1", the reference's "0") hides the timer text and closes
/// the 15px gutter each row leaves for it, down to the 5px the columns use. What it does NOT touch
/// is the warning flash — with the numbers gone, the pulse is the only thing left saying an aura is
/// about to drop, and the reference pulses from `BuffButton_OnUpdate` regardless of the setting.
#[test]
fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
let mut s = harness();
push(
&mut s,
vec![aura(
1126,
"Mark of the Wild",
"Interface\\Icons\\Spell_Nature_Regeneration",
true,
1,
None,
20.0, // inside the 31s warning window, so the flash is live either way
true,
)],
);
frame(&mut s, 0.1);
assert!(shown(&s, "BuffButton0Duration"), "timers on by default");
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
s.resolve();
let shown_gap = s
.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
.unwrap();
assert!((shown_gap - 15.0).abs() < 1e-3, "gutter: {shown_gap}");
// The switch, applied the way the options row applies it.
s.run("SHOW_BUFF_DURATIONS = \"0\"; BenillaBuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0Duration"), "no timer drawn");
assert!(shown(&s, "BuffButton0"), "the icon stays");
s.resolve();
let hidden_gap = s
.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
.unwrap();
assert!((hidden_gap - 5.0).abs() < 1e-3, "gutter: {hidden_gap}");
assert!(
alpha(&s, "BuffButton0") < 1.0,
"the last-31s pulse is not gated on the timers"
);
// And back: the text is re-written from the poll, not from anything it kept.
s.run("SHOW_BUFF_DURATIONS = \"1\"; BenillaBuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
assert!(shown(&s, "BuffButton0Duration"));
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+503 -23
View File
@@ -158,21 +158,18 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The red Close hides the window on the igMainMenuClose kit, the corner X does not EXIST
/// (0989's directed cut), and a page WITHOUT rows keeps Defaults disabled (Controls has rows
/// since 0961, so the rowless check moved to Interface).
/// The red Close hides the window on the igMainMenuClose kit, and the corner X does not EXIST
/// (0989's directed cut).
///
/// This test used to ride a rowless page here to watch Defaults stay disabled, and the stand-in
/// kept moving as the arc filled pages in — Controls until 0961, Interface until 1136, Social
/// until 1139. **There is no rowless category left**, which is the milestone rather than a gap;
/// the guard itself is pinned by `the_defaults_button_is_armed_by_rows_not_by_a_category` below.
#[test]
fn the_close_button_hides_the_window_and_defaults_is_disabled() {
fn the_close_button_hides_the_window() {
let mut s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap(),
"Defaults is dead on a page with no rows"
);
let _ = s.take_sounds();
s.run("OptionsFrameCloseButton:Click()").unwrap();
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
@@ -550,7 +547,16 @@ fn audio_harness() -> UiScript {
fn combat_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
for file in ["UIParent.xml", "CombatText.xml"] {
load_definers(&s, &["UIParent.xml", "CombatText.xml"]);
harness_on(s)
}
/// The files a saved-variable page's DEFINERS live in, loaded ahead of the window (see
/// `combat_harness`). Every page over the second store needs this: a row captures its default from
/// the global's own file-scope assignment at OnLoad, so that file has to have run first — which is
/// exactly the ordering the real manifest guarantees.
fn load_definers(s: &UiScript, files: &[&str]) {
for file in files {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
@@ -558,13 +564,52 @@ fn combat_harness() -> UiScript {
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
}
/// The Interface page's harness (decision 1136), the same posture as `combat_harness` above: the
/// **real** definers ahead of the window, in the manifest's own order, so each row captures the
/// same file-scope value it captures in the client. `SHOW_NEWBIE_TIPS` rides in on `GameTooltip.xml`
/// which `harness_on` already loads; the two quest globals need their own windows —
/// `MerchantFrame.xml` for the coin helpers both quest files reuse and `ScrollTemplates.xml` for
/// the kit `QuestFrame.xml` inherits from (the same chain `quest_tests`/`questlog_tests` load).
/// `SHOW_BUFF_DURATIONS` arrives with the bar it re-anchors (1139), behind the two files
/// `buff_tests` loads ahead of it — `Cooldown.xml` (every button's child) and `ActionBar.xml`
/// (`BENILLA_FALLBACK_ICON`), themselves behind `UIParent.xml`.
fn interface_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(
&s,
&[
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
"BuffFrame.xml",
"MerchantFrame.xml",
"QuestFrame.xml",
"QuestLogFrame.xml",
],
);
harness_on(s)
}
/// The Action Bars page's harness (decision 1136): `ActionBar.xml` declares `LOCK_ACTIONBAR`, and
/// it needs `UIParent.xml` (the managed bottom stack it seats into) and `Cooldown.xml`
/// (`CooldownFrame_SetTimer`, every button's child) ahead of it — the manifest's own order.
fn actionbars_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(&s, &["UIParent.xml", "Cooldown.xml", "ActionBar.xml"]);
harness_on(s)
}
@@ -605,14 +650,14 @@ fn the_audio_page_reads_the_cvar_table_on_select() {
.eval::<bool>("return OptionsFrameContainerBodyAudioRowEnableAllCheck:GetChecked()")
.unwrap());
// Off to a ROWLESS page: body hidden, Defaults disabled again.
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Off to another page: the Audio body goes away with the selection (the swap is what the
// page loop does, and it is the reason a stale row can never be read from the wrong page).
s.run("OptionsFrameCategoryListRowSocial:Click()").unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocial:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1604,8 +1649,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 14 CVar rows + the Combat page's 14 saved-variable rows (decision 1134).
assert_eq!(checked, 28, "every row but one carries a live 1.12 key");
// 16 CVar rows (the Social page's two bubble switches are 1139's) + the Combat page's 14
// saved-variable rows (1134) + the Interface page's 4 (3 from 1136, Buff Durations 1139) and
// the Action Bars page's 1 (1136).
assert_eq!(checked, 35, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1645,7 +1692,16 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let mut raised = 0;
for page in ["Controls", "Audio", "Graphics", "Nameplates", "Combat"] {
for page in [
"Controls",
"Audio",
"Graphics",
"Nameplates",
"Combat",
"Interface",
"ActionBars",
"Social",
] {
s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
let rows: String = s
@@ -1685,8 +1741,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 14 of the 15 CVar rows, plus all 14 of the Combat page's saved-variable rows (1134).
assert_eq!(raised, 28, "every row but Auto Loot has a 1.12 description");
// 16 of the 17 CVar rows (Social's two are 1139's), plus the Combat page's 14 saved-variable
// rows (1134), the Interface page's 4 (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
assert_eq!(raised, 35, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -1923,3 +1980,426 @@ fn what_the_combat_page_writes_survives_a_restart() {
fresh.errors()
);
}
/// The **Action Bars page** (decision 1136) — one row, over the one global on that group whose
/// store is a uvar at all (the five multibar toggles read a `func`, and our two bottom multibars
/// are unconditionally on). It is also the page whose setting had to be BUILT first: 1134 §3 listed
/// `LOCK_ACTIONBAR` as "not defined, and no guard exists".
///
/// The end-to-end teeth are the last block: the row's write reaches the shipped bar's own drag
/// guard in the same VM. `action_bar_tests`/`pet_bar_tests` own the guard's full behaviour
/// (including the shift-click that deliberately still works); this owns the wire between them.
#[test]
fn the_action_bars_page_locks_the_real_bar() {
let mut s = actionbars_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowActionBars:Click()")
.unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyActionBars:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyActionBarsRowLockActionBarCheck:GetChecked() \
and true or false"
)
.unwrap(),
"unchecked: ActionBar.xml ships the bar unlocked"
);
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyActionBarsRowLockActionBarCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "1");
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
// The real bar, in this same VM, now refuses the drag — the row and the guard are wired.
s.set_action(
1,
Some(benilla_ui::script::ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_none(),
"the page's write reached the bar's guard"
);
// Defaults walks it back to ActionBar.xml's own assignment, and the bar drags again.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "0");
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
.unwrap();
assert!(s.cursor_payload().is_some(), "unlocked again");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Interface page** (decision 1136) — the second store's second page, and the three globals
/// 1134 §3 named as ready: already defined, already consumed, needing only a row. Each box reads
/// its definer's own file-scope assignment, and a click writes the global and touches nothing else.
///
/// None of the three carries an `applyFunc`, and that is the property being asserted at the end:
/// the consumer reads the global as it acts, so the write alone changes behaviour — here, the
/// questgiver's instant-text arm, live on the very next show.
#[test]
fn the_interface_page_writes_the_three_stock_globals() {
let mut s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyInterface:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
// Read: all three ship on — QuestFrame.xml's "1" (the director's pin), QuestLogFrame.xml's "1"
// (the reference's advertised default, as a STRING — see that file on 1.12's own number/string
// break), GameTooltip.xml's "1".
for row in ["RowInstantQuestText", "RowAutoQuestWatch", "RowNewbieTips"] {
assert!(
s.eval::<bool>(&format!(
"return OptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
))
.unwrap(),
"{row} reads its global"
);
}
// Write: the global moves, and the CVar table is never reached.
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyInterfaceRowNewbieTipsCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
s.run("OptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "0");
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
// No applyFunc, because the write IS the apply: the questgiver's fade arm reads the global as
// the panel shows, so turning instant text OFF makes the very next show write on.
s.run("OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"0"
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowInstantQuestText.applyFunc == nil"
)
.unwrap(),
"these three need no apply hook"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults here restores **our** shipped assignments, which is the visible payoff of capturing the
/// default at OnLoad instead of restating it (1134 §1): `QUEST_FADING_DISABLE` ships `"1"` by
/// direction (2026-07-17) where 1.12's own table hand-writes `default = "0"`. The page walks back
/// to the value QuestFrame.xml actually assigns — there is no second copy to disagree with it.
#[test]
fn defaults_on_the_interface_page_restores_our_pin_not_the_references() {
let s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run(
"OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click() \
OptionsFrameContainerBodyInterfaceRowNewbieTipsCheck:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"0"
);
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"1",
"back to OUR default, not the reference's \"0\""
);
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "1");
assert!(s
.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:GetChecked() \
and true or false"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The point of the page: what it writes is remembered. The toggle lands in the saved-variables
/// text under the **reference's** global name — `AUTO_QUEST_WATCH`, which is why 1136 renamed our
/// `BENILLA_AUTO_QUEST_WATCH` onto it — and a fresh tree replaying that text comes up on the
/// player's choice instead of QuestLogFrame.xml's shipped one.
#[test]
fn what_the_interface_page_writes_survives_a_restart() {
let s = interface_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run("OptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
assert!(
saved.contains("AUTO_QUEST_WATCH = \"0\""),
"the toggle is in the saved text under the reference's name:\n{saved}"
);
let fresh = interface_harness();
assert_eq!(
fresh.eval::<String>("return AUTO_QUEST_WATCH").unwrap(),
"1"
);
fresh.run(&saved).unwrap();
assert_eq!(
fresh.eval::<String>("return AUTO_QUEST_WATCH").unwrap(),
"0",
"the saved value wins over the file-scope default"
);
fresh.run("ShowUIPanel(OptionsFrame)").unwrap();
fresh
.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!fresh
.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:GetChecked() \
and true or false"
)
.unwrap());
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// **A saved value that has a SIDE EFFECT has to be applied when the chunk lands.** The whole UI's
/// XML runs before `benilla/saved-variables.lua` executes over it (1128), so every file-scope
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` armed its six event
/// registrations from `SHOW_COMBAT_TEXT = "1"`, and nothing re-ran when the saved `"0"` replaced it.
/// The player's "off" came back on at every restart. 1.12 closes this with a hand-written ladder in
/// `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function calls", UIOptionsFrame.lua
/// l.204-220); we hold each side effect on its own row already, so the window runs them there.
#[test]
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
let mut s = combat_harness();
// What the saved chunk does, verbatim: assign over the file-scope default, then the event.
s.run("SHOW_COMBAT_TEXT = \"0\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event(
"COMBAT_TEXT_UPDATE",
vec![
benilla_ui::script::ScriptValue::Str("DAMAGE".into()),
benilla_ui::script::ScriptValue::Str("17".into()),
],
);
assert!(
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 0")
.unwrap(),
"the saved-off master is armed at load: the registrations were never re-run"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Buff Durations** row (decision 1139) — the first setting in this window whose value has a
/// consequence nothing re-derives on its own, and so the first to carry an `applyFunc`. The timer
/// text needs no hook (the bar re-decides it every frame), but the ROW PITCH is stated once: with
/// timers the buff bar's three rows sit 45px apart, without them 35px — the reference's own two
/// geometries (`BuffButtons_UpdatePositions`). The click has to move it, and so does the saved
/// value landing at load, which is what the VARIABLES_LOADED walk is for.
#[test]
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
let gap = |s: &mut UiScript| -> f64 {
s.resolve();
s.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
.unwrap()
};
let mut s = interface_harness();
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
assert!(
(gap(&mut s) - 15.0).abs() < 1e-3,
"the shipped default is the durations-SHOWN geometry: {}",
gap(&mut s)
);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(s
.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:GetChecked() \
and true or false"
)
.unwrap());
s.run("OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
assert!(
(gap(&mut s) - 5.0).abs() < 1e-3,
"the click closed the timer gutter: {}",
gap(&mut s)
);
// Restart: the fresh tree comes up on the shipped geometry, the chunk replaces the value, and
// VARIABLES_LOADED is what puts the bar where the value says.
let saved = s.saved_variables_text();
assert!(
saved.contains("SHOW_BUFF_DURATIONS = \"0\""),
"the switch is in the saved text:\n{saved}"
);
let mut fresh = interface_harness();
fresh.run(&saved).unwrap();
assert!(
(gap(&mut fresh) - 15.0).abs() < 1e-3,
"the chunk moved the variable, not the bar"
);
fresh.fire_event("VARIABLES_LOADED", vec![]);
assert!(
(gap(&mut fresh) - 5.0).abs() < 1e-3,
"the apply walk re-derives the geometry from the saved value: {}",
gap(&mut fresh)
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// **Every category in this window now leads somewhere** (decision 1139). Social was the last one
/// that opened onto an empty page, and the arc that started at 1134 — rows over the second store —
/// closes here: Controls, Interface, Action Bars, Combat, Social, Nameplates, Graphics and Audio
/// all carry rows, and Keybindings runs its own machinery. A category added without a page fails
/// this, which is the point: the honest tree (0950) is now a property the test holds, not a
/// promise the reader has to check.
///
/// The Defaults guard is keyed on ROWS, not on the category being real, so it is pinned directly
/// rather than through a stand-in page — there is no longer one to borrow.
#[test]
fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
let s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let keys: Vec<String> = s
.eval::<String>("return table.concat(OPTIONS_CATEGORY_KEYS, \",\")")
.unwrap()
.split(',')
.map(str::to_string)
.collect();
assert_eq!(keys.len(), 9, "the nine 1.15.9 categories: {keys:?}");
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
"return OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
))
.unwrap();
assert!(
has_rows || key == "Keybindings",
"{key} opens onto nothing — every category leads somewhere since 1139"
);
s.run(&format!("OptionsFrameCategoryListRow{key}:Click()"))
.unwrap();
assert!(
s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap(),
"{key}: Defaults is live on a page that has something to reset"
);
}
// And the guard itself: a key with no rows behind it leaves Defaults asleep.
s.run("OptionsFrame_SelectCategory(\"NotACategory\")")
.unwrap();
assert!(
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap(),
"Defaults is dead when the selected page has no rows"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Social page** (decision 1139) — the two chat-bubble switches, and the last category in
/// this window to stop opening onto nothing. Both are CVar rows: `chat_bubble.rs` transcribed the
/// client's `ChatBubbles`/`ChatBubblesParty` spawn gate faithfully in 0598 and then froze it at a
/// pair of `const bool`, so this is the action-bar lock's shape again — the knob had to become
/// real before the row could mean anything. The page reads the registered table on select, a
/// click queues the flag the host drains onto `BubbleConfig`, and the two are independent (the
/// client gates party lines on their own CVar, which is why party bubbles survive turning
/// say/yell bubbles off).
#[test]
fn the_social_page_toggles_the_chat_bubble_cvars() {
let mut s = audio_harness();
s.set_cvar_host("ChatBubblesParty", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowSocial:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocial:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
// Read from the table, not from a restated default: bubbles on, party bubbles off.
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ChatBubblesParty".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Turning say/yell bubbles off writes only its own switch — party keeps the value above.
s.run("OptionsFrameContainerBodySocialRowChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ChatBubbles".to_string(), "0".to_string())]
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
// Defaults walks the page back to the registered pair — including the ChatBubblesParty "1"
// that deliberately disagrees with the binary's registered "0" (the director's /p ask, 0598).
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -483,6 +483,57 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// The pet bar honours **Lock ActionBars** too (decision 1136) — the reference's second consumer of
/// the same uvar (`PetActionBarFrame.lua:270/278`), reading the global `ActionBar.xml` declares.
/// Both drag ends refuse while locked; the shift-click pick-up above stays live, exactly as the
/// reference leaves it (l.253-255).
#[test]
fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("BenillaPetActionButton_OnDragStart(BenillaPetActionButton4)")
.unwrap();
assert!(
s.take_pet_set_actions().is_empty(),
"a locked bar sends nothing — the slot was never picked up"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
// Shift-click is still the way through, and then the drop is refused too while locked.
s.set_modifiers(true, false, false);
s.run("BenillaPetActionButton_OnClick(BenillaPetActionButton4, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]],
"shift-click picked it up despite the lock"
);
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
.unwrap();
assert!(
s.take_pet_set_actions().is_empty(),
"…and a locked slot will not take the drop"
);
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
.unwrap();
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(4, CLAW_WORD)]],
"unlocked, the same drop lands"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shift-click is the same pick-up, whichever mouse button carried it — the reference's own fork
/// puts shift above the left/right split, so it never toggles autocast by accident.
#[test]
@@ -531,6 +531,44 @@ fn progress_auto_watches_for_five_minutes() {
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
/// The flag that gates the above is the **reference's** uvar under the reference's name and value
/// shape (decision 1136 — it was `BENILLA_AUTO_QUEST_WATCH`, a boolean, while nothing could change
/// it): `AUTO_QUEST_WATCH`, a `"1"`/`"0"` string, which is what the Interface page's row writes.
/// Off, progress no longer watches anything; back on, it does again.
///
/// The default asserted first is the one 1.12 *advertises* and does not deliver: it assigns the
/// NUMBER `1` (UIOptionsFrame.lua:122) while every consumer compares `== "1"`, so the shipped
/// client's auto-watch is dead until the player toggles the box. Ours ships the string.
#[test]
fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
s.run(r#"AUTO_QUEST_WATCH = "0""#).unwrap();
s.fire_event(
"BENILLA_QUEST_PROGRESS",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"the flag off means progress watches nothing"
);
s.run(r#"AUTO_QUEST_WATCH = "1""#).unwrap();
s.fire_event(
"BENILLA_QUEST_PROGRESS",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors());
}
/// The empty-log v1 simplification (this file's XML header comment): zero entries hides every row,
/// disables Abandon, and shows the centered empty-state message instead of `EmptyQuestLogFrame`.
#[test]
@@ -621,9 +621,17 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// The XML declared exactly the reference's three globals, in its order.
// This XML declared exactly the reference's three globals, in its order. Filtered to the
// trainer's own prefix because the registry belongs to the whole UI: every file the harness
// loads adds its own (GameTooltip.xml's SHOW_NEWBIE_TIPS since 1136), and what the OTHER files
// register is not this test's business.
let declared: Vec<String> = s
.saved_variable_names()
.into_iter()
.filter(|n| n.starts_with("TRAINER_"))
.collect();
assert_eq!(
s.saved_variable_names(),
declared,
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",