the video options window goes stock, and water collision returns whole

- the reference's own video options window loads hidden, so pfUI's video skin and any addon reaching for its sliders and check buttons by name finds real frames; our own options window is BenillaOptionsFrame now, and ESC still opens it (2177)
- GetScreenResolutions, GetCurrentResolution, SetScreenResolution, GetRefreshRates, GetVideoCaps, RestartGx and RestoreVideoDefaults answer off the monitor's own modes, so CT_Viewport loads clean (2177)
- three of those verbs were corrected against a second read of the binary: GetRefreshRates answers nothing on a device with no rates, GetVideoCaps always gives a number for triple buffering, and RestoreVideoDefaults writes the ten rows the verb writes (2177)
- the camera's water collision is back with both halves: the boom stops at a water surface, and a swimmer's camera keeps a constant 2/9 yd clearance over the waterline whatever the body underneath does (2179)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 21:04:35 +07:00
co-authored by Claude Fable 5.1
parent e72016630c
commit 9e528e6f86
30 changed files with 3006 additions and 823 deletions
+94 -2
View File
@@ -118,6 +118,98 @@
button:Disable()
end
-- ══ THE THREE STOCK CONSUMERS THAT ASK "IS AN OPTIONS WINDOW OPEN?" (decision 2177) ══
-- All three are the reference's own, off the chain, and all three name the reference's
-- three windows literally. Two of those names are now REAL hidden stock frames
-- (`SoundOptionsFrame` since 2147, `UIOptionsFrame` since 2115) and the third,
-- `OptionsFrame`, became one when the stock VIDEO window joined the manifest — so the
-- whole chain now reads "no", always, while the window the player actually opens is up.
-- Until then `OptionsFrame` WAS our window and every one of them answered by accident.
--
-- **Wrappers, not replacements.** Each stock function keeps running and keeps deciding;
-- ours adds the one term the reference could not know about. A rewrite would be thirty
-- lines of the reference's ladder copied into our tree to drift.
--
-- **HERE, not in `OptionsFrame.xml`, because of load order.** `UpdateMicroButtons` is
-- defined by `MainMenuBarMicroButtons.xml`, manifest l.576 — AFTER our options window at
-- l.522, where a wrapper would capture a nil. This file is the manifest's LAST row
-- (l.1273), so all three exist. It is also the right home on the merits: this is the ESC
-- menu, and these three are exactly "the ESC ladder and the micro button know about our
-- options window".
local Ref_IsOptionFrameOpen = IsOptionFrameOpen
local Ref_ToggleGameMenu = ToggleGameMenu
local Ref_UpdateMicroButtons = UpdateMicroButtons
-- The one term all three add. Existence-guarded because these three wrappers stand in a
-- file that can be seated without the options window: in the client `BenillaOptionsFrame`
-- is manifest row 38 and this file is the last row, so it is always real — but a test kit
-- that seats the game menu alone must get "no options window is open", not a raise inside
-- the ESC handler.
local function BenillaOptions_IsOpen()
return BenillaOptionsFrame and BenillaOptionsFrame:IsVisible()
end
-- `UIParent.lua:996`. One caller: `CloseAllWindows_WithExceptions`, which passes
-- `ignoreCenter = 1` when an options window is up so the ESC sweep does not close the
-- centre panel out from under it. Ours IS the centre panel (`UIPanelWindows` row,
-- `area = "center"`), so answering "no" would have ESC close it through the sweep instead
-- of through its own rung, discarding pending edits with no Cancel semantics.
function IsOptionFrameOpen()
if BenillaOptions_IsOpen() then
return 1
end
return Ref_IsOptionFrameOpen()
end
-- `UIParent.lua:1465-1497`. The ESC ladder, and the rung matters as much as the answer:
-- the reference tries the static popup FIRST, then an open options window, then the game
-- menu. So this runs that first rung itself and returns if it fired — `StaticPopup_Escape -- Pressed` is a pure scan when nothing is shown (`StaticPopup.lua:1879-1893`), so the
-- delegate below re-running it costs nothing and changes nothing — then takes the options
-- rung, then hands the rest of the ladder back untouched.
--
-- The close is our own Close button's click, which is the same discard-and-hide the
-- reference reaches through `OptionsFrameCancel:Click()`: our `<OnHide>` drops the pending
-- page. (That alias is retired — `OptionsFrameCancel` is the stock video window's own
-- button now, and ours loading later would have clobbered it.)
function ToggleGameMenu(clicked)
if clicked then
-- The reference's `clicked` arm closes an open options window and then goes on to
-- toggle the game menu, rather than returning. Same here.
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
end
return Ref_ToggleGameMenu(clicked)
end
if StaticPopup_EscapePressed() then
return
end
if BenillaOptions_IsOpen() then
BenillaOptionsFrameCloseButton:Click()
return
end
return Ref_ToggleGameMenu(clicked)
end
-- `MainMenuBarMicroButtons.lua:47-58`. A POST-hook that can only ever push the button, never
-- release it: the reference has already decided from the six frames it knows, and this adds
-- the seventh. Written this way round so it cannot fight that decision — a wrapper that also
-- set NORMAL would be a second copy of the reference's whole `or` chain.
--
-- **Existence-guarded, and only here.** The shipped manifest always carries
-- `MainMenuBarMicroButtons.xml`, so in the client `Ref_UpdateMicroButtons` and
-- `MainMenuMicroButton` are always real. A partial test kit that seats this file and not
-- the micro bar is the case the guards are for, and a guard is the honest answer there:
-- there is no micro button to push, so there is nothing to do — as against a raise inside
-- an unrelated window's OnShow, which is what an unguarded wrapper turns that into.
function UpdateMicroButtons()
if Ref_UpdateMicroButtons then
Ref_UpdateMicroButtons()
end
if MainMenuMicroButton and BenillaOptions_IsOpen() then
MainMenuMicroButton:SetButtonState("PUSHED", 1)
end
end
-- The CAMP/QUIT dialogs and the three-event driver MOVED OUT, to our UIParent.lua counterpart (the stock UIParent.lua since 1988) — the
-- reference's own homes for both (StaticPopup.lua l.564/586; UIParent.lua l.304-315).
-- They were here only because this is where they were first needed, and 1751 moving them
@@ -179,7 +271,7 @@
something exists behind them. -->
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
there, OptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
there, BenillaOptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
the center-replacement rule for the swap (ShowUIPanel l.697-702: native-center
frames replace each other); hiding the menu explicitly says the same thing
without relying on the eviction path. -->
@@ -191,7 +283,7 @@
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(OptionsFrame)
ShowUIPanel(BenillaOptionsFrame)
</OnClick>
</Scripts>
</Button>
@@ -184,7 +184,7 @@
-- and repainted after a bind lands on one.
KeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "OptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
@@ -302,7 +302,7 @@
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era section list). ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
local scroll = getglobal("OptionsFrameContainerBodyKeybindingsScrollFrame")
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = FauxScrollFrame_GetOffset(scroll)
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
@@ -327,23 +327,23 @@
KeyBindings_UpdateUnbindKey()
end
-- The page entry (OptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
-- The page entry (BenillaOptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
-- the table may have moved under us (a rebind, a set switch) — sync the checkbox to
-- the live set, park the scroll at the top, repaint.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("OptionsFrameContainerBodyKeybindingsCharacterRowCheck")
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
else
box:SetChecked(nil)
end
getglobal("OptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
KeyBindingsPage_Update()
end
function KeyBindings_Output(text)
getglobal("OptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
@@ -356,7 +356,7 @@
end
function KeyBindings_UpdateUnbindKey()
local unbind = getglobal("OptionsFrameContainerUnbind")
local unbind = getglobal("BenillaOptionsFrameContainerUnbind")
if KeyBindingsPage.selected then
unbind:Enable()
else
+182 -163
View File
@@ -277,7 +277,7 @@
-- The `~=` guard is the reference's and it matters: `TutorialsEnabled()` returns the NUMBER
-- 1 or nil, so re-ticking an already-on row does not re-`ResetTutorials()` — which would
-- re-arm every tutorial the player has already dismissed.
function OptionsFrame_SetTutorialsEnabled(value)
function BenillaOptionsFrame_SetTutorialsEnabled(value)
if ( tonumber(value) ~= TutorialsEnabled() ) then
if ( value == "1" ) then
ResetTutorials()
@@ -343,7 +343,7 @@
-- reference at typical resolutions reads nearer two-thirds.
ERA_WINDOW_SCALE = 0.78
function OptionsFrame_UpdateScale()
function BenillaOptionsFrame_UpdateScale()
-- The era panel is registered `checkFit = 1`: the panel manager also shrinks it to
-- fit a small screen (UIPanelUpdateScaleForFit: min over width/height ratios
-- against the panel + 20 units of air each way, capped at 1) — re-expressed here.
@@ -352,7 +352,7 @@
local hFit = GetScreenHeight() / 744
if wFit < k then k = wFit end
if hFit < k then k = hFit end
OptionsFrame:SetScale(k)
BenillaOptionsFrame:SetScale(k)
end
-- The era tab width law (MinimalTab.lua l.7): label width + 40, measured ONCE per show —
@@ -361,7 +361,7 @@
-- OnLoad for a reason that outlives the measure: GetStringWidth answers in the REGION'S
-- OWN units, so its number depends on the owner's effective scale (glyph advances snap to
-- whole physical pixels, so the same label is not proportionally the same width at two
-- scales) — and this window's scale is set by OptionsFrame_UpdateScale in its own <OnShow>,
-- scales) — and this window's scale is set by BenillaOptionsFrame_UpdateScale in its own <OnShow>,
-- after every OnLoad has already run. Fired from the tabs' <OnShow>, which the engine
-- runs pre-order behind the window's own, the scale is the drawn one. No poll and no latch:
-- the engine's font measurer answers inside the Lua call that asked (benilla-ui
@@ -401,20 +401,20 @@
-- Then the page swap: a category with rows (OPTIONS_PAGE_ROWS) shows its body
-- refreshed from the CVars and Defaults goes live with it; every other page is still
-- empty, Defaults disabled.
function OptionsFrame_SelectCategory(key)
OptionsFrame.selectedCategory = key
if OptionsFrameSearchBox:GetText() ~= "" then
OptionsFrameSearchBox:SetText("")
function BenillaOptionsFrame_SelectCategory(key)
BenillaOptionsFrame.selectedCategory = key
if BenillaOptionsFrameSearchBox:GetText() ~= "" then
BenillaOptionsFrameSearchBox:SetText("")
return -- the OnTextChanged restore re-entered with this same key and finished
end
for _, k in ipairs(OPTIONS_CATEGORY_KEYS) do
local row = getglobal("OptionsFrameCategoryListRow" .. k)
local bg = getglobal("OptionsFrameCategoryListRow" .. k .. "Bg")
local row = getglobal("BenillaOptionsFrameCategoryListRow" .. k)
local bg = getglobal("BenillaOptionsFrameCategoryListRow" .. k .. "Bg")
if k == key then
bg:SetVertexColor(1, 1, 0)
bg:Show()
row:SetTextFontObject("GameFontHighlight")
OptionsFrameContainerTitle:SetText(row.pageTitle)
BenillaOptionsFrameContainerTitle:SetText(row.pageTitle)
else
bg:SetVertexColor(0.196, 0.388, 0.8)
bg:Hide()
@@ -422,7 +422,7 @@
end
end
for k in pairs(OPTIONS_PAGE_ROWS) do
local body = getglobal("OptionsFrameContainerBody" .. k)
local body = getglobal("BenillaOptionsFrameContainerBody" .. k)
if k == key then body:Show() else body:Hide() end
end
-- The Keybindings page (KeyBindingsPage.xml, decision 1008) body-swaps like the
@@ -430,22 +430,22 @@
-- leaving disarms a straggling capture (the host seam must never outlive the page)
-- and clears its message line. Its Defaults is the confirmed Reset To Default (the
-- button's OnClick forks), and the Unbind Key button exists only here.
local kb = getglobal("OptionsFrameContainerBodyKeybindings")
local kb = getglobal("BenillaOptionsFrameContainerBodyKeybindings")
if key == "Keybindings" then
kb:Show()
KeyBindingsPage_Refresh()
OptionsFrameContainerUnbind:Show()
OptionsFrameContainerDefaults:Enable()
BenillaOptionsFrameContainerUnbind:Show()
BenillaOptionsFrameContainerDefaults:Enable()
else
kb:Hide()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
OptionsFrameContainerUnbind:Hide()
BenillaOptionsFrameContainerUnbind:Hide()
if OPTIONS_PAGE_ROWS[key] then
OptionsPage_Refresh(key)
OptionsFrameContainerDefaults:Enable()
BenillaOptionsFrameContainerDefaults:Enable()
else
OptionsFrameContainerDefaults:Disable()
BenillaOptionsFrameContainerDefaults:Disable()
end
end
end
@@ -495,7 +495,7 @@
for _, key in ipairs(OPTIONS_CATEGORY_KEYS) do
if OPTIONS_PAGE_ROWS[key] then
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
local row = getglobal("BenillaOptionsFrameContainerBody" .. key .. rkey)
local s = OptionsSearch_Score(row.searchTag, words)
if s then
if not buckets[s] then
@@ -557,15 +557,15 @@
end
for _, key in ipairs(OPTIONS_CATEGORY_KEYS) do
if OPTIONS_PAGE_ROWS[key] then
take("OptionsFrameContainerBodySearchHead" .. key)
take("BenillaOptionsFrameContainerBodySearchHead" .. key)
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
take("OptionsFrameContainerBody" .. key .. rkey)
take("BenillaOptionsFrameContainerBody" .. key .. rkey)
end
end
end
take("OptionsFrameContainerBodySearchHeadKeybindings")
take("BenillaOptionsFrameContainerBodySearchHeadKeybindings")
for i = 1, KEYBINDINGS_SEARCH_ROWS do
take("OptionsFrameContainerBodyKeybindSearch" .. i)
take("BenillaOptionsFrameContainerBodyKeybindSearch" .. i)
end
return bottom
end
@@ -577,8 +577,8 @@
-- would fight the resolver. UpdateScrollChildRect re-drives the bar through
-- OnScrollRangeChanged, which is what shows and hides it (and its trough).
function OptionsScroll_Fit()
local sf = OptionsFrameContainerScroll
local body = OptionsFrameContainerBody
local sf = BenillaOptionsFrameContainerScroll
local body = BenillaOptionsFrameContainerBody
local fw, fh = sf:GetWidth(), sf:GetHeight()
if not fw or fw <= 0 or not fh or fh <= 0 then return end
if math.abs(body:GetWidth() - fw) >= 0.5 then body:SetWidth(fw) end
@@ -606,8 +606,8 @@
f:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -9)
f:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT", 0, -9)
else
f:SetPoint("TOPLEFT", "OptionsFrameContainerBody", "TOPLEFT", 10, -12)
f:SetPoint("TOPRIGHT", "OptionsFrameContainerBody", "TOPRIGHT", -20, -12)
f:SetPoint("TOPLEFT", "BenillaOptionsFrameContainerBody", "TOPLEFT", 10, -12)
f:SetPoint("TOPRIGHT", "BenillaOptionsFrameContainerBody", "TOPRIGHT", -20, -12)
end
end
@@ -616,7 +616,7 @@
function OptionsPage_Layout(key)
local prev = nil
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local name = "OptionsFrameContainerBody" .. key .. rkey
local name = "BenillaOptionsFrameContainerBody" .. key .. rkey
OptionsSearch_Place(name, prev)
getglobal(name):Show()
prev = name
@@ -632,37 +632,37 @@
-- (l.680-682).
function OptionsSearch_Display(text)
-- A new result set starts at the top (the era's own ScrollBox reset on a search).
OptionsFrameContainerScroll:SetVerticalScroll(0)
BenillaOptionsFrameContainerScroll:SetVerticalScroll(0)
for k in pairs(OPTIONS_PAGE_ROWS) do
getglobal("OptionsFrameContainerBody" .. k):Show()
getglobal("BenillaOptionsFrameContainerBody" .. k):Show()
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[k]) do
getglobal("OptionsFrameContainerBody" .. k .. rkey):Hide()
getglobal("BenillaOptionsFrameContainerBody" .. k .. rkey):Hide()
end
getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide()
getglobal("BenillaOptionsFrameContainerBodySearchHead" .. k):Hide()
end
-- The Keybindings furniture leaves the stage with the rest: its body hides (the
-- search paints matched bindings on the shared pool below, not the page list), a
-- straggling capture disarms, and the pool starts blank.
getglobal("OptionsFrameContainerBodyKeybindings"):Hide()
getglobal("OptionsFrameContainerBodySearchHeadKeybindings"):Hide()
getglobal("BenillaOptionsFrameContainerBodyKeybindings"):Hide()
getglobal("BenillaOptionsFrameContainerBodySearchHeadKeybindings"):Hide()
for i = 1, KEYBINDINGS_SEARCH_ROWS do
getglobal("OptionsFrameContainerBodyKeybindSearch" .. i):Hide()
getglobal("BenillaOptionsFrameContainerBodyKeybindSearch" .. i):Hide()
end
KeyBindingsPage.searchPainted = nil
KeyBindings_SetSelected(nil)
OptionsFrameContainerUnbind:Hide()
OptionsFrameContainerTitle:SetText("Search Results")
OptionsFrameContainerDefaults:Hide()
BenillaOptionsFrameContainerUnbind:Hide()
BenillaOptionsFrameContainerTitle:SetText("Search Results")
BenillaOptionsFrameContainerDefaults:Hide()
local order, groups = OptionsSearch_FindMatches(text)
if table.getn(order) == 0 then
OptionsFrameContainerBodyNoResults:Show()
BenillaOptionsFrameContainerBodyNoResults:Show()
return
end
OptionsFrameContainerBodyNoResults:Hide()
BenillaOptionsFrameContainerBodyNoResults:Hide()
local prev = nil
for _, key in ipairs(order) do
local head = "OptionsFrameContainerBodySearchHead" .. key
local head = "BenillaOptionsFrameContainerBodySearchHead" .. key
OptionsSearch_Place(head, prev)
getglobal(head):Show()
prev = head
@@ -675,7 +675,7 @@
for _, index in ipairs(groups[key]) do
if shown < KEYBINDINGS_SEARCH_ROWS then
shown = shown + 1
local name = "OptionsFrameContainerBodyKeybindSearch" .. shown
local name = "BenillaOptionsFrameContainerBodyKeybindSearch" .. shown
KeyBindings_PaintBindRow(name, index)
OptionsSearch_Place(name, prev)
getglobal(name):Show()
@@ -686,17 +686,17 @@
else
local placed = {}
for _, rkey in ipairs(groups[key]) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
local row = getglobal("BenillaOptionsFrameContainerBody" .. key .. rkey)
if row.parentRow and not placed[row.parentRow] then
placed[row.parentRow] = 1
local pname = "OptionsFrameContainerBody" .. key .. row.parentRow
local pname = "BenillaOptionsFrameContainerBody" .. key .. row.parentRow
OptionsSearch_Place(pname, prev)
getglobal(pname):Show()
prev = pname
end
if not placed[rkey] then
placed[rkey] = 1
local name = "OptionsFrameContainerBody" .. key .. rkey
local name = "BenillaOptionsFrameContainerBody" .. key .. rkey
OptionsSearch_Place(name, prev)
row:Show()
prev = name
@@ -711,28 +711,28 @@
-- selection re-applied — SelectCategory re-shows the one body, retitles, and brings
-- Defaults back (the era's no-text arm, l.655-658).
function OptionsSearch_Restore()
OptionsFrameContainerScroll:SetVerticalScroll(0)
BenillaOptionsFrameContainerScroll:SetVerticalScroll(0)
for k in pairs(OPTIONS_PAGE_ROWS) do
OptionsPage_Layout(k)
getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide()
getglobal("BenillaOptionsFrameContainerBodySearchHead" .. k):Hide()
end
for i = 1, KEYBINDINGS_SEARCH_ROWS do
getglobal("OptionsFrameContainerBodyKeybindSearch" .. i):Hide()
getglobal("BenillaOptionsFrameContainerBodyKeybindSearch" .. i):Hide()
end
getglobal("OptionsFrameContainerBodySearchHeadKeybindings"):Hide()
getglobal("BenillaOptionsFrameContainerBodySearchHeadKeybindings"):Hide()
KeyBindingsPage.searchPainted = nil
OptionsFrameContainerBodyNoResults:Hide()
OptionsFrameContainerDefaults:Show()
OptionsFrame_SelectCategory(OptionsFrame.selectedCategory or "Controls")
BenillaOptionsFrameContainerBodyNoResults:Hide()
BenillaOptionsFrameContainerDefaults:Show()
BenillaOptionsFrame_SelectCategory(BenillaOptionsFrame.selectedCategory or "Controls")
end
-- The text gate (era OnSearchTextChanged l.640-652): a repeat of the same text is a
-- no-op, and the FIRST change (searchText still nil — load-time initialization)
-- arms without acting; text clearing restores only if a search was actually live.
function OptionsSearch_OnTextChanged(text)
if text == OptionsFrame.searchText then return end
local had = OptionsFrame.searchText and OptionsFrame.searchText ~= ""
OptionsFrame.searchText = text
if text == BenillaOptionsFrame.searchText then return end
local had = BenillaOptionsFrame.searchText and BenillaOptionsFrame.searchText ~= ""
BenillaOptionsFrame.searchText = text
if text == "" then
if had then OptionsSearch_Restore() end
else
@@ -1045,38 +1045,44 @@
-- refreshing guard: SetMinMaxValues re-clamps the held value and fires OnValueChanged,
-- and that clamp is wiring, not a player gesture — without the guard every load would
-- SetCVar the template's 0 clamped into the new range.
-- ── OptionsFrameSliders — ref OptionsFrame.lua l.24-34 ──────────────────────────────
-- The reference's options-slider table: one row per graphics slider, carrying the bounds
-- the slider is built with. It is PUBLISHED, so addons read and write it — pfUI's
-- `hdgraphic` module opens with `_G.OptionsFrameSliders[3].maxValue = 15`
-- (modules/hdgraphic.lua l.3) and died on the nil, taking the module with it.
-- ── OptionsFrameSliders — THE REFERENCE'S TABLE, NOT OURS ANY MORE (2177) ───────────
-- `OptionsFrameSliders` is the video options window's own slider table (ref
-- `OptionsFrame.lua` l.24-34): one row per graphics slider, carrying the bounds the slider
-- is built with, PUBLISHED so addons can read and write it — pfUI's `hdgraphic` opens with
-- `_G.OptionsFrameSliders[3].maxValue = 15` (modules/hdgraphic.lua l.3).
--
-- **It is the real thing, not a shape put here to satisfy that write.** The three rows
-- below are the source of truth for our own Graphics page: each row's OnLoad reads its
-- bounds from here rather than carrying literals, and the page re-applies them on every
-- OnShow, which is what makes an addon's write actually move the slider. A table nothing
-- read would be a name with no behaviour behind it (1134 §4) — worse than absent, because
-- the write would appear to succeed.
-- **This file used to declare it, and no longer does.** Until 2177 the video window was not
-- loaded, so the name was free and our Graphics page published its own three rows into it
-- to keep that addon write landing on something. The stock window is on the manifest now
-- and brings the reference's own nine rows — and this file loads AFTER it, so a declaration
-- here would not sit beside them, it would REPLACE them, and `OptionsFrame_Load`'s
-- `getglobal("OptionsFrameSlider"..index)` walk would silently stop at three of nine.
--
-- **Three rows, not the reference's nine.** Ours are its rows 1-3 in its own order, and the
-- `func` strings match our CVars exactly (uiscale/farclip/WorldDetail) — which is why
-- pfUI's index 3 lands on Environment Detail here as it does there. The other six
-- (TerrainMip, BaseMip, Gamma, anisotropic, spellEffectLevel, weatherDensity) have no
-- benilla knob behind them; listing them would publish six rows whose writes do nothing.
-- 0959/1649 own which knobs this page ships, and this table follows that, not the reverse.
-- **Nothing is lost, because our three rows WERE its rows 1-3.** Same order, same `func`
-- strings, same bounds: `uiscale` 0.64-1.0 step 0.01, `farclip` 177-777 step
-- (777-177)/10 = 60, `WorldDetail` 0-2 step 1. The Graphics page below still reads
-- `OptionsFrameSliders[1..3]` for its bounds — now off the reference's own table rather
-- than a transcription of it, which is the whole point of 1751 — and pfUI's index 3 still
-- lands on Environment Detail.
--
-- `benillaRow` is ours: the reference addresses its sliders as OptionsFrameSlider1..9, a
-- numbering our page does not use, so each row names the child it drives instead.
-- What WAS ours in those rows is the `benillaRow` field, and it moves here.
-- `BENILLA_GRAPHICS_SLIDER_ROWS` is index → the child of our Graphics page that the
-- reference's row drives; the reference addresses its own sliders as
-- `OptionsFrameSlider1..9`, a numbering our page does not use. An index with no entry is a
-- reference row we ship no knob for (rows 4-9: TerrainMip, BaseMip, Gamma, anisotropic,
-- spellEffectLevel, weatherDensity), and the OnShow re-apply loop skips it.
BENILLA_GRAPHICS_SLIDER_ROWS = { "RowUiScale", "RowFarclip", "RowWorldDetail" }
--
-- **Both WorldDetail verbs are bound now** (2163), which is what makes pfUI's module RUN
-- **Both WorldDetail verbs are bound** (2163), which is what makes pfUI's module RUN
-- rather than merely load: it hooks `SetWorldDetail`/`GetWorldDetail`, and hooking a nil
-- took its extended arm down at the first call. `SetWorldDetail` is `0x488dd0`'s carve —
-- truncate toward zero, `[0, 2]` or raise `"value must be in the range 0, 2"`, write the
-- `0x804518` stops {16, 32, 48} — and `GetWorldDetail` is `0x488d70` answering the stop.
-- They live in `benilla_ui::script::cvars`, not here; this page still drives the CVar
-- directly, so nothing on it calls either. The stock file WILL, once 1751 migrates this
-- window: `OptionsFrame_Save`/`_Load` prefer `getglobal("Set"..func)` / `("Get"..func)`
-- over `SetCVar`/`GetCVar` (`OptionsFrame.lua` l.110/135/206-226).
-- directly, so nothing on it calls either. The STOCK file does, now that it is loaded:
-- `OptionsFrame_Save`/`_Load` prefer `getglobal("Set"..func)` / `("Get"..func)` over
-- `SetCVar`/`GetCVar` (`OptionsFrame.lua` l.110/135/206-226) — but nothing of ours calls
-- those functions, because nothing of ours shows that window.
--
-- **The nine CVars pfUI's `hdgraphic` drives through `ConsoleExec`, each pinned** (2163;
-- `ConsoleExec` itself is bound since 1948 — it writes a registered CVar or pushes a
@@ -1094,14 +1100,6 @@
-- `texLodBias` (`0x6885e2`, whose sink `0x672640` is literally `ret 4`). And `maxLOD` is
-- **not a CVar at all** — zero of the 214 `CVar::Register 0x63db90` sites name it — so
-- pfUI's `ConsoleExec("maxLOD " .. arg)` reaches nothing in the real client either.
OptionsFrameSliders = {
{ text = "UI Scale", func = "uiscale",
minValue = 0.64, maxValue = 1.0, valueStep = 0.01, benillaRow = "RowUiScale" },
{ text = "Terrain Distance", func = "farclip",
minValue = 177, maxValue = 777, valueStep = 60, benillaRow = "RowFarclip" },
{ text = "Environment Detail", func = "WorldDetail",
minValue = 0, maxValue = 2, valueStep = 1, benillaRow = "RowWorldDetail" },
}
function OptionsSliderRow_Setup(row, min, max, step, fmt, stops)
local slider = getglobal(row:GetName() .. "ControlSlider")
@@ -1299,7 +1297,7 @@
else
OptionsRow_Set(row, "0")
end
OptionsPage_UpdateDependencies(OptionsFrame.selectedCategory)
OptionsPage_UpdateDependencies(BenillaOptionsFrame.selectedCategory)
end
function OptionsSlider_OnValueChanged(slider, value)
@@ -1349,7 +1347,7 @@
end
function OptionsPage_Refresh(key)
local base = "OptionsFrameContainerBody" .. key
local base = "BenillaOptionsFrameContainerBody" .. key
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
local box = getglobal(base .. rkey .. "Check")
@@ -1405,7 +1403,7 @@
local live = on
if rkey == "RowComboPoints" and not combo then live = nil end
OptionsRow_SetEnabled(
getglobal("OptionsFrameContainerBodyCombat" .. rkey), live)
getglobal("BenillaOptionsFrameContainerBodyCombat" .. rkey), live)
end
end
return
@@ -1414,7 +1412,7 @@
-- 1.12's own rule for this pair (UIOptionsFrame_UpdateDependencies l.694-700):
-- the mode picker is dead while the switch is unticked. It is the only
-- cross-row dependency on this page.
OptionsRow_SetEnabled(OptionsFrameContainerBodyInterfaceRowTargetOfTargetMode,
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetMode,
SHOW_TARGET_OF_TARGET ~= "0")
return
end
@@ -1424,7 +1422,7 @@
-- MultiBarLeft does not appear without MultiBarRight under it (stock
-- MultiActionBar_Update's conjunction). It is the only cross-row dependency on this page — the two bottom
-- bars, the grid switch and the lock are all independent.
OptionsRow_SetEnabled(OptionsFrameContainerBodyActionBarsRowMultiBar4,
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyActionBarsRowMultiBar4,
SHOW_MULTI_ACTIONBAR_3)
return
end
@@ -1435,8 +1433,8 @@
-- reference's own file has.
local on = GetCVar("MasterSoundEffects") ~= "0"
for _, row in {"RowEnableAmbience", "RowEnableErrorSpeech"} do
local box = getglobal("OptionsFrameContainerBodyAudio"..row.."Check")
local label = getglobal("OptionsFrameContainerBodyAudio"..row.."Label")
local box = getglobal("BenillaOptionsFrameContainerBodyAudio"..row.."Check")
local label = getglobal("BenillaOptionsFrameContainerBodyAudio"..row.."Label")
if on then
box:Enable()
label:SetFontObject("GameFontNormal")
@@ -1483,7 +1481,7 @@
-- not modeled — Defaults resets the VISIBLE page). A pending edit dies with it — the
-- default write supersedes what Apply would have committed.
function OptionsPage_Defaults(key)
local base = "OptionsFrameContainerBody" .. key
local base = "BenillaOptionsFrameContainerBody" .. key
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
row.pending = nil
@@ -1503,17 +1501,17 @@
function OptionsApply_Update()
local pending
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
local base = "BenillaOptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
if getglobal(base .. rkey).pending then pending = 1 end
end
end
if pending then
OptionsFrameApplyButton:Enable()
OptionsFrameApplyButton:Show()
BenillaOptionsFrameApplyButton:Enable()
BenillaOptionsFrameApplyButton:Show()
else
OptionsFrameApplyButton:Disable()
OptionsFrameApplyButton:Hide()
BenillaOptionsFrameApplyButton:Disable()
BenillaOptionsFrameApplyButton:Hide()
end
end
@@ -1521,7 +1519,7 @@
-- value everywhere becomes a real SetCVar, and the button goes away.
function OptionsPage_Apply()
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
local base = "BenillaOptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
local row = getglobal(base .. rkey)
if row.pending then
@@ -1537,7 +1535,7 @@
-- next open re-reads the committed CVars, so the sliders land back on the truth.
function OptionsPage_DiscardPending()
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
local base = "BenillaOptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
getglobal(base .. rkey).pending = nil
end
@@ -1601,16 +1599,16 @@
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<Scripts>
<OnClick>OptionsFrame_SelectCategory(this.categoryKey)</OnClick>
<OnClick>BenillaOptionsFrame_SelectCategory(this.categoryKey)</OnClick>
<!-- 1.14's highlight shows on hover with the blue tint standing (the selected row
holds its locked gold — the guard is UnlockHighlight's job here). -->
<OnEnter>
if OptionsFrame.selectedCategory ~= this.categoryKey then
if BenillaOptionsFrame.selectedCategory ~= this.categoryKey then
getglobal(this:GetName() .. "Bg"):Show()
end
</OnEnter>
<OnLeave>
if OptionsFrame.selectedCategory ~= this.categoryKey then
if BenillaOptionsFrame.selectedCategory ~= this.categoryKey then
getglobal(this:GetName() .. "Bg"):Hide()
end
</OnLeave>
@@ -1658,7 +1656,7 @@
</Layer>
</Layers>
<Scripts>
<OnClick>OptionsFrame_SelectCategory(this.categoryKey)</OnClick>
<OnClick>BenillaOptionsFrame_SelectCategory(this.categoryKey)</OnClick>
<OnEnter>getglobal(this:GetName() .. "Hover"):Show()</OnEnter>
<OnLeave>getglobal(this:GetName() .. "Hover"):Hide()</OnLeave>
</Scripts>
@@ -1886,7 +1884,7 @@
<!-- `toplevel` restored (ref OptionsFrame.xml l.3) — same class as the B180/Bagnon dialog
(decision 1318). NOTE our strata is HIGH where the ref is DIALOG; left as-is, flagged in
the record rather than silently changed. -->
<Frame name="OptionsFrame" parent="UIParent" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Frame name="BenillaOptionsFrame" parent="UIParent" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<!-- The 1.14 System-window dialog, verbatim (BACKDROP_DIALOG_32_32 — wow-ui-source
@@ -2166,7 +2164,7 @@
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- The page title — OptionsFrame_SelectCategory writes it. -->
<!-- The page title — BenillaOptionsFrame_SelectCategory writes it. -->
<FontString name="$parentTitle" inherits="OptionsFontHighlightHuge">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-22"/></Offset></Anchor></Anchors>
</FontString>
@@ -2175,17 +2173,17 @@
<Frames>
<!-- Live on any page with rows (OPTIONS_PAGE_ROWS) and on the Keybindings
page, where it is 1.12's Reset To Default behind the era confirm
(KeyBindingsPage.xml's popup); OptionsFrame_SelectCategory
(KeyBindingsPage.xml's popup); BenillaOptionsFrame_SelectCategory
enables/disables it with the page swap. -->
<Button name="$parentDefaults" inherits="OptionsRedButtonTemplate" text="Defaults">
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-36" y="-16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnClick>
if OptionsFrame.selectedCategory == "Keybindings" then
if BenillaOptionsFrame.selectedCategory == "Keybindings" then
StaticPopup_Show("CONFIRM_RESET_KEYBINDINGS")
else
OptionsPage_Defaults(OptionsFrame.selectedCategory)
OptionsPage_Defaults(BenillaOptionsFrame.selectedCategory)
end
</OnClick>
</Scripts>
@@ -2236,7 +2234,7 @@
</Anchors>
<Scripts>
<!-- The loader fires OnLoad bottom-up, so the body already exists. -->
<OnLoad>self:SetScrollChild(OptionsFrameContainerBody)</OnLoad>
<OnLoad>self:SetScrollChild(BenillaOptionsFrameContainerBody)</OnLoad>
<OnUpdate>OptionsScroll_Fit()</OnUpdate>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
@@ -2254,7 +2252,7 @@
link-stamp as well as by layer. -->
<Frame name="$parentBarTrough" inherits="BenillaScrollTroughTemplate"/>
<!-- The page body: one child frame per category with rows, shown by
OptionsFrame_SelectCategory (Audio 0957, Graphics 0959). Named in
BenillaOptionsFrame_SelectCategory (Audio 0957, Graphics 0959). Named in
full rather than `$parent…`: it keeps the name every page, row and
search seat is derived from, across the scroll frame that now sits
between it and the container. Its SIZE is authored here because a
@@ -2263,7 +2261,7 @@
and height are read; these are the container's own 665 x (613-50),
the seat it held before, and OptionsScroll_Fit keeps them true
against the live rect from there on. -->
<Frame name="OptionsFrameContainerBody">
<Frame name="BenillaOptionsFrameContainerBody">
<Size><AbsDimension x="665" y="563"/></Size>
<Layers>
<Layer level="OVERLAY">
@@ -2712,7 +2710,7 @@
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- Re-apply the slider bounds from OptionsFrameSliders every time the page is shown.
<!-- Re-apply the slider bounds from the REFERENCE's OptionsFrameSliders every time the page is shown.
The rows read the table at OnLoad, which is long before any addon runs; an addon that
writes a bound (pfUI's hdgraphic raises Environment Detail's maxValue to 15) would
otherwise never move the slider it just rewrote. The reference does the same loop in
@@ -2724,10 +2722,10 @@
grammar (0992/1649's stop names), set at OnLoad, and no addon addresses them. -->
<Scripts>
<OnShow>
for i = 1, table.getn(OptionsFrameSliders) do
for i, name in BENILLA_GRAPHICS_SLIDER_ROWS do
local info = OptionsFrameSliders[i]
local row = info.benillaRow and getglobal(this:GetName() .. info.benillaRow)
if ( row ) then
local row = getglobal(this:GetName() .. name)
if ( info and row ) then
OptionsSliderRow_Setup(row, info.minValue, info.maxValue,
info.valueStep, row.fmt, row.stops)
end
@@ -3312,7 +3310,7 @@
`OptionsFrame_EnableCheckBox(checkButton, 1, 1)`). -->
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self, TutorialsEnabled,
OptionsFrame_SetTutorialsEnabled, "1",
BenillaOptionsFrame_SetTutorialsEnabled, "1",
"Show Tutorials", "OPTION_TOOLTIP_SHOW_TUTORIALS")
</OnLoad></Scripts>
</Frame>
@@ -3973,8 +3971,8 @@
</Layers>
<Scripts>
<OnClick>
OptionsFrameSearchBox:SetText("")
OptionsFrameSearchBox:ClearFocus()
BenillaOptionsFrameSearchBox:SetText("")
BenillaOptionsFrameSearchBox:ClearFocus()
</OnClick>
<OnEnter>getglobal(this:GetName() .. "Icon"):SetAlpha(1.0)</OnEnter>
<OnLeave>getglobal(this:GetName() .. "Icon"):SetAlpha(0.5)</OnLeave>
@@ -3986,11 +3984,11 @@
<!-- Placeholder + clear-button toggle, then the search proper (0984). -->
<OnTextChanged>
if this:GetText() == "" then
OptionsFrameSearchBoxInstructions:Show()
OptionsFrameSearchBoxClearButton:Hide()
BenillaOptionsFrameSearchBoxInstructions:Show()
BenillaOptionsFrameSearchBoxClearButton:Hide()
else
OptionsFrameSearchBoxInstructions:Hide()
OptionsFrameSearchBoxClearButton:Show()
BenillaOptionsFrameSearchBoxInstructions:Hide()
BenillaOptionsFrameSearchBoxClearButton:Show()
end
OptionsSearch_OnTextChanged(this:GetText())
</OnTextChanged>
@@ -4007,7 +4005,7 @@
<Scripts>
<OnClick>
PlaySound("igMainMenuClose")
HideUIPanel(OptionsFrame)
HideUIPanel(BenillaOptionsFrame)
</OnClick>
</Scripts>
</Button>
@@ -4028,8 +4026,9 @@
era remembers too — Settings.OpenToCategory's default seat). The scale re-fits
on every show — the era panel manager re-runs checkFit the same way. -->
<OnShow>
OptionsFrame_UpdateScale()
OptionsFrame_SelectCategory(this.selectedCategory or "Controls")
BenillaOptionsFrame_UpdateScale()
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
</OnShow>
<!-- Hiding discards pending edits (the era wipes its modified table on hide; its
close-time confirm dialog is cut — SCOPE note), ends a live search (the
@@ -4037,58 +4036,78 @@
straggling bind capture — the host seam swallowing all input with no window
on screen would be a lockout (KeyBindingsPage.xml, decision 1008). -->
<OnHide>
OptionsFrameSearchBox:SetText("")
BenillaOptionsFrameSearchBox:SetText("")
OptionsPage_DiscardPending()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
if UpdateMicroButtons then UpdateMicroButtons() end
</OnHide>
</Scripts>
</Frame>
<!-- ══ THE ONE SURVIVING ALIAS (decisions 2115, and the Sound window's row) ═══════════════
<!-- ══ NO ALIASES LEFT, AND THIS WINDOW IS NO LONGER `OptionsFrame` (2115, 2147, 2177) ══
The reference has THREE options windows — `OptionsFrame` (video), `SoundOptionsFrame` and
`UIOptionsFrame` (interface). benilla's one window answers to the first name outright,
because that is what this frame is called; the other two are **the reference's own frames,
loaded hidden off the chain**, and neither may ever be aliased onto this one again.
`UIOptionsFrame` (interface). **All three are now the reference's own frames, loaded
hidden off the player's chain**, and none of them may ever be aliased onto this one.
**`UIOptionsFrame`** is loaded at the manifest's l.21 seat, so that name is a real frame
with real children — the whole point of 2115: pfUI's
`skins/blizzard/options-interface.lua` does `UIOptionsFrame:SetWidth(1024)` and then walks
`UIOptionsFrameCheckButton1..69`. Under the old alias that `SetWidth` landed on THIS
window, the one the player opens.
The last of the three to arrive was the VIDEO window, and it is the one that took a name
off this file: the frame above was called `OptionsFrame` for its whole life, and
`Interface\FrameXML\OptionsFrame.xml` declares that name itself. Two frames cannot hold
one global — `CreateFrame` publishes into `_G` and the later one simply wins, silently —
and neither ordering is survivable. Ours later CLOBBERS the stock frame, and pfUI's
`skins/blizzard/options-video.lua` then runs `CreateBackdrop`, `EnableMovable`,
`HookScript(…, "OnShow", …)` and `OptionsFrameHeader:SetTexture("")` against the window the
player opens. Ours earlier is no better: the skin's `OptionsFrameSlider1..9` and
`OptionsFrameCheckButton1..18` would find the stock frame's children while its
`CreateBackdrop(OptionsFrame)` found ours — half a skin, on two different windows. So the
reference's name went back to the reference's window and this one became
`BenillaOptionsFrame`, the `Benilla…` convention every other frame of our own already
wears (`BenillaScriptLogFrame`, `BenillaScrollBarTemplate`).
**`SoundOptionsFrame` is no longer aliased either**, and the reason it had to go is
sharper than the reason it existed. Our file loads AFTER the stock one, so the alias would
not have sat harmlessly beside the real frame — it would have **clobbered it**, and pfUI's
`skins/blizzard/options-sound.lua` would then `StripTextures`, `CreateBackdrop`,
`EnableMovable` and re-anchor THIS window. That is 2115 §2's harm restored, one window
over.
**What the rename cost, and where each cost is paid.** Three things keyed on the old name
had to be restated rather than lost:
· the **panel-manager row** — `UIParent.lua:16`'s `UIPanelWindows["OptionsFrame"]`, which
gave this window the centre slot and the while-dead permission. Restated below, verbatim.
· the **ESC ladder and the micro button** — `ToggleGameMenu`, `IsOptionFrameOpen` and
`UpdateMicroButtons` all name the reference's three windows literally, so the whole chain
now reads "no" while ours is up. Three wrappers in `GameMenuFrame.xml` add the one term
the reference could not know about; that file is the manifest's last row, which is where
`UpdateMicroButtons` finally exists to wrap.
· the **`OptionsFrameCancel` alias** — RETIRED, and by the sharper of the two arguments.
It pointed the reference's name at our Close button so `ToggleGameMenu`'s
`OptionsFrameCancel:Click()` would discard-and-hide this window. That button is now a
real frame in the stock video window, and this file loads after it, so the alias would
not sit beside it — it would **clobber** it, and pfUI's video skin's
`SkinButton(OptionsFrameCancel)` would land on ours. The ESC rung it existed for is the
`ToggleGameMenu` wrapper's now, calling our own Close button by our own name.
**Losing it costs nothing.** Its two stock consumers —
`MainMenuBarMicroButtons.lua` l.47-52 (`UpdateMicroButtons`) and `UIParent.lua:997`
(`IsOptionFrameOpen`) — read all three names UNGUARDED in one `or` chain, and that chain
already contains `OptionsFrame:IsVisible()`, which is this frame's own name. The
`SoundOptionsFrame` term goes from true-via-alias to false-via-real-hidden-frame, and in
both consumers it was redundant with a term that is already true. So while ours is up both
still answer yes, and while it is down both still answer no. 2115 §1 read that alias as
load-bearing for those two; walking them says it never was.
Nothing of ours may `Show()` any of the three stock frames; each is `hidden="true"` in its
own file and that is what keeps them off the screen.
Nothing of ours may `Show()` either stock frame; both are `hidden="true"` in their own
files and that is what keeps them off the screen.
**`OptionsFrameCancel` stays**, and it is the ESC ladder's: the stock `ToggleGameMenu`
(UIParent.lua l.1465-1497, off the chain since 1988) closes an open options window with
`OptionsFrame:IsVisible()` → `OptionsFrameCancel:Click()`. Ours has a corner X whose click
is the same discard-and-hide (`HideUIPanel(OptionsFrame)`, and OnHide discards the pending
page), so that button answers to the reference's name.
**One behaviour changes because the alias went, and it is the right one.**
**One behaviour changed when the `UIOptionsFrame` alias went, and it is the right one.**
`FloatingChatFrame.lua` l.842/860 gate the chat tab and dock hover on
`not UIOptionsFrame:IsShown()`. That now reads the hidden stock frame, so chat-tab hover
`not UIOptionsFrame:IsShown()`. That reads the hidden stock frame, so chat-tab hover
works while OUR options window is open. The reference's gate exists because ITS interface
window is `frameStrata="FULLSCREEN"` with `setAllPoints` and covers the chat; ours is a
panel that does not. No adapter is added for it (2115).
BENILLA_ALLOW_OWN_UI=1 -->
<Script><![CDATA[
OptionsFrameCancel = OptionsFrameCloseButton
-- ══ THIS WINDOW'S PANEL-MANAGER ROW ══════════════════════════════════════════════════
-- `UIPanelWindows` is the table `ShowUIPanel` reads to place a window: `area` picks the
-- slot ("center" is the native-centre one, which no side panel may push aside),
-- `pushable` its rank within that slot, `whileDead` whether it may open with the player
-- dead. A frame with NO row gets none of it — `ShowUIPanel` falls through to a bare
-- `Show()`, unplaced, and death closes it.
--
-- Until now this frame rode the REFERENCE's row. `UIParent.lua:16` keys one on the name
-- `OptionsFrame`, and that was this frame's name, so the game menu's Options button got
-- the centre slot and the while-dead permission for free. That name is now the stock
-- VIDEO options window's, so the row keyed on it is ITS row — and this window needs its
-- own. The three values below are the reference's own, copied verbatim from that line:
-- the placement our window has always had, now stated under the name it now answers to.
--
-- SAFE TO RUN HERE: `UIParent.xml` — which carries `UIParent.lua`, where `UIPanelWindows`
-- is created — is the manifest's 7th row (`benilla.toc` l.144) and this file is its 38th
-- (l.522), so the table is real by the time this executes.
UIPanelWindows["BenillaOptionsFrame"] = { area = "center", pushable = 0, whileDead = 1 }
]]></Script>
</Ui>
+59 -47
View File
@@ -139,8 +139,10 @@ Interface\FrameXML\WorldFrame.xml
# the reference keeps it.
# What stayed ours moved out: the placeholder icon (ScrollTemplates.xml; the window tab template
# and its settle went with it and are the chain's since 1993), 0561's bag verbs (ContainerFrameAdapters.xml), the script log's row
# (ScriptLogFrame.xml), and three aliases in OptionsFrame.xml that answer the reference's three
# options windows and its Cancel button.
# (ScriptLogFrame.xml). The three aliases OptionsFrame.xml used to carry for the reference's three
# options windows and its Cancel button are all gone: each of those four names is a REAL frame off
# the chain now (2115, 2147, 2177), and the last of them — the video window's `OptionsFrame` — is
# why our own window is `BenillaOptionsFrame`.
Interface\FrameXML\UIParent.xml
# ══ THE ITEM-SLOT BASE — the reference's own, off the player's chain (1751, window 24) ══
# `ItemButtonTemplate` and the eight `SetItemButton*` verbs out of `ItemButtonTemplate.lua`. It
@@ -375,55 +377,62 @@ ScrollTemplates.xml
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# ══ THE OPTIONS WIDGET KIT — the reference's own, off the player's chain (1751, window 29) ══
# The four templates addons name for an options panel: `OptionFrameBoxTemplate`,
# `OptionsButtonTemplate`, `OptionsCheckButtonTemplate`, `OptionsSliderTemplate`. Self-contained —
# no `.lua`, no `<Include>` — and a descendant of UIPanelTemplates (`OptionsCheckButtonTemplate`
# inherits `UICheckButtonTemplate`, `OptionsButtonTemplate` inherits `UIPanelButtonTemplate`), so
# it stays seated after that file, which is the line above.
# ══ THE STOCK VIDEO OPTIONS WINDOW — loaded, and HIDDEN, in the reference's own l.19 seat ══
# `FrameXML.toc` l.19, whole: the file, its `<Script file="OptionsFrame.lua"/>` (l.3) and its
# `<Include file="OptionsFrameTemplates.xml"/>` (l.4), which is why ONE row replaces the three
# this manifest used to carry. Both of those were explicit rows here while the window itself was
# not loaded (2115 took the `.lua` for its seven shared helpers and the templates for the kit);
# the window brings them itself now, in its own order, so a second row for either would re-run a
# file that has already run.
#
# THE REFERENCE NEVER GIVES THIS FILE A TOC LINE EITHER: its **video** `OptionsFrame.xml` — toc
# l.19 — pulls it in with `<Include file="OptionsFrameTemplates.xml"/>` at that file's l.4. We do
# not load the video window (decision 2115 says why: its resolution dropdown's OnLoad calls
# `GetCurrentResolution`/`GetScreenResolutions`, nine unbuilt verbs), so the row is explicit and
# it sits in l.19's own seat — which is what puts it here rather than 200 lines below, where it
# used to sit before 2115.
# **Why the window is here at all** (decision 2177). The same argument 2115 made for the Interface
# window and 2147 for the Sound one, and this is the last of the three: an addon that reaches for
# the reference's video-options names finds REAL frames and real functions, because the file is the
# player's own. `hidden="true"` on its own main frame is the whole mechanism — nothing of ours
# shows it, ever. pfUI's `skins/blizzard/options-video.lua` walks `OptionsFrame`,
# `OptionsFrameHeader`, `OptionsFrameSlider1..9`, `OptionsFrameCheckButton1..18`, the three
# dropdowns and `OptionsFrameCancel`/`Okay`/`Defaults`; `CT_Viewport` calls `GetScreenResolutions`
# and `GetCurrentResolution`.
#
# **THE SEAT IS A REQUIREMENT, not tidiness.** `UIOptionsFrame.xml` below declares
# `UIOptionsCheckButtonTemplate` as `inherits="OptionsCheckButtonTemplate"`, resolved at LOAD, and
# that name is this file's. l.19 before l.21 is exactly the reference's own answer to that.
# **IT TOOK A NAME BACK, and that is the load-bearing part.** This file declares `OptionsFrame`,
# which was OUR options window's name until 2177. Two frames cannot hold one global, and no load
# order survives the collision: ours later clobbers the stock frame (pfUI's video skin then strips
# and re-anchors the window the player opens), ours earlier leaves the skin split across two
# windows. So our window is `BenillaOptionsFrame` now (`OptionsFrame.xml` below carries the whole
# argument, plus the three things that were keyed on the old name and had to be restated).
#
# `framexml-transcription-diff.py` reports no handler our transcription was missing, and the swap
# GIVES BACK the reference's own inline hover: ours had factored it into a shared
# `BenillaOptionsWidget_ShowTooltip`, where the reference open-codes it once per widget.
Interface\FrameXML\OptionsFrameTemplates.xml
# ══ THE VIDEO WINDOW'S LUA, WITHOUT ITS WINDOW — the seven helpers (decision 2115) ══════════
# `Interface\FrameXML\OptionsFrame.lua` is the reference's toc l.19 file's own `<Script>`, and
# this row is that half of l.19 alone. **We deliberately do not load the video window's XML**
# (2115): its resolution dropdown's `OnLoad` calls `GetCurrentResolution()`/`GetScreenResolutions()`
# and nine video verbs are unbuilt, and eighteen of its checkboxes are 2004 hardware toggles.
# **The seat is a requirement three times over**, and it is the reference's own answer to all three:
# * `OptionsFrameTemplates.xml` (arriving via the `<Include>`) is a descendant of
# UIPanelTemplates — `OptionsCheckButtonTemplate` inherits `UICheckButtonTemplate`,
# `OptionsButtonTemplate` inherits `UIPanelButtonTemplate` — so it must land after the row
# above, and l.19 after l.17 is exactly that.
# * `UIOptionsFrame.xml` (l.21, below) declares `UIOptionsCheckButtonTemplate` as
# `inherits="OptionsCheckButtonTemplate"`, resolved at LOAD. l.19 before l.21.
# * this file's three dropdowns inherit `UIDropDownMenuTemplate` AND their `<OnLoad>` calls
# `UIDropDownMenu_Initialize`, which runs the initializer it is handed immediately
# (`UIDropDownMenu.lua` l.48-50). The reference seats `UIDropDownMenu.xml` at l.15 for that
# reason; ours is manifest row 14 (l.216), far above.
#
# The `.lua` is a different thing: it is pure declarations and function definitions, and its last
# fifty lines (l.462-515) are SEVEN shared helpers `UIOptionsFrame.lua` calls and nothing else
# defines — `OptionsFrame_Enable/DisableCheckBox`, `.../Slider`, `.../DropDown` and
# `PlayClickSound`. `UIOptionsFrame_Load`, `_SetDefaults` and `_UpdateDependencies` call all seven,
# so the row below would load clean and die on pfUI's first `UIOptionsFrame_Load()` without this.
# WHAT LOADING IT COSTS, measured rather than assumed — the two `<OnLoad>` paths that run engine
# verbs on the spot, and nothing else: the resolution dropdown's (`GetCurrentResolution`,
# `GetScreenResolutions`) and the refresh dropdown's (`GetRefreshRates`, plus `GetCVar("gxRefresh")`
# — unregistered, so a nil and a warn-once, which `UIDropDownMenu_SetSelectedValue` takes). The
# multisample dropdown's third path was already answered (2163). Everything else in the file —
# `OptionsFrame_Load`, `_Save`, `_Cancel`, `_SetDefaults` and the eighteen 2004 hardware toggles —
# hangs off `<OnShow>` and the three buttons, which nothing of ours reaches.
#
# WHAT ELSE IT PUBLISHES, and why each is fine:
# * `OptionsFrameCheckButtons` — the VIDEO window's option table (18 rows). A name 1.12 has and
# we did not; its `OptionsFrameCheckButtonN` frames do not exist here, exactly as they do not
# for any window we have not built.
# * `OptionsFrameSliders` — the video slider table, which **our own `OptionsFrame.xml` overwrites
# below** with the three rows our Graphics page actually drives (pfUI's `hdgraphic` module
# writes `OptionsFrameSliders[3].maxValue`). Order decides collisions and ours is later; that
# is the manifest's rule and this is the PaperDollFrame.lua shape (`ui_script::reference_ui`).
# WHAT IT PUBLISHES that we do not, and why each is right:
# * `OptionsFrameCheckButtons` / `OptionsFrameSliders` — the video window's own two tables, with
# the frames behind them for the first time. `OptionsFrame.xml` below **used to overwrite the
# slider table** with three rows of its own and no longer does: those three WERE the
# reference's rows 1-3, verbatim, and our Graphics page reads them off the reference's table
# now (2177).
# * the seven shared `OptionsFrame_Enable/DisableCheckBox` / `…Slider` / `…DropDown` helpers and
# `PlayClickSound`, which `UIOptionsFrame.lua` calls and nothing else defines — 2115 took this
# `.lua` for them alone, and they arrive the same way, one seat earlier in the file.
# * `OPTIONS_FARCLIP_MIN`/`_MAX`, `ANISOTROPIC_VALUES`, `OPTIONS_FRAME_WIDTH` — constants we do
# not declare.
# * `OptionsFrame_Init/_Load/_Save/_Cancel/_SetDefaults/_UpdateCheckboxes` and the video
# dropdowns' handlers — the video window's own, reaching frames that do not exist. Nothing in
# this manifest calls them; an addon that does gets a raise where it used to get "call a nil
# value", which is not worse and is 1203's "loudly absent" either way.
Interface\FrameXML\OptionsFrame.lua
Interface\FrameXML\OptionsFrame.xml
# ══ THE STOCK SOUND OPTIONS WINDOW — loaded, and HIDDEN, by 2115's own argument ══════════════
# The reference's `FrameXML.toc` seats this file at l.20, exactly between the video window (l.19,
# whose `.lua` is the row above) and the interface window (l.21, the row below). Ours now does too,
@@ -515,7 +524,8 @@ KeyBindingsPage.xml
# The options window (the 0985 provenance split: 0950's era structure, 0978's
# 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
# cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
# page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
# page): the era-shaped `BenillaOptionsFrame` the game menu's Options button opens
# (`OptionsFrame` is the stock VIDEO window's own name again since 2177). Needs
# Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
# (all far above); sits right before GameMenuFrame so the menu — which reaches it by
# name at click time — stays the last, outermost layer.
@@ -571,8 +581,10 @@ Interface\FrameXML\PetActionBarFrame.xml
# UpdateMicroButtons this file defines, and that function reads CharacterFrame, SpellBookFrame,
# QuestLogFrame, GameMenuFrame, OptionsFrame, SoundOptionsFrame, UIOptionsFrame, FriendsFrame,
# WorldMapFrame, HelpFrame and KeyRingButton UNGUARDED at call time, so every one of them is up
# before anything can be shown. `SoundOptionsFrame` and `UIOptionsFrame` are the reference's own
# frames now, both loaded hidden above; `OptionsFrame` is our window's own name. All three answer.
# before anything can be shown. All three options windows are the reference's own frames now,
# loaded hidden above — `OptionsFrame` (2177), `SoundOptionsFrame` (2147), `UIOptionsFrame` (2115) —
# so all three answer, and none of them is ever visible. What that chain CANNOT see is our own
# window: `GameMenuFrame.xml`, the manifest's last row, wraps `UpdateMicroButtons` to add it.
Interface\FrameXML\MainMenuBarMicroButtons.xml
# ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain
# (1751; decision 1974) ══
@@ -1616,6 +1616,39 @@ fn seat_a_session(script: &mut UiScript) {
// any, so `GetCVar` answered nil for every name the client actually ships. Same class as the
// empty addon registry: a state the real client cannot be in.
script.register_cvars(crate::cvars::registered_pairs());
// **A session runs on a DISPLAY, and until 2177 this one ran on none.** The host pushes the
// offered resolutions and the live one (`crate::video::publish_display_modes`); a VM with no
// window behind it gets an empty list, `GetScreenResolutions()` returns nothing and
// `GetCurrentResolution()` answers 0 — a state the real client cannot be in, and exactly the
// 1193/1212 fault this whole function exists to close. `CT_Viewport` is what found it: it
// reads its own screen size as `arg[GetCurrentResolution()]` at load and then indexes
// `CT_Viewport.screenRes` from an event handler, so an empty list turned "the verb is missing"
// into a nil-index two lines later — a moved symptom that would have read as a gap of ours.
//
// One mode, and it is the one this survey already tells the interface it is running at
// (`set_screen_size(1024, 768)` above): a display that offers exactly what the client is using.
// Anything richer would be numbers invented for an instrument.
script.set_screen_resolutions(
vec![benilla_ui::script::ScreenResolution {
width: 1024,
height: 768,
}],
Some(benilla_ui::script::ScreenResolution {
width: 1024,
height: 768,
}),
);
// What this run's device offers, as `crate::cvars` pushes it in the app — same values, because
// six of the seven are properties of this client rather than of the adapter.
script.set_video_caps(benilla_ui::script::VideoCaps {
anisotropic: true,
pixel_shaders: true,
vertex_shaders: true,
trilinear: true,
triple_buffering: false,
max_anisotropy: *benilla_assets::ANISO_RANGE.end(),
hardware_cursor: true,
});
script.set_realm_name("Harness");
// THE BIND POINT. `GetBindLocation()` answered `""` in every VM, and a logged-in character with
// no hearth location is not a state one is in — the server sends `SMSG_BINDPOINTUPDATE` at
+1 -1
View File
@@ -1176,7 +1176,7 @@ mod tests {
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
let mut s = crate::ui_script::keybindings_tests::harness();
crate::ui_script::keybindings_tests::on_page(&mut s);
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
// Expand Movement and arm JUMP's first capsule — JUMP is the classic wheel bind, and one
// of the 1.12 commands that is NOT `runOnUp`, so the reference accepts the wheel on it.
s.run(&format!("{ROW}1Header:Click()")).expect("expand");
+13 -13
View File
@@ -1033,7 +1033,7 @@ pub(super) fn seed_ui_fixture(
// capture pins: the era chrome (nine-slice seams, right-edge straddle), the tab
// plates, the search-box seat, the category list art with Controls selected (the
// OnShow default), and the window's fit scale.
if let Err(e) = script.run("ShowUIPanel(OptionsFrame)") {
if let Err(e) = script.run("ShowUIPanel(BenillaOptionsFrame)") {
warn!("capture: ui-options seed failed to open the window: {e}");
}
}
@@ -1045,9 +1045,9 @@ pub(super) fn seed_ui_fixture(
// no CvarPlugin file load to race, and the rows must read real values, not the
// nil-tolerant zeros — then open and select through the live paths.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowAudio:Click()")
{
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAudio:Click()",
) {
warn!("capture: ui-options-audio seed failed: {e}");
}
}
@@ -1059,7 +1059,7 @@ pub(super) fn seed_ui_fixture(
// open-and-select paths.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowGraphics:Click()")
script.run("ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowGraphics:Click()")
{
warn!("capture: ui-options-graphics seed failed: {e}");
}
@@ -1073,9 +1073,9 @@ pub(super) fn seed_ui_fixture(
// `ChatFrame.xml` declares at file scope, so a hermetic capture sees the shipped "0"
// and the row paints unchecked — which is the shipped default, not a missing load.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowChat:Click()")
{
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowChat:Click()",
) {
warn!("capture: ui-options-chat seed failed: {e}");
}
}
@@ -1108,8 +1108,8 @@ pub(super) fn seed_ui_fixture(
// the call that asked, so there is no settle to wait out.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowControls:Click(); \
BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
) {
warn!("capture: ui-options-dropdown seed failed: {e}");
}
@@ -1126,8 +1126,8 @@ pub(super) fn seed_ui_fixture(
script.register_cvars(crate::cvars::registered_pairs());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); \
OptionsFrameCategoryListRowKeybindings:Click(); \
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
@@ -1145,7 +1145,7 @@ pub(super) fn seed_ui_fixture(
// advance law's visual regression guard.
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameSearchBox:SetText(\"volume\"); OptionsFrameSearchBox:SetFocus()",
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameSearchBox:SetText(\"volume\"); BenillaOptionsFrameSearchBox:SetFocus()",
)
{
warn!("capture: ui-options-search seed failed: {e}");
+65 -1
View File
@@ -717,6 +717,19 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is
// the host, exactly like `cameraSmoothTrackingStyle` above it.
same("cameraTargetSmoothSpeed", "90"),
// **`cameraWaterCollision`** `[0xbe1088]` "1" (`0x50bd63`, default string `0x82e748`) — one of
// the two that ship ON, and this row is its THIRD life. It is **one CVar with two consumers**,
// and this tree has now shipped each of them alone and broken the camera both times: 2149 the
// pivot corridor without the trace (a 19/18 yd step reached continuously), 2170 the trace
// without the corridor (the boom straddling a plane the pivot sits 11 mm under — 2173 §1).
// Both halves are here now, and the row exists to say they may never again be separated.
//
// `0x50e5ec` produces one register. Its `0xf0000` nibble rides the trace mask to all three of
// `0x50e570`'s queries, reaching `0x69cc13` through four direct calls; and `0x50e629` tests
// the SAME register to admit the floor/cap block that lifts the sweep origin to
// `surface + 2/9`. Readers: the camera boom's collision filter
// (`benilla_world::collision::camera_filter`) and `player::camera_water`.
same("cameraWaterCollision", "1"),
// `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the
// four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism:
// wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and
@@ -1452,6 +1465,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in
// `benilla_ui`'s `SetCVar` path, not here.
"camerapivot" => knobs.camera_opts.pivot = v != 0.0,
"camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0,
"camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v,
"camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v,
"cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v,
@@ -1837,8 +1851,39 @@ fn sync_cvars(
)
.collect(),
);
// **What `GetVideoCaps` answers with** (decision 2177) — the seven values the stock video
// window's `OptionsFrame_Load` destructures. Pushed beside the multisample list because it
// is the same kind of fact: what this client's device and presentation path really offer,
// which the VM has no way to ask.
//
// Six of the seven are properties of the client rather than of the adapter, and each is
// true here by construction:
// * shaders — wgpu has no non-programmable path; there is no fixed-function fallback to
// be missing. The reference asked because 2004 hardware could genuinely lack them.
// * trilinear and anisotropy — `benilla_assets::tex_filter` builds every sampler with
// both available; `ANISO_RANGE`'s top is the ceiling the `anisotropic` CVar clamps to
// and is reported raw, because `OptionsFrame.lua:124` matches it against
// `ANISOTROPIC_VALUES = {"1","2","4","8","16"}` with `tonumber` and ignores a value
// that is not one of them.
// * the hardware cursor — `crate::cursor` composites the reference's own
// `Interface\Cursor\*.blp` into an OS cursor on every target (an `NSCursor` on macOS,
// winit's `CursorIcon::Custom` elsewhere).
// * triple buffering — **false, and it is the one that does visible work**. wgpu's
// surface decides its own buffering and benilla exposes no knob, so the reference's own
// `OptionsFrame_Load` hides check button 13 and re-seats button 6 against button 5
// (`OptionsFrame.lua:168-175`). Answering `true` would light a checkbox writing a CVar
// nothing reads — 2115 §2's wrong answer that succeeds.
script.set_video_caps(benilla_ui::script::VideoCaps {
anisotropic: true,
pixel_shaders: true,
vertex_shaders: true,
trilinear: true,
triple_buffering: false,
max_anisotropy: *benilla_assets::ANISO_RANGE.end(),
hardware_cursor: true,
});
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 84] = [
let session: [(&str, String); 85] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1883,6 +1928,7 @@ fn sync_cvars(
"cameraTargetSmoothSpeed",
camera_opts.target_smooth_speed.to_string(),
),
("cameraWaterCollision", flag(camera_opts.water_collision)),
("cameraTerrainTilt", flag(camera_opts.terrain_tilt)),
(
"cameraGroundSmoothSpeed",
@@ -2407,6 +2453,14 @@ mod tests {
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
assert!(camera_opts.pivot, "the binary registers cameraPivot \"1\"");
assert_eq!(
d["cameraWaterCollision"] != 0.0,
camera_opts.water_collision
);
assert!(
camera_opts.pivot && camera_opts.water_collision,
"the binary registers cameraPivot and cameraWaterCollision both \"1\""
);
assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt);
assert!(
!camera_opts.terrain_tilt,
@@ -2774,6 +2828,16 @@ mod tests {
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_FRILL_DENSITY));
// **`RestoreVideoDefaults`' row list, welded the same way** (2177). It lives in
// `benilla-ui` beside the binding that walks it and cannot see this table, so a rename or
// a retirement here would turn one of its rows into a silent skip — the verb would restore
// eleven settings out of twelve and say nothing. This is the only place that can notice.
for key in benilla_ui::script::VIDEO_DEFAULT_CVARS {
assert!(
REGISTERED.iter().any(|r| r.name.eq_ignore_ascii_case(key)),
"{key}: RestoreVideoDefaults would restore it, and nothing registers it"
);
}
for (n, frill) in benilla_ui::script::WORLD_DETAIL_STOPS.iter().enumerate() {
assert_eq!(
*frill,
+1
View File
@@ -55,6 +55,7 @@ mod world_focus;
// 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 camera_water;
// The five NAMED camera poses the player can jump between (decision 1745) — `camera_saved`'s
// complement: that one remembers where you left the camera, this one where you decided it should
// be able to go. Same reason for living inside `player/`: it writes the rig's `pub(super)` fields.
+66 -2
View File
@@ -1266,9 +1266,58 @@ pub(super) fn seat_on_subject(
// switch move the camera smoothly instead of teleporting it ([`PivotGlide`]; wow-re
// `pivot-height-glide.md`). A far-sight subject supplies the target the same way the body
// does — one channel, whatever it is looking at.
let orbit_pivot = rig
let live_pivot = rig
.pivot
.advance(view.remote.map(|v| v.pivot_height).or(body_pivot), dt);
// **`cameraWaterCollision`'s corridor** — the half 2149 and 2170 each shipped without, and the
// reason the option is atomic. [`super::camera_water`] carries the block, the byte sites and
// the why; this is only the wiring.
//
// Classified against the channel's **target** (`[cam+0x1c8]`), never its live value — the
// polarity three of wow-re's seven cold workers inverted, arbitrated at the bytes. Banding
// against a continuously-eased quantity chatters on its own, independently of the corridor.
//
// `headroom` is `1.0`: the reference scales the reach by the hit fraction of a vertical
// head-room probe at `0x50e6bd` that this client has never had. Absent and named, not stubbed
// (1203) — a low ceiling over a swimmer keeps the full reach, which is a pre-existing gap in
// the pivot rather than something the corridor introduces.
let (_, pivot_target) = rig.pivot.probe();
let (band, depth) = super::camera_water::classify(
// Our own body's cached surface, and only when the camera is watching our own body:
// `0x511ad0` reads the camera TARGET's liquid object and far sight carries none.
view.remote
.is_none()
.then_some(dynamics.surface_y)
.flatten(),
orbit_pos.y,
pivot_target,
);
let corridor = if dynamics.options.water_collision {
super::camera_water::corridor(band, depth, live_pivot)
} else {
super::camera_water::corridor_off(live_pivot)
};
let orbit_pivot = super::camera_water::pivot_height(&corridor, pivot_target, live_pivot, 1.0);
// **And the corridor's floor lifts the SWEEP ORIGIN, which is the half that actually stops the
// pin.** The reference builds the boom's start from the clamped `*heightOut` itself
// (`0x50e786`), so in the surface band it sweeps from `surface + 2/9`; this client roots the
// boom at the capsule's top hemisphere centre instead, a pre-existing divergence that is
// harmless on land and decisive here. A surface-swimming human male's head sits
// `surface + 0.171`, and the camera probe has a `0.15` radius — so the probe starts **21 mm**
// clear of the water it is now allowed to hit, and a fraction of a degree of downward pitch
// collapses the arm from 15 yd to nothing.
//
// Measured, not reasoned: the end-to-end pitch sweep in `benilla_world::collision`
// (`a_surface_swimmers_arm_moves_smoothly_through_the_whole_pitch_range`) put that step at
// **6.65 yd** with the corridor applied to the framing pivot alone. Lifting the origin to the
// corridor floor takes it to a fraction of a yard. `max`, so this can only ever raise the
// origin: dry land and the submerge band are untouched, and nothing here can push the sweep
// start down into geometry.
let sweep_from = Vec3::new(
sweep_from.x,
sweep_from.y.max(orbit_pos.y + corridor.floor),
sweep_from.z,
);
// **`cameraTerrainTilt`'s probe and channel.** The probe looks at the ground AHEAD of the
// subject, not under it: a horizontal ray along its facing from `feet + 5/3`, its hit pulled
// back `5/18`, then a `64/9` drop — so the slope is the rise of the ground you are walking
@@ -1429,7 +1478,22 @@ pub(super) fn seat_camera(
let boom_len = boom.length().max(1.0e-3);
// The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes,
// drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh.
let hit = collide.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0);
//
// **And the waterline, under `cameraWaterCollision`** — registered `"1"`, so this is on out of
// the box. `0x50e5ec` ORs the `0xf0000` ADT-liquid nibble into the word all three of
// `0x50e570`'s queries carry, and it reaches `0x69cc13` through four direct calls, where it
// gates a Möller–Trumbore intersection (`0x7c2c40`, hit distance written at `0x7c2e5f`) over
// the chunk's four MCLQ slots. Two-sided, and with no near floor — which is precisely why the
// pivot corridor above is not optional: nothing in the trace itself stops a boom that starts
// on the water plane, so the *origin* is what has to be lifted clear.
let hit = collide.cast_camera(
cam_probe,
head,
Quat::IDENTITY,
boom,
0.0,
dynamics.options.water_collision,
);
// The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the
// driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never
// pivots. Written here because here is the only place that knows.
@@ -88,12 +88,27 @@ pub(crate) struct CameraOptions {
pub(crate) bob_frequency: f32,
/// `cameraBobbingSmoothSpeed` — the DECAY rate, see [`BOB_SMOOTH_SPEED_DEFAULT`].
pub(crate) bob_smooth_speed: f32,
/// **`cameraWaterCollision`** — registered **"1"** (`0x50bd63`, default string `0x82e748`), so
/// this is ON out of the box.
///
/// **It is one CVar with two consumers, and shipping either alone is a visible defect.** The
/// read at `0x50e5ec` produces one register: its `0xf0000` nibble rides the trace mask to all
/// three of `0x50e570`'s queries (the boom hits a bare waterline), and `0x50e629` tests the
/// *same* register to admit the pivot floor/cap block ([`super::camera_water`]).
///
/// This tree has shipped each half on its own and broken the camera both times — 2149 the
/// corridor without the trace, 2170 the trace without the corridor (2173 §1: a surface
/// swimmer's pivot sits 11 mm under the plane, so the boom straddles it every frame). The two
/// are inseparable: the corridor's surface arm is what lifts the sweep origin to
/// `surface + 2/9`, clear of the geometry the mask just switched on.
pub(crate) water_collision: bool,
}
impl Default for CameraOptions {
fn default() -> Self {
Self {
pivot: true,
water_collision: true,
pivot_dx_max: PIVOT_DX_MAX_DEFAULT,
pivot_dy_min: PIVOT_DY_MIN_DEFAULT,
target_smooth_speed: TARGET_SMOOTH_SPEED_DEFAULT,
@@ -129,6 +144,16 @@ pub(super) struct DynamicsInput {
/// argument. It rides here because it is a camera CVar reaching a camera kernel, which is what
/// this struct is for (2163).
pub(super) nearclip: f32,
/// **The liquid surface over the driven body's feet** (Bevy-Y), as the last movement tick
/// cached it — [`crate::player::state::Player::liquid_surface`]. Feeds
/// [`super::camera_water::classify`], the half of `cameraWaterCollision` that is not the trace
/// mask.
///
/// Our own body's, so it is `None`-equivalent for a far-sight subject: `0x511ad0` reads the
/// camera TARGET's liquid object, and `view_subject` carries no movement state for a watched
/// unit — the same gap the swim framing preset has, recorded rather than papered over. A
/// totem watched across a lake gets the dry-land corridor.
pub(super) surface_y: Option<f32>,
}
/// What the four mechanisms' gates need to know about the **followed unit** — the conjuncts that
@@ -0,0 +1,368 @@
//! **The water corridor** — `cameraWaterCollision`'s *other* consumer, and the reason the option is
//! atomic.
//!
//! One CVar read at `0x50e5ec` produces one register, and that register has **two** consumers:
//!
//! * `esi`'s `0xf0000` nibble rides the trace mask to all three of `0x50e570`'s queries, so the
//! camera boom hits a bare waterline ([`benilla_world::collision`]); and
//! * `0x50e629 test esi,0xf0000` admits the **floor/cap block** transcribed here, which re-bases the
//! framing pivot against the waterline *before* the boom's origin is built from it (`0x50e786`).
//!
//! **Shipping either half alone is a visible defect, and this client has now shipped each once.**
//! 2149 built the corridor without the trace and put a discontinuity on screen; 2170 built the trace
//! without the corridor and pinned the camera to the waterline, because a surface swimmer's framing
//! pivot sits 11 mm *under* the water plane and the boom then straddles it every frame (2173 §1).
//! The floor is what lifts the sweep origin clear — to `surface + 2/9` — so the boom never starts
//! near the plane. Neither half is optional; see [`corridor`].
//!
//! Transcribed from wow-re `ui/scratch/camera-water-corridor-spec.md` §1–§4 and §7 (a two-round §5:
//! seven cold workers, byte arbitration on the `(live, target)` polarity, and a rate sweep of the
//! verified loop). Constants: `[0x8089cc] = 5/6`, `[0x8089d0] = 2/9`, `[0x8089d4] = 5/9`,
//! `[0x808a04] = 1/9`.
//!
//! **§5 · The two catch-up blocks, named and NOT built** (`camera-catchup-blocks.md`).
//!
//! `0x50eeb0` (pivot height) and `0x50ee5d` (distance) are a matched pair that pull a live channel
//! down when it has drifted more than `1/9` above what the solver just produced:
//! `live > H + 1/9` strictly (equality and NaN skip), then a **hard store** of `H + 1/9 + 2⁻²⁰`
//! into the live field, plus an ease start at that value with duration **`2.0` s** — and the
//! channel's *target* deliberately left alone, so the ease walks back to where the camera was
//! always headed. Both can only ever decrease their field: `0x50e8bf` caps the arm at
//! `[cam+0xec]`, so `live − H ≥ 0` always, and no mirror block exists.
//!
//! **They are absent together, on purpose.** Three reasons, in order of weight:
//!
//! 1. **They are one mechanism, not two.** `ecx = 2.0f` — the duration for *both* — is loaded once
//! at `0x50ee63`, inside the DISTANCE block's predicate. Lifting `0x50eeb0` alone stores
//! garbage for its duration. This module's entire history is shipping one half of a pair
//! (2149, 2170, 2173); a spec that opens by warning against it gets taken at its word.
//! 2. **Neither is gated on `cameraWaterCollision`** — control reaches them on two edges with no
//! band bit, CVar or mode flag read anywhere in `0x50eeb0`–`0x50eef8`. They are general camera
//! behaviour that this option merely *exposes*, and the distance twin changes what zoom does
//! after any collision, on land as much as in water. That is its own change to a hot path,
//! with its own gate to earn.
//! 3. Their only effect here is on the corridor's **upper** edge, and it makes an already-small
//! artifact smaller. The lower edge — the one you can actually see — is unaffected: the eye is
//! stored at `0x50ede5` before either block runs.
//!
//! Also underived, and flagged rather than guessed: whether the followed object's `z` is swept or
//! pinned entering the submerge band. `CMovement::UpdateLiquid` (`0x6a7650`) hands the physics step
//! a plane at exactly `z = liquidSurfaceZ`, which rules out a fixed offset but does not settle it,
//! and it sits in unresolved tension with the `0x610520` buoyancy equilibrium `|depth − 0.75h| <
//! 0.001`. This client builds the pinned reading.
/// `[0x8089cc]` — the corridor's resting floor, and the depth the pivot is pinned to while the
/// camera target is in the SUBMERGE band. Also the cap's own floor there.
const REST: f32 = 5.0 / 6.0;
/// `[0x8089d0]` — the SURFACE band's width, and the height above the waterline the floor lifts the
/// pivot to inside it. **This is the number that makes the trace survivable**: 2/9 yd of clearance
/// between the sweep origin and the plane it would otherwise start on top of.
const SURFACE_BAND: f32 = 2.0 / 9.0;
/// `[0x8089d4]` — the SUBMERGE band's outer edge. Its **only** reference image-wide is `0x511b83`,
/// in the classifier (three independent censuses) — it is emphatically *not* the constant in the
/// corridor's arm B, which is [`REST`]. 2149 shipped `5/9` there and stepped `7/9` where the
/// reference steps `19/18`; that single substitution is most of why its numbers came out at
/// 1.036/0.293 instead of 1.0556/0.2778.
const SUBMERGE_EDGE: f32 = 5.0 / 9.0;
/// `[0x808a04]` — the minimum head-room the pivot keeps above the corridor floor, and the offset
/// the catch-up blocks of §5 would pin their live field to.
const MIN_HEADROOM: f32 = 1.0 / 9.0;
/// **Which liquid band the camera target is in** — the two bits `0x511ad0` writes into `[cam+0x90]`
/// (`0x100000` surface, `0x200000` submerge) and the only thing image-wide that writes either.
///
/// `0x511ae3` clears **both** unconditionally before any test, which is why there is no hysteresis
/// anywhere in this mechanism: every frame re-derives the band from scratch. That matters for what
/// can and cannot hide a step — see [`corridor`]'s note on the lower edge.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum WaterBand {
/// Neither bit — no liquid, or deeper than [`SUBMERGE_EDGE`] past the pivot target.
Clear,
/// `0x100000` — the target is at or near the surface. The floor lifts the pivot to
/// `surface + 2/9`.
Surface,
/// `0x200000` — a 1/3 yd window below the surface band. The pivot is pinned to
/// `surface − 5/6`.
Submerge,
}
/// **`0x511ad0`** — classify the camera target's depth, and return `d`.
///
/// `d = liquidSurfaceZ − targetObject.position.z`: the **followed unit's own origin** (feet), read
/// through vtable slot `+0x14`, not the pivot and not `*heightOut`. `[ebp+0x8]` (the pivot base)
/// comes from the same call at `0x50e9ce`, so both are measured from one origin — which is what
/// lets `d` be compared against a pivot height at all.
///
/// **The surface height is cached, never queried here.** `0x670630` is a field accessor:
/// `0x670637 test byte ptr [ecx+0x90],0x20` gates it and `0x670640 mov eax,[ecx+0x98]` returns it.
/// An invalid or null liquid object makes `0x511ad0` return `+0.0` with both bits cleared — hence
/// `surface_y: None` mapping to [`WaterBand::Clear`] and `d = 0.0`, not to a panic or a probe.
///
/// **The comparison is against the pivot TARGET, not its live value** — the asymmetry that three of
/// seven cold workers inverted and that was arbitrated at the bytes: `[cam+0x1c8]` is the target and
/// `[cam+0xfc]` the live value (the only directional store is `0x50f3e7 call 0x5b7bb0 →
/// 0x50f3ec fstp [esi+0xfc]`; `0x50f3ff`'s `[0x1c8] := [0xfc]` is a settle guarded by
/// `|Δ| < 2⁻²²`). The classifier reads the target; the corridor seeds its cap from the live value.
/// Getting this backwards bands against a continuously-eased quantity and chatters on its own,
/// independently of any of the steps below.
pub(super) fn classify(surface_y: Option<f32>, feet_y: f32, target: f32) -> (WaterBand, f32) {
let Some(surface_y) = surface_y else {
return (WaterBand::Clear, 0.0);
};
let d = surface_y - feet_y;
let excess = d - target;
// Equality at `2/9` lands in SUBMERGE (the low side) and equality at `5/9` in `Clear` (the high
// side) — the two edges' tie-breaks go opposite ways, which is the reference's own shape and
// not a symmetry worth tidying.
let band = if excess < SURFACE_BAND {
WaterBand::Surface
} else if excess < SUBMERGE_EDGE {
WaterBand::Submerge
} else {
WaterBand::Clear
};
(band, d)
}
/// The floor/cap pair the corridor block leaves in `[ebp-0x4]` / `[ebp-0xc]`.
#[derive(Clone, Copy, Debug)]
pub(super) struct Corridor {
/// `[ebp-0x4]`, seeded [`REST`] at `0x50e604`.
pub(super) floor: f32,
/// `[ebp-0xc]`, seeded from the **live** pivot height at `0x50e615`.
pub(super) cap: f32,
}
/// **`0x50e629`–`0x50e685`** — the floor/cap block, gated on the CVar's own nibble.
///
/// | arm | predicate | floor | cap |
/// |---|---|---|---|
/// | gate off | `0x50e629 test esi,0xf0000` → `je 0x50e687` | `5/6` | `live` |
/// | **A** surface | `0x50e637 test eax,0x100000` | `d + 2/9` | `max(live, d + 2/9)` |
/// | **B** submerge | `0x50e65f test eax,0x200000` | `5/6` (untouched) | `max(d − 5/6, 5/6)` |
/// | **C** clear | both bits clear | `5/6` | `live` |
///
/// Arm A is the whole point: it pins the framing pivot at `surface + 2/9` while the target is at the
/// waterline, which is what puts 2/9 yd between the boom's origin and the water plane the same CVar
/// just made solid.
///
/// **The lower edge steps `−19/18` and that is the reference's own behaviour, not a defect.** At
/// `excess = 2/9` the pivot leaves `surface + 2/9` for `surface − 5/6` in one frame: `2/9 + 5/6 =
/// 19/18 ≈ 1.0556 yd`. Every candidate hiding mechanism was checked and refuted — no hysteresis
/// (`0x511ae3` clears both bits before any test), `d` is not quantised, and band B is a reachable
/// 1/3 yd window, and the reference's own catch-up cannot absorb it: `0x50eeb0` runs AFTER the
/// eye is stored (`0x50ede5`), and the whole function has zero backward branches.
///
/// **What keeps it off screen in normal play is that it is never approached continuously.** `d` is
/// the movement tick's pinned swim depth and the pivot target steps on the same swim flag, so
/// `d − target` is piecewise constant while swimming: a surface-swimming human male sits `0.011238`
/// into band A, `0.211` short of the edge. The corridor is crossed by **diving**, once — which is
/// exactly the event the reference wants to move the camera for. This is why 2173 §2's rule is
/// "compute where the camera lands", not "forbid every step": the step is real, intended, and
/// [`assert_bounded_step`](super::camera_channel::assert_bounded_step) is where its size is written
/// down rather than eyeballed.
pub(super) fn corridor(band: WaterBand, d: f32, live: f32) -> Corridor {
match band {
WaterBand::Surface => {
let floor = d + SURFACE_BAND;
Corridor {
floor,
cap: live.max(floor),
}
}
WaterBand::Submerge => Corridor {
floor: REST,
cap: (d - REST).max(REST),
},
WaterBand::Clear => Corridor {
floor: REST,
cap: live,
},
}
}
/// The corridor the CVar's `off` state leaves behind — `0x50e629`'s `je` straight to `0x50e687`,
/// with both seeds untouched. Identical to [`WaterBand::Clear`]'s arm, which is why turning the
/// option off is indistinguishable from standing on dry land and not a second code path.
pub(super) fn corridor_off(live: f32) -> Corridor {
Corridor {
floor: REST,
cap: live,
}
}
/// **`0x50e756` then `0x50e767`** — the clamped pivot height the boom's origin is built from at
/// `0x50e786`.
///
/// `FLOOR + max(max(target, live) − FLOOR, 1/9)`, then capped. The head-room sweep scales the
/// bracketed term by its hit fraction, so `headroom` is `1.0` on a clear probe and the hit's `t`
/// otherwise — a low ceiling pulls the pivot down toward the floor rather than through it.
///
/// **Floor first, cap second, so the cap wins on a crossed corridor** (`FLOOR + 1/9 > CAP`, which is
/// arm A's normal state whenever the live pivot is below the waterline). That ordering is what
/// actually delivers `surface + 2/9`: the floor sets the corridor and the cap collapses onto it.
///
/// Neither clamp fires on NaN — both `je`s need C0 and C3 clear, so a NaN takes the assigning
/// fall-through and the seeds stand. Rust's `max`/`min` on `f32` return the non-NaN operand, which
/// is the same outcome by a different route; a NaN `d` therefore yields the resting corridor rather
/// than poisoning the pivot, and there is a test for it.
pub(super) fn pivot_height(c: &Corridor, target: f32, live: f32, headroom: f32) -> f32 {
let reach = (target.max(live) - c.floor).max(MIN_HEADROOM) * headroom;
(c.floor + reach).min(c.cap)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::camera_channel::assert_bounded_step;
/// A human male's two numbers, both already shipping elsewhere in this tree: the swim framing
/// pivot (`cam+0x124`, off the model's Stand/Swim sequence boxes) and the depth the movement
/// tick pins a surface swimmer's feet to (`0.75 · CollisionHeight`, `h = 2.0309999`).
const SWIM_PIVOT: f32 = 1.512_012;
const SURFACE_DEPTH: f32 = 0.75 * 2.031;
/// The pivot's height **relative to the water plane** — what the player actually sees, and the
/// quantity every claim in this module is about. Feet at the origin, so `d` is the surface.
fn pivot_over_surface(d: f32, target: f32, live: f32) -> f32 {
let (band, dd) = classify(Some(d), 0.0, target);
let c = corridor(band, dd, live);
pivot_height(&c, target, live, 1.0) - d
}
/// **The defect 2170 shipped, stated as a test.** In the surface band the pivot is lifted to
/// `2/9` above the waterline — so the boom's origin starts clear of the plane the same CVar
/// makes solid, instead of 11 mm underneath it.
#[test]
fn the_surface_band_lifts_the_pivot_clear_of_the_water() {
let over = pivot_over_surface(SURFACE_DEPTH, SWIM_PIVOT, SWIM_PIVOT);
assert!(
(over - SURFACE_BAND).abs() < 1e-5,
"a surface swimmer's pivot must sit 2/9 ABOVE the plane, got {over:+}"
);
assert!(
over > 0.0,
"and above it at all — this is the whole difference between 2170 and a working camera"
);
}
/// **Why the reference never shows the lower step in normal swimming.** `d` is the movement
/// tick's pinned depth and the pivot target steps on the same swim flag, so `d − target` is
/// piecewise constant: a human male sits 0.0112 into a band 0.2222 wide. The corridor is
/// crossed by diving, deliberately, once — not grazed at the surface.
#[test]
fn a_surface_swimmer_sits_far_inside_the_band_and_never_grazes_its_edge() {
let excess = SURFACE_DEPTH - SWIM_PIVOT;
assert!(
(excess - 0.011_238).abs() < 1e-4,
"the pinned excess is 0.011238, got {excess}"
);
assert_eq!(
classify(Some(SURFACE_DEPTH), 0.0, SWIM_PIVOT).0,
WaterBand::Surface
);
assert!(
SURFACE_BAND - excess > 0.2,
"and it clears the edge by 0.211 yd, which is what makes the step below unreachable \
while swimming"
);
}
/// **The dive step, written down.** `2/9 + 5/6 = 19/18`. Not smoothed, not hidden — the
/// reference's own deliberate "the pivot drops under the water with you" event, and the number
/// 2149 got wrong (it shipped `5/9` in arm B and stepped 7/9).
#[test]
fn crossing_into_the_submerge_band_drops_the_pivot_by_nineteen_eighteenths() {
let edge = SWIM_PIVOT + SURFACE_BAND;
let above = pivot_over_surface(edge - 1e-4, SWIM_PIVOT, SWIM_PIVOT);
let below = pivot_over_surface(edge + 1e-4, SWIM_PIVOT, SWIM_PIVOT);
assert!(
(above - SURFACE_BAND).abs() < 1e-3,
"just inside: +2/9, got {above:+}"
);
assert!(
(below + REST).abs() < 1e-3,
"just past: −5/6, got {below:+}"
);
let step = below - above;
assert!(
(step + 19.0 / 18.0).abs() < 1e-3,
"the step is −19/18 = −1.0556, got {step:+}"
);
}
/// The upper edge — `+5/18`. **This is also what this client ships**, because the reference's
/// two catch-up blocks are absent (module §5): with them, leaving the submerge band steps
/// `+1/9` at most and can go negative on a fast ascent. `+0.278` yd where the reference gives
/// at most `+0.111`, once, on surfacing — smaller than the dive step above it, which is
/// intended and four times larger.
#[test]
fn leaving_the_submerge_band_with_the_live_pivot_held_is_the_closed_forms_five_eighteenths() {
let edge = SWIM_PIVOT + SUBMERGE_EDGE;
let inside = pivot_over_surface(edge - 1e-4, SWIM_PIVOT, SWIM_PIVOT);
let outside = pivot_over_surface(edge + 1e-4, SWIM_PIVOT, SWIM_PIVOT);
let step = outside - inside;
assert!(
(step - 5.0 / 18.0).abs() < 1e-3,
"the held-live step is +5/18 = +0.2778, got {step:+}"
);
}
/// **The sweep 2165 §2 exists for, and the gate this feature ships behind.** Walk the water
/// depth across BOTH edges and bound the step. Inside every band the pivot is continuous to
/// well under a millimetre per 1 mm of depth; the only steps in the whole range are the two
/// pinned above.
#[test]
fn the_corridor_is_continuous_inside_every_band() {
let t = SWIM_PIVOT;
// Band A's interior, approached from dry land and stopping short of the edge.
assert_bounded_step((t - 1.5, t + SURFACE_BAND - 0.01), 0.001, 0.002, |d| {
pivot_over_surface(d, t, t)
});
// Band B's interior.
assert_bounded_step(
(t + SURFACE_BAND + 0.01, t + SUBMERGE_EDGE - 0.01),
0.001,
0.002,
|d| pivot_over_surface(d, t, t),
);
// Past the outer edge, down to a proper dive.
assert_bounded_step((t + SUBMERGE_EDGE + 0.01, t + 4.0), 0.001, 0.002, |d| {
pivot_over_surface(d, t, t)
});
}
/// And the whole range in one walk, with the largest intended jump as the bound — the form
/// `assert_bounded_step`'s doc asks for, so a NEW cliff anywhere cannot hide behind the known
/// one being larger.
#[test]
fn the_only_jump_in_the_whole_range_is_the_dive() {
let t = SWIM_PIVOT;
assert_bounded_step((t - 1.5, t + 4.0), 0.0005, 19.0 / 18.0 + 1e-3, |d| {
pivot_over_surface(d, t, t)
});
}
/// Turning the option off is standing on dry land — the same arm, not a second path. This is
/// the assertion that keeps the two halves atomic: with the CVar clear there is no corridor
/// AND (at the call site) no liquid in the trace mask.
#[test]
fn the_option_off_is_indistinguishable_from_dry_land() {
let off = corridor_off(SWIM_PIVOT);
let dry = corridor(WaterBand::Clear, 3.0, SWIM_PIVOT);
assert_eq!(off.floor, dry.floor);
assert_eq!(off.cap, dry.cap);
assert_eq!(off.floor, REST);
}
/// No liquid object is `+0.0` with both bits clear (`0x511ad0`'s null return), and a NaN depth
/// takes the assigning fall-through rather than poisoning the pivot.
#[test]
fn a_missing_surface_is_clear_and_a_nan_depth_does_not_poison_the_pivot() {
assert_eq!(classify(None, 0.0, SWIM_PIVOT), (WaterBand::Clear, 0.0));
let (band, d) = classify(Some(f32::NAN), 0.0, SWIM_PIVOT);
assert_eq!(band, WaterBand::Clear, "NaN clears both bits");
let h = pivot_height(&corridor(band, d, SWIM_PIVOT), SWIM_PIVOT, SWIM_PIVOT, 1.0);
assert!(h.is_finite(), "the pivot stays finite, got {h}");
}
}
@@ -217,6 +217,8 @@ pub(super) fn control(
let dynamics = camera_dynamics::DynamicsInput {
options: *pointer.4,
nearclip: pointer.5.nearclip,
// Last frame's cached surface — the reference's own lag (`Player::liquid_surface`).
surface_y: player.liquid_surface,
smooth_style: pointer.3.style,
tracking_style: pointer.3.tracking_style,
subject: camera_dynamics::SubjectState {
@@ -759,6 +761,9 @@ pub(super) fn control(
// the verified `0x6030c0` boundary — B7 resolved, decision 0226) so wading the line
// doesn't flicker between the two physics regimes.
let surface_y = swim::surface_over_feet(world, player.pos);
// Cache it for the camera's water corridor, which reads it a frame later exactly as the
// reference's `0x670630` accessor does (see `Player::liquid_surface`).
player.liquid_surface = surface_y;
let swimming = swim::update_swimming(&mut player, surface_y, time.elapsed_secs());
if let Some(surface) = surface_y {
move_trace::swim(player.pos.y, surface, swimming, player.collision_height.0);
+12
View File
@@ -1016,6 +1016,18 @@ pub(crate) struct Player {
/// zero every depth line collapses to 0 and the avatar swims on dry land. It defaults to
/// [`DEFAULT_COLLISION_HEIGHT`] and is replaced once our body's display id resolves.
pub(crate) collision_height: crate::entities::CollisionHeight,
/// **The liquid surface over our feet, as the last movement tick left it** — Bevy-Y, `None`
/// when there is no liquid there.
///
/// Cached rather than re-queried, because that is what the reference reads. `0x511ad0`'s depth
/// comes through `0x670630`, which is a **field accessor** and not a query: `0x670637 test
/// byte ptr [ecx+0x90],0x20` gates it and `0x670640 mov eax,[ecx+0x98]` returns whatever the
/// movement tick last stored. The camera's water corridor
/// ([`super::camera_water::classify`]) therefore reads *this*, one frame behind, which is the
/// same lag the reference's own camera runs at — and it is load-bearing, not incidental: a
/// depth that is piecewise constant is what keeps `d - target` from grazing a band edge while
/// swimming (2173, and `camera-water-corridor-spec.md` §7).
pub(crate) liquid_surface: Option<f32>,
/// The **mover pitch** (radians, +up) — the client's persistent per-unit pitch
/// (`CMovement+0x20`, the swim §5's TU-B): **held** when unsteered (an idle floater keeps its
/// pitch — never auto-leveled; the only zeroing writer `0x7c6e80` fires from
+1 -1
View File
@@ -730,7 +730,7 @@ mod tests {
/// first of those mints a VM: the world-entry path that fills the banks
/// ([`Tutorials::world_enter`]) never runs, so a push gated on "the bank moved" never fires
/// again. `TutorialsEnabled()` then answers nil for the rest of the session, the Show
/// Tutorials row loads OFF, and ticking it back on passes `OptionsFrame_SetTutorialsEnabled`'s
/// Tutorials row loads OFF, and ticking it back on passes `BenillaOptionsFrame_SetTutorialsEnabled`'s
/// `~=` guard into `ResetTutorials()` — a `CMSG_TUTORIAL_RESET` that re-arms, account-wide,
/// every popup the player had already dismissed.
#[test]
@@ -222,8 +222,10 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "GameMenuFrame.xml");
s.run("ShowUIPanel(OptionsFrame)").unwrap();
assert!(s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"the menu is down — the options rung is what must eat this press"
@@ -232,7 +234,8 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
// Press 1: the options rung eats it — the window closes and the menu stays down.
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap(),
!s.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap(),
"ESC closed the options window"
);
assert!(
@@ -87,32 +87,32 @@ pub(crate) fn label(s: &UiScript, token: &str, raw: &str) -> String {
/// Open the options window on the Keybindings page.
pub(crate) fn on_page(s: &mut UiScript) {
s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#)
s.run(r#"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrame_SelectCategory("Keybindings")"#)
.unwrap();
assert!(s.errors().is_empty(), "on page: {:?}", s.errors());
}
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
#[test]
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
let mut s = harness();
on_page(&mut s);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsVisible()")
.unwrap(),
"Unbind Key exists on this page"
);
assert!(
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
!s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"…disabled until a capsule is selected"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The section tree: the registry's category tokens, first-appearance order — exactly
// 1.12's file order — every section a COLLAPSED header row (the era default).
@@ -178,12 +178,13 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
label(&s, "BINDING_HEADER_CHAT", "BINDING_HEADER_CHAT")
);
// Leaving the page hides its body and the Unbind button.
s.run(r#"OptionsFrame_SelectCategory("Controls")"#).unwrap();
s.run(r#"BenillaOptionsFrame_SelectCategory("Controls")"#)
.unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -202,7 +203,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"a selected capsule arms the capture"
);
assert!(
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
@@ -227,7 +228,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Key Bound Successfully"
);
@@ -241,7 +242,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
);
let victim = label(&s, "BINDING_NAME_ATTACKTARGET", "ATTACKTARGET");
assert!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap()
.contains(&victim),
"the newly-bare victim is named"
@@ -260,7 +261,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// so in red, exactly as it would for any other stolen key. That the wheel's own default
// victim is a real command is half of why the old refusal read as plausible.
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
format!(
"|cffff0000{} Function is Now Unbound!|r",
@@ -278,7 +279,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"the refused slot restored its key"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Can't bind mousewheel to actions with up and down states"
);
@@ -292,7 +293,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// otherwise: the armed seam swallows all input with no window on screen).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run("HideUIPanel(OptionsFrame)").unwrap();
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
assert!(!s.bind_capture_armed(), "OnHide disarms");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -311,7 +312,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
label(&s, "BINDING_NAME_JUMP", "JUMP")
);
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run("OptionsFrameContainerUnbind:Click()").unwrap();
s.run("BenillaOptionsFrameContainerUnbind:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "NUMPAD0" and k2 == nil"#
@@ -323,7 +324,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
s.run("OptionsFrameCloseButton:Click()").unwrap();
s.run("BenillaOptionsFrameCloseButton:Click()").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"JUMP",
@@ -332,7 +333,8 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
// Reset To Default: the page's Defaults button behind the era confirm — JUMP's real
// defaults return and the reset itself commits.
on_page(&mut s);
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s
@@ -351,20 +353,22 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
// The ladder (the ESC binding's own body): the options rung hides the window — since 1008
// that IS the whole gesture for keybinds too (live-commit; nothing to revert).
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap());
// The character-specific checkbox (1.12's set model, era confirm-on-uncheck-only law):
// CHECK switches to the character set and saves it into existence at once.
on_page(&mut s);
s.take_keybind_requests();
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s.character_bindings_exist());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
// UNCHECK is destructive: the box springs back and the 1.12 confirm decides. Cancel
// first — still on the character set.
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -372,7 +376,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
"return BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
)
.unwrap(),
"the box springs back until the popup decides"
@@ -382,7 +386,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
assert!(s.character_bindings_exist());
// Accept: back to the account set, the character set dropped (load-then-save order — the
// account file must not inherit the character binds).
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.current_binding_set(), 1);
@@ -398,29 +402,32 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
let mut s = harness();
s.run(r#"BINDING_NAME_JUMP = "Jump""#).unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.take_keybind_requests();
// A query that matches one binding and no CVar row: the Keybindings group head shows,
// with the match painted LIVE on the search pool (the era reflows its real rows the
// same way).
s.run(r#"OptionsFrameSearchBox:SetText("jump")"#).unwrap();
s.run(r#"BenillaOptionsFrameSearchBox:SetText("jump")"#)
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Description:GetText()"
)
.unwrap(),
"Jump"
);
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.unwrap());
// The result row is LIVE: its capsule arms, the bind lands and commits, the row relabels.
s.run("OptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
@@ -430,24 +437,26 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()"
)
.unwrap(),
"H"
);
// The head is the era redirect: clicking it ends the search on the Keybindings page.
s.run("OptionsFrameContainerBodySearchHeadKeybindings:Click()")
s.run("BenillaOptionsFrameContainerBodySearchHeadKeybindings:Click()")
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert_eq!(
s.eval::<String>("return OptionsFrame.selectedCategory")
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
"Keybindings"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -518,7 +527,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
KeyBindingsPage_Update()"#,
)
.unwrap();
const SF: &str = "OptionsFrameContainerBodyKeybindingsScrollFrame";
const SF: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrame";
assert!(s
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
.unwrap());
@@ -542,12 +551,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
// What B217 actually cleared — a second bar in the shared gutter — still holds, and that is
// what is asserted.
assert!(
!s.eval::<bool>("return OptionsFrameContainerScrollBar:IsVisible()")
!s.eval::<bool>("return BenillaOptionsFrameContainerScrollBar:IsVisible()")
.unwrap(),
"only one bar is on screen: the list's own owns the gutter"
);
let body_right = s
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
.eval::<f64>("return BenillaOptionsFrameContainerBodyKeybindings:GetRight()")
.unwrap();
let rows_right = s.eval::<f64>(&format!("return {ROW}1:GetRight()")).unwrap();
let bar_left = s
@@ -569,7 +578,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
// overhanging it 21/20 so each arrow drops into the 16-tall socket the art carries (B224; the
// law and its ref citation are in BenillaScrollTrough_Seat). Asserted off the ARROWS here,
// which is the half the eye judges: 5 units of cap above the up arrow, 4 below the down arrow.
const TROUGH: &str = "OptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
const TROUGH: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
let bar = format!("{SF}ScrollBar");
let g = |f: &str, m: &str| s.eval::<f64>(&format!("return {f}:{m}()")).unwrap();
assert!(
@@ -725,21 +734,27 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
// The search tags are the DISPLAY names uppercased (era AddSearchTags), so the query is the
// label the app's strings give the row, not its token.
let query = label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1").to_lowercase();
s.run(&format!(r#"OptionsFrameSearchBox:SetText("{query}")"#))
.unwrap();
s.run(&format!(
r#"BenillaOptionsFrameSearchBox:SetText("{query}")"#
))
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Description:GetText()"
)
.unwrap(),
label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1"),
"the row wears the app's own string"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()"
)
.unwrap(),
"CTRL-1"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1226,7 +1226,7 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
// The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is
// stock's three lines — assign the global, MultiActionBar_Update(), SetActionBarToggles(…) —
// plus the manage pass.
s.run("OptionsFrameContainerBodyActionBarsRowMultiBar2Check:Click()")
s.run("BenillaOptionsFrameContainerBodyActionBarsRowMultiBar2Check:Click()")
.unwrap();
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_TOGGLE_ARGC").unwrap(),
File diff suppressed because it is too large Load Diff
@@ -1227,11 +1227,25 @@ mod tests {
// including the `.lua` a chain `.xml` sources, which is where most of them live.
let toc = &super::super::addons::Addon::builtin().toc.files;
// Load order: a manifest entry at its line, a reached addon's file after everything.
//
// **Every manifest entry, not only the chain half.** The map is read twice — once for a
// chain file's own seat, and once for OURS, to decide which of two definitions stands
// (`ours_wins` below). Keyed on the chain alone it had no entry for any file of ours, so
// the second read was an index into a map that could not contain it and the whole
// instrument panicked with `no entry found for key` — on the first of our files that
// shares a name with a stock one, which is the only case it exists to report.
let chain = gated_chain_entries();
let pos: std::collections::HashMap<&String, usize> = chain
let pos: std::collections::HashMap<&String, usize> = toc
.iter()
.enumerate()
.map(|(k, f)| (f, toc.iter().position(|t| t == f).unwrap_or(toc.len() + k)))
.map(|(k, f)| (f, k))
.chain(
chain
.iter()
.enumerate()
.filter(|(_, f)| !toc.contains(f))
.map(|(k, f)| (f, toc.len() + k)),
)
.collect();
let mut chain_home: std::collections::HashMap<String, (String, usize)> =
std::collections::HashMap::new();
@@ -1321,10 +1335,17 @@ mod tests {
};
for name in declares(&text) {
if let Some(home) = chain_frames.get(&name) {
// **The LEAF, compared whole — `ends_with` was a mask.** `home` is a bare
// `FrameXML.toc` line (`OptionsFrame.xml`) and the manifest carries full chain
// paths, so a suffix test made `Interface\FrameXML\UIOptionsFrame.xml` answer
// "we already load OptionsFrame.xml". It does not: they are two different
// windows, and that one substring silently emptied this table of every
// collision the VIDEO window has with ours — the exact set the instrument
// exists to print before a swap.
let already = toc
.iter()
.filter(|f| super::is_chain_entry(f))
.any(|f| f.ends_with(home.as_str()));
.any(|f| f.rsplit(['\\', '/']).next() == Some(home.as_str()));
// A template's name is a registry key rather than a frame, but two files
// holding one is the same question, so it is reported the same way.
if !already {
@@ -1491,6 +1512,56 @@ mod tests {
// reads clean. Each belongs to whichever window's migration left it, and each is one
// binding or one sourced file away.
const KNOWN: &[(&str, &str, &str)] = &[
(
"OptionsFrame.xml",
"GetGamma",
"the display-gamma pair, and the only two of the video window's engine verbs \
decision 2177 did not build. **Fully pinned and deliberately absent**, which is \
1203's other permitted outcome. wow-re `ui/scratch/video-options-verbs.md` §3 \
carves both: `GetGamma 0x4891c0` returns `1.0 - gamma` and `SetGamma 0x4891f0` \
writes `gamma := 1.0 - v` (the `dc 2d` at `0x4891d0` is FSUBR, `mem - ST(0)`), so \
the unit is the SLIDER's offset and not the CVar's — a `gamma`-valued 1.0 passed \
in writes `gamma = 0`, `pow(x,0) = 1`, a fully white ramp, with no clamp anywhere \
to catch it. The value reaches the hardware synchronously inside `SetCVar`, \
through the change callback `0x4034d0` uploading a ramp. **And that upload is \
skipped in windowed mode** (format byte `+0x07` = `gxWindow`) — which is every \
mode benilla has, since this client ships borderless fullscreen and no exclusive \
mode-set at all (`crate::video`'s module doc). So a byte-faithful pair here would \
be a setting that never moves a pixel, and the honest mechanism is a different \
one: a final full-screen correction over the composited image. That is a render \
feature with its own look, its own `gamma` CVar row and its own record — not a \
line in a bindings file, and not something to register a key for with no reader \
(1134 §4). Neither verb is reachable at LOAD; both are `OptionsFrame_Load` \
(l.153) / `_Cancel` (l.260) / slider 6's `OnValueChanged`, and that function \
already stops earlier — at slider 4, `TerrainMip`, whose `GetCVar` is nil, the \
reference having a real `GetTerrainMip 0x488fb0` where we have none — so building \
the pair alone would move the raise, not remove it.",
),
(
"OptionsFrame.xml",
"SetGamma",
"the display-gamma pair, and the only two of the video window's engine verbs \
decision 2177 did not build. **Fully pinned and deliberately absent**, which is \
1203's other permitted outcome. wow-re `ui/scratch/video-options-verbs.md` §3 \
carves both: `GetGamma 0x4891c0` returns `1.0 - gamma` and `SetGamma 0x4891f0` \
writes `gamma := 1.0 - v` (the `dc 2d` at `0x4891d0` is FSUBR, `mem - ST(0)`), so \
the unit is the SLIDER's offset and not the CVar's — a `gamma`-valued 1.0 passed \
in writes `gamma = 0`, `pow(x,0) = 1`, a fully white ramp, with no clamp anywhere \
to catch it. The value reaches the hardware synchronously inside `SetCVar`, \
through the change callback `0x4034d0` uploading a ramp. **And that upload is \
skipped in windowed mode** (format byte `+0x07` = `gxWindow`) — which is every \
mode benilla has, since this client ships borderless fullscreen and no exclusive \
mode-set at all (`crate::video`'s module doc). So a byte-faithful pair here would \
be a setting that never moves a pixel, and the honest mechanism is a different \
one: a final full-screen correction over the composited image. That is a render \
feature with its own look, its own `gamma` CVar row and its own record — not a \
line in a bindings file, and not something to register a key for with no reader \
(1134 §4). Neither verb is reachable at LOAD; both are `OptionsFrame_Load` \
(l.153) / `_Cancel` (l.260) / slider 6's `OnValueChanged`, and that function \
already stops earlier — at slider 4, `TerrainMip`, whose `GetCVar` is nil, the \
reference having a real `GetTerrainMip 0x488fb0` where we have none — so building \
the pair alone would move the raise, not remove it.",
),
(
"ContainerFrame.xml",
"KeyRingButtonIDToInvSlotID",
@@ -1575,46 +1646,6 @@ mod tests {
"a registered 1.12 binding whose body is uncarved (wow-re `bindings.md`, structural row only); a \
wow-re orchestrator is out on it and it is built when the carve lands (1960). Reached by TRADE_REPLACE_ENCHANT's Accept, an event this engine does not fire yet.",
),
(
"OptionsFrame.lua",
"GetCurrentResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetRefreshRates",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetScreenResolutions",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestartGx",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestoreVideoDefaults",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetScreenResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
];
let mut missing: Vec<(String, String)> = Vec::new();
@@ -1537,7 +1537,7 @@ fn the_shipped_manifest_opens_the_macro_icon_picker() {
/// Four claims, and each one has a way to fail that nothing else here would catch:
///
/// 1. **`UIOptionsFrame` is the reference's frame, not an alias onto ours.** The alias
/// (`UIOptionsFrame = OptionsFrame`) is what this replaces, and under it every one of these
/// (`UIOptionsFrame = OptionsFrame`, when that was our window's name) is what this replaces, and under it every one of these
/// reads would have succeeded while pointing at the window the player opens — pfUI's skin does
/// `UIOptionsFrame:SetWidth(1024)`. So the test asks for the reference's own CHILDREN, which an
/// alias could never grow.
@@ -1548,7 +1548,7 @@ fn the_shipped_manifest_opens_the_macro_icon_picker() {
/// own comment *"Hack to get around load order dependencies"*, so the rows are there only if
/// the options window's manifest row sits above the bars', as `FrameXML.toc` l.21 vs l.39 has
/// it. Reorder the manifest and this is what goes red.
/// 4. **Ours is still the player's.** `GameMenuButtonOptions` opens `OptionsFrame`.
/// 4. **Ours is still the player's.** `GameMenuButtonOptions` opens `BenillaOptionsFrame`.
#[test]
fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1602,7 +1602,7 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
);
}
assert!(
s.eval::<bool>("return UIOptionsFrame ~= OptionsFrame")
s.eval::<bool>("return UIOptionsFrame ~= BenillaOptionsFrame")
.unwrap(),
"the alias is gone: the stock frame is its own frame, not ours under a second name"
);
@@ -1642,7 +1642,8 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
);
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
.unwrap(),
"the player's Options button opens OUR window"
);
assert!(
@@ -1658,7 +1659,8 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
///
/// 1. **`SoundOptionsFrame` is the reference's frame, not an alias onto ours.** This one is not
/// the interface window's story repeated: our `OptionsFrame.xml` loads *after* the stock file,
/// so `SoundOptionsFrame = OptionsFrame` would not have sat beside the real frame — it would
/// so `SoundOptionsFrame = OptionsFrame` — the alias as it was written, when that was our
/// window's name — would not have sat beside the real frame; it would
/// have **clobbered** it, and pfUI's `options-sound.lua` would then have stripped textures off
/// and re-anchored the window the player opens. The check is the reference's own CHILDREN,
/// which an alias could never grow, plus the identity itself.
@@ -1668,7 +1670,7 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
/// `slider:SetValue(value.initialValue)` for four sliders, and all four of their CVars are
/// registered. The three check-button CVars that were missing are built now too, so the walk
/// has nothing to trip on.
/// 4. **Ours is still the player's** — the ESC menu's Options button opens `OptionsFrame`, and
/// 4. **Ours is still the player's** — the ESC menu's Options button opens `BenillaOptionsFrame`, and
/// neither stock window.
#[test]
fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
@@ -1712,7 +1714,7 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
);
}
assert!(
s.eval::<bool>("return SoundOptionsFrame ~= OptionsFrame")
s.eval::<bool>("return SoundOptionsFrame ~= BenillaOptionsFrame")
.unwrap(),
"the alias is gone — and because OUR file loads later, an alias would have CLOBBERED the \
real frame rather than merely shadowed it"
@@ -1754,7 +1756,8 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
// 4 · ours is still the window the ESC menu opens.
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
.unwrap(),
"the player's Options button opens OUR window"
);
assert!(
@@ -1763,9 +1766,10 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
"…and never the stock Sound one"
);
// The alias's two stock consumers still answer correctly WITHOUT it: both read all three
// window names unguarded in one `or` chain, and that chain already contains
// `OptionsFrame:IsVisible()` — this frame's own name. Ours is open here, so both say yes.
// The two stock consumers still answer correctly with every alias retired. They read the
// reference's three window names unguarded in one `or` chain, and since 2177 all three are
// real hidden frames — so that chain is now false whatever our window is doing, and the
// wrappers `GameMenuFrame.xml` installs are the whole reason these still say yes.
assert!(
s.eval::<bool>("return IsOptionFrameOpen() and true or false")
.unwrap(),
@@ -1773,6 +1777,357 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
);
}
/// **The reference's own VIDEO options window is on the manifest, HIDDEN, it owns the
/// `OptionsFrame` name again, and ours is still the player's** (decision 2177).
///
/// The last of the reference's three options windows, and the only one whose arrival took a name
/// off a file of ours — which is what makes this test different from its two siblings above.
///
/// Six claims:
///
/// 1. **`OptionsFrame` is the stock video window**, by children only that file declares. The list
/// is exactly what pfUI's `skins/blizzard/options-video.lua` walks — `OptionsFrameHeader`, the
/// five box frames, `OptionsFrameSlider1..9`, `OptionsFrameCheckButton1..18`, the three
/// dropdowns and the three buttons. Under the old arrangement `CreateBackdrop(OptionsFrame)`
/// and `OptionsFrameHeader:SetTexture("")` landed on the window the player opens.
/// 2. **It is never shown** — `hidden="true"` at the stock file's own xml l.5.
/// 3. **Ours is a different frame with a different name, and it is still the player's**: the ESC
/// menu's Options button opens `BenillaOptionsFrame` and no stock window, and it holds the
/// native-centre panel slot, which is the `UIPanelWindows` row that had to be restated.
/// 4. **The three stock "is an options window open?" consumers still answer correctly.** They
/// name the reference's three windows literally, and all three are hidden frames now, so the
/// honest chain is false — the wrappers in `GameMenuFrame.xml` are the whole reason
/// `IsOptionFrameOpen` says yes while ours is up, and the ESC ladder still closes it.
/// 5. **`OptionsFrameSliders` is the reference's nine rows**, not our three, and our Graphics page
/// reads its bounds off them.
/// 6. **The verbs answer with the shapes their consumers demand** — `"WxH"` with no decoration,
/// a `GetCurrentResolution` that really indexes the list `CT_Viewport.lua:105` reads it against,
/// the single `0` that makes `OptionsFrame_GetRefreshRates` grey its own dropdown, and seven
/// caps whose flags are `1`/nil (the one type assignment that satisfies both `not hasPixel\
/// Shaders` and `hasTripleBuffering == 1` in the same function).
#[test]
fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The display facts the host pushes in a real run. 1600x900 is deliberately NOT one of the
// monitor's modes: the windowed size a player runs at usually is not, and the setter's promise
// that `GetCurrentResolution` indexes a row that exists is exactly what that case tests.
s.set_screen_resolutions(
vec![
benilla_ui::script::ScreenResolution {
width: 1280,
height: 720,
},
benilla_ui::script::ScreenResolution {
width: 1920,
height: 1080,
},
],
Some(benilla_ui::script::ScreenResolution {
width: 1600,
height: 900,
}),
);
s.set_video_caps(benilla_ui::script::VideoCaps {
anisotropic: true,
pixel_shaders: true,
vertex_shaders: true,
trilinear: true,
triple_buffering: false,
max_anisotropy: 16,
hardware_cursor: true,
});
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// 1 · the reference's own frame, by the children only it declares — and this list IS what
// pfUI's `skins/blizzard/options-video.lua` walks.
for name in [
"OptionsFrameHeader",
"OptionsFrameDisplay",
"OptionsFrameWorldAppearance",
"OptionsFrameBrightness",
"OptionsFramePixelShaders",
"OptionsFrameMiscellaneous",
"OptionsFrameResolutionDropDown",
"OptionsFrameRefreshDropDown",
"OptionsFrameMultiSampleDropDown",
"OptionsFrameOkay",
"OptionsFrameCancel",
"OptionsFrameDefaults",
"OptionsFrameSlider1",
"OptionsFrameSlider9",
"OptionsFrameCheckButton1",
"OptionsFrameCheckButton18",
] {
assert!(
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
.unwrap(),
"{name} — pfUI's options-video skin walks every one of these"
);
}
assert!(
s.eval::<bool>("return OptionsFrame ~= BenillaOptionsFrame")
.unwrap(),
"the video window's name is the video window's; ours answers to its own"
);
assert!(
s.eval::<bool>("return OptionsFrameCancel:GetParent() == OptionsFrame")
.unwrap(),
"`OptionsFrameCancel` is the stock window's own button now — the alias that pointed it at \
our Close button would have clobbered it, ours loading later"
);
// 2 · …and hidden, by its own file's attribute.
assert!(
!s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
"the stock Video window must never be on screen"
);
// 5 · the reference's own nine slider rows, and our Graphics page standing on them.
assert_eq!(
s.eval::<f64>("return table.getn(OptionsFrameSliders)")
.unwrap() as i32,
9,
"our three-row overwrite is gone — this is the reference's table"
);
assert_eq!(
s.eval::<String>("return OptionsFrameSliders[3].func")
.unwrap(),
"WorldDetail",
"pfUI's hdgraphic writes index 3 and must still land on Environment Detail"
);
let (lo, hi): (f64, f64) = s
.eval(
"local sl = BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlSlider \
return sl:GetMinMaxValues()",
)
.expect("our Terrain Distance row's bounds");
assert_eq!(
(lo, hi),
(
s.eval::<f64>("return OptionsFrameSliders[2].minValue")
.unwrap(),
s.eval::<f64>("return OptionsFrameSliders[2].maxValue")
.unwrap()
),
"our row is built from the REFERENCE's row 2, not from a transcription of it"
);
// 6 · the verbs, at the shapes their consumers demand.
let res: Vec<String> = s
.eval("return { GetScreenResolutions() }")
.expect("the resolution list");
assert_eq!(
res,
["1280x720", "1600x900", "1920x1080"],
"`WxH`, ascending by pixel AREA (the reference's own key), with the live windowed size \
folded in — 1600×900 is not a monitor mode and is exactly the case CT_Viewport needs"
);
// `CT_Viewport.lua:105` is literally `arg[GetCurrentResolution()]` over these varargs, and
// `:107` then re-parses it with `string.find(currRes, \"(%d+)x(%d+)\")`. Both, verbatim.
let (w, h): (f64, f64) = s
.eval(
"local function pick(...) local r = arg[GetCurrentResolution()] \
local _, _, x, y = string.find(r, \"(%d+)x(%d+)\") return tonumber(x), tonumber(y) end \
return pick(GetScreenResolutions())",
)
.expect("CT_Viewport's own read");
assert_eq!(
(w, h),
(1600.0, 900.0),
"CT_Viewport must find the live size"
);
// **Zero return values**, which is the reference's own "nothing to offer" answer
// (`0x48c136 xor eax,eax`). It was written here as a single `0` first, from
// `OptionsFrame_GetRefreshRates`'s `arg.n == 1 and arg[1] == 0` opening; the binary has no
// `push 0` path at all, and that FrameXML branch is reachable only when the OS reports a `0`
// refresh rate for every matching mode.
let rates: Vec<f64> = s.eval("return { GetRefreshRates() }").expect("the rates");
assert!(
rates.is_empty(),
"the reference answers ZERO values, never a lone 0: {rates:?}"
);
assert!(
s.eval::<bool>("return OptionsFrameRefreshDropDownButton:IsEnabled() == 1")
.unwrap(),
"…so the dropdown is left empty but ENABLED — the greying branch is the driver-quirk one, \
and inventing a 0 to reach it would be inventing a value the binary never produces \
(`IsEnabled` answers a NUMBER — wow-re `binding-shapes.tsv` 0x7800b0)"
);
// The optional index argument is tolerated, which is the shape `SetScreenResolution` shares.
assert!(
s.eval::<bool>("return table.getn({ GetRefreshRates(2) }) == 0")
.unwrap(),
"the argument is optional AND ignored here; it must not raise"
);
// The caps, at the two types the one consuming function demands of them.
let caps: Vec<String> = s
.eval(
"local out = {} \
local t = { GetVideoCaps() } \
for i = 1, 7 do table.insert(out, tostring(t[i])) end \
return out",
)
.expect("the seven caps");
assert_eq!(
caps,
["1", "1", "1", "1", "0", "16", "1"],
"THREE shapes: four flags as 1/nil, slot 5 an unconditional NUMBER, slot 6 raw"
);
// The stock defect this reproduces, asserted as a defect: `OptionsFrame_Load` tests
// `not hasTripleBuffering` twice and `hasTripleBuffering == 1` once. Slot 5 is never nil, and
// `0` is truthy in Lua — so the two `not` clauses are dead in the reference too, and only the
// `== 1` one decides anything. Answering nil here would REVIVE a branch the binary cannot reach.
assert!(
s.eval::<bool>(
"local a, b, c, d, hasTriple = GetVideoCaps() \
return (not (not hasTriple)) and hasTriple ~= 1"
)
.unwrap(),
"`not hasTripleBuffering` must stay FALSE (the dead clause) while `== 1` is also false"
);
// `SetScreenResolution`'s three carved argument behaviours, and the one deliberate divergence.
assert!(
s.eval::<bool>("SetScreenResolution() return GetCVar(\"gxResolution\") == \"1280x720\"")
.unwrap(),
"the tolerant family: a missing argument selects entry ONE, it does not raise"
);
assert!(
s.eval::<bool>("SetScreenResolution(2.7) return GetCVar(\"gxResolution\") == \"1600x900\"")
.unwrap(),
"truncated toward zero — 2.7 is entry 2"
);
assert!(
s.eval::<bool>("SetScreenResolution(99) return GetCVar(\"gxResolution\") == \"1920x1080\"")
.unwrap(),
"out of range CLAMPS to the last entry — the reference reads `list[count]`, one past the \
end, and reproducing an out-of-bounds read to apply an uninitialised size is not fidelity"
);
// …and now the CVar says 1920x1080 while the live window is still 1600x900, which is exactly
// the case `GetCurrentResolution` must answer from the WINDOW rather than from the CVar.
assert_eq!(
s.eval::<f64>("return GetCurrentResolution()").unwrap(),
2.0,
"the live size is still entry 2; a pick stages `gxResolution` and takes effect on apply"
);
// 3 · ours is still the window the ESC menu opens, in the centre slot.
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
.unwrap(),
"the player's Options button opens OUR window"
);
assert!(
!s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
"…and never the stock Video one"
);
assert_eq!(
s.eval::<String>("return GetCenterFrame():GetName()")
.unwrap(),
"BenillaOptionsFrame",
"the restated `UIPanelWindows` row — without it `ShowUIPanel` places nothing"
);
// 4 · the three stock consumers, which now hear about our window only through the wrappers.
assert!(
s.eval::<bool>("return IsOptionFrameOpen() and true or false")
.unwrap(),
"UIParent.lua:996 must see an open options window"
);
assert!(
s.eval::<bool>("return MainMenuMicroButton:GetButtonState() == \"PUSHED\"")
.unwrap(),
"MainMenuBarMicroButtons.lua:47-58 must show the micro button pushed"
);
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
.unwrap(),
"the ESC ladder's options rung must close OUR window, in the reference's own order"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsShown()").unwrap(),
"…and take that press, rather than falling through to the game menu"
);
assert!(
!s.eval::<bool>("return IsOptionFrameOpen() and true or false")
.unwrap(),
"with ours closed and all three stock windows hidden, the honest answer is no"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The ten names the video window's slider walk must NOT find** (decision 2177).
///
/// `OptionsFrame_Load:110` and `_Save:208-209` do `getglobal("Get"..value.func)` /
/// `("Set"..value.func)` over the nine `OptionsFrameSliders` rows and **branch on the result**: a
/// hit is called, a miss falls through to `GetCVar`/`SetCVar`. Of the eighteen composed names, six
/// resolve to real bindings in the reference — `Get/SetWorldDetail`, `Get/SetTerrainMip`,
/// `Get/SetBaseMip` — and ten must resolve to nil. Defining any of the ten changes this window's
/// behaviour **without erroring**, which is why it needs an assertion rather than a comment.
///
/// The trap that makes it worth pinning: `GetFarclip 0x488f00` and `SetFarclip 0x488f30` really do
/// exist in the reference, with a capital F, while `value.func` is the lowercase `"farclip"`. The
/// stock client takes the CVar path for far clip **only because `getglobal` is case-sensitive** — so
/// this asserts that too, against a name we DO define.
///
/// (wow-re `ui/scratch/video-options-verbs.md` §5 and §7.7, both VERIFIED.)
#[test]
fn the_video_windows_ten_composed_names_stay_nil() {
let _data = benilla_formats::wow_data_or_skip!();
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(), "manifest load errors: {failures:#?}");
for name in [
"Getuiscale",
"Setuiscale",
"Getfarclip",
"Setfarclip",
"Getanisotropic",
"Setanisotropic",
"GetspellEffectLevel",
"SetspellEffectLevel",
"GetweatherDensity",
"SetweatherDensity",
] {
assert!(
s.eval::<bool>(&format!("return getglobal({name:?}) == nil"))
.unwrap(),
"{name} must not exist — `OptionsFrame_Load` branches on it and would stop using the \
CVar path for that slider"
);
}
// Case-sensitivity, against a name that IS defined (2163's Environment Detail pair).
assert!(
s.eval::<bool>("return getglobal(\"GetWorldDetail\") ~= nil")
.unwrap(),
"the control: this one is real"
);
assert!(
s.eval::<bool>("return getglobal(\"Getworlddetail\") == nil")
.unwrap(),
"`getglobal` must stay case-sensitive — case-folding it would resolve `Getfarclip` to the \
reference's `GetFarclip` and silently change which path the far-clip slider takes"
);
}
/// **pfUI's `UIOptionsFrame_Save()` path runs clean, and `_Load()` stops at exactly one thing**
/// (decision 2115).
///
+25 -3
View File
@@ -147,6 +147,25 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
s.run(MULTI_ACTION_BAR_STAND_INS)
.expect("the multibar stand-ins");
}
// **Our options window's Graphics rows read the REFERENCE's slider table, at OnLoad**
// (decision 2177): `OptionsFrameSliders[1..3]` are the bounds those three rows are built with,
// and they come from `Interface\FrameXML\OptionsFrame.lua` — which the shipped manifest loads
// as part of the stock VIDEO window, one seat above ours. A kit that seats our file alone has
// to bring it too, and brings the reference's own file rather than a transcription of the
// three rows: a stand-in here would be a second copy of numbers whose whole point is that
// they are no longer ours. Seated BEFORE the load, for the same reason MultiActionBars is:
// the rows index it from inside this very load walk.
if path
.rsplit('/')
.next()
.is_some_and(|l| l.eq_ignore_ascii_case("OptionsFrame.xml"))
&& !super::reference_ui::is_chain_entry(&path)
{
let bytes = read("Interface/FrameXML/OptionsFrame.lua")
.expect("the reference's own OptionsFrame.lua");
s.run_chunk_named(&bytes, "@Interface\\FrameXML\\OptionsFrame.lua")
.expect("the video window's slider table");
}
let report = benilla_ui::loader::load_in(s, &doc, &path, &provider);
assert!(
report.errors.is_empty(),
@@ -289,9 +308,12 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
-- The four options/menu windows `IsOptionFrameOpen` (l.997) and `ToggleGameMenu` (l.1467)
-- index unguarded. `IsOptionFrameOpen` is on the path of every window close, so a kit that
-- loads no options window raised on the first bag click. In the shipped manifest all four
-- names are real — `OptionsFrame` is our window's own, `UIOptionsFrame` and
-- `SoundOptionsFrame` are the reference's own files loaded hidden — but a KIT is a prefix of
-- the manifest and may load none of them, which is what these stand-ins are for.
-- names are real, and since 2177 all three options windows are the REFERENCE's own files,
-- loaded hidden — including `OptionsFrame`, the video window, which used to be our own
-- window's name. Ours is `BenillaOptionsFrame` now and is not in this list: it is not a name
-- the reference indexes, and the wrappers in `GameMenuFrame.xml` are what tell these two
-- functions about it. A KIT is a prefix of the manifest and may load none of the four, which
-- is what these stand-ins are for.
local function benilla_seat_options()
benilla_seat({ "GameMenuFrame", "OptionsFrame", "UIOptionsFrame", "SoundOptionsFrame" })
if not OptionsFrameCancel then
@@ -1197,7 +1197,16 @@ fn a_clean_world_entry_raises_only_the_warnings_we_have_named() {
// zero corpus call sites, and benilla has no IME — so nothing here could ever fire it, and
// `SCRIPT_KINDS`' rule is that a name we cannot fire stays out. The refusal is the honest
// answer; the row is the price of saying it out loud.
const KNOWN: [&str; 1] = ["OnInputLanguageChanged"];
//
// **`gxRefresh` stays out permanently too** (decision 2177). The stock VIDEO options window
// reads it in `OptionsFrameRefreshDropDown_OnLoad` — one of the two `<OnLoad>` paths that run
// on the spot when that file loads — and benilla does not register it, because a refresh rate
// is only selectable through an exclusive mode-set and this client ships none on any target
// (`crate::video`'s module doc walks each). `GetRefreshRates` therefore returns the
// reference's own "no rates available" sentinel, the dropdown greys itself, and nothing ever
// reads the variable. Registering it would be a key with no reader — 1134 §4's silent
// pretence — so the warn-once is the honest answer and this row is the price of saying so.
const KNOWN: [&str; 2] = ["OnInputLanguageChanged", "unknown CVar 'gxRefresh'"];
let unexpected: Vec<String> = script
.diagnostics()
+180 -4
View File
@@ -63,6 +63,7 @@
//! **`AutoNoVsync`, never `Immediate`.** On macOS/Metal, explicit `Immediate` both rails *and*
//! takes ~1 s `nextDrawable` stalls — measured, and pinned at [`crate::capture::probe_uncap_mode`].
use benilla_ui::script::ScreenResolution;
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode, WindowResolution};
@@ -255,11 +256,173 @@ pub(crate) struct VideoPlugin;
impl Plugin for VideoPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<VideoConfig>()
.init_resource::<GxRestarts>()
.add_systems(Startup, (log_display_session, check_window_pinned).chain())
.add_systems(Update, (apply_present_mode, apply_window_mode));
.add_systems(
Update,
(
(drain_restart_gx, (apply_present_mode, apply_window_mode)).chain(),
publish_display_modes,
),
);
}
}
/// How many `RestartGx()` calls the interface has made — the video window's "apply the staged
/// settings now" (decision 2177).
///
/// A generation counter rather than a flag: [`apply_present_mode`] and [`apply_window_mode`] each
/// keep their own `Local` of the last value they acted on, so one bump forces exactly one
/// re-assertion in each, whichever order they run in and however many frames apart.
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct GxRestarts(u32);
/// Move the interface's `RestartGx()` calls into [`GxRestarts`].
///
/// benilla applies its video settings live (this module's doc says so for `gxVSync` and the
/// display mode), so what a restart means HERE is "re-assert them against the window now" rather
/// than "tear the device down and rebuild it". The two systems below do the asserting; this one
/// only carries the request across the VM boundary.
fn drain_restart_gx(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut restarts: ResMut<GxRestarts>,
) {
let Some(mut script) = script else {
return;
};
let asks = script.take_restart_gx_asks();
if asks == 0 {
// Never touch the resource on a quiet frame: a `ResMut` deref-mut is a change signal, and
// both consumers below are gated on the value moving.
return;
}
restarts.0 = restarts.0.wrapping_add(asks);
info!("video: RestartGx — re-asserting the display mode and present mode");
}
/// **The reference's own three filters on the resolution list** (wow-re
/// `ui/scratch/video-options-verbs.md` §1.1, all VERIFIED at `0x48bcfa`–`0x48bd18`), in its own
/// order: keep iff `w/h >= 1.248`, `w >= 800`, `h >= 600`.
///
/// The aspect constant is `[0x804570]`, the f32 `1.2480000257492065` — chosen just under 5:4 so it
/// admits 5:4, 4:3, 16:10 and 16:9 and rejects square and portrait modes. It is not a
/// widescreen-only gate; the `widescreen` CVar is a separate switch [`SCREEN_FALLBACK`] describes.
fn offerable(r: ScreenResolution) -> bool {
r.width >= 800 && r.height >= 600 && f64::from(r.width) / f64::from(r.height) >= 1.248
}
/// **What the list is when enumeration produces nothing** — the reference's four hardcoded modes,
/// in its own append order (`0x48bda2`, `0x48bddf`, `0x48be43`, `0x48bea7`).
///
/// In the reference this is a "produced nothing" path, not an else: it is taken when the display
/// enumeration survives no mode, when the `widescreen` CVar's record is missing, or when
/// `widescreen == 0`. **benilla does not register `widescreen`** (`0x63a747`, registered default
/// `"1"`), so only the first of the three reaches here — a headless run, or a monitor whose modes
/// all fail [`offerable`]. Registering it would be a knob whose whole effect is to shrink this
/// dropdown to these four, which is a setting on its own merits and not this list's business.
const SCREEN_FALLBACK: [ScreenResolution; 4] = [
ScreenResolution {
width: 800,
height: 600,
},
ScreenResolution {
width: 1024,
height: 768,
},
ScreenResolution {
width: 1280,
height: 1024,
},
ScreenResolution {
width: 1600,
height: 1200,
},
];
/// The pair [`publish_display_modes`] remembers between frames: the list it last pushed and the
/// index into it. One name because they are one fact — a list without its index cannot be read —
/// and because a `Local` spelling it inline is what `-D clippy::type-complexity` refuses.
type PublishedModes = Option<(Vec<ScreenResolution>, Option<ScreenResolution>)>;
/// **What the Video options window's resolution dropdown offers, and where the client is in it** —
/// the host half of `GetScreenResolutions` / `GetCurrentResolution` / `SetScreenResolution`
/// (decision 2177).
///
/// The reference enumerates the graphics device's display modes, because picking one is a
/// mode-set. benilla ships no exclusive mode at all (this module's doc walks why, per target), so
/// what a pick here really changes is the **windowed size** — `gxResolution`, which
/// `SetScreenResolution` writes and [`apply_window_mode`] applies on the next frame.
///
/// **The unit is LOGICAL pixels, not the monitor's physical mode table, and that is deliberate.**
/// `gxResolution` already means a logical inner size everywhere else in this client
/// ([`boot_windowed_size`] hands it straight to `WindowResolution`), so offering physical sizes
/// would make the dropdown's rows and the CVar they write mean two different things on every
/// HiDPI display — a 1600×900 pick landing a 3200×1800 window. One unit end to end beats matching
/// the reference's spelling into a variable that means something else here.
///
/// The rows are the monitor's own distinct mode sizes plus its full size, in logical units, put
/// through [`offerable`] — device data and the reference's own filters, not a ladder we invented —
/// and [`SCREEN_FALLBACK`] when that survives nothing. The live window size is added by
/// [`UiScript::set_screen_resolutions`] if it is not already among them, which is the common case
/// (a 1600×900 window on a 4K panel, or anything below the 800×600 floor) and the one
/// `CT_Viewport.lua:201` depends on: it reads its own screen size as `arg[GetCurrentResolution()]`
/// and silently falls back to 4:3 on a miss.
///
/// **Recomputed on change, where the reference builds it once and never invalidates it** — its own
/// list survives a `gxRestart`, a `widescreen` toggle and a monitor change (a 25-hit dword census
/// of the count global says so). That is a cache bug to leave behind, not a mechanism: a client
/// that can be dragged between monitors has to answer for the one it is on.
fn publish_display_modes(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
windows: Query<&Window, With<PrimaryWindow>>,
monitors: Query<&bevy::window::Monitor>,
mut last: Local<crate::ui_script::VmMemo<PublishedModes>>,
) {
let Some(mut script) = script else {
return;
};
let Ok(window) = windows.single() else {
return;
};
let res = &window.resolution;
let current = Some(ScreenResolution {
width: res.width() as u32,
height: res.height() as u32,
})
.filter(|r| r.width > 0 && r.height > 0);
let mut offered: Vec<ScreenResolution> = Vec::new();
for m in &monitors {
// The monitor's own scale — not the window's. A window straddling two displays reports
// whichever winit last gave it, and these rows describe the PANEL.
let scale = if m.scale_factor > 0.0 {
m.scale_factor
} else {
1.0
};
let logical = |size: UVec2| ScreenResolution {
width: (size.x as f64 / scale).round() as u32,
height: (size.y as f64 / scale).round() as u32,
};
offered.push(logical(m.physical_size()));
offered.extend(m.video_modes.iter().map(|v| logical(v.physical_size)));
}
offered.retain(|r| offerable(*r));
if offered.is_empty() {
offered.extend(SCREEN_FALLBACK);
}
offered.sort_by_key(|r| (u64::from(r.width) * u64::from(r.height), r.width, r.height));
offered.dedup();
// **VM-keyed** (decision 1290): a `ReloadUI` replaces the VM, and the fresh one has been
// pushed nothing. A plain `Local` here would remember the OLD VM's list and skip the push
// that the new VM needs, leaving `GetScreenResolutions` empty for the rest of the session.
let memo = last.get(&script);
if memo.as_ref() == Some(&(offered.clone(), current)) {
return;
}
*memo = Some((offered.clone(), current));
script.set_screen_resolutions(offered, current);
}
/// **Did the window actually get the size `$WOW_WIN` asked for?** Refuse the run if not.
///
/// The window manager is free to clamp a requested inner size to the display, and macOS does. The
@@ -406,10 +569,14 @@ fn display_session() -> String {
/// reconciles them.
fn apply_present_mode(
cfg: Res<VideoConfig>,
restarts: Res<GxRestarts>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut last: Local<Option<bool>>,
mut last_restart: Local<u32>,
) {
if last.replace(cfg.vsync) == Some(cfg.vsync) {
// A `RestartGx()` re-asserts even when nothing moved — that is what the caller asked for.
let forced = std::mem::replace(&mut *last_restart, restarts.0) != restarts.0;
if last.replace(cfg.vsync) == Some(cfg.vsync) && !forced {
return;
}
let want = present_mode(cfg.vsync);
@@ -437,10 +604,16 @@ fn apply_present_mode(
/// frame of every launch. Matching on the variant is the honest question.
fn apply_window_mode(
cfg: Res<VideoConfig>,
restarts: Res<GxRestarts>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut last: Local<Option<DisplayMode>>,
mut last_restart: Local<u32>,
) {
if last.replace(cfg.display) == Some(cfg.display) {
// A `RestartGx()` re-asserts even when nothing moved, and that includes re-applying
// `gxResolution` to a window already in windowed mode — the one video setting whose value can
// have moved without the MODE moving, and therefore the one this verb is most useful for.
let forced = std::mem::replace(&mut *last_restart, restarts.0) != restarts.0;
if last.replace(cfg.display) == Some(cfg.display) && !forced {
return;
}
let Ok(mut window) = windows.single_mut() else {
@@ -455,7 +628,7 @@ fn apply_window_mode(
WindowMode::BorderlessFullscreen(_)
)
);
if already {
if already && !forced {
return;
}
// Leaving fullscreen has to hand the size back, because entering it **overwrote**
@@ -537,6 +710,9 @@ mod tests {
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
})
// The plugin's resource, seated by hand because this test runs the one system rather than
// the plugin — `apply_window_mode` reads it to know a `RestartGx()` asked for a re-assert.
.init_resource::<GxRestarts>()
.add_systems(Update, apply_window_mode);
let win = app
.world_mut()
+6 -3
View File
@@ -139,9 +139,12 @@ const UIPARENT_STAND_INS: &str = r#"
-- The four options/menu windows `IsOptionFrameOpen` (l.997) and `ToggleGameMenu` (l.1467)
-- index unguarded. `IsOptionFrameOpen` is on the path of every window close, so a kit that
-- loads no options window raised on the first bag click. In the shipped manifest all four
-- names are real — `OptionsFrame` is our window's own, `UIOptionsFrame` and
-- `SoundOptionsFrame` are the reference's own files loaded hidden — but a KIT is a prefix of
-- the manifest and may load none of them, which is what these stand-ins are for.
-- names are real, and since 2177 all three options windows are the REFERENCE's own files,
-- loaded hidden — including `OptionsFrame`, the video window, which used to be our own
-- window's name. Ours is `BenillaOptionsFrame` now and is not in this list: it is not a name
-- the reference indexes, and the wrappers in `GameMenuFrame.xml` are what tell these two
-- functions about it. A KIT is a prefix of the manifest and may load none of the four, which
-- is what these stand-ins are for.
local function benilla_seat_options()
benilla_seat({ "GameMenuFrame", "OptionsFrame", "UIOptionsFrame", "SoundOptionsFrame" })
if not OptionsFrameCancel then
+440
View File
@@ -55,6 +55,79 @@ pub struct MultisampleFormat {
pub samples: u32,
}
/// One screen resolution the Video options dropdown offers, in **physical pixels**.
///
/// The reference enumerates the graphics device's own display modes; benilla has no exclusive
/// mode-set to make (`benilla_app::video`'s module doc: Wayland has no client-side mode-setting,
/// X11's is XRandR on the *desktop*, macOS has none, and WoW itself deleted exclusive fullscreen in
/// 8.0.1), so the list is the sizes this client can actually present a window at. Either way it is
/// a fact about the display that the VM cannot ask for itself, so the host pushes it.
///
/// **The `"WxH"` spelling is the contract, not a convenience.** `OptionsFrame.lua:281-284` finds
/// the `x`, `strsub`s both sides and evaluates `width/height > 4/3` for the WIDESCREEN tag, and
/// `CT_Viewport.lua:107` re-reads it with `string.find(currRes, "(%d+)x(%d+)")`. Two independent
/// consumers, both of which fail silently on a space, a suffix, or an `X`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScreenResolution {
pub width: u32,
pub height: u32,
}
impl ScreenResolution {
/// The reference's ordering key, made total: **pixel area first** (`0x58be40`'s whole body is
/// `a.h*a.w - b.h*b.w`), then width, then height. The tie-break is ours — see
/// [`super::UiScript::set_screen_resolutions`].
pub(crate) fn sort_key(&self) -> (u64, u32, u32) {
(
u64::from(self.width) * u64::from(self.height),
self.width,
self.height,
)
}
}
impl std::fmt::Display for ScreenResolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}x{}", self.width, self.height)
}
}
/// What `GetVideoCaps` answers with — the seven values `OptionsFrame_Load` destructures at
/// `OptionsFrame.lua:59`, in that order.
///
/// ```lua
/// local hasAnisotropic, hasPixelShaders, hasVertexShaders, hasTrilinear,
/// hasTripleBuffering, maxAnisotropy, hasHardwareCursor = GetVideoCaps();
/// ```
///
/// In the reference these are the D3D/GL device's own answers, cached at start-up. Here they are
/// what wgpu and this client's own presentation path really do — pushed by the host, which is the
/// only side that holds a `RenderAdapter`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct VideoCaps {
pub anisotropic: bool,
pub pixel_shaders: bool,
pub vertex_shaders: bool,
pub trilinear: bool,
/// **A BACKEND constant, not a device capability** — `caps+0x20` is written by the two device
/// constructors only (1 under Direct3D, 0 under OpenGL) and read by `GetVideoCaps` alone, so
/// `hasTripleBuffering == 1` means "the backend is Direct3D". Its name is the one INFERRED
/// label in the seven (wow-re `ui/scratch/video-options-verbs.md` §2.2).
///
/// `false` here, and it is pushed as the **number 0**, never nil: benilla is not Direct3D and
/// exposes no buffering knob, so check button 13 is hidden and button 6 re-seated
/// (`OptionsFrame.lua:168-175`) — while the file's two `not hasTripleBuffering` clauses stay
/// dead, which is what the reference does in both backends.
pub triple_buffering: bool,
/// The raw maximum sample count, matched against `ANISOTROPIC_VALUES = {"1","2","4","8","16"}`
/// with `tonumber` (`OptionsFrame.lua:124`) — so it must be one of those numbers to move the
/// slider's ceiling, and anything else leaves `value.maxValue` where it was. Pushed raw, or
/// **nil iff 0**; the reference is effectively never nil here (its base ctor seeds the field
/// to 1), which is why its consumers gate on slot 1 or on `< 2` rather than on nil.
pub max_anisotropy: u32,
pub hardware_cursor: bool,
}
impl super::UiScript {
/// Hand registration the config file's persisted values (decision 1291): name → value, keys
/// lowercased here. Set **before** any `register_cvars` / addon `RegisterCVar` runs in this
@@ -163,6 +236,54 @@ impl super::UiScript {
pub fn set_multisample_formats(&mut self, formats: Vec<MultisampleFormat>) {
self.model_mut().multisample_formats = formats;
}
/// Publish the screen resolutions the Video options dropdown offers, and which one the client
/// is currently at.
///
/// **`current` is a promise, not a hint.** `CT_Viewport.lua:201` reads its own screen size as
/// `arg[GetCurrentResolution()]` over `GetScreenResolutions()`'s varargs and silently falls
/// back to 4:3 on a miss, so a current size that is not in the list is a wrong answer that
/// succeeds. The host therefore hands the live size here and this pushes it INTO the list if
/// the display's own mode table does not carry it — a windowed client at 1600×900 on a 4K
/// panel is exactly that case, and it is the common one.
///
/// Both halves land together because they are one fact: a list without its index cannot be
/// read, and an index into a list that moved is worse than none.
///
/// **Ordered by pixel AREA**, which is the reference's own key (`0x58be40`: `a.w*a.h - b.w*b.h`,
/// with no tie-break at all). Ours breaks ties on width then height, deliberately: the
/// reference's comparator plus its adjacent-only dedupe can leak a duplicate `"WxH"` when two
/// distinct modes share an area — 1280×960 and 1600×768 are both 1 228 800 — and reproducing an
/// unstable sort is aping a quirk, not implementing the mechanism.
pub fn set_screen_resolutions(
&mut self,
mut offered: Vec<ScreenResolution>,
current: Option<ScreenResolution>,
) {
offered.sort_by_key(ScreenResolution::sort_key);
offered.dedup();
let current = current.map(|c| {
offered.iter().position(|r| *r == c).unwrap_or_else(|| {
let at = offered.partition_point(|r| r.sort_key() < c.sort_key());
offered.insert(at, c);
at
})
});
let mut model = self.model_mut();
model.screen_resolutions = offered;
model.current_resolution = current;
}
/// Publish what this run's device and presentation path really offer, behind `GetVideoCaps`.
pub fn set_video_caps(&mut self, caps: VideoCaps) {
self.model_mut().video_caps = caps;
}
/// Drain the `RestartGx()` calls queued since the last call — each is one "apply the staged
/// video settings now", and the host answers by re-asserting them against the window.
pub fn take_restart_gx_asks(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().restart_gx_asks)
}
}
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
@@ -383,6 +504,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
install_video_verbs(lua)?;
lua.globals().set(
"SetCVar",
lua.create_function(|lua, args: mlua::MultiValue| {
@@ -418,6 +541,323 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_world_detail_verbs(lua)
}
/// **What `RestoreVideoDefaults` puts back** — the settings this client's video path backs that the
/// reference's own verb writes, each named by the row it is.
///
/// **The reference does NOT restore registered defaults, and this list is not a guess at which it
/// does** (wow-re `ui/scratch/video-options-verbs.md` §4.3, the round that corrected benilla's
/// first reading). `0x48dad0` → `0x639a20` maps the already-matched `VideoHardware.dbc` row through
/// nine lookup tables into a hardware-recommended settings struct, copies the `CGxFormat` **preset**
/// that row names for this adapter (`[0xc4e6ac]` → `0xc518e0 + 0x38·n`) into the working device
/// record, writes that record into twelve `gx*` CVars, performs a full synchronous `RestartGx`, and
/// then writes sixteen further graphics CVars from the recommendations. The registered strings
/// (`"640x480"`, `"75"`, `"16"`…) are never consulted.
///
/// **benilla has no such pass, and that is why its registered defaults ARE its recommended
/// configuration.** There is no `VideoHardware.dbc` matching here and no preset table: what a fresh
/// install runs is each `REGISTERED` row's own value, clamped by the device where the device has a
/// say (`MsaaFormats::clamp`, `tex_filter`'s aniso ceiling). So this restores each row to its
/// registered default — the same *meaning*, reached the way this client reaches it — and that is a
/// stated divergence rather than a transcription.
///
/// The membership, though, is the reference's: these are the names in its twelve-plus-sixteen that
/// benilla registers. It does **not** touch `uiScale`, `useUiScale` or `anisotropic` — an earlier
/// draft of this list did, reasoning from the video window's Lua tables instead of from the verb.
///
/// **`WorldDetail` is ours and rides with `frillDensity`**: 2163 welded the panel stop to the
/// reference's own clutter CVar, so restoring one without the other would leave the pair describing
/// two different detail levels.
///
/// `benilla_app::cvars` holds the matching test that every name here is registered — the weld that
/// keeps a rename or a retirement from turning a row into a silent no-op.
pub const VIDEO_DEFAULT_CVARS: &[&str] = &[
// of the twelve `gx*` the device record writes (`0x639c40`), the six benilla registers
"gxWindow",
"gxResolution",
"gxVSync",
"gxColorBits",
"gxDepthBits",
"gxMultisample",
// of the sixteen further graphics CVars (`0x639a60`), the three benilla registers
"farclip",
"frillDensity",
"trilinear",
// ours, welded to `frillDensity` (2163)
"WorldDetail",
];
/// **The Video options window's own engine verbs** — the seven `OptionsFrame.xml` needs that
/// nothing else in this client provides.
///
/// The window is loaded off the player's chain and kept hidden (decision 2177, the shape 2115 and
/// 2147 set for the Interface and Sound windows): nothing of ours shows it, and it exists so that
/// an addon reaching for the reference's video-options names finds real frames and real functions
/// instead of an alias onto a window of ours.
///
/// **Three of the seven run at LOAD** — `GetScreenResolutions`, `GetCurrentResolution` and
/// `GetRefreshRates`, from the resolution and refresh dropdowns' `<OnLoad>`, because
/// `UIDropDownMenu_Initialize` calls the initializer it is handed *immediately*
/// (`UIDropDownMenu.lua:48-50`). The file cannot load at all without them, and the window being
/// `hidden="true"` is precisely what keeps the other four out of that set: they hang off `OnShow`
/// and the three buttons. The **tenth** name that load reaches, which benilla's own census of this
/// window missed, is plain `GetCVar` (`OptionsFrame.lua:300`, `GetCVar("gxRefresh")`).
///
/// The three multisample verbs the same window needs are above; `GetWorldDetail`/`SetWorldDetail`
/// are in [`install_world_detail_verbs`]. `GetGamma`/`SetGamma` are deliberately NOT here — see
/// `ui_script::reference_ui`'s KNOWN table for what they would cost and why they are a feature of
/// their own rather than a line in this function.
///
/// **What must NOT be defined, which is as load-bearing as what is.** `OptionsFrame_Load:110` does
/// `getglobal("Get"..value.func)` over the nine slider rows and branches on the result; of the
/// eighteen composed names, six resolve to real bindings in the reference
/// (`Get/SetWorldDetail`, `Get/SetTerrainMip`, `Get/SetBaseMip`) and **ten must resolve to nil** —
/// `Getuiscale`, `Getfarclip`, `Getanisotropic`, `GetspellEffectLevel`, `GetweatherDensity` and
/// their five setters — so those rows fall through to `GetCVar`/`SetCVar`. Defining any of them
/// changes this window's behaviour without erroring. The trap is `GetFarclip`/`SetFarclip`, which
/// DO exist at `0x488f00`/`0x488f30` with a capital F while `value.func` is `"farclip"`: the stock
/// client takes the CVar path only because `getglobal` is case-sensitive. Pinned by
/// `benilla_app`'s `the_video_windows_ten_composed_names_stay_nil`.
fn install_video_verbs(lua: &Lua) -> mlua::Result<()> {
// ── GetScreenResolutions ─────────────────────────────────────────────────────────────────
// A vararg of `"WxH"` strings. The format is pinned by two independent consumers, neither of
// which raises on a wrong one: `OptionsFrame.lua:281-284` splits on the `x` and evaluates
// `width/height > 4/3` to decide the WIDESCREEN tag, and `CT_Viewport.lua:107` re-parses with
// `string.find(currRes, "(%d+)x(%d+)")` to size its viewport. A space, an `X`, or a trailing
// suffix silently gives one of them the wrong answer.
lua.globals().set(
"GetScreenResolutions",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = mlua::MultiValue::new();
for r in &model.screen_resolutions {
out.push_back(Value::String(lua.create_string(r.to_string())?));
}
Ok(out)
})?,
)?;
// ── GetCurrentResolution ─────────────────────────────────────────────────────────────────
// The **1-based index** into that list — `0x48bfa2 inc eax` over the same array, which is the
// only reading `arg[GetCurrentResolution()]` (`CT_Viewport.lua:105`) can have.
//
// **`1.0` on a miss, never nil and never 0** (`0x48bf88 push 0x3ff00000` — the f64 `1.0`), and
// the reference takes that path on every failure: no CVar record, a NULL string, an empty
// list, or a value simply not in it. It is the family idiom — `GetCurrentMultisampleFormat`
// `0x48c580` answers `1.0` the same way, and `SetMultisampleFormat` above already does. A `1`
// is therefore not proof of a match, which is exactly the trap the reference walks into: its
// `gxResolution` registers at `"640x480"`, below its own list's 800×600 floor, so a fresh
// config reports index 1 while running 640×480.
//
// **Where ours reads from is a stated divergence.** The reference parses the `gxResolution`
// CVar (`0x48bf20`), which on a mode-setting client IS the live mode. benilla ships no
// exclusive mode-set: `gxResolution` is the WINDOWED size, and while the client is borderless
// fullscreen the two differ. The host therefore pushes the live window size, because "what
// resolution am I at" is what every consumer means — `CT_Viewport` scales its viewport by it —
// and reporting a windowed size while filling a 4K panel would be a wrong answer that
// succeeds. A pick made while fullscreen still lands in `gxResolution` and takes effect on the
// way out, which is the honest reading of a windowed-size setting.
lua.globals().set(
"GetCurrentResolution",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.current_resolution.map_or(1.0, |i| (i + 1) as f64))
})?,
)?;
// ── SetScreenResolution ──────────────────────────────────────────────────────────────────
// 1-based, from `UIDropDownMenu_GetSelectedID(OptionsFrameResolutionDropDown)`
// (`OptionsFrame.lua:239`). Three things about the argument, all carved at `0x48bfd0`:
//
// * **The TOLERANT family, not the raising one.** `0x48bfe6 xor esi,esi` seats a default index
// of 0 and there is no `0x6f4940` anywhere in the body, so a missing, nil or non-numeric
// argument silently selects **the first entry** rather than erroring. A numeric string is
// accepted (the `0x6f7c20` coercion).
// * **Truncated toward zero** — `0x40a2b0 __ftol` with RC=11, so `2.7` is entry 2.
// * **Out of range is where we deliberately diverge.** `0x48c008 dec eax` / `0x48c009 cmp` /
// the failure block at `0x48c00d` converge index 0, negatives and anything past the count on
// `list[count]` — one element past the last, read unguarded. It does not fault only because
// the array keeps a spare slot. We clamp to the last entry instead: reproducing an
// out-of-bounds read to apply an uninitialised resolution is not fidelity.
//
// **The same-value guard is real behaviour and is reproduced** (`0x48c081 je 0x48c0c1`): if the
// requested size equals the one `gxResolution` already holds, the reference returns having done
// nothing at all — no write, no restart — and so does this. `set_from_engine` would swallow the
// write anyway; the restart below is what makes the guard observable.
//
// **And it issues its own `gxRestart`** (`0x48c0b7 mov ecx,0x842978` → the console executor),
// which is why `OptionsFrame_Save` calling `RestartGx()` afterwards restarts a resolution
// change twice. Ours does the same, through the same counted request the host drains — benilla
// applies the size live, so the restart is a re-assertion rather than a device rebuild, but the
// CALL is the reference's and belongs here rather than only at the Okay button.
lua.globals().set(
"SetScreenResolution",
lua.create_function(|lua, id: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.screen_resolutions.is_empty() {
return Ok(());
}
// nil / non-numeric → 0 → entry 1. Truncate, then clamp both ends.
let idx = id.map_or(0.0, f64::trunc);
let last = model.screen_resolutions.len() - 1;
let r = model.screen_resolutions[if idx < 1.0 {
0
} else {
(idx as usize - 1).min(last)
}];
let want = r.to_string();
if model
.cvars
.get("gxresolution")
.is_some_and(|slot| slot.value.eq_ignore_ascii_case(&want))
{
return Ok(());
}
set_from_engine(&mut model, "gxResolution", want);
model.restart_gx_asks += 1;
Ok(())
})?,
)?;
// ── GetRefreshRates ─────────────────────────────────────────────────────────────────────
// **Zero return values, and that IS the reference's "nothing to offer" answer** —
// `0x48c136 xor eax,eax; ret`. It was written here as a single `0` first, from
// `OptionsFrame_GetRefreshRates`'s `if ( arg.n == 1 and arg[1] == 0 )` opening; the binary
// contains **no `push 0` path at all**. That FrameXML branch is reachable only when the OS
// reports `dmDisplayFrequency == 0` for every matching mode — Win32's encoding for "the
// hardware default" — which the adjacent dedupe collapses to one `0`. It does not special-case
// the empty answer: `for i = 1, 0` runs zero times and the dropdown is left **empty but
// enabled**, which is what the reference does on a device with nothing to say.
//
// benilla's answer is always the empty one, because a refresh rate is only selectable through
// an exclusive mode-set and this client ships none on any target (`benilla_app::video`'s
// module doc walks each). Pushing the monitor's rates would be a dropdown whose every pick did
// nothing — 2115 §2's wrong answer that succeeds — and pushing a lone `0` to grey the control
// would be inventing a value the binary never produces.
//
// **The optional index argument is tolerated and ignored**, which is the reference's own shape
// (`0x48c0ec xor esi,esi`): with no argument it asks for the rates of the FIRST — smallest —
// resolution in the list, never the selected one, and the stock caller passes nothing.
lua.globals().set(
"GetRefreshRates",
lua.create_function(|_, _: mlua::MultiValue| Ok(mlua::MultiValue::new()))?,
)?;
// ── GetVideoCaps ─────────────────────────────────────────────────────────────────────────
// Seven values in `OptionsFrame.lua:59`'s own order, and **THREE push shapes, not two**
// (`0x48db40`, each polarity read through to `lua_pushnil 0x6f37f0`):
//
// * slots 1,2,3,4,7 — **nil, or the number 1**. `caps+0xa8/0x98/0x94/0xa4/0xc4`. Two of them
// test against `-1` rather than 0, because D3D maps ps_1_1 and vs_1_1 to the *value* 0 and a
// `!= 0` test would report "no shaders" on hardware that has them.
// * slot 5 `hasTripleBuffering` — **`0x48dbcb fild dword [edi+0x20]`, straight-line, NO
// branch**: always a number, never nil.
// * slot 6 `maxAnisotropy` — **nil iff 0**, else the raw value.
//
// No `lua_pushboolean` anywhere; the `1` is the f64 `1.0`.
//
// **Slot 5 is a backend constant, and getting its TYPE right is what reproduces a stock
// defect.** `caps+0x20` has two writers image-wide (the two device constructors: 1 under
// Direct3D, 0 under OpenGL) and one reader, this verb — so `== 1` means "the backend is
// Direct3D", independent of GPU, driver and the `gxTripleBuffer` CVar. `OptionsFrame_Load`
// tests `not hasTripleBuffering` twice and `hasTripleBuffering == 1` once; `0` is TRUTHY in
// Lua and slot 5 is never nil, so **the two `not` tests can never fire in either backend** and
// only the `== 1` one works. benilla is not Direct3D and exposes no buffering knob, so it
// answers the number **0**: check button 13 is hidden and button 6 re-seated
// (`OptionsFrame.lua:168-175`), and the two dead clauses stay dead, which is correct.
// Answering nil instead would *revive* them — a branch the reference cannot reach.
lua.globals().set(
"GetVideoCaps",
lua.create_function(|lua, ()| {
let caps = lua
.app_data_ref::<Model>()
.expect("model app_data")
.video_caps;
let flag = |b: bool| if b { Value::Number(1.0) } else { Value::Nil };
let mut out = mlua::MultiValue::new();
out.push_back(flag(caps.anisotropic));
out.push_back(flag(caps.pixel_shaders));
out.push_back(flag(caps.vertex_shaders));
out.push_back(flag(caps.trilinear));
// Slot 5: unconditional number, per the straight-line `fild` above.
out.push_back(Value::Number(if caps.triple_buffering { 1.0 } else { 0.0 }));
// Slot 6: nil iff 0. The reference is effectively never nil here — its base ctor seeds
// `caps+0xac = 1` — so a consumer gates on slot 1 or on `< 2`, never on nil.
out.push_back(if caps.max_anisotropy == 0 {
Value::Nil
} else {
Value::Number(caps.max_anisotropy as f64)
});
out.push_back(flag(caps.hardware_cursor));
Ok(out)
})?,
)?;
// ── RestartGx ────────────────────────────────────────────────────────────────────────────
// "Apply the staged video settings now." `0x48dab0` is three instructions — it hands the line
// `"gxRestart"` to the CONSOLE COMMAND EXECUTOR `0x63ce00`, which looks the action up and calls
// its handler `0x639f60` **synchronously**. That handler reads no CVar by name: the staging was
// done at `SetCVar` time by each gx CVar's own change callback, which writes the pending device
// format record and can veto the write outright; the handler validates that record, applies it
// with `DeviceSetFormat`, and then COMMITS the fourteen latched CVars so `GetCVar` catches up.
//
// **benilla has no such latch and no such device rebuild.** Its video settings apply as they
// are written — the departure `benilla_app::video`'s module doc already states for `gxVSync`
// ("wgpu reconfigures the surface on the next frame") and for the display mode ("ours takes
// effect on the click"). So the verb's postcondition is already true when it is called, which
// is not the same as the verb having nothing to do: the caller is asking for the settings to be
// in effect *now*, and the honest answer is to make the host re-assert them against the window
// rather than wait for its change detection to notice something it may already have seen.
//
// A counted request the host drains (`take_restart_gx_asks`), the shape `Screenshot()` uses and
// for the same reason: no payload, and two calls in a frame are two requests. **It is not a
// stub** — a stub would be an empty body pretending the settings had been applied.
// `gxMultisample` is the one setting no restart can deliver here, and none can in the reference
// either: 1629 latches the sample count at camera spawn, which is what `SetMultisampleFormat`
// above already documents as "applies at next launch".
lua.globals().set(
"RestartGx",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.restart_gx_asks += 1;
Ok(())
})?,
)?;
// ── RestoreVideoDefaults ─────────────────────────────────────────────────────────────────
// **The Defaults button's whole effect, and the FrameXML proves it.** `OptionsFrame_SetDefaults`
// sets every check button and slider from `GetCVarDefault` — and then the button's own
// `<OnClick>` calls `HideUIPanel(OptionsFrame)` (`OptionsFrame.xml:688-690`), throwing those
// widget values away without ever running `_Save`. So the widgets are cosmetic; this verb is
// what actually moves the settings, and it must, or Defaults would do nothing at all.
//
// What it restores, and why that is not the reference's own values, is
// [`VIDEO_DEFAULT_CVARS`]'s doc. Each row rides the ordinary change queue, so the host's knob
// sync applies it and the config file is marked dirty — the same route a Lua `SetCVar` takes.
// `set_from_engine`, not `write_cvar`, because this is the engine moving a value it owns rather
// than a script asking: no `CVAR_UPDATE` token, the minimap-zoom shape.
//
// **The restart is the reference's own, in the reference's own place** — `0x639a51` calls the
// `gxRestart` handler directly and synchronously, between the twelve gx writes and the sixteen
// quality ones, and it destroys and recreates the device *and the OS window*. Ours issues the
// same counted request; here it is a re-assertion rather than a rebuild, but the call belongs.
lua.globals().set(
"RestoreVideoDefaults",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for name in VIDEO_DEFAULT_CVARS {
let Some(default) = model
.cvars
.get(&name.to_ascii_lowercase())
.map(|slot| slot.default.clone())
else {
// A bare VM with no host behind it registers none of these; there is nothing to
// restore and nothing to warn about, exactly as `set_from_engine` reasons.
continue;
};
set_from_engine(&mut model, name, default);
}
model.restart_gx_asks += 1;
Ok(())
})?,
)?;
Ok(())
}
/// The `WorldDetail` slider's **stop**, benilla's spelling — the panel position `0`/`1`/`2`, held as
/// a CVar because 1.12 has no such CVar and our own Graphics page needs somewhere to keep it (the
/// `autoLootDefault` posture; the app's table carries the reasoning and the `Same` row).
+2 -2
View File
@@ -201,8 +201,8 @@ pub use cursor::{
CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::{
MultisampleFormat, CVAR_FRILL_DENSITY, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS,
CVAR_WORLD_DETAIL, WORLD_DETAIL_STOPS,
MultisampleFormat, ScreenResolution, VideoCaps, CVAR_FRILL_DENSITY, CVAR_NAMEPLATE_ENEMIES,
CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
+27
View File
@@ -804,6 +804,29 @@ pub(crate) struct Model {
/// formats rather than inventing some.
pub(crate) multisample_formats: Vec<super::cvars::MultisampleFormat>,
/// The screen resolutions the Video options window's dropdown offers, ascending — pushed by
/// the host ([`super::UiScript::set_screen_resolutions`]) from the display this client is on,
/// and read by `GetScreenResolutions` / `GetCurrentResolution` / `SetScreenResolution`.
///
/// Empty until the host pushes it. A VM with no window behind it (every extract test) offers
/// none rather than inventing a ladder, and the reference's own consumer walks it zero times.
pub(crate) screen_resolutions: Vec<super::cvars::ScreenResolution>,
/// **Where in that list the client actually is** — the index `GetCurrentResolution` answers,
/// 0-based here and reported 1-based, which is the form `CT_Viewport.lua:105` indexes the
/// `GetScreenResolutions` varargs with (`arg[GetCurrentResolution()]`).
///
/// Held as an index rather than a size so the two can never disagree: the host guarantees the
/// live size is IN the list when it pushes, which is what makes that addon's read total.
/// `None` while the list is empty.
pub(crate) current_resolution: Option<usize>,
/// What this run's device and presentation path really offer, behind `GetVideoCaps` — pushed
/// by the host, which is the only side holding a `RenderAdapter`.
pub(crate) video_caps: super::cvars::VideoCaps,
/// `RestartGx()` calls queued since the host last drained them — the video window's
/// "apply the staged settings now" button, counted like [`Self::screenshot_asks`] because the
/// request carries no payload.
pub(crate) restart_gx_asks: u32,
/// 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>,
@@ -1962,6 +1985,10 @@ impl Model {
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
multisample_formats: Vec::new(),
screen_resolutions: Vec::new(),
current_resolution: None,
video_caps: super::cvars::VideoCaps::default(),
restart_gx_asks: 0,
saved_names: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
+286 -5
View File
@@ -35,6 +35,22 @@ pub(crate) enum CollisionLayer {
Walk,
/// WMO faces only the **camera** collides with — the camera/LOS gather (skip NOCAMCOLLIDE `0x02`).
Camera,
/// **Liquid surfaces** — the wet-cell lattice of every MCLQ layer and WMO pool, on a layer of
/// their own so that *no* query sees them unless it asks.
///
/// This is `cameraWaterCollision`'s primary consumer and the shape the reference gives it: the
/// CVar does not re-lane any geometry, it adds `0xf0000` to the **trace mask** the solver hands
/// its three collision queries (`0x50e5ec`; the nibble is read at `0x69cc13` and gates a
/// per-layer intersection over the chunk's four MCLQ slots — `0x10000` river/lake, `0x20000`
/// ocean, `0x40000` magma, `0x80000` slime). A per-trace mask is exactly a `SpatialQueryFilter`,
/// which is why this is a layer and not a component the camera looks for.
///
/// wow-re `ui/scratch/water-band-discontinuity.md`. It **refutes** that tree's own
/// `camera-arm-liquid-blind.md` §2, a VERIFIED NEGATIVE that stood from June: the census was
/// correct and controlled, but a capability requested through an argument flag is invisible to
/// any census of call sites. Decision 2149 rested benilla on that verdict and built the wrong
/// half of the feature; 2165 took it out.
Liquid,
}
/// `CollisionLayers` for a WMO's **walking** collider — member of [`CollisionLayer::Walk`] only, so the
@@ -49,6 +65,16 @@ pub(crate) fn camera_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL)
}
/// `CollisionLayers` for a **liquid surface** — member of [`CollisionLayer::Liquid`] only.
///
/// Every existing filter omits that bit, so these colliders are inert to the body, the camera, the
/// mouse pick and the particle snap alike until a query asks for them by name. A swimmer must not
/// be stopped by the water they are in, and nothing but the camera sweep under
/// `cameraWaterCollision` has any business hitting a waterline.
pub(crate) fn liquid_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Liquid, LayerMask::ALL)
}
/// **How many times the world's collider set has changed** — bumped when [`crate::terrain_stream`]
/// attaches a batch of built colliders, and when any collider is removed (a tile or placement
/// streaming out, an entity despawning).
@@ -174,10 +200,16 @@ impl WorldCollision<'_, '_> {
/// …and what the third-person **camera** collides with: the other way round on those two WMO
/// face layers (it takes NOCAMCOLLIDE faces the body walks through, and skips the DETAIL faces
/// the body walks on).
pub(crate) fn camera_filter() -> SpatialQueryFilter {
SpatialQueryFilter::from_mask(LayerMask(
CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(),
))
/// `liquid` adds the waterline — `cameraWaterCollision`'s primary consumer. The reference does
/// exactly this and no more: `0x50e5ec` ORs `0xf0000` into the trace mask word the solver hands
/// its three collision queries, so the CVar changes *what this one query is allowed to see* and
/// re-lanes nothing. `false` and it is byte-for-byte the filter it always was.
pub(crate) fn camera_filter(liquid: bool) -> SpatialQueryFilter {
let mut mask = CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits();
if liquid {
mask |= CollisionLayer::Liquid.to_bits();
}
SpatialQueryFilter::from_mask(LayerMask(mask))
}
/// Sweep `shape` along `movement` against the body's world.
@@ -219,6 +251,10 @@ impl WorldCollision<'_, '_> {
/// Deliberately avian's own two-sided cast rather than the one-sided law above: the facing
/// gate exists because a *body* must not stand on a face wound away from it, and a camera has
/// no such contract — it just must not end up inside geometry, from either side.
///
/// `liquid` is `cameraWaterCollision`, straight through: with it set the boom stops at a
/// waterline the way it stops at ground, which is the whole of the option's primary effect and
/// what its own tooltip promises the player.
pub fn cast_camera(
&self,
shape: &Collider,
@@ -226,6 +262,7 @@ impl WorldCollision<'_, '_> {
rotation: Quat,
movement: Vec3,
skin_width: f32,
liquid: bool,
) -> Option<MoveHitData> {
self.ms.cast_move(
shape,
@@ -233,7 +270,7 @@ impl WorldCollision<'_, '_> {
rotation,
movement,
skin_width,
&Self::camera_filter(),
&Self::camera_filter(liquid),
)
}
@@ -352,6 +389,250 @@ impl WorldCollision<'_, '_> {
}
}
/// **`cameraWaterCollision` is a trace mask and nothing else.** The same waterline collider is a
/// wall to the camera boom with the CVar on, thin air with it off, and thin air to the walking body
/// either way — which is the whole of the reference's `0x50e5ec`, and the reason a swimmer is never
/// stopped by the water they are in.
#[cfg(test)]
mod liquid_trace_mask {
use super::*;
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
/// A headless world holding one 10×10 horizontal surface on [`CollisionLayer::Liquid`].
fn world_with_waterline() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>();
app.world_mut().spawn((
RigidBody::Static,
Collider::trimesh(
vec![
Vec3::new(-5.0, 0.0, -5.0),
Vec3::new(5.0, 0.0, -5.0),
Vec3::new(5.0, 0.0, 5.0),
Vec3::new(-5.0, 0.0, 5.0),
],
vec![[0u32, 2, 1], [0, 3, 2]],
),
liquid_layers(),
Transform::default(),
));
app.finish();
app.cleanup();
app.update();
app
}
/// Drop a small probe from above the surface onto it, through `filter`.
fn descend(app: &mut App, filter: SpatialQueryFilter) -> Option<f32> {
app.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
ms.cast_move(
&Collider::sphere(0.1),
Vec3::new(0.0, 3.0, 0.0),
Quat::IDENTITY,
Vec3::new(0.0, -6.0, 0.0),
0.0,
&filter,
)
.map(|h| h.distance)
})
.expect("system runs")
}
/// A waterline at `y = 0`, tessellated at the **real** liquid cell size.
///
/// Deliberately not one huge quad: an MCNK liquid layer is a 9×9 lattice over the chunk's
/// 33.333 yd, so its cells are 4.167 yd and its triangles are that big and no bigger. A sphere
/// swept at grazing incidence against a 120 yd triangle is numerically unstable in a way it is
/// not against a 4 yd one, and a test that models the water as one quad measures the solver's
/// conditioning rather than the client's behaviour — which is exactly the sort of
/// unrepresentative fixture that lets a defect through.
fn flooded_world() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>();
app.init_resource::<MoverTraceExclusions>();
// 32×32 cells of 4.1667 yd — 133 yd square, 2048 triangles, the shape a streamed
// MCLQ layer actually presents.
const CELL: f32 = 33.333_332 / 8.0;
const N: usize = 32;
let half = CELL * N as f32 * 0.5;
let mut verts = Vec::with_capacity((N + 1) * (N + 1));
for r in 0..=N {
for c in 0..=N {
verts.push(Vec3::new(
c as f32 * CELL - half,
0.0,
r as f32 * CELL - half,
));
}
}
let mut tris = Vec::with_capacity(N * N * 2);
for r in 0..N {
for c in 0..N {
let i = (r * (N + 1) + c) as u32;
let stride = (N + 1) as u32;
tris.push([i, i + stride, i + 1]);
tris.push([i + 1, i + stride, i + stride + 1]);
}
}
app.world_mut().spawn((
RigidBody::Static,
Collider::trimesh(verts, tris),
liquid_layers(),
Transform::default(),
));
app.finish();
app.cleanup();
app.update();
app
}
/// A surface-swimming human male: feet `0.75·h` under the plane, boom rooted at the capsule's
/// top hemisphere centre, 15 yd of zoom.
const SURFACE_DEPTH: f32 = 0.75 * 2.031;
const HEAD_OVER_FEET: f32 = 2.027_777_7 - 1.0 / 3.0;
const ZOOM: f32 = 15.0;
/// What 2170 framed at — the bare swim preset `cam+0x124`, which puts the orbit centre 11 mm
/// UNDER the plane.
const UNCORRECTED_PIVOT: f32 = 1.512_012;
/// The arm length the solver settles on for a swimmer whose feet are at `feet_y`, framing at
/// `pivot_over_feet`, sweeping from `origin_over_feet`, looking at `pitch` radians (+up).
fn open_arm(app: &mut App, feet_y: f32, geo: (f32, f32), pitch: f32) -> f32 {
let feet = Vec3::new(0.0, feet_y, 0.0);
let head = feet + Vec3::Y * geo.1;
let pivot = feet + Vec3::Y * geo.0;
let fwd = Quat::from_euler(EulerRot::YXZ, 0.0, pitch, 0.0) * Vec3::NEG_Z;
let boom = (pivot - fwd * ZOOM) - head;
let len = boom.length();
app.world_mut()
.run_system_once(move |c: WorldCollision| {
c.cast_camera(
&Collider::sphere(0.15),
head,
Quat::IDENTITY,
boom,
0.0,
true,
)
.map_or(len, |h| h.distance)
})
.expect("system runs")
}
/// 2170's geometry at a given depth: the framing pivot is the bare swim preset, so it rides the
/// **body** — and the boom is rooted at the head, likewise.
fn uncorrected(_feet_y: f32) -> (f32, f32) {
(UNCORRECTED_PIVOT, HEAD_OVER_FEET)
}
/// The corridor's geometry at a given depth: arm A pins the pivot to `d + 2/9` and its floor
/// lifts the sweep origin to the same place, so **both ride the water plane** rather than the
/// body.
fn corrected(feet_y: f32) -> (f32, f32) {
let floor = (0.0 - feet_y) + 2.0 / 9.0;
(floor, HEAD_OVER_FEET.max(floor))
}
/// Worst single-step change in arm length as the swimmer's depth is walked over `span`, at the
/// given pitch.
fn worst_over_depth(
app: &mut App,
geo: impl Fn(f32) -> (f32, f32),
pitch: f32,
span: f32,
n: usize,
) -> f32 {
let mut worst: f32 = 0.0;
let mut prev: Option<f32> = None;
for i in 0..=n {
let feet_y = -SURFACE_DEPTH - span * 0.5 + span * i as f32 / n as f32;
let d = open_arm(app, feet_y, geo(feet_y), pitch);
if let Some(q) = prev {
worst = worst.max((d - q).abs());
}
prev = Some(d);
}
worst
}
/// **The gate this feature ships behind, with a positive control that reproduces the bug.**
///
/// The axis is **depth**, not pitch — which is the correction that made this test worth having.
/// A first attempt swept the camera *pitch* and found all three geometries merely steep, no
/// cliff anywhere; that was the wrong question, because the director's report was of a camera
/// snapping *while swimming at the surface*, with the mouse still. What varies then is the
/// swimmer's depth, by fractions of a millimetre, as the float resolver settles against its
/// rest cap.
///
/// **2170's geometry turns half a millimetre of that into ten yards of camera.** Its framing
/// pivot is the bare swim preset, so the orbit centre rides the BODY — and sits 11 mm under the
/// water plane. The boom's far end therefore straddles the plane, and whether the sweep hits at
/// all flips with the settle. Snap in, ease out, snap in: exactly what was reported.
///
/// **The corridor's geometry cannot do this, structurally.** Arm A pins the pivot to `d + 2/9`
/// and its floor lifts the sweep origin to the same place — both defined FROM THE SURFACE, so
/// the camera's clearance over the water is a constant 2/9 yd no matter what the body does
/// underneath it. Depth stops being an input. That is the property worth having, and it is why
/// this is a fix rather than a tuning: nothing here was made smaller, it was made independent.
#[test]
fn a_swimmers_settle_cannot_move_the_camera_once_the_corridor_holds_it() {
let mut app = flooded_world();
// The control, at the pitch where the old geometry is worst: looking slightly down, which
// swings the seat up across the plane.
let control = worst_over_depth(&mut app, uncorrected, (-2.0f32).to_radians(), 0.30, 600);
assert!(
control > 5.0,
"the positive control must reproduce the regression — half a millimetre of settle \
should swing 2170's camera by yards, got {control}"
);
// …and the corridor, across the whole range a swimmer looks through.
for pitch_deg in [-25.0f32, -10.0, -2.0, -0.5, 0.0, 0.5, 2.0, 10.0, 25.0] {
let step = worst_over_depth(&mut app, corrected, pitch_deg.to_radians(), 0.30, 600);
assert!(
step < 0.1,
"at {pitch_deg} deg the settle moved the arm {step} yd — the corridor's whole \
claim is that depth is no longer an input (the control, for scale, was {control})"
);
}
}
#[test]
fn the_waterline_stops_the_camera_only_when_the_cvar_asks_for_it() {
let mut app = world_with_waterline();
let on = descend(&mut app, WorldCollision::camera_filter(true));
assert!(
on.is_some_and(|d| (d - 2.9).abs() < 0.05),
"with cameraWaterCollision the boom stops at the surface, got {on:?}"
);
assert_eq!(
descend(&mut app, WorldCollision::camera_filter(false)),
None,
"with the CVar off the camera passes through, exactly as it did before this existed"
);
assert_eq!(
descend(&mut app, WorldCollision::body_filter()),
None,
"and the BODY passes through either way — a swimmer is not stopped by their own water"
);
}
}
/// The contact pipeline we deliberately do not run (decision 1232, disabled in `world_plugins`).
///
/// These two tests are a matched pair: the first shows the cost was real — a kinematic trimesh
@@ -24,7 +24,9 @@ use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool};
use crate::collision::liquid_layers;
use crate::lighting::WATER_SHININESS;
use avian3d::prelude::{Collider, RigidBody};
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::{LiquidExt, LiquidMaterial};
use benilla_assets::LockRecover;
@@ -292,9 +294,41 @@ pub(crate) fn spawn_liquids<'a>(
.entity(*entities.last().expect("just pushed"))
.insert(FoamPatch);
}
// **The waterline the camera sweep can hit** under `cameraWaterCollision` — inert to every
// other query, because nothing else asks for [`CollisionLayer::Liquid`].
if let Some(collider) = liquid_collider(lq) {
commands
.entity(*entities.last().expect("just pushed"))
.insert((collider, RigidBody::Static, liquid_layers()));
}
}
}
/// The **collision** shape for one [`LiquidMesh`] — the same wet-cell triangles the render mesh
/// draws, as a parry trimesh on [`CollisionLayer::Liquid`](crate::collision::CollisionLayer).
///
/// `lq.indices` is already the wet derivative (6 per wet cell), so this is the waterline exactly
/// where there is water and nothing over the dry cells of a part-flooded chunk — which is the
/// difference between a camera that stops at a lake's edge and one that stops in mid-air over the
/// shore. Built inline rather than through [`crate::terrain_stream::PendingCollider`]'s async pool:
/// an MCNK layer's 9×9 lattice is at most 64 cells, 128 triangles, next to the 256 a terrain chunk
/// defers, and a WMO pool's MLIQ is the same order.
///
/// `None` for a degenerate layer with no triangles, which is a real case (an MCLQ layer whose cells
/// are all dry) and not a failure.
fn liquid_collider(lq: &LiquidMesh) -> Option<Collider> {
let tris: Vec<[u32; 3]> = lq
.indices
.chunks_exact(3)
.map(|c| [c[0], c[1], c[2]])
.collect();
if tris.is_empty() {
return None;
}
let verts: Vec<Vec3> = lq.positions.iter().map(|p| wow_to_bevy(*p)).collect();
Some(Collider::trimesh(verts, tris))
}
/// Build the Bevy render mesh for one [`LiquidMesh`]: positions mapped WoW→Bevy (`lq.positions` are
/// raw WoW coords — absolute for MCLQ, WMO-model-local for WMO liquid), a flat up normal, the tiling
/// UVs, and the per-vertex swatch `V` packed into UV1.x for the shader's colour/opacity ramp. The
@@ -439,6 +473,15 @@ pub(crate) fn spawn_wmo_liquids<'a>(
if !lq.kind.is_fullbright() {
commands.entity(surface).insert(FoamPatch);
}
// The camera's waterline, as on the ADT path. The shape is MODEL-LOCAL here and the
// entity carries the placement `transform`, so avian lifts it into the world the same way
// it lifts the render mesh — no second bake, and a rotated pool stays consistent with the
// surface the player sees.
if let Some(collider) = liquid_collider(lq) {
commands
.entity(surface)
.insert((collider, RigidBody::Static, liquid_layers()));
}
entities.push(surface);
}
}