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