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