the client starts remembering: zoom, camera, filters, a Combat page

- minimap zoom and the camera's distance and pitch persist across sessions
- the trainer's point-filter is a saved variable now and survives a relog, resetting per packet like the reference
- the options window gains its Combat page, over rows that can write the UI's own saved globals
- the step-up is a rise, not a jump forward — and the descent cone's cap belongs to the body, on every leg

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-08 17:13:23 +07:00
co-authored by Claude Fable 5
parent 1415f14b8a
commit 8a502fe1d6
25 changed files with 2254 additions and 120 deletions
+21 -2
View File
@@ -42,22 +42,41 @@
COMBAT_TEXT_LOCATIONS = {}
-- The option family's saved-variables (ref UIOptionsFrame.lua l.125-152), at the ref
-- defaults except the master (see the header). No options panel binds them yet — flip in
-- Lua when wanted.
-- defaults except the master (see the header). Each is assigned AT FILE SCOPE and
-- registered on the line under it — the reference's own load order (decision 1128):
-- defaults first, the saved chunk over the top, consumers third; reverse any two and a
-- saved value can never win. The Options window's Combat page drives all fourteen
-- (decision 1134) and takes its Defaults values from these very assignments, read at the
-- row's OnLoad — which runs before the saved chunk — so each default lives in one place,
-- here, and the panel cannot drift from it.
SHOW_COMBAT_TEXT = "1"
RegisterForSave("SHOW_COMBAT_TEXT")
COMBAT_TEXT_SHOW_LOW_HEALTH_MANA = "1"
RegisterForSave("COMBAT_TEXT_SHOW_LOW_HEALTH_MANA")
COMBAT_TEXT_SHOW_AURAS = "1"
RegisterForSave("COMBAT_TEXT_SHOW_AURAS")
COMBAT_TEXT_SHOW_AURA_FADE = "0"
RegisterForSave("COMBAT_TEXT_SHOW_AURA_FADE")
COMBAT_TEXT_SHOW_COMBAT_STATE = "1"
RegisterForSave("COMBAT_TEXT_SHOW_COMBAT_STATE")
COMBAT_TEXT_SHOW_DODGE_PARRY_MISS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_DODGE_PARRY_MISS")
COMBAT_TEXT_SHOW_RESISTANCES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_RESISTANCES")
COMBAT_TEXT_SHOW_REPUTATION = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REPUTATION")
COMBAT_TEXT_SHOW_REACTIVES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REACTIVES")
COMBAT_TEXT_SHOW_FRIENDLY_NAMES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_FRIENDLY_NAMES")
COMBAT_TEXT_SHOW_COMBO_POINTS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_COMBO_POINTS")
COMBAT_TEXT_SHOW_MANA = "0"
RegisterForSave("COMBAT_TEXT_SHOW_MANA")
COMBAT_TEXT_SHOW_HONOR_GAINED = "1"
RegisterForSave("COMBAT_TEXT_SHOW_HONOR_GAINED")
COMBAT_TEXT_FLOAT_MODE = "1"
RegisterForSave("COMBAT_TEXT_FLOAT_MODE")
-- The message words (shipped enUS GlobalStrings values, hardcoded like the rest of our
-- enUS-only data; or-guarded so an earlier definition wins).
+5 -3
View File
@@ -22,9 +22,11 @@
macro; CharacterFrame.xml's note).
- TwentyFourHourTime = 1 is SET here: the reference sets it in Localization.lua's
LocalizeFrames() — this install is enGB, and enGB is a 24-hour locale — called at
VARIABLES_LOADED, which benilla doesn't fire yet (no saved-variables arc; BagFrame.xml's
precedent). So the tooltip reads "21:07" exactly as the reference client on this install
does; the 12-hour AM/PM branches stay verbatim below for the day a locale/CVar drives it.
VARIABLES_LOADED. benilla fires that event since decision 1128 and could carry this as a saved
variable (it is on 1128's follow-on list), but the value here is a LOCALE fact, not a player
toggle: this install is enGB and enGB is a 24-hour locale. So the tooltip reads "21:07" exactly
as the reference client on this install does; the 12-hour AM/PM branches stay verbatim below
for the day a locale/CVar drives it.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
+333 -12
View File
@@ -614,6 +614,15 @@
},
Graphics = { "RowUiScale", "RowWorldDetail" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
-- 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).
Combat = {
"RowCombatText", "RowFloatMode", "RowLowHealthMana", "RowAuras", "RowAuraFade",
"RowCombatState", "RowDodgeParryMiss", "RowResistances", "RowReputation",
"RowReactives", "RowFriendlyNames", "RowComboPoints", "RowEnergyGains",
"RowHonorGained",
},
}
-- `tip` is the GlobalStrings KEY of the row's description (B223, decision 1054) — the key,
@@ -627,6 +636,9 @@
-- (SettingControls.lua l.773 AddSearchTags(name)), uppercased at registration
-- (Blizzard_Settings.lua l.60).
row.searchTag = string.upper(label)
-- Kept, not just applied: the disabled-state font (OptionsRow_SetEnabled) has to pick
-- the SAME size the indent chose, or greying a child row also promotes it.
row.child = child
local l = getglobal(row:GetName() .. "Label")
l:SetText(label)
if child then
@@ -639,6 +651,52 @@
end
end
-- ── The row's STORE: a CVar, or a saved-variable global (decision 1134) ────────────
-- 1.12's own Interface panel has TWO stores, not one: each UIOptionsFrameCheckButtons
-- entry carries either `cvar = "name"` (an engine knob) or `uvar = "GLOBAL"` (a
-- RegisterForSave'd Lua global), and UIOptionsFrame_Load/_Save fork on exactly that field
-- (UIOptionsFrame.lua l.230-377). Values are strings either way — "1"/"0" for a toggle —
-- so only the accessor differs, and every row in this file now reads and writes through
-- this trio instead of calling Get/SetCVar directly.
function OptionsRow_Get(row)
if row.uvar then return getglobal(row.uvar) end
return GetCVar(row.cvar)
end
-- The write. A global's consumers are Lua and do not poll, so a uvar row also runs its
-- `applyFunc` — the reference's own per-uvar side effects (SIMPLE_CHAT → FCF_Set_*,
-- HIDE_PARTY_INTERFACE → RaidOptionsFrame_UpdatePartyFrames, the combat-text family →
-- CombatText_UpdateDisplayedMessages), moved from its batched Okay to our live write
-- because this window commits on the click (0961's grammar), not on a Save button.
function OptionsRow_Set(row, value)
if row.uvar then
setglobal(row.uvar, value)
if row.applyFunc then row.applyFunc() end
return
end
SetCVar(row.cvar, value)
end
-- The registered default — GetCVarDefault's counterpart for a global. `udefault` is the
-- value the defining XML assigned at FILE SCOPE, captured at this row's OnLoad: the whole
-- UI's XML loads before the saved-variables chunk executes over it (decision 1128's load
-- order), so at OnLoad the global still holds its shipped default. That makes the
-- assignment the single source of the default — the panel restates nothing and so cannot
-- drift from it, where the reference's table carries a second hand-written copy.
function OptionsRow_Default(row)
if row.uvar then return row.udefault end
return GetCVarDefault(row.cvar)
end
-- A row whose store is a saved-variable global rather than a CVar. Same signature as
-- OptionsRow_OnLoad with the global's name in the cvar seat; the caller adds `applyFunc`
-- afterwards when the setting needs one.
function OptionsGlobalRow_OnLoad(row, uvar, label, tip, child)
OptionsRow_OnLoad(row, nil, label, tip, child)
row.uvar = uvar
row.udefault = getglobal(uvar)
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,
@@ -688,7 +746,7 @@
function OptionsDropdownEntry_OnClick()
local dd = UIDropDownMenu_GetCurrentDropDown()
local row = dd:GetParent()
SetCVar(row.cvar, this.value)
OptionsRow_Set(row, this.value)
OptionsDropdown_ShowValue(row)
end
@@ -702,7 +760,7 @@
-- actually picks an entry. (A non-numeric stray shows raw and checks nothing.)
function OptionsDropdown_ShowValue(row)
local dd = getglobal(row:GetName() .. "Dropdown")
local v = GetCVar(row.cvar)
local v = OptionsRow_Get(row)
local match
for _, e in ipairs(row.entries) do
if e.value == v then match = e end
@@ -800,9 +858,9 @@
local on = checked
if row.invert then on = not on end
if on then
SetCVar(row.cvar, 1)
OptionsRow_Set(row, "1")
else
SetCVar(row.cvar, 0)
OptionsRow_Set(row, "0")
end
OptionsPage_UpdateDependencies(OptionsFrame.selectedCategory)
end
@@ -828,7 +886,7 @@
end
end
getglobal(control:GetName() .. "Value"):SetText(OptionsRow_FormatValue(row, value))
if row.cvar and not row.refreshing then
if (row.cvar or row.uvar) and not row.refreshing then
-- Two decimals cover every page's grid (the 5% volumes, uiScale's 0.01, whole
-- yards); trailing zeros stripped so a value at the registered default
-- compares equal to it as a STRING and stays out of the config file ("0.40"
@@ -841,14 +899,14 @@
-- slider that rescales itself mid-drag is exactly what this flag
-- exists to prevent. Dragging back onto the committed value clears the
-- pending (era's IsModified: a pending EQUAL to current isn't one).
if str == GetCVar(row.cvar) then
if str == OptionsRow_Get(row) then
row.pending = nil
else
row.pending = str
end
OptionsApply_Update()
else
SetCVar(row.cvar, str)
OptionsRow_Set(row, str)
end
end
end
@@ -865,10 +923,10 @@
-- GAMEFIELD_DESELECT arm (`checked = 1` when the CVar reads "0").
if row.invert then
local checked
if GetCVar(row.cvar) == "0" then checked = 1 end
if OptionsRow_Get(row) == "0" then checked = 1 end
box:SetChecked(checked)
else
box:SetChecked(GetCVar(row.cvar))
box:SetChecked(OptionsRow_Get(row))
end
elseif row.entries then
OptionsDropdown_ShowValue(row)
@@ -877,7 +935,7 @@
row.refreshing = 1
-- Pending first (era GetValue returns the pendingValue while one is
-- staged): coming back to a page with an un-applied edit shows the edit.
slider:SetValue(tonumber(row.pending or GetCVar(row.cvar) or "0") or 0)
slider:SetValue(tonumber(row.pending or OptionsRow_Get(row) or "0") or 0)
row.refreshing = nil
-- SetValue fires only on a CHANGE — write the readout unconditionally,
-- from the slider's OWN value: in range that IS the CVar (SetValue stores
@@ -896,6 +954,25 @@
-- (SoundOptionsFrame_UpdateDependencies disables buttons 2 and 4, Ambience and Error
-- Speech; Enable Music stays live, the panel's own quirk).
function OptionsPage_UpdateDependencies(key)
if key == "Combat" then
-- The 1.12 rule, verbatim (UIOptionsFrame_UpdateDependencies' "Additional combat
-- text dependancies" block): the master off greys EVERY sub-toggle and the scroll
-- dropdown. Combo Points carries the reference's second gate too — it is disabled
-- for anyone who is not a rogue or a druid, since nobody else has combo points.
local on = SHOW_COMBAT_TEXT ~= "0"
local _, class = UnitClass("player")
class = class and string.upper(class)
local combo = class == "ROGUE" or class == "DRUID"
for _, rkey in ipairs(OPTIONS_PAGE_ROWS.Combat) do
if rkey ~= "RowCombatText" then
local live = on
if rkey == "RowComboPoints" and not combo then live = nil end
OptionsRow_SetEnabled(
getglobal("OptionsFrameContainerBodyCombat" .. rkey), live)
end
end
return
end
if key ~= "Audio" then return end
local on = GetCVar("MasterSoundEffects") ~= "0"
local box = OptionsFrameContainerBodyAudioRowEnableAmbienceCheck
@@ -909,6 +986,36 @@
end
end
-- Grey or wake one row, whichever control it wears (the Audio rule above predates this and
-- reaches its one checkbox by name). A dropdown row greys the way 1.12's own
-- OptionsFrame_DisableDropDown does — the capsule's toggle BUTTON disabled so the list
-- cannot open, and its text in GRAY_FONT_COLOR — since our kit's
-- UIDropDownMenu_DisableButton addresses a LIST row (level, id), not the capsule.
function OptionsRow_SetEnabled(row, on)
local name = row:GetName()
local box = getglobal(name .. "Check")
local dd = getglobal(name .. "Dropdown")
if box then
if on then box:Enable() else box:Disable() end
elseif dd then
local text = getglobal(dd:GetName() .. "Text")
if on then
getglobal(dd:GetName() .. "Button"):Enable()
text:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g,
HIGHLIGHT_FONT_COLOR.b)
else
getglobal(dd:GetName() .. "Button"):Disable()
text:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
end
end
local label = getglobal(name .. "Label")
if on then
label:SetFontObject(row.child and "GameFontNormalSmall" or "GameFontNormal")
else
label:SetFontObject(row.child and "GameFontDisableSmall" or "GameFontDisable")
end
end
-- Defaults, live on any page with rows: every row's CVar back to its registered
-- default (era resets per-setting the same way; the era all-pages confirm dialog is
-- not modeled — Defaults resets the VISIBLE page). A pending edit dies with it — the
@@ -918,7 +1025,7 @@
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
row.pending = nil
SetCVar(row.cvar, GetCVarDefault(row.cvar))
OptionsRow_Set(row, OptionsRow_Default(row))
end
OptionsPage_Refresh(key)
end
@@ -956,7 +1063,7 @@
for _, rkey in ipairs(rows) do
local row = getglobal(base .. rkey)
if row.pending then
SetCVar(row.cvar, row.pending)
OptionsRow_Set(row, row.pending)
row.pending = nil
end
end
@@ -1868,6 +1975,214 @@
</Frame>
</Frames>
</Frame>
<!-- The COMBAT page (decision 1134) — the first page whose rows
write saved-variable GLOBALS rather than CVars, and the first
thing that can change any of the ~20 stock globals 1128
ported. It is 1.12's AdvancedOptionsCombatText box, whole: the
master, the scroll-mode dropdown, and the twelve sub-toggles,
in the reference box's own anchor-chain order, with its
GlobalStrings labels and OPTION_TOOLTIP_* keys verbatim and
each sub-toggle at the reference's default (the assignment in
CombatText.xml, which is also where the Defaults button reads
from). Every row runs CombatText_UpdateDisplayedMessages as
its applyFunc — the reference's own post-Save call, moved to
the click because this window writes live (0961). -->
<Frame name="$parentCombat" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowCombatText" 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, "SHOW_COMBAT_TEXT",
"Enable Floating Combat Text", "OPTION_TOOLTIP_SHOW_COMBAT_TEXT")
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<!-- The scroll mode. 1.12's own dropdown, values and all
(UIOptionsFrameCombatTextDropDown_Initialize: "1" up,
"2" down, "3" arc), worn as an era dropdown row. -->
<Frame name="$parentRowFloatMode" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCombatText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCombatText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_FLOAT_MODE",
"Combat Text Float Mode", "OPTION_TOOLTIP_COMBAT_TEXT_MODE", 1)
OptionsDropdownRow_Setup(self, {
{ text = "Scroll Up", value = "1" },
{ text = "Scroll Down", value = "2" },
{ text = "Arc", value = "3" },
})
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLowHealthMana" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFloatMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFloatMode" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA",
"Show Low Mana and Health",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAuras" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLowHealthMana" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLowHealthMana" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURAS",
"Show Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURAS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAuraFade" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAuras" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAuras" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURA_FADE",
"Show Fading Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURA_FADE", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCombatState" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAuraFade" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAuraFade" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_COMBAT_STATE",
"Show Combat State",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBAT_STATE", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowDodgeParryMiss" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCombatState" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCombatState" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS",
"Show Dodge/Parries/Misses",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowResistances" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowDodgeParryMiss" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowDodgeParryMiss" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_RESISTANCES",
"Show Damage Reduction",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_RESISTANCES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowReputation" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowResistances" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowResistances" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_REPUTATION",
"Show Reputation",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REPUTATION", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowReactives" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowReputation" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowReputation" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_REACTIVES",
"Show Reactive Spells and Abilities",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REACTIVES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFriendlyNames" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowReactives" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowReactives" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_FRIENDLY_NAMES",
"Show Friendly Healer Names",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_FRIENDLY_NAMES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<!-- Combo Points carries the reference's SECOND gate: greyed
for anyone who is not a rogue or a druid — see
OptionsPage_UpdateDependencies. -->
<Frame name="$parentRowComboPoints" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFriendlyNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFriendlyNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_COMBO_POINTS",
"Show Combo Points",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBO_POINTS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowEnergyGains" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowComboPoints" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowComboPoints" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_MANA",
"Show Energy Gains",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_MANA", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowHonorGained" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowEnergyGains" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnergyGains" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_HONOR_GAINED",
"Show Honor Gained",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_HONOR_GAINED", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
</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
@@ -2161,6 +2476,12 @@
getglobal(self:GetName() .. "Label"):SetText("Graphics")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadCombat" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Combat"
getglobal(self:GetName() .. "Label"):SetText("Combat")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadNameplates" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Nameplates"
+43 -12
View File
@@ -26,8 +26,11 @@
renders each row by type; a header click folds its group, a service click selects it.
- THE FILTER IS A DROPDOWN. BenillaTrainerFilterDropDown (over UIDropDownMenu.xml's kit) with
three keepShownOnClick check rows (Available/Unavailable/Already Known) driving the engine's
state-visibility filter — replacing the three inline sort checkboxes. "Already Known" starts
OFF (the backport's TRAINER_FILTER_USED default), set once in OnLoad.
state-visibility filter — replacing the three inline sort checkboxes. Its three states are
SAVED VARIABLES (decision 1128): defaults 1/1/0 at file scope — "Already Known" starts OFF,
the shipped 1.12.1 Blizzard_TrainerUI.lua:6-9's own values — restored from
benilla/saved-variables.lua at load, and pushed into the engine on show because the list
builder resets its mask on every packet.
- THE SCROLL BAR IS REAL. BenillaTrainerListScrollFrame (BenillaFauxScrollFrameTemplate): a
draggable Slider bar + up/down step arrows over the fixed 11 rows (the faux-scroll re-binds
rows to a moving offset — it never really scrolls a child). The wheel still lives on the
@@ -72,6 +75,21 @@
local TRAINER_REQ_SKILL_RANK_RED = "%s (|cffff2020%d|r)" -- TRAINER_REQ_SKILL_RANK_RED (3742)
local TRAINER_REQ_ABILITY = "|cffffffff%s|r" -- TRAINER_REQ_ABILITY (3737)
local TRAINER_REQ_ABILITY_RED = "|cffff2020%s|r" -- TRAINER_REQ_ABILITY_RED (3738)
-- ── The state filter's three SAVED VARIABLES (decision 1128) ─────────────────────────────────
-- The reference's own globals, values and mechanism: Blizzard_TrainerUI.lua:6-9 assigns these
-- at FILE SCOPE and its `.toc` declares them `## SavedVariables`; the client executes the
-- saved file over them AFTER this file loads and BEFORE the load event, so a remembered value
-- wins over the default. benilla's ported UI is one FrameXML tree rather than addons, so the
-- declaration rides `RegisterForSave` (FrameXML's own API for its 38 globals) and the same
-- ordering is the app's load seam. File scope is load-bearing: assign these in OnLoad and the
-- default would clobber the restored value every launch.
TRAINER_FILTER_AVAILABLE = 1
TRAINER_FILTER_UNAVAILABLE = 1
TRAINER_FILTER_USED = 0
RegisterForSave("TRAINER_FILTER_AVAILABLE")
RegisterForSave("TRAINER_FILTER_UNAVAILABLE")
RegisterForSave("TRAINER_FILTER_USED")
local LABEL_AVAILABLE = "Available" -- AVAILABLE (84)
local LABEL_UNAVAILABLE = "Unavailable" -- UNAVAILABLE (3840)
local LABEL_USED = "Already Known" -- USED (3868)
@@ -417,11 +435,16 @@
-- appeared to move, and because `this.checked` then read stale-true forever, a filter switched
-- off could never be switched back on.
function BenillaTrainerFilterDropDown_OnClick()
if this.checked then
SetTrainerServiceTypeFilter(this.value, 0)
else
SetTrainerServiceTypeFilter(this.value, 1)
-- The engine filter AND the saved global, together: the global is what survives the
-- restart and what BenillaTrainerFrame_ApplyFilter pushes back after the per-packet
-- reset, so writing only the engine would lose the toggle at the next list (the ref's
-- own setglobal pair, Blizzard_TrainerUI.lua:526-530).
local on = 0
if not this.checked then
on = 1
end
setglobal("TRAINER_FILTER_" .. strupper(this.value), on)
SetTrainerServiceTypeFilter(this.value, on)
BenillaFauxScrollFrame_SetOffset(getglobal("BenillaTrainerListScrollFrame"), 0)
BenillaTrainerFrame_Update()
end
@@ -453,11 +476,18 @@
frame:RegisterEvent("PLAYER_MONEY")
-- The faux-scroll frame repaints the list through this window's Update.
getglobal("BenillaTrainerListScrollFrame").updateFunc = BenillaTrainerFrame_Update
-- The backport's default state filter: Available + Unavailable on, Already Known OFF
-- (TRAINER_FILTER_USED = 0). Set once; the engine persists a user's later toggles.
SetTrainerServiceTypeFilter("available", 1)
SetTrainerServiceTypeFilter("unavailable", 1)
SetTrainerServiceTypeFilter("used", 0)
end
-- Push the three saved globals into the engine — the ref's ClassTrainerFrame <OnShow>
-- (Blizzard_TrainerUI.xml:531-533), and it is NOT redundant: the list builder rewrites the
-- engine's filter mask to its own default on every SMSG_TRAINER_LIST (byte-verified,
-- `0x4d75d9 mov ds:0xb73a1c,3`; decision 1128), so this is what puts the player's remembered
-- choice back. Called on show and at the top of TRAINER_SHOW — our repaint runs before
-- ShowUIPanel, so applying it only in OnShow would leave one frame painted under the reset.
function BenillaTrainerFrame_ApplyFilter()
SetTrainerServiceTypeFilter("available", TRAINER_FILTER_AVAILABLE)
SetTrainerServiceTypeFilter("unavailable", TRAINER_FILTER_UNAVAILABLE)
SetTrainerServiceTypeFilter("used", TRAINER_FILTER_USED)
end
function BenillaTrainerFrame_SetTitle(name)
@@ -471,6 +501,7 @@
function BenillaTrainerFrame_OnEvent(frame)
if event == "TRAINER_SHOW" then
-- Fresh session: top of the list, a first selection (ref ClassTrainerFrame_Show).
BenillaTrainerFrame_ApplyFilter()
BenillaTrainerFrame_SetTitle(arg1)
BenillaFauxScrollFrame_SetOffset(getglobal("BenillaTrainerListScrollFrame"), 0)
if GetTrainerSelectionIndex() == 0 then
@@ -803,7 +834,7 @@
<OnLoad>BenillaTrainerFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaTrainerFrame_OnEvent(self)</OnEvent>
<OnMouseWheel>BenillaTrainerFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>PlaySound("igCharacterInfoOpen")</OnShow>
<OnShow>BenillaTrainerFrame_ApplyFilter(); PlaySound("igCharacterInfoOpen")</OnShow>
<OnHide>CloseTrainer(); PlaySound("igCharacterInfoClose")</OnHide>
</Scripts>
</Frame>
+127 -1
View File
@@ -28,6 +28,7 @@ use std::time::Instant;
use bevy::prelude::*;
use crate::clutter::ClutterConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::LookConfig;
use crate::sound::SoundConfig;
@@ -36,6 +37,7 @@ use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::view::{ViewDistance, FARCLIP_RANGE};
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page
/// actually wires — never ahead of the knob (see the module doc).
@@ -72,6 +74,13 @@ 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 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
// `set_zoom` writes the live index and `CVar::Set`s the CVar in one breath. The knob is
// [`crate::minimap::MinimapZoom`], the widget's live index is seeded from it at UI load.
("minimapZoom", "3"),
("minimapInsideZoom", "3"),
];
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
@@ -124,6 +133,7 @@ struct Knobs<'a> {
loot: &'a mut LootConfig,
names: &'a mut NameConfig,
clutter: &'a mut ClutterConfig,
minimap: &'a mut MinimapZoom,
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -153,11 +163,22 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// 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,
// The two zoom indices (1131) clamp exactly like the client's `set_zoom` (`0x6daa10`:
// clamp at 5) — the widget clamps again on the way in, so a hand-edited level lands
// in range whichever path it takes.
"minimapzoom" => knobs.minimap.outdoor = zoom_index(v),
"minimapinsidezoom" => knobs.minimap.inside = zoom_index(v),
_ => return false,
}
true
}
/// A stored minimap zoom level → a valid index: truncate to int and clamp into
/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp.
fn zoom_index(v: f32) -> u8 {
v.clamp(0.0, f32::from(MINIMAP_ZOOM_LEVELS - 1)) as u8
}
/// Startup: read `benilla/config.toml` (absent file = all defaults, not an error) and apply it
/// to the knob resources — except keys the environment overrides this session (their resources
/// already read the env var in their `Default`s). The VM does not exist yet; [`sync_cvars`]
@@ -173,6 +194,7 @@ fn load_config(
mut loot: ResMut<LootConfig>,
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -183,6 +205,7 @@ fn load_config(
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
};
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
@@ -251,6 +274,7 @@ fn sync_cvars(
mut loot: ResMut<LootConfig>,
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
) {
let Some(mut script) = script else {
return;
@@ -258,7 +282,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); 16] = [
let session: [(&str, String); 18] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -278,6 +302,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()),
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
];
for (name, value) in session {
script.set_cvar_host(name, &value);
@@ -300,6 +326,7 @@ fn sync_cvars(
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
@@ -424,6 +451,12 @@ 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 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();
assert_eq!(d["minimapZoom"], f32::from(zoom.outdoor));
assert_eq!(d["minimapInsideZoom"], f32::from(zoom.inside));
assert_eq!(zoom.outdoor, benilla_ui::widget::MINIMAP_DEFAULT_ZOOM);
}
#[test]
@@ -442,6 +475,7 @@ mod tests {
alpha_ref: 0.5,
fade_far: 70.0,
};
let mut minimap = MinimapZoom::default();
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -451,6 +485,7 @@ mod tests {
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -481,6 +516,14 @@ mod tests {
assert_eq!(knobs.clutter.density, 1.0);
assert!(apply_to_knobs("worlddetail", "7", &mut knobs));
assert_eq!(knobs.clutter.density, 3.0);
// The zoom pair (1131): each index lands on its own field, clamped like `set_zoom`.
assert!(apply_to_knobs("minimapZoom", "5", &mut knobs));
assert_eq!(knobs.minimap.outdoor, 5);
assert_eq!(knobs.minimap.inside, 3, "the two indices are independent");
assert!(apply_to_knobs("minimapinsidezoom", "9", &mut knobs));
assert_eq!(knobs.minimap.inside, MINIMAP_ZOOM_LEVELS - 1);
assert!(apply_to_knobs("minimapZoom", "-2", &mut knobs));
assert_eq!(knobs.minimap.outdoor, 0);
// A bad value is consumed (known key) and the resource keeps its truth.
assert!(apply_to_knobs("uiScale", "banana", &mut knobs));
assert_eq!(knobs.scale.0, 0.9);
@@ -542,6 +585,7 @@ mod tests {
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -575,6 +619,88 @@ mod tests {
std::fs::remove_dir_all(&tmp).ok();
}
/// The minimap's zoom rides the same loop, driven from the **engine** rather than a Lua
/// `SetCVar` (decision 1131): the `+`/`-` buttons call `Minimap:SetZoom`, which writes the live
/// index and its CVar together — and that has to reach the knob and the file exactly like a
/// settings row's write does, or the level is forgotten at the next launch.
#[test]
fn a_minimap_setzoom_reaches_the_knob_and_the_file() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-mmzoom-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _u = EnvGuard::unset("WOW_UI_SCALE");
let _f = EnvGuard::unset("WOW_FARCLIP");
let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
// The previous session left the outdoor map zoomed right in.
crate::local_state::write_atomic(
&tmp.join("config.toml"),
"[cvars]\nminimapZoom = \"5\"\n",
)
.unwrap();
let mut app = App::new();
app.add_plugins(bevy::MinimalPlugins)
.insert_resource(SoundConfig::default())
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
.insert_resource(ViewDistance { farclip: 777.0 })
.init_resource::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
// Startup restored the knob, and the VM's table answers with it — which is what the UI-load
// seam hands to `set_minimap_zoom` when the widget is born.
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 5);
assert_eq!(app.world().resource::<MinimapZoom>().inside, 3);
let seed = {
let z = app.world().resource::<MinimapZoom>();
(z.outdoor, z.inside)
};
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
assert_eq!(script.cvar("minimapZoom").as_deref(), Some("5"));
// The UI-load seam's own order: the widget is born (at its `MinimapState` default),
// THEN the persisted level is pushed into it. Seeding a widget that does not exist yet
// is a no-op — which is exactly why that call sits after `load_ingame_ui`.
script.run(r#"m = CreateFrame("Minimap", "Mini")"#).unwrap();
script.set_minimap_zoom(seed.0, seed.1);
assert_eq!(script.eval::<u8>("return m:GetZoom()").unwrap(), 5);
// The player zooms out two notches with the minimap's own buttons.
script.run("m:SetZoom(m:GetZoom() - 2)").unwrap();
}
app.update();
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 3);
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(
!text.contains("minimapZoom"),
"back at the registered default 3, so it leaves the diff entirely:\n{text}"
);
// …and one more notch out is a real diff again.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("m:SetZoom(1)")
.unwrap();
app.update();
app.world_mut().write_message(AppExit::Success);
app.update();
let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap();
assert!(text.contains("minimapZoom = \"1\""), "{text}");
assert_eq!(app.world().resource::<MinimapZoom>().outdoor, 1);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn the_toml_round_trips() {
let cfg = LocalConfig {
+3
View File
@@ -139,6 +139,7 @@ mod ui_pet_doll;
mod ui_pet_stats;
mod ui_quest;
mod ui_quest_log;
mod ui_saved;
mod ui_script;
mod ui_session;
mod ui_shapeshift;
@@ -228,6 +229,7 @@ use ui_pet_doll::UiPetDollPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_saved::UiSavedPlugin;
use ui_script::UiScriptPlugin;
use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
@@ -782,6 +784,7 @@ pub fn run(build: BuildId) -> AppExit {
// The item-text reader (ItemTextFrame.xml): right-clicked bag letters (mail-made permanent
// copies) read in the reference reader window over the shared ask-once item-text cache.
.add_plugins(UiItemTextPlugin)
.add_plugins(UiSavedPlugin)
.add_plugins(UiTrainerPlugin)
// The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the
// NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node
+28
View File
@@ -80,6 +80,24 @@ pub(crate) fn bindings_character_path(realm: &str, character: &str) -> Option<Pa
home().map(|h| h.join("bindings").join(format!("{key}.txt")))
}
/// `benilla/saved-variables.lua` — the Lua saved-variables file (decision 1128; the
/// `WTF/Account/<ACC>/SavedVariables.lua` analog). Install-scoped, like the reference's account
/// scope: written whole at logout/exit, executed as a chunk at UI load. One file, because our
/// ported UI is one FrameXML tree rather than a set of addons — when third-party addons land they
/// get `benilla/saved/<Addon>.lua`, the reference's per-addon shape.
pub(crate) fn saved_variables_path() -> Option<PathBuf> {
home().map(|h| h.join("saved-variables.lua"))
}
/// `benilla/camera/<realm>-<character>.txt` — the third-person camera pose (decision 1131; the
/// `<Char>/camera-settings.txt` analog, and character-scoped for the same reason it is there: a
/// gnome rogue and a tauren warrior want different zooms). Two lines, the reference's own keys and
/// order, so the file stays readable beside its ancestor.
pub(crate) fn camera_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("camera").join(format!("{key}.txt")))
}
/// Make an arbitrary realm/character name safe as one path component: anything outside
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
/// escape the folder or collide with the path separator.
@@ -165,6 +183,11 @@ mod tests {
macros_account_path(),
Some(tmp.join("benilla/macros/account.txt"))
);
// The saved-variables resident (decision 1128) — one install-scoped file.
assert_eq!(
saved_variables_path(),
Some(tmp.join("benilla/saved-variables.lua"))
);
assert_eq!(
macros_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla/macros/Hydraxian_Waterlords-Probeone.txt"))
@@ -174,6 +197,11 @@ mod tests {
Some(tmp.join("benilla/macros/___evil-a_b.txt")),
"no name can escape the folder"
);
// The camera-pose resident (decision 1131) — character-scoped, same key shape.
assert_eq!(
camera_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla/camera/Hydraxian_Waterlords-Probeone.txt"))
);
// 1 · the explicit override wins over the install root.
let _h2 = EnvGuard::set("BENILLA_HOME", tmp.join("elsewhere").to_str().unwrap());
+29
View File
@@ -42,6 +42,8 @@ use benilla_formats::{tile_to_world, world_to_tile, AreaPoiCatalog, MinimapTrans
use interior::{interior_group_selection, wmo_minimap_stem};
use benilla_ui::widget::MINIMAP_DEFAULT_ZOOM;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::player::Player;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuadMask, UiQuads};
@@ -131,6 +133,32 @@ fn load_tile(asset_server: &AssetServer) -> impl Fn(&str) -> Handle<Image> + '_
#[derive(Resource, Default)]
pub(crate) struct MinimapWidget(pub(crate) Option<MinimapSlot>);
/// The **persisted** half of the minimap zoom (decision 1131) — the client's two CVar objects
/// `minimapZoom` / `minimapInsideZoom`, whose registered default is `"3"` in both cases (wow-re,
/// VERIFIED at the `RegisterCVar 0x63db90` argument slot). The *live* indices are the widget's
/// ([`benilla_ui::widget::MinimapState`]); this is the durable knob [`crate::cvars`] loads out of
/// `config.toml` and saves back into it. It is **read once** — when the in-game UI materializes and
/// the fresh widget is seeded from it (`UiScript::set_minimap_zoom`) — and written whenever
/// `Minimap:SetZoom` reports a new level, which is exactly the client's own split: `set_zoom` writes
/// the live index *and* the CVar, and the minimap reset path re-seeds the index from the CVar.
#[derive(Resource)]
pub(crate) struct MinimapZoom {
/// `minimapZoom` — the outdoor index (the chunk table's).
pub(crate) outdoor: u8,
/// `minimapInsideZoom` — the indoor index (the radius table's), persisted separately so zooming
/// indoors never disturbs the outdoor level.
pub(crate) inside: u8,
}
impl Default for MinimapZoom {
fn default() -> Self {
Self {
outdoor: MINIMAP_DEFAULT_ZOOM,
inside: MINIMAP_DEFAULT_ZOOM,
}
}
}
/// One extracted Minimap widget: where it sits on screen (y-down logical px), its paint key, and
/// its live widget state.
pub(crate) struct MinimapSlot {
@@ -736,6 +764,7 @@ pub(crate) struct MinimapPlugin;
impl Plugin for MinimapPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MinimapWidget>()
.init_resource::<MinimapZoom>()
.init_resource::<MinimapTileCache>()
.init_resource::<blips::MinimapBlipHover>()
.add_systems(Startup, setup_minimap.after(AssetSet::Open))
+4
View File
@@ -32,6 +32,9 @@ use crate::ui_script::PointerOverUi;
mod arc;
pub(crate) mod camera;
// The remembered camera pose (decision 1131) — it lives inside `player/` so it can read the rig's
// own `pub(super)` fields instead of widening them for a module outside.
mod camera_saved;
mod drunk;
mod follow;
@@ -136,6 +139,7 @@ impl Plugin for PlayerPlugin {
app.insert_resource(cam);
}
follow::plugin(app);
camera_saved::plugin(app);
app.init_resource::<camera::LookConfig>();
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world,
+3 -3
View File
@@ -96,8 +96,8 @@ impl PressGesture {
/// factor cap is inferred from the ref-client comparison, not byte-verified — the RE pinned the CVar
/// *defaults* and the 50 hard cap, not the factor slider's own max.) Our starting zoom is 15 — pulled
/// back a bit further than vanilla's own default for a wider view.
const CAM_DIST_MIN: f32 = 0.0;
const CAM_DIST_MAX: f32 = 30.0;
pub(super) const CAM_DIST_MIN: f32 = 0.0;
pub(super) const CAM_DIST_MAX: f32 = 30.0;
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
/// Yards the wheel moves the target per notch — `CameraZoomIn`/`CameraZoomOut`'s default `amount`
/// (VERIFIED 1.0 in `WoW.exe`).
@@ -120,7 +120,7 @@ pub(crate) struct LookConfig {
/// Camera pitch clamp (radians) — **VERIFIED ±89.00°** (`WoW.exe` `0x8089d8`/`0x8089dc` =
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`, wow-re `follow-camera`). A single uniform
/// clamp at every zoom level — the reference has **no** distinct first-person look-down limit.
const CAM_PITCH_LIMIT: f32 = 89.0 * std::f32::consts::PI / 180.0;
pub(super) const CAM_PITCH_LIMIT: f32 = 89.0 * std::f32::consts::PI / 180.0;
/// Camera-collision probe radius (yd): a small sphere swept from the camera pivot toward the desired
/// camera seat each frame. Its radius is the margin kept between the camera and the surface it stops
/// at, so the near plane doesn't poke through the wall. Smaller than the player capsule — the camera
@@ -0,0 +1,262 @@
//! The **camera pose** the client remembers (decision 1131) — orbit distance and pitch, per
//! character. The fourth resident of 0954's folder to be character-scoped, and the smallest: two
//! floats.
//!
//! The reference keeps exactly these two, in `WTF/Account/<ACC>/<REALM>/<CHAR>/camera-settings.txt`
//! — read out of a real 26-character tree (decision 1128's persistence-surface map):
//!
//! ```text
//! cameraDistance 16.068569
//! 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
//! `benilla/camera/<realm>-<character>.txt` ([`crate::local_state::camera_character_path`]) — a file
//! that stays readable beside its ancestor.
//!
//! **Character-scoped, deliberately.** It is where the reference puts it, and it is what the setting
//! means: a gnome and a tauren want different zooms, and a tank and a healer want different pitches.
//! (The minimap's zoom, the other half of 1131, is install-scoped for the same reason the reference
//! makes it a CVar: it is about the map widget, not the character in front of it.)
//!
//! **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.
//!
//! **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
//! is the normal first run.
use std::path::PathBuf;
use bevy::prelude::*;
use crate::char_select::ClientState;
use super::camera::{
CameraControl, FlyCam, WorldCamera, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT,
};
/// The persisted pose's file keys — the reference's own spellings, in the reference's own order.
const KEY_DISTANCE: &str = "cameraDistance";
const KEY_PITCH: &str = "cameraPitch";
/// Which character's file we are on, and where it lives. `identity` doubles as the once-per-
/// character latch (the macro/binding load's pattern), and it is what the *save* keys off — by the
/// time `OnExit(InWorld)` fires, the roster may already have moved on.
#[derive(Resource, Default)]
pub(super) struct CameraPoseFile {
identity: Option<(String, String)>,
path: Option<PathBuf>,
}
/// 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.
///
/// **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.
fn pitch_from_file(degrees: f32) -> f32 {
(-degrees.to_radians()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT)
}
/// The live [`FlyCam::pitch`] → the persisted `cameraPitch` (see [`pitch_from_file`]).
fn pitch_to_file(radians: f32) -> f32 {
-radians.to_degrees()
}
/// Render the pose exactly as the reference's writer does: two keys, one per line, `%f`'s six
/// decimals, LF, trailing newline.
fn render(distance: f32, pitch_radians: f32) -> String {
format!(
"{KEY_DISTANCE} {distance:.6}\n{KEY_PITCH} {:.6}\n",
pitch_to_file(pitch_radians)
)
}
/// 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.
fn parse(text: &str) -> (Option<f32>, Option<f32>) {
let (mut distance, mut pitch) = (None, None);
for line in text.lines() {
let mut it = line.split_whitespace();
let (Some(key), Some(value)) = (it.next(), it.next()) else {
continue;
};
let Ok(v) = value.parse::<f32>() else {
warn!("camera pose: unparseable {key} '{value}' ignored");
continue;
};
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)),
_ => {}
}
}
(distance, pitch)
}
/// Restore the pose once the roster names the character — the same identity the macro and binding
/// loads key off, so all three land on the same character on the same frame it becomes knowable.
///
/// Sets the rig's three distances together (`distance`, `target_distance`, `collision_distance`):
/// seeding only the target would make every login open with a visible glide out from the default 15,
/// which is precisely the "we forgot" the file exists to end.
fn load_camera_pose(
roster: Res<crate::char_select::Roster>,
mut file: ResMut<CameraPoseFile>,
mut rig: ResMut<CameraControl>,
mut cam: Query<&mut FlyCam, With<WorldCamera>>,
) {
let Some(id) = crate::ui_macro::identity(&roster) else {
return;
};
if file.identity.as_ref() == Some(&id) {
return; // already restored for this character
}
let Ok(mut cam) = cam.single_mut() else {
return; // the camera entity is not up yet — try again next frame, identity unlatched
};
file.path = crate::local_state::camera_character_path(&id.0, &id.1);
file.identity = Some(id);
let Some(path) = file.path.clone() else {
return; // hermetic capture, or no install — session-only, defaults stand
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
// Absent is the normal first-run case; anything else is worth a line.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!("camera pose: cannot read {}: {e}", path.display());
return;
}
};
let (distance, pitch) = parse(&text);
if let Some(d) = distance {
rig.distance = d;
rig.target_distance = d;
rig.collision_distance = d;
}
if let Some(p) = pitch {
cam.pitch = p;
}
info!("camera pose: restored from {}", path.display());
}
/// Write the pose from the live rig. Keyed off the path the *load* resolved, not off the roster:
/// on `OnExit(InWorld)` the roster is already unwinding, and the character whose pose this is is the
/// one we logged in as.
fn save(file: &CameraPoseFile, rig: &CameraControl, pitch: f32) {
let Some(path) = &file.path else {
return; // never loaded (glue-only run, hermetic capture) — nothing to write back
};
// `target_distance`, never `distance`: the live one is the collision-pulled arm, so quitting
// with your back to a wall would otherwise save the wall's zoom instead of your own.
let body = render(rig.target_distance, pitch);
if let Err(e) = crate::local_state::write_atomic(path, &body) {
warn!("camera pose: cannot write {}: {e}", path.display());
}
}
/// `OnExit(InWorld)`: `/logout` back to the glue, or a disconnect.
fn save_on_session_end(
file: Res<CameraPoseFile>,
rig: Res<CameraControl>,
cam: Query<&FlyCam, With<WorldCamera>>,
) {
if let Ok(cam) = cam.single() {
save(&file, &rig, cam.pitch);
}
}
/// `AppExit`: quitting the client. Reads the message rather than a state edge because a quit from
/// in-world never leaves `InWorld` ([`crate::ui_saved`]'s same reason).
fn save_on_exit(
file: Res<CameraPoseFile>,
rig: Res<CameraControl>,
cam: Query<&FlyCam, With<WorldCamera>>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
return;
}
if let Ok(cam) = cam.single() {
save(&file, &rig, cam.pitch);
}
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<CameraPoseFile>()
.add_systems(
Update,
// Before the controller reads the rig, so the restored pose is what the first in-world
// frame renders rather than something the player watches glide into place. Not gated on
// capture mode: a capture resolves no state path at all (0954's hermetic rule), so this
// is already inert there.
load_camera_pose
.before(super::control)
.run_if(in_state(ClientState::InWorld)),
)
.add_systems(OnExit(ClientState::InWorld), save_on_session_end)
.add_systems(Update, save_on_exit);
}
#[cfg(test)]
mod tests {
use super::*;
/// The file we write is byte-for-byte the reference's shape: its two keys, its order, its six
/// decimals, LF, trailing newline — checked against a real `camera-settings.txt` from the RE
/// tree (`Account/ONE/VMaNGOS/One`, `16.068569` / `13.449968`).
#[test]
fn the_rendered_file_matches_the_references_shape() {
let text = render(16.068_57, pitch_from_file(13.449_968));
assert_eq!(text, "cameraDistance 16.068569\ncameraPitch 13.449968\n");
}
/// Round trip: what we write parses back to what we had, through the sign flip and both clamps.
#[test]
fn the_pose_round_trips() {
let pitch = pitch_from_file(24.2);
assert!(pitch < 0.0, "a positive saved pitch looks DOWN internally");
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}");
// The reference's one negative sample survives too (camera slightly below, looking up).
let (_, up) = parse("cameraPitch -4.749999\n");
assert!(up.unwrap() > 0.0);
}
/// A hand-edited or truncated file never lands an illegal pose: each key is independent, the
/// distance clamps to the zoom range, the pitch to ±89°, and junk is skipped rather than fatal.
#[test]
fn a_hand_edited_file_cannot_land_an_illegal_pose() {
let (d, p) = parse("cameraDistance 999\ncameraPitch 400\n");
assert_eq!(d, Some(CAM_DIST_MAX));
assert_eq!(p, Some(-CAM_PITCH_LIMIT));
let (d, p) = parse("cameraDistance -3\n");
assert_eq!(
(d, p),
(Some(CAM_DIST_MIN), None),
"one key restores one half"
);
let (d, p) = parse("cameraDistance banana\nfutureKey 1\n\ncameraPitch nan\n");
assert_eq!((d, p), (None, None), "junk and unknown keys are skipped");
}
}
+270 -78
View File
@@ -507,24 +507,43 @@ pub(crate) fn grounded_step(
}
_ => None,
};
if ride_to.is_none() {
if let Some(landed) = attempt.verdict.landed() {
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
// so the slide and the snap below are skipped.
return GroundedStep {
center: landed,
ground: None,
climb: Some(landed.y - center.y),
snap: None,
steep_support: false,
};
}
// **The pop is a RISE, not a jump forward** (decision 1130). 0209 committed the whole maneuver as
// one position — rise, advance the probe's full [`STEP_UP_ADVANCE`], and land on the floor the
// probe found — which put a **1.19 yd horizontal teleport** in every step-up: ten frames' worth
// of travel in one frame, at ten times walking speed. That lurch, not the height, is what the
// director reported as Goldshire's tables feeling teleporty.
//
// The reference does none of it (wow-re `ret2-commit-law.md`, a §5 trio):
// - `max(H·tan50°, r+1/720)` = 1.1917536 is **only ever a sweep distance**. `ebx` @`0x636193`
// has exactly two uses, both the length argument to `0x632ba0`. It is never added to `pos`.
// - the resolver holds `L / t_remaining` invariant across hits, deflections and misses, so the
// frame's **total horizontal is bounded by its own substep budget** — `dx = 1.19` where a
// walking frame is 0.117 cannot arise on this path at all;
// - all seven position writes in `[0x6367b0, 0x637140)` are **relative**. There is no absolute
// snap anywhere in the walk resolver, so landing the body *on* a probe's floor was wrong in
// kind and not merely in magnitude;
// - what the certified arm commits is a free vertical segment of `H` (`0x635d5b` — the response
// is always `(0,0,s)`), after which `0x63694d` restores **the caller's own heading** with the
// budget that is left.
//
// So: rise in place, then let the ordinary slide and the ordinary settle below own the frame,
// exactly as they do on flat ground. The settle cannot strand the body up there — the reference
// sets `0x4000000` *before* computing the settle's reach and only clears it *after*
// (`0x636edf`, inside the settle), so the probe is always at least `H + 1/36` and always reaches
// back past the height just gained: it finds the obstacle's top, or the ground we left.
let popped = match attempt.verdict {
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(
ms,
capsule,
center,
start,
horiz_vel,
dt,
&MoveAndSlideConfig::default(),
@@ -587,10 +606,14 @@ pub(crate) fn grounded_step(
// step-down reach by the same yard (`0x633e35`) so the float still follows the ground down.
// Both halves are here: the reach grows by the offset, and the snap stops that far short of the
// floor. Zero for everyone not hovering, which is the ordinary case and unchanged.
let d = slid - center;
let d = slid - start;
let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO
+ STEP_SNAP_SLACK
+ if support.steep { STEP_UP_HEIGHT } else { 0.0 }
+ if support.steep || popped.is_some() {
STEP_UP_HEIGHT
} else {
0.0
}
+ surface_offset;
let hit = cast(slid, Vec3::NEG_Y * reach);
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
@@ -612,7 +635,7 @@ pub(crate) fn grounded_step(
// the ground being left behind, and it gets no say.
// The height this frame's ride earned, read before the snap can spend any of it. (The two snap
// arms below are mutually exclusive — walkable or steep — so one value serves both.)
let ride_rise = (slid.y - center.y).max(0.0);
let ride_rise = (slid.y - start.y).max(0.0);
// **The step-down follows the surface; it does not leave it** (decision 1127). The reference's
// finalize writes `pos.z -= min(clearance, d_h·1.8493990)` *before* it classifies anything, and a
// steep landing is answered by the multipass rather than by a fall — a `ret 2` continues,
@@ -625,60 +648,74 @@ pub(crate) fn grounded_step(
// `dy=+0.000 dx=0.118` with the surface 0.162 below, then eighteen airborne frames landing 2.1 yd
// downrange. That is the dive.
//
// **The descent is unconditional, it is bounded by the probe itself, and only the *support* is
// conditional.** Byte-exact (`0x636e45`–`0x636e52`, wow-re `step-off-recourse.md` §1): the
// finalize sweeps down by `L` and writes `pos.z -= achieved`, where `achieved` is the sweep's own
// output — the clearance when something is under us, and the **whole of `L`** when nothing is —
// and only *then* does it classify what it found. So the cap and the reach are the same number,
// and there is no second, smaller bound: `L` is how far the body may fall to stay with its
// surface this frame, whatever the surface turns out to be.
// **The reach is how far we can SEE; the descent is how far we may GO — and for a capsule those
// are not the same number** (decision 1132). Byte-exact the finalize is one line
// (`0x636e45`–`0x636e52`, wow-re `step-off-recourse.md` §1): sweep down by `L`, write
// `pos.z -= achieved`, *then* classify. `achieved` is the sweep's own output, so on the reference
// the descent is bounded only by `L` — no second, smaller cap exists anywhere on that path.
//
// 1127 shipped a separate `min(drop, d_h·1.8494 + 1/36)` cap because at the time `reach` also
// carried the bogus flat 2.028 and could not be used as the bound. With the reach corrected
// above, the two are one quantity again — and the difference is exactly what closes the last of
// the dive: on a face **steeper than the cone** the old cap could never keep up (walking off the
// 0.91 yd step, whose face is 66°, the surface drops 0.256 in a frame the cone allowed 0.242, so
// every frame leaked its remainder into a small fall). Once the first frame latches the
// steep-support bit, `L` grows by `H` and the following frames can take the whole clearance.
if let Some(h) = hit.as_ref().filter(|h| h.normal1.y < GROUND_COS) {
// 1129 read that literally and dropped 1127's separate cone cap. The director's eye caught what
// the reading missed: *"the pop down seems too instant while the ref seems more smoothed … the
// ref is def not diving forward of the fence, it's still stepping down."*
//
// **The reason is the shape, and it is 1123's divergence on the down side.** The reference's
// mover is a cone at the foot, so stepping off a fence its **skirt stays in contact with the
// edge** the whole way down — its `achieved` is never large, because something is always just
// beneath it. The descent comes out at the cone's own slope, `d_h·1.8494` a frame, without any
// cap being needed: the geometry is the cap. Our capsule touches the edge side-on and then hangs
// clear, so the identical instruction sees the entire remaining drop and spends it at once. Same
// law, different body, opposite look.
//
// So we port the cone's *effect*, which is what 1123 said we would do for the solid we do not
// have: **one cone's worth of descent per frame.** The deeper reach stays exactly as 1129 left
// it — it is what lets us see the ground and stay grounded on a face steeper than the cone,
// which is what keeps the dive gone. Seeing further and falling further are different questions,
// and only the second one is the director's "instant".
let cone_reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK;
if let Some(h) = hit {
let drop = (h.distance - surface_offset).max(0.0);
slid.y -= drop;
// **Support is earned by descending, not by touching.** A bound alone reads a body *resting*
// on a steep face as supported — standing still the reach collapses to the slack, the resting
// clearance is ~0, and any "is it within reach" test passes — which perches a motionless body
// on a 60° bank forever instead of letting it slide off. That is 1121's named "standing on
// >50° faces" hazard, and it is a worse defect than the dive this recourse exists to fix.
//
// Requiring real downward progress states the actual condition: this is a step-*down* in
// progress, not a resting state. A body standing on a bank, or traversing one across the
// slope, makes no progress and gets nothing. (The reference reaches the same place by a
// different road — its `0x4000000` is set only by a multipass `ret 2`, a full rise-step-settle
// certification that a body with nowhere to descend to cannot pass.)
steep_support = drop > STEP_SNAP_SLACK;
}
// **Nothing under us: descend the whole probe anyway, then let the fall elect.** The same
// `pos.z -= achieved` runs on the no-hit leg with `achieved == L` — wow-re `step-off-recourse.md`
// §1, "no committed record: `pos.z` has ALREADY dropped by the full `L`" — so the frame that
// leaves a ledge starts its fall a probe's-worth lower instead of flat. A ride is exempt: mid-ride
// the body legitimately hangs clear of everything below, and this would spend the climb.
if hit.is_none() && !rode {
slid.y -= (reach - surface_offset).max(0.0);
}
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
// `max(…, 0)` is the reference's, and it is the difference between a float and a pop: the
// snap **only ever descends** (`0x636e8d`–`0x636e9e` skips the write when `L − 1.0 < 0`), so
// a hovering body that is already within its clearance is left where it is rather than
// yanked up to it. The rise to clearance is a separate rate-limited climb — see [`step`].
let drop = (h.distance - surface_offset).max(0.0);
if !(rode && drop >= ride_rise) {
slid.y -= drop;
ground = Some(h.entity);
let walkable = h.normal1.y >= GROUND_COS;
// A ride's height is earned (above): only a floor higher than the ride began on may end it.
if !(rode && walkable && drop >= ride_rise) {
slid.y -= drop.min(cone_reach);
if drop > cone_reach {
// **Further down than the cone could rest, but in sight.** This is a step-down still
// in progress — the reference's `0x4000000` case exactly — so we stay grounded and
// keep the deep reach open, and the next frames finish it a cone's worth at a time.
// Without this the body would be left hanging and the fall would elect, which is the
// dive coming back through the cap.
steep_support = true;
} else if walkable {
ground = Some(h.entity);
} else {
// **Support is earned by descending, not by touching.** Resting *on* a steep face
// gives a clearance of ~0, and standing still the bound is the slack alone — so a
// "within reach" test alone would perch a motionless body on a 60° bank forever
// instead of letting it slide off (1121's named hazard, and worse than the dive).
// Real downward progress states the actual condition: descending, not resting.
steep_support = drop > STEP_SNAP_SLACK;
}
}
} else if !rode {
// **Nothing in sight: descend, then let the fall elect** — the same `pos.z -= achieved` runs
// on the no-hit leg with `achieved == L` ("no committed record: `pos.z` has ALREADY dropped
// by the full `L`"), so the frame that leaves a ledge starts its fall lower instead of flat.
// A ride is exempt: mid-ride the body legitimately hangs clear of everything below, and this
// would spend the climb.
//
// **And it takes the same cone cap as the hit legs, for the same reason.** Left uncapped it
// was harmless only while the reach was a cone's worth anyway — once the steep-support bit
// opens the deep reach, "descend the full `L`" is a 1.25 yd drop in one frame. That is what
// the director was still feeling after 1132 capped the other two legs: their fence
// step-downs came through *this* branch, `snap miss (reach 1.25) dy=-1.250`, five of them in
// one capture. The cap is a property of our body, not of which leg found the floor.
slid.y -= (reach - surface_offset).max(0.0).min(cone_reach);
}
GroundedStep {
center: slid,
// A ride is a climb too — the trace reads the same whichever regime moved the body.
climb: rode.then_some(slid.y - center.y),
// A ride is a climb too, and so is a pop — the trace reads the same whichever regime
// moved the body, and all three are measured from the frame's entry height.
climb: (rode || popped.is_some()).then_some(slid.y - center.y),
// **Supported by a certified steep contact** — the reference's `0x4000000`, whose meaning
// decision 1125 settled. Two ways to earn it and one meaning: mid-ride the body is held up
// by the edge under its skirt (climbing), and mid-step-down it is resting on the surface it
@@ -686,7 +723,7 @@ pub(crate) fn grounded_step(
// straight down and sees only a steep face, so without this the frame reads as airborne and
// gravity takes the body off the surface — the climb undone on the way up, the dive on the
// way down. A walkable floor ends it: ordinary grounding takes over from there.
steep_support: (rode && ground.is_none()) || steep_support,
steep_support: ((rode || popped.is_some()) && ground.is_none()) || steep_support,
ground,
snap,
}
@@ -865,7 +902,10 @@ pub(crate) enum StepVerdict {
ny: f32,
dy: f32,
},
/// Committed: this landing **is** the frame's motion.
/// Certified: the obstacle can be cleared. What the frame then *does* is
/// [`grounded_step`]'s — a foot-cone ride or a pop of `up`, never this probe's own advance
/// (decision 1130). `landed` is where the probe's settle found floor, kept for the trace: it is
/// a **diagnostic**, one full [`STEP_UP_ADVANCE`] downrange, and committing it was the teleport.
Commit {
landed: Vec3,
up: f32,
@@ -874,16 +914,6 @@ pub(crate) enum StepVerdict {
},
}
impl StepVerdict {
/// The committed capsule centre, if this attempt took the frame.
pub(crate) fn landed(self) -> Option<Vec3> {
match self {
StepVerdict::Commit { landed, .. } => Some(landed),
_ => None,
}
}
}
impl std::fmt::Display for StepVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
@@ -1343,6 +1373,168 @@ mod tests {
);
}
#[test]
fn a_step_up_never_outruns_the_frame_it_happens_in() {
// **The pin on decision 1130.** The reference holds `L / t_remaining` invariant across every
// hit, deflection and miss in the walk resolver, so a frame's total horizontal displacement
// is bounded by its own substep budget — `dx = 1.19` where a walking frame is 0.117 "cannot
// arise from this path" (wow-re `ret2-commit-law.md`, claim 2). 0209 committed the probe's
// full advance as motion, which put exactly that lurch in every single step-up.
//
// **A Goldshire table, profiled from the director's own capture** — the shape they reported
// as teleporty. The capture's pop reads `step hit h=+0.73 n=(-0.04,+0.00,+1.00) up=1.00
// fwd=1.19 dy=+0.737 -> COMMIT`, and the frame it produced was `dy=+0.737 dx=1.191`.
//
// The `h=+0.73` is the whole point and it is why this fixture is a **table and not a step**:
// against a capsule a plain vertical riser reports its contact at the hemisphere centre,
// ≈0.33 up, which is inside the foot cone and rides. Only a face whose *lowest* collidable
// part is already above the band contacts high — a top slab overhanging recessed legs, which
// is exactly what a tavern table is. Before this test **no fixture in the suite reached the
// pop regime at all**, which is how 0209's teleport survived every green run for a year.
//
// 0.737 ┌────────────── table top
// 0.62 ├──┐ slab front face ← the contact, above the cone (0.617)
// ┆ └──┐ underside
// 0.0 ┆ │──────── leg, recessed by more than a body radius
// 0.60 0.95
const P: [(f32, f32); 6] = [
(-2.0, 0.0),
(0.95, 0.0),
(0.95, 0.62),
(0.60, 0.62),
(0.60, 0.737),
(3.0, 0.737),
];
let track = world_from_profile(&P)
.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 mut center = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5, 0.0);
let mut support = Support::default();
let mut rows = Vec::new();
for _ in 0..24 {
let g =
grounded_step(&ms, &capsule, &filter, center, Vec3::X * 7.0, dt, support);
rows.push((g.center.x - center.x, g.center.y - CAPSULE_HEIGHT * 0.5));
center = g.center;
support.steep = g.steep_support;
}
rows
})
.unwrap();
let worst = track.iter().map(|r| r.0).fold(0.0_f32, f32::max);
assert!(
worst <= TRAVEL_60FPS + 1.0e-3,
"no frame may travel further than the {TRAVEL_60FPS:.3} yd it asked to walk; worst \
was {worst:.3}\n{track:#?}"
);
let top = track.last().expect("frames").1;
assert!(
top > 0.70,
"and it still has to get onto the 0.737 yd table: ended at {top:.3}\n{track:#?}"
);
}
#[test]
fn stepping_off_a_fence_hugs_the_edge_down_instead_of_dropping_at_once() {
// **The pin on decision 1132**, and the director's report: *"the pop down seems too instant
// while the ref seems more smoothed … the ref is def not diving forward of the fence, it's
// still stepping down."*
//
// A 0.72 yd fence with a ~53° chamfered lip — the geometry their Goldshire capture measured
// (`snap ny=0.628 STEEP` on the lip frame, then the drop). The reference's foot cone keeps
// its skirt on that edge the whole way down and descends at the cone's own slope; our capsule
// hangs clear and, uncapped, spends the entire remaining drop in one frame (0.644 in the
// capture, `reach 1.24`).
//
// Two things must hold together, and only together: **no frame gives up more than a cone's
// worth**, and **the body never leaves the ground on the way down** — a cap alone would just
// be the dive again, one frame later.
const P: [(f32, f32); 5] = [
(-2.0, 0.72),
(0.60, 0.72),
(0.69, 0.60),
(0.692, 0.0),
(3.0, 0.0),
];
let frames = walk_from(
world_from_profile(&P),
Vec3::new(-1.0, 0.72, 0.0),
Vec3::X,
14,
);
let cone = TRAVEL_60FPS * STEP_SLOPE_RATIO + STEP_SNAP_SLACK;
let worst = frames
.windows(2)
.map(|w| w[0].0 - w[1].0)
.fold(0.0_f32, f32::max);
assert!(
worst <= cone + 1.0e-3,
"the step-down must take at most a cone's worth ({cone:.3}) a frame; worst was \
{worst:.3}\n{frames:#?}"
);
let air = frames
.iter()
.position(|&(_, _, steep, walk)| !steep && !walk);
assert!(
air.is_none(),
"and it must stay on the surface the whole way down, not fall the rest: first \
unsupported frame {air:?}\n{frames:#?}"
);
let bottom = frames.last().expect("frames").0;
assert!(
bottom < 0.05,
"and it does have to get all the way down: ended at {bottom:.3}\n{frames:#?}"
);
}
#[test]
fn a_frame_that_finds_nothing_still_spends_only_one_cone() {
// **The branch 1132's first cut missed.** When the probe finds nothing the reference still
// writes `pos.z -= achieved` with `achieved == L` — harmless while `L` is a cone's worth, and
// a **1.25 yd teleport** once the steep-support bit opens the deep reach. That is what the
// director was still feeling after the two hit legs were capped: their fence step-downs came
// through here, `snap miss (reach 1.25) dy=-1.250`, five of them in one capture.
//
// Stated at the branch rather than through a fixture, because reproducing the *miss* needs
// the body clear of the wall it just left, and a profiled fence puts its riser directly under
// the capsule (it hugs that riser down instead — which is `stepping_off_a_fence_hugs_the_edge
// _down…`, the case that does have geometry). Here: carrying the bit, moving, with the floor
// beyond even the deep reach, so the probe cannot hit anything.
const P: [(f32, f32); 2] = [(-2.0, 0.0), (3.0, 0.0)];
let dy = world_from_profile(&P)
.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 center = Vec3::new(0.0, CAPSULE_HEIGHT * 0.5 + 1.6, 0.0);
let g = grounded_step(
&ms,
&capsule,
&filter,
center,
Vec3::X * 7.0,
dt,
Support {
offset: 0.0,
steep: true,
},
);
g.center.y - center.y
})
.unwrap();
let cone = TRAVEL_60FPS * STEP_SLOPE_RATIO + STEP_SNAP_SLACK;
assert!(
-dy <= cone + 1.0e-3,
"a frame that found nothing may still spend only a cone's worth ({cone:.3}); it spent \
{:.3}",
-dy
);
}
#[test]
fn a_motionless_body_is_never_perched_on_a_steep_slope() {
// A 60° bank. Walk onto it, then STOP. Standing still the cone bound collapses to the slack,
@@ -1528,8 +1720,8 @@ mod tests {
})
.unwrap();
assert!(
v.landed().is_none(),
"a face above the rise ceiling must never commit, got {v}"
!matches!(v, StepVerdict::Commit { .. }),
"a face above the rise ceiling must never certify, got {v}"
);
}
+213
View File
@@ -0,0 +1,213 @@
//! The **saved-variables** host side (decision 1128) — the file, the load seam, the write
//! triggers. The engine half (the `RegisterForSave` declaration set and the serializer) is
//! [`benilla_ui::script`]'s `saved` module; this is the part that touches the disk and the clock.
//!
//! **Load** — one chunk, executed straight into the VM immediately after the in-game UI's XML has
//! loaded and before anything runs against it, then `VARIABLES_LOADED`. That ordering is the whole
//! mechanism, byte-verified in wow-re (`system/ui/scratch/savedvariables-protocol.md`): the
//! reference's `AddOn_Load 0x51f240` runs the addon's own files (step 2 — where the file-scope
//! `TRAINER_FILTER_* = 1` defaults are assigned), *then* executes the saved file over the top
//! (step 4), *then* fires the load event (step 6). Defaults first, saved values second, consumers
//! third — reverse any two and the saved value can never win.
//!
//! **Write** — `OnExit(InWorld)` (a `/logout` or a disconnect) and `AppExit`, the two edges our
//! session has. The reference writes from exactly one place, the UI shutdown `0x490bd0`, reached
//! from five roots (logout to character select, quit, disconnect, application exit, `/reload`),
//! with `PLAYER_LOGOUT` fired just before; it has no autosave, no dirty bit, and no Lua binding
//! that can force a write. We keep that shape rather than debouncing like [`crate::cvars`] does:
//! these are a handful of scalars a player toggles a few times a session, and the file is written
//! whole from the live globals, so there is nothing an intermediate write would preserve.
//!
//! Divergence, disclosed (1128): the reference rotates the old file to `.bak` and then truncates
//! in place — a one-shot, not crash safety (`MoveFileW` without `MOVEFILE_REPLACE_EXISTING`, its
//! result discarded, so after the first rotation it overwrites forever, and nothing ever reads a
//! `.bak`). We write through [`crate::local_state::write_atomic`] instead, which is what that dance
//! was reaching for.
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use crate::char_select::ClientState;
pub(crate) struct UiSavedPlugin;
impl Plugin for UiSavedPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnExit(ClientState::InWorld), save_on_session_end)
.add_systems(Update, save_on_exit);
}
}
/// Execute the saved-variables file into the VM and fire `VARIABLES_LOADED`.
///
/// Called from the in-game UI load ([`crate::ui_script`]) at the reference's own seam — after the
/// XML, before anything consumes it. A missing file is the normal first-run case (defaults stand);
/// a malformed one warns and is left on disk untouched, so a hand edit that fails to parse costs
/// this session's settings and not the file.
pub(crate) fn load_saved_variables(script: &mut UiScript) {
// `None` = hermetic capture, or no install — session-only state, and the event below still fires.
if let Some(path) = crate::local_state::saved_variables_path() {
match std::fs::read_to_string(&path) {
Ok(text) => {
if let Err(e) = script.run(&text) {
warn!(
"saved variables: {} did not load ({e}) — running on defaults",
path.display()
);
} else {
info!("saved variables: loaded {}", path.display());
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("saved variables: cannot read {}: {e}", path.display()),
}
}
// `VARIABLES_LOADED` fires whether or not there was a file: it means "the settings are now what
// they are going to be", which is as true on a first run or a hermetic capture as after a real
// restore — the reference fires it as a step of the load sequence, not conditionally. A window
// that waits on it (GameTime.xml, BagFrame.xml) must not be left uninitialized in a capture.
script.fire_event("VARIABLES_LOADED", vec![]);
}
/// Write the file from the live globals. No-op when nothing is registered — the UI never loaded
/// (a glue-only run, a capture), and an empty write would be a wipe rather than a save. The
/// reference *does* delete the file when an addon declares nothing, which is a different case:
/// there, the addon loaded and its declaration list is genuinely empty.
fn save(script: &mut UiScript) {
let names = script.saved_variable_names();
if names.is_empty() {
return;
}
let Some(path) = crate::local_state::saved_variables_path() else {
return;
};
let body = script.saved_variables_text();
for w in script.take_warnings() {
warn!("saved variables: {w}");
}
match crate::local_state::write_atomic(&path, &format!("{HEADER}{body}")) {
Ok(()) => info!(
"saved variables: wrote {} ({} names)",
path.display(),
names.len()
),
Err(e) => warn!("saved variables: cannot write {}: {e}", path.display()),
}
}
/// The file's own header. The reference writes no header at all (its files open with a bare blank
/// line); ours says what the file is, because a visible folder invites a look.
const HEADER: &str = "\
-- benilla saved variables (decision 1128) — the UI's own remembered settings.
-- Written at logout/exit from the live values; executed as a Lua chunk at UI load.
";
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — the reference's
/// logout-to-character-select and disconnect roots.
fn save_on_session_end(script: Option<NonSendMut<UiScript>>) {
if let Some(mut script) = script {
save(&mut script);
}
}
/// `AppExit`: quitting the client — the reference's quit / application-exit roots. Reads the
/// message rather than a state edge because a quit from in-world never leaves `InWorld`.
fn save_on_exit(script: Option<NonSendMut<UiScript>>, mut exits: MessageReader<AppExit>) {
if exits.read().next().is_none() {
return;
}
if let Some(mut script) = script {
save(&mut script);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// A VM with one declared global and a `VARIABLES_LOADED` witness.
fn script(value: &str) -> UiScript {
let s = UiScript::new().unwrap();
s.run(&format!(
"KEPT = {value} RegisterForSave(\"KEPT\") \
VL_SEEN = 0 \
local f = CreateFrame(\"Frame\") \
f:RegisterEvent(\"VARIABLES_LOADED\") \
f:SetScript(\"OnEvent\", function() VL_SEEN = VL_SEEN + 1 end)"
))
.unwrap();
s
}
/// The disk half, end to end: the file lands in the folder, carries its header, and a *different*
/// VM's value is replaced by the saved one — then `VARIABLES_LOADED` fires, once, after it.
#[test]
fn the_file_round_trips_through_the_folder_and_then_fires_variables_loaded() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-sv-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let mut s = script("7");
save(&mut s);
let path = tmp.join("saved-variables.lua");
let text = std::fs::read_to_string(&path).expect("the file was written");
assert!(text.starts_with("-- benilla saved variables"), "{text}");
assert!(text.contains("KEPT = 7"), "{text}");
// The restart: this VM's own default is 1, the file says 7, and the file wins.
let mut fresh = script("1");
load_saved_variables(&mut fresh);
assert_eq!(fresh.eval::<i64>("return KEPT").unwrap(), 7);
assert_eq!(
fresh.eval::<i64>("return VL_SEEN").unwrap(),
1,
"VARIABLES_LOADED fires exactly once, after the chunk"
);
// A malformed file is left alone, and this session simply runs on defaults.
crate::local_state::write_atomic(&path, "KEPT = = 3\n").unwrap();
let mut broken = script("1");
load_saved_variables(&mut broken);
assert_eq!(broken.eval::<i64>("return KEPT").unwrap(), 1);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "KEPT = = 3\n");
std::fs::remove_dir_all(&tmp).ok();
}
/// Hermetic: a capture run neither reads a machine's settings nor writes any back, even with
/// `BENILLA_HOME` pointing somewhere writable (0954's law, and the reason captures are stable).
#[test]
fn a_capture_run_neither_reads_nor_writes() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-svcap-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let _c = EnvGuard::set("WOW_CAPTURE", "ui-saved");
let mut s = script("7");
save(&mut s);
assert!(!tmp.exists(), "a capture must not plant a settings file");
// The load is a no-op too — but the event still fires, so a window waiting on it is not stuck.
let mut fresh = script("1");
load_saved_variables(&mut fresh);
assert_eq!(fresh.eval::<i64>("return KEPT").unwrap(), 1);
assert_eq!(fresh.eval::<i64>("return VL_SEEN").unwrap(), 1);
}
/// Nothing registered = the UI never loaded: writing would be a wipe, so it is skipped.
#[test]
fn an_empty_declaration_set_writes_nothing() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-svempty-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let mut bare = UiScript::new().unwrap();
save(&mut bare);
assert!(!tmp.exists());
}
}
+13 -1
View File
@@ -304,11 +304,23 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
if world.resource::<IngameUiLoaded>().0 || !ui_wanted(world) {
return;
}
let Some(script) = world.remove_non_send_resource::<UiScript>() else {
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
let _ = load_ingame_ui(&script);
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
let zoom = world.resource::<crate::minimap::MinimapZoom>();
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
crate::ui_saved::load_saved_variables(&mut script);
world.insert_non_send_resource(script);
world.resource_mut::<IngameUiLoaded>().0 = true;
}
@@ -542,6 +542,32 @@ fn audio_harness() -> UiScript {
s
}
/// The Combat page's harness (decision 1134): the **real** `CombatText.xml` loaded ahead of the
/// window, in the manifest's own order (it is file 59, OptionsFrame.xml file 281), so the rows
/// capture the same file-scope defaults and the same `applyFunc` they capture in the client —
/// nothing about the family is restated here. `UIParent.xml` comes first because CombatText's
/// strings anchor to it.
fn combat_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
for file in ["UIParent.xml", "CombatText.xml"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
harness_on(s)
}
/// Selecting Audio shows the page body, arms Defaults, and every row reads the CVar table:
/// sliders take the stored value with the era rounded-percent readout, checkboxes take the flag.
/// Leaving the page hides it and puts Defaults back to sleep.
@@ -1578,7 +1604,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
assert_eq!(checked, 14, "every row but one carries a live 1.12 key");
// 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");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1618,7 +1645,7 @@ 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"] {
for page in ["Controls", "Audio", "Graphics", "Nameplates", "Combat"] {
s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
let rows: String = s
@@ -1658,5 +1685,241 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
assert_eq!(raised, 14, "14 of the 15 rows have a 1.12 description");
// 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");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
/// saved-variable GLOBAL rather than a CVar, and so the first thing that can change any of the
/// stock globals 1128 ported. The page is 1.12's AdvancedOptionsCombatText box: it reads the
/// globals on select, a click writes the global (and *nothing* reaches the CVar table), and each
/// write runs the family's `applyFunc` — `CombatText_UpdateDisplayedMessages`, whose visible
/// effect is the per-type `show` flag and the scroll function.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
let mut s = combat_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyCombat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
// Read: each box shows its global, at CombatText.xml's own file-scope value.
for (row, checked) in [
("RowCombatText", true),
("RowAuras", true),
("RowAuraFade", false),
("RowDodgeParryMiss", false),
] {
assert_eq!(
s.eval::<bool>(&format!(
"return OptionsFrameContainerBodyCombat{row}Check:GetChecked() and true or false"
))
.unwrap(),
checked,
"{row} reads its global"
);
}
// The dropdown capsule reads COMBAT_TEXT_FLOAT_MODE = "1" as its named stop.
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyCombatRowFloatModeDropdownText:GetText()"
)
.unwrap(),
"Scroll Up"
);
// Write: the global moves, the CVar table is untouched, and the applyFunc lands.
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyCombatRowDodgeParryMissCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_DODGE_PARRY_MISS")
.unwrap(),
"1"
);
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
assert_eq!(
s.eval::<i64>("return COMBAT_TEXT_TYPE_INFO[\"MISS\"].show or 0")
.unwrap(),
1,
"applyFunc ran: the message type is live now"
);
// The dropdown writes its global and applies too (the scroll function follows the mode).
s.run(
"OptionsFrameContainerBodyCombatRowFloatModeDropdownButton:Click() \
DropDownList1Button2:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"2"
);
assert!(
s.eval::<bool>("return COMBAT_TEXT_SCROLL_FUNCTION == CombatText_StandardScroll")
.unwrap(),
"mode 2 keeps the standard scroll with a downward step (CombatText.xml's own arm)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The master's dependency rule, 1.12's verbatim: `SHOW_COMBAT_TEXT` off greys **every** other
/// row on the page including the dropdown, and back on wakes them — except Combo Points, which
/// carries the reference's second gate and stays greyed for anyone who is not a rogue or druid.
#[test]
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
let mut s = combat_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
let enabled = |s: &mut UiScript, row: &str, control: &str| -> bool {
s.eval::<bool>(&format!(
"return OptionsFrameContainerBodyCombat{row}{control}:IsEnabled() and true or false"
))
.unwrap()
};
// No player class in this VM, so Combo Points is greyed from the start while its siblings
// are live — the two gates are independent.
assert!(enabled(&mut s, "RowAuras", "Check"));
assert!(!enabled(&mut s, "RowComboPoints", "Check"));
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
assert!(
!enabled(&mut s, "RowAuras", "Check"),
"the master off greys every sub-toggle"
);
assert!(
!enabled(&mut s, "RowFloatMode", "DropdownButton"),
"…and the scroll dropdown, so its list cannot even open"
);
assert!(
enabled(&mut s, "RowCombatText", "Check"),
"the master itself stays live — it is the way back"
);
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert!(enabled(&mut s, "RowAuras", "Check"));
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
assert!(
!enabled(&mut s, "RowComboPoints", "Check"),
"the class gate outlives the master's return"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on a saved-variable page restores each global to **the value its own XML assigned at
/// file scope** — captured at the row's OnLoad, which the load order guarantees runs before the
/// saved chunk (1128). The panel restates no defaults of its own, so it cannot drift from
/// CombatText.xml the way the reference's hand-copied `default` field can.
#[test]
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
let s = combat_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
// Move three of them off their shipped values, in both directions and both flavors.
s.run(
"OptionsFrameContainerBodyCombatRowAurasCheck:Click() \
OptionsFrameContainerBodyCombatRowReputationCheck:Click() \
OptionsFrameContainerBodyCombatRowFloatModeDropdownButton:Click() \
DropDownList1Button3:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
"0"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_REPUTATION")
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"3"
);
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_REPUTATION")
.unwrap(),
"0"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyCombatRowFloatModeDropdownText:GetText()"
)
.unwrap(),
"Scroll Up",
"the capsule follows the reset"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The point of the whole page: what it writes is **remembered**. A toggle lands in the
/// saved-variables text (1128's serializer) under its own name, and a fresh VM that executes that
/// text comes up with the player's choice rather than CombatText.xml's shipped default.
#[test]
fn what_the_combat_page_writes_survives_a_restart() {
let s = combat_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
s.run("OptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
assert!(
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
"the toggle is in the saved text:\n{saved}"
);
// The restart: a fresh tree at its shipped defaults, then the saved chunk over the top.
let fresh = combat_harness();
assert_eq!(
fresh
.eval::<String>("return COMBAT_TEXT_SHOW_HONOR_GAINED")
.unwrap(),
"1"
);
fresh.run(&saved).unwrap();
assert_eq!(
fresh
.eval::<String>("return COMBAT_TEXT_SHOW_HONOR_GAINED")
.unwrap(),
"0",
"the saved value wins over the file-scope default"
);
// And the page paints the restored value the next time it is opened.
fresh.run("ShowUIPanel(OptionsFrame)").unwrap();
fresh
.run("OptionsFrameCategoryListRowCombat:Click()")
.unwrap();
assert!(!fresh
.eval::<bool>(
"return OptionsFrameContainerBodyCombatRowHonorGainedCheck:GetChecked() and true or false"
)
.unwrap());
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
@@ -412,3 +412,24 @@ fn the_font_registry_alone_covers_the_whole_bake_plan() {
registry_only.len()
);
}
/// The whole shipped UI survives `VARIABLES_LOADED` (decision 1128) — the event the saved-variables
/// load fires at every launch, and which nothing fired before this arc existed.
///
/// Today no shipped file registers it, so this asserts a clean no-op; the moment one does (the
/// combat-text option family and `TwentyFourHourTime` are next on 1128's list) this is what catches a
/// handler that errors on the one event that runs before any window has been shown.
#[test]
fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "loader errors: {failures:?}");
let _ = s.errors(); // drain anything the load itself logged; this test is about the event
s.fire_event("VARIABLES_LOADED", vec![]);
assert!(
s.errors().is_empty(),
"VARIABLES_LOADED script errors: {:?}",
s.errors()
);
}
@@ -34,6 +34,10 @@ fn load_xml(s: &UiScript, file: &str) -> usize {
/// Load the trainer window + all its deps into a fresh script, screen sized, with every state filter
/// ON (the XML defaults "Already Known" off — the tests want the full tree, deterministic indices).
///
/// The filter's source of truth is the three **saved globals** (decision 1128), which the window
/// pushes into the engine on every show — so a test that wants the full tree sets those, not the
/// engine's own `SetTrainerServiceTypeFilter`, which the next `TRAINER_SHOW` would overwrite.
fn trainer_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -45,9 +49,8 @@ fn trainer_script() -> UiScript {
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set/_Clear/_SetColor live here
load_xml(&s, "TrainerFrame.xml");
s.run(
"SetTrainerServiceTypeFilter('available',1) \
SetTrainerServiceTypeFilter('unavailable',1) \
SetTrainerServiceTypeFilter('used',1)",
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s
@@ -600,3 +603,115 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
assert_eq!(offset(&mut s), 0, "and stops at the top, never past it");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The filter is remembered across a restart** (decision 1128) — the whole persistence path, in
/// one test: the shipped XML declares its three globals for saving at file scope, a dropdown toggle
/// writes the *global* (not only the engine mask), the serialized file carries it, and a fresh VM
/// that loads the same XML and then executes that file comes up with the toggled filter applied.
///
/// It also pins the two halves that are easy to "simplify" apart and silently break: writing only
/// the engine mask in the click handler would lose the choice at the next list packet (the reference
/// rebuilds that mask on every one), and applying the globals only in `<OnShow>` would leave the
/// first painted frame under the reset.
#[test]
fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
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.
assert_eq!(
s.saved_variable_names(),
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"TRAINER_FILTER_USED",
]
);
// Toggle "Unavailable" off through the dropdown row's own handler (pre-click state on `this`).
s.run("this = { value = 'unavailable', checked = 1 }; BenillaTrainerFilterDropDown_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return TRAINER_FILTER_UNAVAILABLE").unwrap(),
0,
"the click must write the SAVED global, not just the engine mask"
);
let text = s.saved_variables_text();
assert!(
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
"the file carries the toggle: {text}"
);
// The restart: a fresh VM with the same XML (file-scope defaults stand), then the saved file
// executed over them, then a trainer opens.
let mut fresh = trainer_script();
fresh.run(&text).unwrap();
fresh.set_money(50);
fresh.set_trainer(Some(menu()));
fresh.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(
!fresh
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"the remembered filter reached the engine"
);
assert_eq!(
fresh.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"Cleave (the unavailable service) is hidden on the very first paint after the restart"
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// A **new list packet resets the engine's filter mask** to the builder's own default — mask 3 at a
/// class/tradeskill/pet trainer, mask 5 (available|used) at a mount trainer — and clears the collapse
/// set, byte-verified (decision 1128). This is the engine half of the pair above: the reset is why
/// the window re-pushes its saved globals on every show.
#[test]
fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
let mut s = trainer_script();
s.set_trainer(Some(menu()));
s.run("BenillaTrainerFrame_ApplyFilter()").unwrap();
// Collapse a group, and turn a state off directly (no global) — both are engine-side state.
s.run("CollapseTrainerSkillLine(1) SetTrainerServiceTypeFilter('used', 1)")
.unwrap();
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
assert!(s.eval::<i64>("return GetNumTrainerServices()").unwrap() < 6);
s.reset_trainer_filter(0);
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap(),
"mask 3: available|unavailable, already-known OFF"
);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('available') == 1")
.unwrap());
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"nothing collapsed any more (6 rows less the already-known service the mask now hides)"
);
// A mount trainer wants available|used instead — what makes a known mount visible at all.
s.reset_trainer_filter(1);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
assert!(!s
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
}
+16 -1
View File
@@ -66,6 +66,11 @@ pub(crate) struct TrainerOpen {
pub(crate) trainer_type: u32,
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
/// A `SMSG_TRAINER_LIST` has landed and the feed has not yet handed it to the engine. Drives the
/// engine's **per-packet** filter/collapse reset ([`UiScript::reset_trainer_filter`]) — the
/// reference's builder rewrites both masks on every list packet, and only on a packet (decision
/// 1128). It is not the same edge as a snapshot change: those re-push the same list.
pub(crate) fresh_list: bool,
}
impl TrainerOpen {
@@ -81,6 +86,7 @@ impl TrainerOpen {
self.trainer_type = trainer_type;
self.services = services;
self.greeting = greeting;
self.fresh_list = true;
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
@@ -89,6 +95,7 @@ impl TrainerOpen {
self.services.clear();
self.trainer_type = 0;
self.greeting.clear();
self.fresh_list = false;
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
@@ -300,7 +307,8 @@ fn snapshot(
#[allow(clippy::too_many_arguments)]
fn feed_trainer(
script: Option<NonSendMut<UiScript>>,
open: Res<TrainerOpen>,
// ResMut only to consume the fresh-packet latch below — the feed never authors trainer content.
mut open: ResMut<TrainerOpen>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
@@ -336,6 +344,13 @@ fn feed_trainer(
let Some(skill_lines) = skill_lines.as_deref() else {
return;
};
// A new list packet resets the state filter and the collapse set in the engine, exactly as the
// reference's builder does (decision 1128) — before the snapshot goes in, so `TRAINER_SHOW`
// finds the reset mask and the window's own show handler pushes the SAVED filter back over it.
if open.fresh_list {
script.reset_trainer_filter(open.trainer_type);
open.fresh_list = false;
}
let fresh = snapshot(
&open,
&spells.catalog,
+22
View File
@@ -93,6 +93,28 @@ impl super::UiScript {
}
}
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
/// minimap's zoom is the case that needs it — the client's `set_zoom` (`0x6da8e0`) writes the live
/// index *and* `CVar::Set`s `minimapZoom`/`minimapInsideZoom` in the same breath, which is exactly
/// why the level survives a restart. So this queues the change like a Lua `SetCVar` would (the host
/// must hear it and dirty the config file), unlike [`super::UiScript::set_cvar_host`], whose whole
/// point is *not* to echo the value it just loaded.
///
/// A name the host never registered is a **silent** no-op here, not a warning: engine writes are
/// code, not UI content, so a miss means this build's host doesn't back the var (a bare test VM) —
/// and the registered set is welded to the code truths by `crate::cvars`'s own test.
pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
let Some(slot) = model.cvars.get_mut(&name.to_ascii_lowercase()) else {
return;
};
if slot.value == value {
return;
}
slot.value = value.clone();
let registered = slot.name.clone();
model.cvar_changes.push((registered, value));
}
/// Push the warn-once for an unknown CVar name into the model's warning stream.
fn warn_unknown(model: &mut Model, name: &str) {
let key = name.to_ascii_lowercase();
+26 -1
View File
@@ -8,6 +8,14 @@
//! (decision 0203). The model attrs (`minimapPlayerModel=`/`minimapArrowModel=`) are not
//! modeled yet — deferred with phase 3's blips.
//!
//! **The level persists** (decision 1131). The client keeps two halves: the live indices
//! (`0x86f698` outdoor / `0x86f69c` indoor — our [`MinimapState`]) and the two CVar objects
//! `minimapZoom`/`minimapInsideZoom` (both registered default `"3"`), which are what `Config.wtf`
//! actually stores. `set_zoom` writes *both*; the minimap reset path (`0x6d9008`–`0x6d901f`)
//! re-seeds the live index from the CVar's parsed int. benilla runs the same two halves at the same
//! seam: `SetZoom` below writes the index and the CVar, and the app seeds the widget from the CVar
//! table once, when the in-game UI materializes ([`super::UiScript::set_minimap_zoom`]).
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for Minimap frames — the same duck-typing posture as StatusBar's.
@@ -57,7 +65,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// inside flag selects, so the +/- buttons zoom the map you're actually looking at and
// each mode keeps its own level.
let clamped = (zoom.max(0.0) as u8).min(MINIMAP_ZOOM_LEVELS - 1);
with_minimap(lua, &this, |m| m.set_active_zoom(clamped))
let inside = with_minimap(lua, &this, |m| {
m.set_active_zoom(clamped);
m.inside
})?;
// …and it persists the level in the same breath: `set_zoom` writes the live index AND
// `CVar::Set`s the matching CVar (`minimapInsideZoom` indoors, `minimapZoom` out), which
// is the whole reason a zoom level survives a restart. The borrow above is released
// before this one — both reach the same `Model` app_data.
super::cvars::set_from_engine(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
if inside {
"minimapInsideZoom"
} else {
"minimapZoom"
},
clamped.to_string(),
);
Ok(())
})?,
)?;
m.set(
+21
View File
@@ -79,6 +79,7 @@ mod pvp;
mod quest;
mod quest_log;
mod region;
mod saved;
mod scrollframe;
mod session;
mod shapeshift;
@@ -278,6 +279,7 @@ impl UiScript {
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
pointer::install(&lua)?;
@@ -375,6 +377,25 @@ impl UiScript {
}
}
/// Seed every Minimap widget's two zoom indices from the persisted levels — the client's
/// minimap reset path copying each CVar object's parsed int into its live index
/// (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`; wow-re
/// `wmo-interior-minimap.md`, VERIFIED). Called **once**, when the in-game UI materializes and
/// the widget exists — not per frame: from then on the widget's index is the live truth and
/// `Minimap:SetZoom` keeps the CVar following it, so a repeated push would fight the +/- buttons.
/// Both indices clamp into `[0, MINIMAP_ZOOM_LEVELS)` exactly like `set_zoom`, so a hand-edited
/// `config.toml` cannot seed an out-of-range level (decision 1131).
pub fn set_minimap_zoom(&mut self, zoom: u8, inside_zoom: u8) {
let top = crate::widget::MINIMAP_ZOOM_LEVELS - 1;
let (zoom, inside_zoom) = (zoom.min(top), inside_zoom.min(top));
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
m.zoom = zoom;
m.inside_zoom = inside_zoom;
}
}
}
/// Load and run a Lua chunk (text-only; the sandbox rejects bytecode). Errors propagate to the
/// caller (a *load-time* error is the caller's to see; *handler* errors during events go to
/// [`UiScript::errors`]).
+5
View File
@@ -215,6 +215,10 @@ pub(crate) struct Model {
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
pub(crate) cvars_warned: HashSet<String>,
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
pub(crate) saved_names: Vec<String>,
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
@@ -868,6 +872,7 @@ impl Model {
cvars: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
saved_names: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
action_states: HashMap::new(),
+317
View File
@@ -0,0 +1,317 @@
//! **Saved variables** — the Lua-level settings that survive a restart (decision 1128).
//!
//! The reference client has *two* mechanisms for this, byte-verified in wow-re
//! (`system/ui/scratch/savedvariables-protocol.md`):
//!
//! 1. **`RegisterForSave("NAME")`** — the Lua API FrameXML uses for its **38** globals
//! (`LOCK_ACTIONBAR`, `CHAT_LOCKED`, the eleven `COMBAT_TEXT_*`, …), written to the flat
//! `WTF/Account/<ACC>/SavedVariables.lua` and read back by a hand-rolled line parser
//! (`0x4913b0`, strings and numbers only) before `VARIABLES_LOADED` fires.
//! 2. **`## SavedVariables` in a `.toc`** — one file per addon, *executed as a Lua chunk*
//! (`AddOn_Load 0x51f240` step 4, before `ADDON_LOADED` at step 6). Exactly one stock addon
//! uses it: `Blizzard_TrainerUI`, for the trainer window's three filter globals.
//!
//! benilla's ported UI is **one FrameXML tree, not addons** ([`crate::script`]'s manifest), so the
//! two collapse into one here: the *declaration* API is (1) — the one our XML can call — and the
//! *load* is (2)'s, a real chunk, because we already have a Lua VM and executing beats a line
//! parser that cannot carry a table. One file, install-scoped (the `benilla/` folder is already
//! per-install, so it IS the reference's account scope — decision 0954). The host side — path,
//! load seam, write triggers — is the app's `ui_saved` module's.
//!
//! **Deliberate divergences from the reference's serializer**, all recorded in 1128: LF and no
//! leading blank line (its `"\r\n"`-per-variable prefix means every file it writes *starts* with a
//! blank line); Rust's shortest round-tripping float formatting instead of `%.16g` (which loses a
//! double needing 17 digits); table entries emitted in a **sorted** order instead of raw `pairs`
//! order, so a settings file diffs cleanly; `\r` escaped rather than written raw (raw CR inside a
//! quoted string is what the reference emits and it does not re-parse); and a cycle is dropped
//! rather than cut by writing a lightuserdata sentinel *into the caller's table*. What is faithful
//! is the grammar — `NAME = value`, `nil` written out, bracketed keys, TAB indent, trailing comma —
//! so a file is readable by, and swappable with, the reference's own.
use std::collections::HashSet;
use std::ffi::c_void;
use std::fmt::Write as _;
use mlua::{Lua, Table, Value};
use super::Model;
/// How deep a table may nest before the serializer gives up. The reference recurses uncapped (only
/// its indent saturates at `0x80`); a settings file is not a place where 32 levels is anything but
/// a bug, and a bound is what keeps a hostile or accidental structure from eating the stack.
const MAX_DEPTH: usize = 32;
impl super::UiScript {
/// The registered names, in registration order — what the host writes out (the reference's
/// own emission order, and stable across runs because the load order is).
pub fn saved_variable_names(&self) -> Vec<String> {
self.model_mut().saved_names.clone()
}
/// Serialize every registered global into the settings file's body, `NAME = value` per line.
///
/// A name whose value cannot round-trip through a Lua chunk (a function, a widget reference,
/// any userdata — and `inf`/`NaN`, which are not Lua literals) is **skipped with a warning**
/// rather than written as something that would fail to load; the reference loses the same set,
/// silently. `nil` IS written (`NAME = nil`), which is how the reference records a toggle that
/// has never been touched.
pub fn saved_variables_text(&self) -> String {
let names = self.saved_variable_names();
let mut out = String::new();
let mut unwritable = Vec::new();
for name in &names {
let value: Value = match self.lua().globals().get(name.as_str()) {
Ok(v) => v,
Err(e) => {
unwritable.push(format!("{name} ({e})"));
continue;
}
};
let mut seen = HashSet::new();
match serialize(&value, 1, &mut seen) {
Some(text) => {
let _ = writeln!(out, "{name} = {text}");
}
None => unwritable.push(name.clone()),
}
}
if !unwritable.is_empty() {
self.model_mut().warnings.push(format!(
"saved variables: not serializable, skipped: {}",
unwritable.join(", ")
));
}
out
}
}
/// One Lua value as the text of a Lua expression, or `None` when it cannot be one.
///
/// `depth` is the indent level of the *contents* of a table at this position (so a top-level value
/// starts at 1); `seen` carries the table identities on the current path — a repeat is a cycle and
/// drops that entry. Note it is the *path*, not every table ever visited: a shared subtable is
/// legitimately written twice (as it must be, since the file has no way to express aliasing).
fn serialize(v: &Value, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
match v {
Value::Nil => Some("nil".to_string()),
Value::Boolean(b) => Some(b.to_string()),
Value::Integer(i) => Some(i.to_string()),
Value::Number(n) => number(*n),
Value::String(s) => Some(quote(&s.to_string_lossy())),
Value::Table(t) => table(t, depth, seen),
// Functions, threads, userdata (every widget reference is one — a frame's Lua value is a
// table whose `[0]` is a lightuserdata handle, RF-0023) cannot be written as a literal.
_ => None,
}
}
/// A Lua number literal. Integral values print without a fractional part (`1`, not `1.0` — the
/// reference's `%.16g` does the same, and our XML compares these against integers); everything
/// else takes Rust's shortest form that round-trips exactly. `inf`/`NaN` have no literal at all.
fn number(n: f64) -> Option<String> {
if !n.is_finite() {
return None;
}
if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
return Some(format!("{}", n as i64));
}
Some(format!("{n}"))
}
/// A quoted Lua string. The reference escapes exactly four characters (`\000`, `\n`, `\"`, `\\`)
/// and writes CR and every high byte raw; we add `\r` (raw CR in a quoted string is not something
/// its own loader could read back) and keep high bytes raw, so localized text stays legible.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\000"),
c => out.push(c),
}
}
out.push('"');
out
}
/// A table constructor: `{\n<tabs>[key] = value,\n<tabs-1>}`. Keys are always bracketed (the
/// reference's shape, and it sidesteps every reserved-word and non-identifier question); entries
/// are **sorted** — integer keys ascending, then strings alphabetically — so the file is stable
/// across runs instead of following Lua's hash order.
fn table(t: &Table, depth: usize, seen: &mut HashSet<*const c_void>) -> Option<String> {
if depth > MAX_DEPTH || !seen.insert(t.to_pointer()) {
return None;
}
let mut ints: Vec<(i64, String)> = Vec::new();
let mut strs: Vec<(String, String)> = Vec::new();
for pair in t.pairs::<Value, Value>() {
let Ok((k, v)) = pair else { continue };
let Some(value) = serialize(&v, depth + 1, seen) else {
continue; // an unserializable entry drops, like the reference's
};
match k {
Value::Integer(i) => ints.push((i, value)),
// A float key that is integral is the same slot as the integer in Lua 5.1.
Value::Number(n) if n.fract() == 0.0 => ints.push((n as i64, value)),
Value::String(s) => strs.push((s.to_string_lossy(), value)),
_ => continue, // a table/bool/function key cannot be written as a literal
}
}
seen.remove(&t.to_pointer());
ints.sort_by_key(|(k, _)| *k);
strs.sort_by(|a, b| a.0.cmp(&b.0));
let indent = "\t".repeat(depth);
let close = "\t".repeat(depth.saturating_sub(1));
let mut out = String::from("{\n");
for (k, v) in ints {
let _ = writeln!(out, "{indent}[{k}] = {v},");
}
for (k, v) in strs {
let _ = writeln!(out, "{indent}[{}] = {v},", quote(&k));
}
out.push_str(&close);
out.push('}');
Some(out)
}
/// Register the `RegisterForSave` global.
///
/// Divergence, disclosed: the reference's binding is **taint-gated to Blizzard code**
/// (`0x4884e0`), because a third-party addon writing into the shared account file would be a
/// security surface. benilla has no taint model yet and loads no third-party addons, so the gate
/// has nothing to gate; when addon loading lands, this is one of the bindings that needs it (and
/// an addon's own `## SavedVariables` file is the mechanism it should use instead — the `.toc`
/// directive [`crate::toc::Toc::list`] already parses).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"RegisterForSave",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.saved_names.contains(&name) {
model.saved_names.push(name);
}
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The round trip that is the whole point: what the writer emits, the loader's own chunk
/// restores — through a fresh VM, exactly as a restart does.
#[test]
fn the_declared_globals_round_trip_through_a_fresh_vm() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
TRAINER_FILTER_AVAILABLE = 1
TRAINER_FILTER_UNAVAILABLE = 0
NEVER_TOUCHED = nil
CHAT_LABEL = "say \"hi\"\nnow"
OPACITY = 0.5
SPELLBOOK_PAGENUMBERS = { [2] = 3, [1] = 1, ["odd key"] = { [1] = true } }
RegisterForSave("TRAINER_FILTER_AVAILABLE")
RegisterForSave("TRAINER_FILTER_UNAVAILABLE")
RegisterForSave("NEVER_TOUCHED")
RegisterForSave("CHAT_LABEL")
RegisterForSave("OPACITY")
RegisterForSave("SPELLBOOK_PAGENUMBERS")
RegisterForSave("TRAINER_FILTER_AVAILABLE")
"#,
)
.unwrap();
assert_eq!(
s.saved_variable_names(),
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"NEVER_TOUCHED",
"CHAT_LABEL",
"OPACITY",
"SPELLBOOK_PAGENUMBERS",
],
"registration order, and a re-register is not a second entry"
);
let text = s.saved_variables_text();
// The grammar: one statement per line, `nil` written out, integral numbers bare, keys
// bracketed and SORTED (integers ascending, then strings), tab-indented, trailing comma.
assert_eq!(
text,
"TRAINER_FILTER_AVAILABLE = 1\n\
TRAINER_FILTER_UNAVAILABLE = 0\n\
NEVER_TOUCHED = nil\n\
CHAT_LABEL = \"say \\\"hi\\\"\\nnow\"\n\
OPACITY = 0.5\n\
SPELLBOOK_PAGENUMBERS = {\n\
\t[1] = 1,\n\
\t[2] = 3,\n\
\t[\"odd key\"] = {\n\
\t\t[1] = true,\n\
\t},\n\
}\n",
"got:\n{text}"
);
assert!(s.take_warnings().is_empty(), "nothing was unserializable");
// A fresh VM (the restart), the file's chunk, and every value is back.
let fresh = UiScript::new().unwrap();
fresh.run(&text).unwrap();
assert_eq!(
fresh
.eval::<i64>("return TRAINER_FILTER_AVAILABLE + TRAINER_FILTER_UNAVAILABLE")
.unwrap(),
1
);
assert!(fresh.eval::<bool>("return NEVER_TOUCHED == nil").unwrap());
assert_eq!(
fresh.eval::<String>("return CHAT_LABEL").unwrap(),
"say \"hi\"\nnow"
);
assert_eq!(fresh.eval::<f64>("return OPACITY").unwrap(), 0.5);
assert_eq!(
fresh
.eval::<i64>("return SPELLBOOK_PAGENUMBERS[2]")
.unwrap(),
3
);
assert!(fresh
.eval::<bool>("return SPELLBOOK_PAGENUMBERS['odd key'][1]")
.unwrap());
}
/// A value with no Lua literal is skipped and named, never written as something that would
/// break the next load — the one place we improve on the reference, which loses these silently.
#[test]
fn unserializable_values_are_skipped_with_one_warning() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
A_FUNCTION = function() end
NOT_A_NUMBER = 1/0
KEPT = 7
CYCLE = {}
CYCLE.self = CYCLE
RegisterForSave("A_FUNCTION")
RegisterForSave("NOT_A_NUMBER")
RegisterForSave("KEPT")
RegisterForSave("CYCLE")
"#,
)
.unwrap();
let text = s.saved_variables_text();
// The cycle's own entry drops; the table itself still writes (empty here).
assert_eq!(text, "KEPT = 7\nCYCLE = {\n}\n", "got:\n{text}");
let warns = s.take_warnings();
assert_eq!(warns.len(), 1, "one line, not one per name: {warns:?}");
assert!(warns[0].contains("A_FUNCTION") && warns[0].contains("NOT_A_NUMBER"));
// And what it wrote is loadable.
UiScript::new().unwrap().run(&text).unwrap();
}
}
@@ -126,3 +126,66 @@ fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
"the outdoor index survived an indoor zoom"
);
}
/// **The level persists** (decision 1131). `SetZoom` writes the live index *and* the matching CVar
/// — `minimapInsideZoom` while inside a WMO, `minimapZoom` outside — the client's own `set_zoom` →
/// `CVar::Set` pair, which is the whole reason a zoom survives a restart. The host push in the
/// other direction (the seed) does *not* echo back as a change.
#[test]
fn setzoom_persists_the_level_through_the_cvar_it_belongs_to() {
let mut s = script();
s.register_cvars([("minimapZoom", "3"), ("minimapInsideZoom", "3")]);
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
.unwrap();
// The seed is a HOST write: it moves both live indices and queues nothing (the host is the
// one that just read them off disk — an echo would re-dirty the file it loaded).
s.set_minimap_zoom(1, 4);
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 1);
assert!(s.take_cvar_changes().is_empty(), "the seed must not echo");
// Outdoors, a zoom writes the outdoor CVar and only that one.
s.run("m:SetZoom(5)").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("minimapZoom".to_string(), "5".to_string())]
);
assert_eq!(s.cvar("minimapInsideZoom").as_deref(), Some("3"));
// Indoors it writes the indoor one — the two levels persist separately, like the indices.
s.set_minimap_inside(true);
s.run("m:SetZoom(0)").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("minimapInsideZoom".to_string(), "0".to_string())]
);
assert_eq!(s.cvar("minimapZoom").as_deref(), Some("5"));
// A no-op zoom queues nothing: quiet frames stay quiet, and the config stays clean.
s.run("m:SetZoom(0)").unwrap();
assert!(s.take_cvar_changes().is_empty());
// The seed clamps like `set_zoom` does, so a hand-edited config.toml cannot seed out of range.
s.set_minimap_zoom(99, 99);
s.set_minimap_inside(false);
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
MINIMAP_ZOOM_LEVELS - 1
);
}
/// A VM whose host registered nothing (a bare test harness, a glue-only run) still zooms — the
/// engine-side CVar write is a silent no-op there, not a warning: engine writes are code, not UI
/// content, so a miss means this build's host does not back the var.
#[test]
fn zooming_without_a_registered_cvar_table_is_silent() {
let mut s = script();
s.run(r#"m = CreateFrame("Minimap", "TestMinimap") m:SetZoom(4)"#)
.unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 4);
assert!(s.take_cvar_changes().is_empty());
assert!(
s.take_warnings().is_empty(),
"no warning for an engine write"
);
}
@@ -279,6 +279,31 @@ impl super::UiScript {
}
}
/// **Reset the state filter and the collapse set — one `SMSG_TRAINER_LIST` arriving.**
///
/// Byte-verified (wow-re `system/ui/scratch/trainer-service-suppression.md`, decision 1128): the
/// list builder writes the filter mask itself on every packet — `0x4d75d9 mov ds:0xb73a1c,3`
/// (available|unavailable, "already known" OFF), or `5` (available|used) when `trainerType == 1`
/// — alongside `ds:0xb73a20 = ds:0xb73a24 = 0xffffffff`, which is "no group collapsed". So the
/// player's filter choice does NOT live in the engine across trainer visits in the reference: it
/// lives in the saved variable `TRAINER_FILTER_*`, and the window's show handler pushes it back
/// over this reset (decision 1128; `TrainerFrame.xml`'s `BenillaTrainerFrame_ApplyFilter`).
///
/// This is the **packet** edge, not the content edge — [`Self::set_trainer`] runs on every
/// snapshot change (an item template landing, a name resolving), and the reference's repaint path
/// `0x4d7d40` does not touch either mask. Reset there and a filter would evaporate mid-window.
pub fn reset_trainer_filter(&mut self, trainer_type: u32) {
let mut model = self.model_mut();
// Mask 5 at a mount/"talent" trainer: available + already-known, which is what makes a
// known mount visible under its "My Talents" header at all (decision 1124's group -1).
model.trainer_filter = if trainer_type == TRAINER_TYPE_MOUNT {
[true, false, true]
} else {
[true, true, false]
};
model.trainer_collapsed.clear();
}
/// Drain the **spell ids** `BuyTrainerService` queued since the last call (the engine resolves each
/// clicked filtered row to its service's spell id, so the app sends `CMSG_TRAINER_BUY_SPELL`
/// without needing to know the filter/index mapping).