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