camera rows land in Options, brightness arrives, /reload loses nothing
- fourteen settings get their switch on the Options window: the Camera box from Follow Terrain and Head Bob to Water Collision and Smart Pivot, plus Attack on Assist, Auto Clear AFK, Auto Self Cast, Enhanced Tooltips, Player Guild Names and Show Target Damage (2180) - the Graphics page gains Weather Intensity, scaling rain, snow and mist, and Brightness, applied as a final pass over the whole finished frame; GetGamma and SetGamma answer in the slider's unit (2181, 2182) - hovering a disabled button fires no OnEnter, no OnLeave and no highlight, as the reference's does, so AtlasLoot's unset QuickLook buttons stop throwing a Lua error on every hover (2183) - a chat window dragged, resized, recoloured or renamed just before a /reload keeps the change, and the meeting stone's minimap icon comes back after a /reload while you are still queued (2184) - swimming at the surface, the camera stops snapping into your character every couple of seconds: the water leg of the camera trace is a bare ray, as the reference's is, so the boom stops on the plane rather than a probe radius short of it (2185) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -425,6 +425,13 @@
|
||||
local body = getglobal("BenillaOptionsFrameContainerBody" .. k)
|
||||
if k == key then body:Show() else body:Hide() end
|
||||
end
|
||||
-- A category switch starts at the TOP (2180), which is what entering and leaving a
|
||||
-- search have always done two functions down and what the era's own ScrollBox does
|
||||
-- per category. It was invisible until this record, because no PAGE outran its area
|
||||
-- — only the search results did, and both of their edges reset. Now that Controls
|
||||
-- carries sixteen rows, leaving it scrolled and clicking Interface would land the
|
||||
-- player halfway down a page they have not seen the top of.
|
||||
BenillaOptionsFrameContainerScroll:SetVerticalScroll(0)
|
||||
-- The Keybindings page (KeyBindingsPage.xml, decision 1008) body-swaps like the
|
||||
-- CVar pages but runs its own machinery: entering rebuilds the section tree;
|
||||
-- leaving disarms a straggling capture (the host seam must never outlive the page)
|
||||
@@ -784,10 +791,42 @@
|
||||
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
|
||||
.. "modern Classic offers these same two."
|
||||
|
||||
-- Brightness (2182). 1.12's own OPTION_TOOLTIP_GAMMA cannot ride this row: its whole
|
||||
-- second sentence is "Increase brightness until you can clearly see all 21 levels of gray
|
||||
-- bars to the right", and those bars are the stock video window's own calibration art,
|
||||
-- which this page does not have (2182 §7 names it as a follow-up). The row also has to answer the
|
||||
-- question the reference never had to — why a brightness control works here at all, when
|
||||
-- 1.12's did nothing outside exclusive fullscreen.
|
||||
BENILLA_TOOLTIP_BRIGHTNESS =
|
||||
"Lightens or darkens the whole picture — nights, caves and interiors are what it is "
|
||||
.. "for.\n\n"
|
||||
.. "This is the same correction 1.12 applied through the display's own gamma ramp, "
|
||||
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
|
||||
.. "finished image instead, so it works in both display modes."
|
||||
|
||||
OPTIONS_PAGE_ROWS = {
|
||||
-- Seven rows became sixteen at 2180, and the nine that arrived were not new
|
||||
-- settings: every one names a CVar this client already registered AND read, with no
|
||||
-- control anywhere a player could reach. The four camera toggles are why the record
|
||||
-- exists — 2149 built their mechanisms and 2179 rebuilt water collision, and none of
|
||||
-- the four could be turned off from inside the game.
|
||||
--
|
||||
-- SEATS: every row that was already here keeps the era-derived seat it had; each new
|
||||
-- row takes the seat its own 1.12 box gives it, that box's COLUMNS read left to
|
||||
-- right and flattened into this single one (the rule this page's Block Trades
|
||||
-- comment already states). BasicOptionsGeneral runs 45 -> 3 -> 16 down its middle
|
||||
-- column and 14 -> 61 down its right, so Attack on assist and Auto Clear AFK follow
|
||||
-- Sticky Targeting and Auto Self Cast follows Block Trades. BasicOptionsCamera runs
|
||||
-- the following-style dropdown -> 24 -> 26, then Slider2 -> 27 -> 15, then Slider3
|
||||
-- -> Slider4 — which is the block below verbatim, and which leaves Max Camera
|
||||
-- Distance exactly where it already sat, last.
|
||||
Controls = {
|
||||
"RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse",
|
||||
"RowMouseSpeed", "RowCameraFollowStyle", "RowMaxCameraDistance",
|
||||
"RowStickyTarget", "RowAssistAttack", "RowClearAFK",
|
||||
"RowBlockTrades", "RowAutoSelfCast",
|
||||
"RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
|
||||
"RowCameraFollowStyle", "RowFollowTerrain", "RowHeadBob",
|
||||
"RowAutoFollowSpeed", "RowWaterCollision", "RowSmartPivot",
|
||||
"RowMouseLookSpeed", "RowMaxCameraDistance",
|
||||
},
|
||||
Audio = {
|
||||
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
|
||||
@@ -795,17 +834,23 @@
|
||||
"RowBackgroundSound",
|
||||
},
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
|
||||
"RowWeatherDensity",
|
||||
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
|
||||
},
|
||||
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
|
||||
-- reference's own relationship twice over: its CheckButton22 hangs off 21 with a +26
|
||||
-- x indent, and UIOptionsFrame_UpdateDependencies l.702-708 greys 22 (and 23) from
|
||||
-- 21's own tick. Its knob is `UnitNamePlayerGuild`, built by 2149's §6.
|
||||
Nameplates = {
|
||||
"RowEnemyPlates", "RowFriendlyPlates",
|
||||
"RowPlayerNames", "RowNpcNames", "RowOwnName",
|
||||
"RowPlayerNames", "RowGuildNames", "RowNpcNames", "RowOwnName",
|
||||
},
|
||||
-- The stock globals that were already defined AND consumed when 1134 opened the store
|
||||
-- (1136; Buff Durations 1139), plus the two equipment-display toggles (1472), in the
|
||||
-- reference's own on-screen order. Its Basic Options Display box is three COLUMNS read
|
||||
-- left to right: A gives Status Text (CheckButton2), B gives Show Cloak → Show Helm
|
||||
-- left to right: A gives Enhanced Tooltips (CheckButton12, added 2180) then Status
|
||||
-- Text (2), B gives Show Cloak → Show Helm
|
||||
-- (31 → 20, sitting under the name toggles that live on our Nameplates page), C gives
|
||||
-- the 42 → 39 → 66 chain. The **Help** box closes the page, and since 2077 it is
|
||||
-- the box's own order rather than its one shippable row: 28 (Show Tutorials), 44
|
||||
@@ -818,7 +863,7 @@
|
||||
-- "Raid & Party" — so there is no column order to preserve, only a neighbour to pick.
|
||||
-- It sits under Status Text because that is the other unit-frame readout on the page.
|
||||
Interface = {
|
||||
"RowStatusText", "RowTargetOfTarget", "RowTargetOfTargetMode",
|
||||
"RowEnhancedTooltips", "RowStatusText", "RowTargetOfTarget", "RowTargetOfTargetMode",
|
||||
"RowShowCloak", "RowShowHelm",
|
||||
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
|
||||
"RowProfanityFilter",
|
||||
@@ -887,6 +932,13 @@
|
||||
"RowCombatState", "RowDodgeParryMiss", "RowResistances", "RowReputation",
|
||||
"RowReactives", "RowFriendlyNames", "RowComboPoints", "RowEnergyGains",
|
||||
"RowHonorGained",
|
||||
-- The damage-NUMBER trio (2180) shares the reference's box and NOT its master:
|
||||
-- 19 -> 11 -> 9 close the AdvancedOptionsCombatText anchor chain, and the
|
||||
-- "Additional combat text dependancies" block that greys 53…69 never names them
|
||||
-- — 19 runs its own pair instead (UIOptionsFrame.lua l.709-714). So Show Target
|
||||
-- Damage is a second, independent master on this page, and the two under it are
|
||||
-- its children.
|
||||
"RowShowDamage", "RowPeriodicDamage", "RowPetDamage",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -931,6 +983,16 @@
|
||||
function OptionsRow_Get(row)
|
||||
if row.uvar then return getglobal(row.uvar) end
|
||||
if row.getFunc then
|
||||
-- A NUMERIC api row (2182): the store is an engine getter whose answer is a
|
||||
-- number, not a flag. 1.12's own video panel is where this shape comes from --
|
||||
-- its slider table's `func` fork calls `getglobal("Get"..func)` and feeds the
|
||||
-- result straight to `slider:SetValue` (OptionsFrame.lua l.110/135) -- and the
|
||||
-- Brightness row is the one benilla ships. Normalised through the SAME two-decimal
|
||||
-- strip the slider writes with, so a value sitting on the default compares equal
|
||||
-- to it as a string and the pending/committed test below still works.
|
||||
if row.numeric then
|
||||
return (string.gsub(string.format("%.2f", row.getFunc()), "%.?0+$", ""))
|
||||
end
|
||||
if row.getFunc() then return "1" else return "0" end
|
||||
end
|
||||
return GetCVar(row.cvar)
|
||||
@@ -965,10 +1027,41 @@
|
||||
-- straight through) — so the engine binding reads its argument numerically and "0"
|
||||
-- means off. No applyFunc: the setter IS the side effect.
|
||||
if row.setFunc then
|
||||
row.setFunc(value)
|
||||
-- A numeric api row hands the setter a NUMBER, which is what 1.12 does for the
|
||||
-- shape it borrows from: `OptionsFrame_Save` passes `slider:GetValue()`
|
||||
-- (OptionsFrame.lua l.206-226), not a string.
|
||||
if row.numeric then
|
||||
row.setFunc(tonumber(value))
|
||||
else
|
||||
row.setFunc(value)
|
||||
end
|
||||
return
|
||||
end
|
||||
SetCVar(row.cvar, value, row.cvarEvent)
|
||||
OptionsRow_WritePartner(row, value)
|
||||
end
|
||||
|
||||
-- **The PARTNER write (2180).** Three of 1.12's own rows drive a SECOND CVar that
|
||||
-- appears in neither option table, from the special-case arms of `UIOptionsFrame_Save`:
|
||||
-- Show Pet Melee Damage writes `PetSpellDamage` beside `PetMeleeDamage` (l.334-336), and
|
||||
-- the two camera-speed sliders each write their pitch twin at a fixed ratio (l.351-356,
|
||||
-- yaw/4 for the follow speed and yaw/2 for the look speed). Without this a row is a HALF
|
||||
-- write — the box reads "pet damage off" while pet SPELL damage keeps floating — and it
|
||||
-- is silent, because `SetCVar` on a name nobody wrote is not an error, it is a value
|
||||
-- that stayed where it was.
|
||||
--
|
||||
-- `scale` nil means a flag: the same "1"/"0" string goes to both. A number scales, and
|
||||
-- the result is formatted the way the slider path formats its own write (two decimals,
|
||||
-- trailing zeros stripped) so a partner sitting at its registered default compares equal
|
||||
-- to it as a STRING and stays out of the config file.
|
||||
function OptionsRow_WritePartner(row, value)
|
||||
if not row.partner then return end
|
||||
local v = value
|
||||
if row.partner.scale then
|
||||
v = string.gsub(
|
||||
string.format("%.2f", tonumber(value) * row.partner.scale), "%.?0+$", "")
|
||||
end
|
||||
SetCVar(row.partner.cvar, v)
|
||||
end
|
||||
|
||||
-- The registered default — GetCVarDefault's counterpart for a global. `udefault` is the
|
||||
@@ -1003,6 +1096,15 @@
|
||||
row.fdefault = default
|
||||
end
|
||||
|
||||
-- The same, for a pair whose value is a NUMBER rather than a flag (2182) -- 1.12's video
|
||||
-- panel's own `func` fork. `default` is still a string, because every store in this kit
|
||||
-- answers in strings and the row compares them; the Brightness row computes it from
|
||||
-- GetCVarDefault so the CVar table stays the one source.
|
||||
function OptionsNumFuncRow_OnLoad(row, getFunc, setFunc, default, label, tip, child)
|
||||
OptionsFuncRow_OnLoad(row, getFunc, setFunc, default, label, tip, child)
|
||||
row.numeric = 1
|
||||
end
|
||||
|
||||
-- ── THE LOAD-TIME LADDER IS THE REFERENCE'S OWN AGAIN (decision 2115) ──────────────
|
||||
--
|
||||
-- The problem it solves is real and unchanged (decision 1139): the whole UI's XML runs
|
||||
@@ -1069,9 +1171,15 @@
|
||||
-- `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" }
|
||||
-- reference row we ship no knob for (rows 4, 5, 7 and 8: TerrainMip, BaseMip,
|
||||
-- anisotropic, spellEffectLevel), and the OnShow re-apply loop skips it. Rows 6 and 9 are
|
||||
-- Brightness (2182) and Weather Intensity (2181), which is why this table now has HOLES
|
||||
-- rather than a run: the loop below is `for i, name in` — Lua 5.0's old-style `pairs` —
|
||||
-- so it walks the keys that exist and an `ipairs` would have stopped at 3.
|
||||
BENILLA_GRAPHICS_SLIDER_ROWS = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
[6] = "RowBrightness", [9] = "RowWeatherDensity",
|
||||
}
|
||||
--
|
||||
-- **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
|
||||
@@ -1104,6 +1212,8 @@
|
||||
function OptionsSliderRow_Setup(row, min, max, step, fmt, stops)
|
||||
local slider = getglobal(row:GetName() .. "ControlSlider")
|
||||
row.fmt = fmt
|
||||
-- Kept for the "travel" readout, which is a share of this row's own range.
|
||||
row.minv, row.maxv = min, max
|
||||
-- A grid that IS a short named ladder passes its names here (Environment Detail's
|
||||
-- Low/Medium/High over 0/1/2): the groove is still the reference's slider, and the
|
||||
-- readout says the stop rather than its index. 1.12's own slider printed no value at
|
||||
@@ -1219,6 +1329,16 @@
|
||||
if i > table.getn(row.stops) then i = table.getn(row.stops) end
|
||||
return row.stops[i]
|
||||
end
|
||||
-- A row whose stored value is a CENTRED OFFSET: show where the thumb is, as a share
|
||||
-- of the groove (2182). Brightness is the case -- its stored unit is the reference's
|
||||
-- own `1 - gamma`, which runs -0.5 .. +0.5 with the default at 0, and a readout of
|
||||
-- "0%" on a brightness control that is doing nothing wrong is exactly backwards. As
|
||||
-- travel it reads 0 .. 100 with the default at 50, the way a brightness control reads
|
||||
-- everywhere else, while the value the store keeps stays the reference's.
|
||||
if row.fmt == "travel" and row.minv and row.maxv and row.maxv > row.minv then
|
||||
return string.format("%d%%",
|
||||
math.floor((value - row.minv) / (row.maxv - row.minv) * 100 + 0.5))
|
||||
end
|
||||
-- A row whose stored value is a FACTOR but whose meaning is a distance: show the
|
||||
-- distance (decision 1140 — cameraDistanceMaxFactor over the 15 yd base). The number
|
||||
-- the player is choosing is "how far out the wheel stops", not "times 1.4".
|
||||
@@ -1391,21 +1511,59 @@
|
||||
function OptionsPage_UpdateDependencies(key)
|
||||
if key == "Combat" then
|
||||
-- The 1.12 rule, verbatim (UIOptionsFrame_UpdateDependencies' "Additional combat
|
||||
-- text dependancies" block): the master off greys EVERY sub-toggle and the scroll
|
||||
-- dropdown. Combo Points carries the reference's second gate too — it is disabled
|
||||
-- for anyone who is not a rogue or a druid, since nobody else has combo points.
|
||||
-- text dependancies" block): the master off greys every FLOATING-TEXT sub-toggle
|
||||
-- and the scroll dropdown. Combo Points carries the reference's second gate too —
|
||||
-- it is disabled for anyone who is not a rogue or a druid, since nobody else has
|
||||
-- combo points.
|
||||
local on = SHOW_COMBAT_TEXT ~= "0"
|
||||
local _, class = UnitClass("player")
|
||||
class = class and string.upper(class)
|
||||
local combo = class == "ROGUE" or class == "DRUID"
|
||||
-- The damage-number trio is NOT under this master (2180). The reference's own
|
||||
-- "Additional combat text dependancies" block names 53…69 and stops; 19/11/9 are
|
||||
-- governed by the separate test at l.709-714. Greying them here would be a rule
|
||||
-- the reference does not have, and it would read as "floating combat text off
|
||||
-- means no damage numbers", which is the opposite of what CombatDamage does.
|
||||
local damage = {
|
||||
RowShowDamage = 1, RowPeriodicDamage = 1, RowPetDamage = 1,
|
||||
}
|
||||
for _, rkey in ipairs(OPTIONS_PAGE_ROWS.Combat) do
|
||||
if rkey ~= "RowCombatText" then
|
||||
if rkey ~= "RowCombatText" and not damage[rkey] then
|
||||
local live = on
|
||||
if rkey == "RowComboPoints" and not combo then live = nil end
|
||||
OptionsRow_SetEnabled(
|
||||
getglobal("BenillaOptionsFrameContainerBodyCombat" .. rkey), live)
|
||||
end
|
||||
end
|
||||
-- 1.12's second gate on this page, verbatim (l.709-714): Show Target Damage off
|
||||
-- greys Periodic Damage and Pet Damage. The CVars keep their values — this is a
|
||||
-- UI dependency, not a write — and the engine agrees with the shape: at
|
||||
-- `CombatDamage` "0" both of `0x607140`/`0x6128b0`'s branch targets are
|
||||
-- epilogues, so nothing floats from any source and the two sub-gates below have
|
||||
-- nothing left to gate.
|
||||
local dmg = GetCVar("CombatDamage") ~= "0"
|
||||
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyCombatRowPeriodicDamage, dmg)
|
||||
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyCombatRowPetDamage, dmg)
|
||||
return
|
||||
end
|
||||
if key == "Controls" then
|
||||
-- 1.12's own rule (l.715-719): the Auto-Follow Speed slider is dead while the
|
||||
-- following style is Never, because there is no follow to set a speed for. The
|
||||
-- reference tests its dropdown's selected ID against 3; ours stores the ENGINE's
|
||||
-- values, where Never is "0" — and answers to "3" as well, since that is what
|
||||
-- the reference's own dropdown writes and what a config or addon carrying it
|
||||
-- means (the row's `alt`, decision 1502).
|
||||
local style = GetCVar("cameraSmoothStyle")
|
||||
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyControlsRowAutoFollowSpeed,
|
||||
style ~= "0" and style ~= "3")
|
||||
return
|
||||
end
|
||||
if key == "Nameplates" then
|
||||
-- 1.12's own rule (l.702-708): Player Names off greys Player Guild Names. The
|
||||
-- reference greys its PvP-title twin (CheckButton23) from the same test; that
|
||||
-- row is still on the unbacked census, so there is one child here, not two.
|
||||
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyNameplatesRowGuildNames,
|
||||
GetCVar("UnitNamePlayer") ~= "0")
|
||||
return
|
||||
end
|
||||
if key == "Interface" then
|
||||
@@ -1455,8 +1613,35 @@
|
||||
local name = row:GetName()
|
||||
local box = getglobal(name .. "Check")
|
||||
local dd = getglobal(name .. "Dropdown")
|
||||
-- A SLIDER row greys the way 1.12's own OptionsFrame_DisableSlider does (l.481-486):
|
||||
-- the THUMB is hidden — which is what says "this groove is dead" at a glance — and
|
||||
-- the readout goes gray. Its Low/High end captions have no counterpart here; this kit
|
||||
-- carries one readout instead of the reference's two.
|
||||
--
|
||||
-- **And the mouse bit, which the reference does not need and we do.** There is no
|
||||
-- `Slider:Disable()` to call — 1.12 gives a Slider no enabled state at all, which is
|
||||
-- why this kit's Slider has no such method (script/slider.rs's `begin_drag`) — and a
|
||||
-- thumbless 1.12 slider is already undraggable. Ours is not: a press anywhere on the
|
||||
-- track seats the value (0250 §5's jump), so a greyed groove would still answer a
|
||||
-- click. `EnableMouse(false)` is how the reference keeps anything off the press, and
|
||||
-- it takes the frame out of the hit test, so the row underneath keeps its hover wash
|
||||
-- instead of losing it with the control.
|
||||
local sl = getglobal(name .. "ControlSlider")
|
||||
if box then
|
||||
if on then box:Enable() else box:Disable() end
|
||||
elseif sl then
|
||||
local thumb = getglobal(name .. "ControlSliderThumb")
|
||||
local value = getglobal(name .. "ControlValue")
|
||||
if on then
|
||||
sl:EnableMouse(true)
|
||||
thumb:Show()
|
||||
value:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g,
|
||||
NORMAL_FONT_COLOR.b)
|
||||
else
|
||||
sl:EnableMouse(false)
|
||||
thumb:Hide()
|
||||
value:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
|
||||
end
|
||||
elseif dd then
|
||||
local text = getglobal(dd:GetName() .. "Text")
|
||||
if on then
|
||||
@@ -2314,19 +2499,60 @@
|
||||
down the right, so the two belong adjacent when the
|
||||
box is flattened to one column. Its tooltip key IS the
|
||||
reference's, unlike the row above. -->
|
||||
<Frame name="$parentRowBlockTrades" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- The three BasicOptionsGeneral switches whose knob this client already had and
|
||||
whose box nobody could reach (2180). Labels are the reference's GlobalStrings
|
||||
verbatim, ASSIST_ATTACK's sentence case included — the page's own rule is
|
||||
"1.12 GlobalStrings where the row exists there", and quietly Title-Casing one
|
||||
string is how a transcription starts drifting from what it transcribes. -->
|
||||
<Frame name="$parentRowAssistAttack" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "assistAttack", "Attack on assist",
|
||||
"OPTION_TOOLTIP_ASSIST_ATTACK")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowClearAFK" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowAssistAttack" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowAssistAttack" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "autoClearAFK", "Auto Clear AFK",
|
||||
"OPTION_TOOLTIP_CLEAR_AFK")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowBlockTrades" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowClearAFK" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowClearAFK" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>OptionsRow_OnLoad(self, "BlockTrades", "Block Trades",
|
||||
"OPTION_TOOLTIP_BLOCK_TRADES")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowAutoLoot" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- Auto Self Cast (1745). The one row on this page that ships at a value the
|
||||
reference does not register — "1" against its "0", a named deviation in
|
||||
`cvars::REGISTERED` because with it off an unbindable friendly cast falls
|
||||
into the targeting-cursor machine, which is unmodeled, leaving no path at
|
||||
all. The Defaults button walks it back to OUR shipped value, which is what
|
||||
GetCVarDefault answers and the only value that is true here. -->
|
||||
<Frame name="$parentRowAutoSelfCast" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowBlockTrades" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowBlockTrades" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "autoSelfCast", "Auto Self Cast",
|
||||
"OPTION_TOOLTIP_AUTO_SELF_CAST")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowAutoLoot" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoSelfCast" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoSelfCast" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<!-- No tip: 1.12 has no Auto Loot row, so no
|
||||
OPTION_TOOLTIP_* for it either (verified — the
|
||||
5875 GlobalStrings has no AUTO_LOOT key at all,
|
||||
@@ -2343,11 +2569,12 @@
|
||||
</Frame>
|
||||
<!-- Mouse Sensitivity (decision 1140). Last because 1.12
|
||||
puts its four sliders in their own box BELOW the
|
||||
checkbox columns (UIOptionsFrameSliders), and this is
|
||||
the only one of the four whose knob we have: the other
|
||||
three drive auto-follow yaw speed, mouse-look yaw speed
|
||||
and the max-follow-distance factor, none of which our
|
||||
camera carries yet. Range and step are the reference's
|
||||
checkbox columns (UIOptionsFrameSliders), and it was the
|
||||
only one of the four whose knob we had when 1140
|
||||
wrote this row. All four ship now — the
|
||||
max-follow-distance factor below it (1140), and the
|
||||
auto-follow and mouse-look speeds in the camera block
|
||||
under the following-style dropdown (2180). Range and step are the reference's
|
||||
own (0.5 .. 1.5 by 0.05); the percent readout is the
|
||||
era's default and reads a multiplier exactly right. -->
|
||||
<Frame name="$parentRowMouseSpeed" inherits="OptionsSliderRowTemplate">
|
||||
@@ -2401,11 +2628,113 @@
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowMaxCameraDistance" inherits="OptionsSliderRowTemplate">
|
||||
<!-- ── The four 1.12 camera toggles, and the two speed sliders (2180) ──────
|
||||
2149 built all four mechanisms and 2179 rebuilt water collision at the
|
||||
bytes; until this block none of them could be turned off from inside the
|
||||
game. The stock UIOptionsFrame carries all four checkboxes and benilla
|
||||
loads that window HIDDEN, deliberately (2115) — so "the reference has the
|
||||
box" and "the player can reach the box" had come apart, and only this
|
||||
window can close it.
|
||||
|
||||
Order is BasicOptionsCamera's own, its three columns flattened: the
|
||||
following-style dropdown above, then 24 -> 26, then Slider2 -> 27 -> 15,
|
||||
then Slider3 -> Slider4. Labels and OPTION_TOOLTIP_* keys are the
|
||||
reference's GlobalStrings verbatim. -->
|
||||
<Frame name="$parentRowFollowTerrain" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowCameraFollowStyle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowCameraFollowStyle" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraTerrainTilt", "Follow Terrain",
|
||||
"OPTION_TOOLTIP_FOLLOW_TERRAIN")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Head Bob is FIRST PERSON ONLY, and nothing in the row says so because
|
||||
nothing in the reference's row does either: the gate's first conjunct is
|
||||
`[cam+0xec] <= 1/6` on the zoom distance (2149 §3). A player who ticks it
|
||||
at an over-the-shoulder zoom sees no change, which is the reference's own
|
||||
behaviour and not a dead row. -->
|
||||
<Frame name="$parentRowHeadBob" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFollowTerrain" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFollowTerrain" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraBobbing", "Head Bob",
|
||||
"OPTION_TOOLTIP_HEAD_BOB")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Auto-Follow Speed (1502 built the knob; this is its slider). Range and step
|
||||
are UIOptionsFrameSliders row 2's own — 90..270 by 10, deg/s — stated here
|
||||
rather than read off that table, which is what the two sliders already on
|
||||
this page do. Greyed while the following style is Never, the reference's
|
||||
own dependency (see OptionsPage_UpdateDependencies).
|
||||
|
||||
**One HALF of the reference's write, on purpose.** `UIOptionsFrame_Save`
|
||||
also writes `cameraPitchSmoothSpeed` at a quarter of this value; benilla
|
||||
does not register that name, because the auto-follow here has ONE rate
|
||||
(`FollowRig`, a single `yaw_speed`) and a CVar with no reader is 1134 §4's
|
||||
silent pretence. Its Mouse Look Speed neighbour below does carry its
|
||||
partner, because that one's twin is real here. -->
|
||||
<Frame name="$parentRowAutoFollowSpeed" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowHeadBob" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowHeadBob" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraYawSmoothSpeed", "Auto-Follow Speed",
|
||||
"OPTION_TOOLTIP_AUTO_FOLLOW_SPEED")
|
||||
OptionsSliderRow_Setup(self, 90, 270, 10, "raw")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Water Collision (2179). ONE CVar with two consumers that may never again be
|
||||
separated: the `0xf0000` nibble rides the trace mask to all three of
|
||||
`0x50e570`'s queries, and `0x50e629`'s `test esi,0xf0000` admits the pivot
|
||||
floor/cap block. Ticking this box moves both halves or neither. -->
|
||||
<Frame name="$parentRowWaterCollision" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoFollowSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoFollowSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraWaterCollision", "Water Collision",
|
||||
"OPTION_TOOLTIP_WATER_COLLISION")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowSmartPivot" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWaterCollision" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWaterCollision" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraPivot", "Smart Pivot",
|
||||
"OPTION_TOOLTIP_SMART_PIVOT")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Mouse Look Speed (2115 registered the CVar; this is the control it never
|
||||
had). UIOptionsFrameSliders row 3, 90..270 by 10. It carries the
|
||||
reference's PARTNER write — `cameraPitchMoveSpeed` at half the yaw value
|
||||
(UIOptionsFrame.lua l.355-356), which is exactly the 180/90 pair both are
|
||||
registered at — so dragging this row keeps the two axes in the ratio the
|
||||
reference ships them in instead of moving one and leaving the other. -->
|
||||
<Frame name="$parentRowMouseLookSpeed" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowSmartPivot" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowSmartPivot" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraYawMoveSpeed", "Mouse Look Speed",
|
||||
"OPTION_TOOLTIP_MOUSE_LOOK_SPEED")
|
||||
OptionsSliderRow_Setup(self, 90, 270, 10, "raw")
|
||||
self.partner = { cvar = "cameraPitchMoveSpeed", scale = 0.5 }
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowMaxCameraDistance" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseLookSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseLookSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "cameraDistanceMaxFactor",
|
||||
"Max Camera Distance", "OPTION_TOOLTIP_MAX_FOLLOW_DIST")
|
||||
@@ -2614,11 +2943,82 @@
|
||||
{ "Low", "Medium", "High" })
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowDisplayMode" inherits="OptionsDropdownRowTemplate">
|
||||
<!-- Brightness (2182) — the reference's slider 6, and the
|
||||
one row on this page whose store is an engine PAIR
|
||||
rather than the CVar directly. That is 1.12's own
|
||||
arrangement: its slider table gives this row
|
||||
`func = "Gamma"`, so `OptionsFrame_Load`/`_Save` reach
|
||||
`GetGamma`/`SetGamma` and the CVar is written by the
|
||||
engine, not the panel. The reason it matters here is
|
||||
the UNIT — `GetGamma()` is `1 - gamma`, an offset
|
||||
centred on 0, and a row that wrote the slider value
|
||||
into `gamma` directly would invert the control and
|
||||
put a fully white ramp at the top of its travel. -->
|
||||
<Frame name="$parentRowBrightness" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- The default is the reference's own computation
|
||||
-- rather than a second copy of the number:
|
||||
-- OptionsFrame_SetDefaults displays this row as
|
||||
-- `1 - GetCVarDefault("gamma")` (OptionsFrame.lua
|
||||
-- l.441-445), so the CVar table stays the single
|
||||
-- source and Restore Defaults here lands exactly
|
||||
-- where the reference's would. The `or 1` is for a
|
||||
-- VM whose host registered nothing (a bare kit),
|
||||
-- where the reference would have had a record to
|
||||
-- read because its own lookup creates one.
|
||||
local gd = tonumber(GetCVarDefault("gamma")) or 1
|
||||
OptionsNumFuncRow_OnLoad(self, GetGamma, SetGamma,
|
||||
tostring(1 - gd), "Brightness",
|
||||
"BENILLA_TOOLTIP_BRIGHTNESS")
|
||||
OptionsSliderRow_Setup(self, OptionsFrameSliders[6].minValue,
|
||||
OptionsFrameSliders[6].maxValue,
|
||||
OptionsFrameSliders[6].valueStep, "travel")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Weather Intensity (2181) — the reference's slider 9,
|
||||
the last of its nine and the nearest of 2177 §10's
|
||||
named-not-done: benilla has driven precipitation off
|
||||
`weatherDensity` since 0310 and only the switch was
|
||||
missing. It rides the CVar directly like every other
|
||||
row here; the reference's own row would reach it
|
||||
through `SetCVar` too (`func = "weatherDensity"` is a
|
||||
CVar name, not one of the four engine bindings — its
|
||||
`Setfarclip`-shaped `getglobal("Set".."weatherDensity")`
|
||||
finds nothing, so `OptionsFrame_Save` falls through).
|
||||
-->
|
||||
<Frame name="$parentRowWeatherDensity" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowBrightness" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowBrightness" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "weatherDensity", "Weather Intensity",
|
||||
"OPTION_TOOLTIP_WEATHER_DETAIL")
|
||||
-- The stop names are OURS, and they name the
|
||||
-- reference's own quality table rather than a
|
||||
-- borrowed preset ladder: `0x67b870` maps 0/1/2/3
|
||||
-- onto the spawn-rate gains {0.1, 0.33, 0.66, 1.0}
|
||||
-- in `[0x8680ec]`, so the top stop really is the
|
||||
-- full rate and the bottom is a tenth of it. 1.12
|
||||
-- labelled no stop on this slider -- it labelled
|
||||
-- the two ENDS -- but every slider row in this kit
|
||||
-- carries a readout, and "2" is the one thing that
|
||||
-- seat could say that means nothing to a player.
|
||||
OptionsSliderRow_Setup(self, OptionsFrameSliders[9].minValue,
|
||||
OptionsFrameSliders[9].maxValue,
|
||||
OptionsFrameSliders[9].valueStep, nil,
|
||||
{ "Minimal", "Low", "Medium", "Full" })
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowDisplayMode" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWeatherDensity" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWeatherDensity" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Display Mode (1650): MODERN CLASSIC's row, not
|
||||
-- 1.12's, because 1627 already gave us modern
|
||||
@@ -2939,6 +3339,64 @@
|
||||
self.applyFunc = "CombatText_UpdateOrLoad" -- by NAME, resolved when applied: this file loads before its definer now (1938)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- ── The damage NUMBERS (2180): a second master and its two children ──────
|
||||
Same box as the floating-combat-text family above and a different
|
||||
hierarchy: `CombatDamage` is the master of what floats over a UNIT,
|
||||
`SHOW_COMBAT_TEXT` of what scrolls beside the player, and the reference
|
||||
keeps their dependency blocks apart (UIOptionsFrame.lua l.709-714 against
|
||||
l.727-741). All three are CVar rows with host readers, so the write is the
|
||||
apply and none carries the family's `applyFunc`.
|
||||
|
||||
`CombatDamage`'s help text says "damage numbers" and it does more than
|
||||
that: its only two readers are the localized-WORD emitter `0x607140` and
|
||||
the "%d" NUMBER emitter `0x6128b0`, and both branch targets are epilogues
|
||||
— at "0" nothing floats over any unit from any source, misses and
|
||||
immunities included. -->
|
||||
<Frame name="$parentRowShowDamage" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowHonorGained" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowHonorGained" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "CombatDamage", "Show Target Damage",
|
||||
"OPTION_TOOLTIP_SHOW_DAMAGE")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Periodic Damage reaches further than its label: `CombatLogPeriodicSpells`
|
||||
off takes the whole `SMSG_PERIODICAURALOG` body — every
|
||||
CHAT_MSG_SPELL_PERIODIC_* line AND the DoT/HoT tick number AND the
|
||||
periodic miss word (`0x626dee` jumps to a bare epilogue). The row is the
|
||||
reference's wording over the reference's behaviour; neither is ours to
|
||||
improve. -->
|
||||
<Frame name="$parentRowPeriodicDamage" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowShowDamage" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowShowDamage" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "CombatLogPeriodicSpells", "Periodic Damage",
|
||||
"OPTION_TOOLTIP_LOG_PERIODIC_EFFECTS", 1)
|
||||
self.parentRow = "RowShowDamage"
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Pet Damage carries the reference's PARTNER write: its box drives BOTH
|
||||
`PetMeleeDamage` and `PetSpellDamage` (UIOptionsFrame_Save l.334-336), the
|
||||
second of which appears in no option table at all — which is why 2077's
|
||||
census, reading that table, could see two of the three. Without the
|
||||
partner the box would say "pet damage off" while pet SPELL damage kept
|
||||
floating, and nothing would say so. -->
|
||||
<Frame name="$parentRowPetDamage" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowPeriodicDamage" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowPeriodicDamage" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "PetMeleeDamage", "Pet Damage",
|
||||
"OPTION_TOOLTIP_SHOW_PET_MELEE_DAMAGE", 1)
|
||||
self.parentRow = "RowShowDamage"
|
||||
self.partner = { cvar = "PetSpellDamage" }
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- The ACTION BARS page (1136's lock row; the five switches above
|
||||
@@ -3141,11 +3599,25 @@
|
||||
window carrying a `cvarEvent`: TextStatusBar.xml has
|
||||
had a CVAR_UPDATE watcher since 1082 and nothing to
|
||||
wake it. -->
|
||||
<Frame name="$parentRowStatusText" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- Enhanced Tooltips (2180), and it takes the head seat because it takes the
|
||||
reference's: the Display box's first column is 12 -> 2 -> 21, and 12 is the
|
||||
first CheckButton the file declares. Its consumers are LUA — the action and
|
||||
pet bars fork their plate corner on `GetCVar("UberTooltips")` — which is why
|
||||
`apply_to_knobs` has nothing to do for it and why the row had none of the
|
||||
usual "no reader" reason to be absent; it was simply never seated. -->
|
||||
<Frame name="$parentRowEnhancedTooltips" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UberTooltips", "Enhanced Tooltips",
|
||||
"OPTION_TOOLTIP_USE_UBERTOOLTIPS")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowStatusText" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowEnhancedTooltips" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnhancedTooltips" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "statusBarText",
|
||||
"Status Bar Text", "OPTION_TOOLTIP_STATUS_BAR")
|
||||
@@ -3480,8 +3952,9 @@
|
||||
[0xc4da34] bit 0 / bit 3), then the three 1.12 UnitName*
|
||||
checkboxes over the overhead-name gates (nameplates.rs
|
||||
NameConfig), rows in the 1.12 Interface Options' own index
|
||||
order (21/30/67), labels its GlobalStrings verbatim
|
||||
(SHOW_PLAYER_NAMES/SHOW_NPC_NAMES/SHOW_OWN_NAME).
|
||||
order (21/22/30/67 — 22, Player Guild Names, arrived with
|
||||
2180), labels its GlobalStrings verbatim (SHOW_PLAYER_NAMES/
|
||||
SHOW_GUILD_NAMES/SHOW_NPC_NAMES/SHOW_OWN_NAME).
|
||||
|
||||
The plate pair leads because it is what "Nameplates" means to
|
||||
a player pressing V, and it is the pair that had no surface at
|
||||
@@ -3525,11 +3998,29 @@
|
||||
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names",
|
||||
"OPTION_TOOLTIP_SHOW_PLAYER_NAMES")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowNpcNames" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- Player Guild Names (2149 §6 built the line; 2180 seats the row). A CHILD of
|
||||
Player Names in both of the reference's own senses: its CheckButton22 hangs
|
||||
off 21 with a +26 x indent, and UIOptionsFrame_UpdateDependencies greys it
|
||||
from 21's tick. `UnitNamePlayerGuild` is not a SHOW gate — ShouldShowName
|
||||
reads only bits 0x1/0x2/0x4 — it gates one LINE of the overhead stack, which
|
||||
is why the dependency is real rather than cosmetic: with player names off
|
||||
there is no stack for the guild line to be the second row of. -->
|
||||
<Frame name="$parentRowGuildNames" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowPlayerNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowPlayerNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "UnitNamePlayerGuild", "Player Guild Names",
|
||||
"OPTION_TOOLTIP_SHOW_GUILD_NAMES", 1)
|
||||
self.parentRow = "RowPlayerNames"
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowNpcNames" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNameNPC", "NPC Names",
|
||||
"OPTION_TOOLTIP_SHOW_NPC_NAMES")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -601,7 +601,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
//
|
||||
// `PetSpellDamage` has no row in `UIOptionsFrameCheckButtons` — the *Show Pet Melee Damage*
|
||||
// box writes both (`UIOptionsFrame_Save` l.334-336) — which is why 2077's census, which reads
|
||||
// that table, could not see it while it saw its two siblings.
|
||||
// that table, could not see it while it saw its two siblings. Our own Pet Damage row carries
|
||||
// the same partner write (2180); all three are on the Combat page, under `CombatDamage`.
|
||||
same("CombatDamage", "1"),
|
||||
same("PetMeleeDamage", "1"),
|
||||
same("PetSpellDamage", "1"),
|
||||
@@ -637,7 +638,9 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// is a shape-A binding (`0x790980`) that raises on a nil in the reference too, so the whole
|
||||
// window's `_Load` (and `_SetDefaults`, through `GetCVarDefault`) stopped at slider 3.
|
||||
// `cameraPitchMoveSpeed` has no row of its own: `UIOptionsFrame_Save` writes it as
|
||||
// `sliderValue / 2` beside the yaw one (l.352-356), which is exactly this 180/90 pair.
|
||||
// `sliderValue / 2` beside the yaw one (l.352-356), which is exactly this 180/90 pair — and
|
||||
// the Controls page's Mouse Look Speed row carries that partner write (2180), so dragging it
|
||||
// keeps the two axes in the ratio the registrar ships them at.
|
||||
//
|
||||
// **Both defaults are the reference's, and the shipped feel does not change** — the two facts
|
||||
// are compatible only because the unit divergence is carried in `camera::LOOK_YAW_PER_SPEED`
|
||||
@@ -695,7 +698,10 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// The auto-follow's rate (1502), °/s — 1.12's own AUTO_FOLLOW_SPEED slider
|
||||
// (`UIOptionsFrameSliders`, 90..270 by 10), registered at the binary's "180.0" (`[0xbe1070]`).
|
||||
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
|
||||
// slew. No row yet — the slider is a one-line follow-on now that the knob exists.
|
||||
// slew. Its slider is the Controls page's Auto-Follow Speed row (2180), greyed while the
|
||||
// following style is Never — and it writes only this one, where the reference also writes
|
||||
// `cameraPitchSmoothSpeed` at a quarter of it: that name is deliberately unregistered here,
|
||||
// because `FollowRig` has a single rate and a key with no reader is 1134 §4's pretence.
|
||||
same("cameraYawSmoothSpeed", "180"),
|
||||
// **The four 1.12 camera-option toggles** (decision 2149) — the `UIOptionsFrame` checkboxes
|
||||
// FOLLOW_TERRAIN / HEAD_BOB / SMART_PIVOT / WATER_COLLISION, all four of which sat on the
|
||||
@@ -1030,6 +1036,53 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// **not** one of `hwDetect`'s sixteen (scan of `[0x639a60, 0x639b80)`: sixteen record-pointer
|
||||
// reads, `0xc7f2e4` absent), so nothing overwrites it on any path.
|
||||
same("anisotropic", "1"),
|
||||
// **Weather Intensity** — the video panel's slider 9 (`OptionsFrame.lua:29`,
|
||||
// `func = "weatherDensity"`, a real CVar name rather than an engine binding), and the nearest
|
||||
// of 2177 §10's named-not-done: benilla has had the feature since 0310 and only the switch was
|
||||
// missing. The reader is `benilla_world::weather::WeatherState::weather_density`, which scales
|
||||
// the rain/snow/mist spawn rate through the reference's own `0x67b870` quality table
|
||||
// {0.1, 0.33, 0.66, 1.0}. Rendering only — it never touches the wire grade, the two ramp
|
||||
// channels, or the storm/fog blend, so no server-visible behaviour rides it.
|
||||
//
|
||||
// The reference registers **`"2"`** at `0x67b806` (flags 0, callback `0x67b870`, name string
|
||||
// `0x8685ac`) — wow-re `cvar/scratch/graphics-cost-cvar-census.md` §4, whose §10 table also
|
||||
// lists this row among the twelve the reference install's `Config.wtf` moves off its default.
|
||||
deviates(
|
||||
"weatherDensity",
|
||||
"3",
|
||||
"2",
|
||||
"2181: every precipitation rate in `benilla-world`'s own precipitation module was \
|
||||
derived and graded against the reference install's own apitrace captures, and that \
|
||||
install runs \
|
||||
`SET weatherDensity \"3\"` (K = 1.0) — so 3 is the value a benilla-vs-reference \
|
||||
side-by-side is correct at, and the registered 2 would thin every rate to 0.66 against \
|
||||
the only client we compare with. The slider is how a player takes it back down",
|
||||
),
|
||||
// **Brightness** (decision 2182) — the reference's `gamma`, registered at `0x402d70` with
|
||||
// name `0x82e924` `"Gamma"`, default string `0x82e92c` **`"1.0"`** and flags **0** (not
|
||||
// latched, so its change callback `0x4034d0` applies on the write).
|
||||
//
|
||||
// There the callback builds `ramp[i] = __ftol(pow(i · 1/255, gamma) · 65535)` (`0x591680`) and
|
||||
// hands the 3×256 words to `GDI32!SetDeviceGammaRamp` — and **skips the upload windowed**
|
||||
// (`byte[dev+0x20b]` = `CGxFormat +0x07` = `gxWindow`), which is every mode benilla has. So the
|
||||
// reader here is not a ramp upload: it is [`crate::ui_gamma::DisplayGamma`], the same curve
|
||||
// applied to the same values one stage later, inside the pass that already owns the composited
|
||||
// image's single decode. wow-re `ffxeffects/scratch/whole-frame-grade-verdict.md` §(a) for the
|
||||
// curve and `ui/scratch/video-options-verbs.md` §3 for the verbs.
|
||||
//
|
||||
// 1.0 is the identity ramp — load-bearing rather than tidy: at the default this client's
|
||||
// output is what it was before the setting existed, so no visual golden moves.
|
||||
//
|
||||
// **Written `"1.000000"` rather than `"1.0"` or `"1"`, and that is not cosmetic.** Every value
|
||||
// this key ever receives comes through `SetGamma`, whose `SStrPrintf(buf, 0x10, "%f", …)` is
|
||||
// six decimals — so the row that Restore Defaults produces is `"1.000000"`, and a default
|
||||
// string in any other spelling would make it compare *moved* and write a `config.toml` line
|
||||
// holding the default value. The slider rows dodge this with their own trailing-zero strip
|
||||
// (`OptionsSlider_OnValueChanged`); a row whose store is an engine verb cannot, because the
|
||||
// verb owns the formatting. So the table speaks the verb's spelling instead, and
|
||||
// [`sync_cvars`] seeds it the same way. `Same` is still exact: the test parse-compares, and
|
||||
// the reference registers this value as `"1.0"` (`0x82e92c`).
|
||||
same("gamma", "1.000000"),
|
||||
// **Render scale** (decision 1639) — benilla's own CVar, no 1.12 counterpart, in the
|
||||
// `boothHalfRate` / `SoundOutputLimiter` mould: the reference has no such dial because it has
|
||||
// no second buffer to hang one on. The world renders into the composite lane's off-screen image
|
||||
@@ -1214,6 +1267,8 @@ pub(crate) struct KnobParams<'w> {
|
||||
names: ResMut<'w, NameConfig>,
|
||||
plates: ResMut<'w, VPlateMode>,
|
||||
clutter: ResMut<'w, ClutterConfig>,
|
||||
weather: ResMut<'w, benilla_world::weather::WeatherState>,
|
||||
display_gamma: ResMut<'w, crate::ui_gamma::DisplayGamma>,
|
||||
minimap: ResMut<'w, MinimapZoom>,
|
||||
bubbles: ResMut<'w, BubbleConfig>,
|
||||
zoom: ResMut<'w, ZoomLimit>,
|
||||
@@ -1257,6 +1312,8 @@ impl KnobParams<'_> {
|
||||
names: &mut self.names,
|
||||
plates: &mut self.plates,
|
||||
clutter: &mut self.clutter,
|
||||
weather: &mut self.weather,
|
||||
display_gamma: &mut self.display_gamma,
|
||||
minimap: &mut self.minimap,
|
||||
bubbles: &mut self.bubbles,
|
||||
zoom: &mut self.zoom,
|
||||
@@ -1296,6 +1353,12 @@ struct Knobs<'a> {
|
||||
names: &'a mut NameConfig,
|
||||
plates: &'a mut VPlateMode,
|
||||
clutter: &'a mut ClutterConfig,
|
||||
/// The weather driver's own state — `weatherDensity` writes ONE byte of it
|
||||
/// ([`benilla_world::weather::WeatherState::weather_density`]), the particle-density
|
||||
/// step; every other field on it is the wire's, not a setting's (2181).
|
||||
weather: &'a mut benilla_world::weather::WeatherState,
|
||||
/// The display-brightness ramp the UI lane's decode applies (2182).
|
||||
display_gamma: &'a mut crate::ui_gamma::DisplayGamma,
|
||||
minimap: &'a mut MinimapZoom,
|
||||
bubbles: &'a mut BubbleConfig,
|
||||
zoom: &'a mut ZoomLimit,
|
||||
@@ -1508,6 +1571,23 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
// resulting density change exactly as it does for the row above (0992's setter law, which
|
||||
// is the callback's own chunk rebuild).
|
||||
"frilldensity" => knobs.clutter.set_frill_density(v),
|
||||
// Weather Intensity, the panel's 0..3 step 1 (2181). The reference's callback is
|
||||
// `0x67b870`, a jump table (`0x67b8e8`) mapping 0/1/2/3 onto the quality cells
|
||||
// {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]` (wow-re
|
||||
// `cvar/scratch/graphics-cost-cvar-census.md` §4). What that table does with an
|
||||
// off-grid int is NOT carved, so the clamp here is this table's own standing
|
||||
// posture rather than a fidelity claim — and it costs nothing either way, because
|
||||
// `WeatherState::density_gain` already `.min(3)`s its own index.
|
||||
"weatherdensity" => knobs.weather.weather_density = v.trunc().clamp(0.0, 3.0) as u8,
|
||||
// Brightness (2182). The clamp is OURS and the reference has none — the reason it
|
||||
// costs one is on [`crate::ui_gamma::GAMMA_RANGE`], and nothing a player can reach
|
||||
// from the panel meets it.
|
||||
"gamma" => {
|
||||
knobs.display_gamma.0 = v.clamp(
|
||||
*crate::ui_gamma::GAMMA_RANGE.start(),
|
||||
*crate::ui_gamma::GAMMA_RANGE.end(),
|
||||
)
|
||||
}
|
||||
// The two zoom indices (1131) clamp exactly like the client's `set_zoom` (`0x6daa10`:
|
||||
// clamp at 5) — the widget clamps again on the way in, so a hand-edited level lands
|
||||
// in range whichever path it takes.
|
||||
@@ -1805,6 +1885,8 @@ fn sync_cvars(
|
||||
names,
|
||||
plates,
|
||||
clutter,
|
||||
weather,
|
||||
display_gamma,
|
||||
minimap,
|
||||
bubbles,
|
||||
zoom,
|
||||
@@ -1883,7 +1965,7 @@ fn sync_cvars(
|
||||
hardware_cursor: true,
|
||||
});
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 85] = [
|
||||
let session: [(&str, String); 87] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1977,6 +2059,9 @@ fn sync_cvars(
|
||||
("WorldDetail", (clutter.density - 1.0).to_string()),
|
||||
// …and the same density in the reference's own cells-per-chunk (2151).
|
||||
("frillDensity", clutter.frill_density().to_string()),
|
||||
("weatherDensity", weather.weather_density.to_string()),
|
||||
// Six decimals, matching `SetGamma`'s own `"%f"` — see the row's comment.
|
||||
("gamma", format!("{:.6}", display_gamma.0)),
|
||||
("ChatBubbles", flag(bubbles.all)),
|
||||
("ChatBubblesParty", flag(bubbles.party)),
|
||||
("profanityFilter", flag(text_filter.profanity)),
|
||||
@@ -2334,6 +2419,7 @@ mod tests {
|
||||
"gxDepthBits",
|
||||
"gxResolution",
|
||||
"realmList",
|
||||
"weatherDensity",
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -2490,6 +2576,14 @@ mod tests {
|
||||
// The V-plate pair welds to VPlateMode's defaults — both OFF, which is the reference's
|
||||
// own boot state on both of its halves (the `[0xc4da34]` bitmask and FrameXML's
|
||||
// `NAMEPLATES_ON = nil`). Enemy plates were the 0167 director pin until 1804.
|
||||
// Weather Intensity (2181): the CVar's default and the weather driver's own must be
|
||||
// the same rain, or a fresh config writes a row the world does not agree with. This is
|
||||
// also where the DEVIATION is held honest — the reference registers "2" and the row
|
||||
// above says why we ship 3; the weld makes sure it is 3 in both places.
|
||||
assert_eq!(
|
||||
d["weatherDensity"],
|
||||
f32::from(benilla_world::weather::WeatherState::default().weather_density)
|
||||
);
|
||||
let plates = VPlateMode::default();
|
||||
assert_eq!(d[crate::vplates::CVAR_ENEMIES] != 0.0, plates.enemies);
|
||||
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
|
||||
@@ -2556,6 +2650,8 @@ mod tests {
|
||||
alpha_ref: 0.5,
|
||||
fade_far: 70.0,
|
||||
};
|
||||
let mut weather = benilla_world::weather::WeatherState::default();
|
||||
let mut display_gamma = crate::ui_gamma::DisplayGamma::default();
|
||||
let mut minimap = MinimapZoom::default();
|
||||
let mut bubbles = BubbleConfig::default();
|
||||
let mut zoom = ZoomLimit::default();
|
||||
@@ -2602,6 +2698,8 @@ mod tests {
|
||||
names: &mut names,
|
||||
plates: &mut plates,
|
||||
clutter: &mut clutter,
|
||||
weather: &mut weather,
|
||||
display_gamma: &mut display_gamma,
|
||||
minimap: &mut minimap,
|
||||
bubbles: &mut bubbles,
|
||||
zoom: &mut zoom,
|
||||
@@ -2800,6 +2898,23 @@ mod tests {
|
||||
// The row `GetCVar` answers is the same field seen the other way round.
|
||||
assert!(apply_to_knobs("WorldDetail", "1", &mut knobs));
|
||||
assert_eq!(knobs.clutter.density, 2.0);
|
||||
// Weather Intensity (2181): the panel's four stops land whole, an off-grid value
|
||||
// truncates toward zero the way every int-valued row here does, and both ends clamp.
|
||||
for (wrote, want) in [
|
||||
("0", 0u8),
|
||||
("1", 1),
|
||||
("2", 2),
|
||||
("3", 3),
|
||||
("2.9", 2),
|
||||
("9", 3),
|
||||
("-4", 0),
|
||||
] {
|
||||
assert!(apply_to_knobs("weatherDensity", wrote, &mut knobs));
|
||||
assert_eq!(
|
||||
knobs.weather.weather_density, want,
|
||||
"weatherDensity {wrote}"
|
||||
);
|
||||
}
|
||||
assert_eq!(knobs.clutter.frill_density(), 32.0);
|
||||
// And the pair is NAMED as a pair, in the registered spelling and the lowercased one, so
|
||||
// `$WOW_CLUTTER_DENSITY` cannot take one spelling of this knob for the session and leave
|
||||
@@ -2828,6 +2943,13 @@ mod tests {
|
||||
assert!(REGISTERED
|
||||
.iter()
|
||||
.any(|r| r.name == benilla_ui::script::CVAR_FRILL_DENSITY));
|
||||
// …and the display-gamma pair's (2182), for exactly the same reason: `GetGamma` answers
|
||||
// `1 - <this CVar>` and `SetGamma` writes `1 - v` into it, both from a crate that cannot
|
||||
// see this table, so an unregistered name would make the getter answer a constant 0 and
|
||||
// the setter write into nothing.
|
||||
assert!(REGISTERED
|
||||
.iter()
|
||||
.any(|r| r.name == benilla_ui::script::CVAR_GAMMA));
|
||||
// **`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
|
||||
@@ -2986,6 +3108,8 @@ mod tests {
|
||||
.init_resource::<crate::ui_chat::combat::CombatLogRanges>()
|
||||
.init_resource::<crate::combat_text::DamageTextGates>()
|
||||
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
|
||||
.init_resource::<benilla_world::weather::WeatherState>()
|
||||
.init_resource::<crate::ui_gamma::DisplayGamma>()
|
||||
.init_resource::<ClickConfig>()
|
||||
.init_resource::<crate::target::AssistAttack>()
|
||||
.init_resource::<LootConfig>()
|
||||
|
||||
@@ -111,8 +111,7 @@ use controller::control;
|
||||
// `/follow` (decision 0890): chat asks with the message, `crate::target` resolves the subject into
|
||||
// the state, and this module owns the motion.
|
||||
use camera::{
|
||||
apply_zoom_scroll, model_pivot_height, run_look_session, CameraProbe, FlyCam, LookButton,
|
||||
CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT,
|
||||
apply_zoom_scroll, model_pivot_height, run_look_session, FlyCam, LookButton, CAM_DIST_DEFAULT,
|
||||
};
|
||||
pub(crate) use camera::{head_height, CameraControl, CameraPivot};
|
||||
pub(crate) use follow::{FollowRequest, FollowState};
|
||||
@@ -291,7 +290,7 @@ pub(super) type TransportQuery<'w, 's> = Query<
|
||||
/// before the controller reads them orders against this — the two scripted probe drivers do
|
||||
/// (`capture::probe_look` / `capture::probe_cam`, decision 1174). A set rather than the `control`
|
||||
/// symbol itself: an instrument may name the gameplay system it runs against, but exporting
|
||||
/// `control` would drag its private parameter types (`MoveSpeed`, `CameraProbe`, `PressGesture`)
|
||||
/// `control` would drag its private parameter types (`MoveSpeed`, `PlayerCapsule`, `PressGesture`)
|
||||
/// out with it, which is exactly the internals-publishing 1173 rejected a crate wall to avoid.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct PlayerControlSet;
|
||||
|
||||
@@ -10,8 +10,6 @@ use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
|
||||
|
||||
use avian3d::prelude::*;
|
||||
|
||||
use super::camera_channel::{Arm, SmoothChannel};
|
||||
use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt};
|
||||
use crate::creature_anim::wrap_pi;
|
||||
@@ -628,11 +626,6 @@ impl FollowRig {
|
||||
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`, wow-re `follow-camera`). A single uniform
|
||||
/// clamp at every zoom level — the reference has **no** distinct first-person look-down limit.
|
||||
pub(super) const CAM_PITCH_LIMIT: f32 = 89.0 * std::f32::consts::PI / 180.0;
|
||||
/// Camera-collision probe radius (yd): a small sphere swept from the camera pivot toward the desired
|
||||
/// camera seat each frame. Its radius is the margin kept between the camera and the surface it stops
|
||||
/// at, so the near plane doesn't poke through the wall. Smaller than the player capsule — the camera
|
||||
/// threads gaps the body can't fit.
|
||||
pub(super) const CAM_COLLISION_RADIUS: f32 = 0.3;
|
||||
/// How fast the camera glides back out to the player's chosen zoom once an obstruction clears (1/s).
|
||||
/// Pull-*in* is instant (a wall must never sit between the camera and the character); only the
|
||||
/// push-*out* eases — the vanilla feel of the camera snapping close past an obstacle and easing back.
|
||||
@@ -778,12 +771,6 @@ impl PivotGlide {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small sphere swept from the camera pivot toward the desired camera seat each frame to keep walls
|
||||
/// from sliding between the camera and the character (camera collision). Built once at startup like
|
||||
/// [`PlayerCapsule`]; smaller than the body capsule so the camera can thread gaps the player can't.
|
||||
#[derive(Resource)]
|
||||
pub(super) struct CameraProbe(pub(super) Collider);
|
||||
|
||||
/// Which mouse button is driving mouse-look, if any — the two vanilla look modes. While looking, the
|
||||
/// OS cursor is hidden + locked in place (relative motion drives the camera); `cursor_stash` is the
|
||||
/// position it's restored to on release so it reappears exactly where the user pressed. `pub(crate)`
|
||||
@@ -1250,7 +1237,6 @@ pub(super) fn seat_on_subject(
|
||||
cam: &mut FlyCam,
|
||||
cam_t: &mut Mut<Transform>,
|
||||
collide: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
cam_probe: &Collider,
|
||||
follow: &FollowInput,
|
||||
dynamics: &DynamicsInput,
|
||||
) {
|
||||
@@ -1378,7 +1364,6 @@ pub(super) fn seat_on_subject(
|
||||
cam,
|
||||
cam_t,
|
||||
collide,
|
||||
cam_probe,
|
||||
follow,
|
||||
dynamics,
|
||||
);
|
||||
@@ -1408,7 +1393,6 @@ pub(super) fn seat_camera(
|
||||
cam: &mut FlyCam,
|
||||
cam_t: &mut Mut<Transform>,
|
||||
collide: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
cam_probe: &Collider,
|
||||
follow: &FollowInput,
|
||||
dynamics: &DynamicsInput,
|
||||
) {
|
||||
@@ -1486,19 +1470,19 @@ pub(super) fn seat_camera(
|
||||
// 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,
|
||||
);
|
||||
//
|
||||
// **And that water leg is a RAY, not this probe sphere** (decision 2185): `0x7c2c40` is a
|
||||
// ray/triangle test and `0x672170` carries no radius, so the `2/9` yd the corridor lifts the
|
||||
// origin by is a clearance budgeted for a point. The probe is `0.3` — it does not fit, and a
|
||||
// level boom behind a surface swimmer was coming back pinned at zero. The split lives in
|
||||
// [`benilla_world::collision::WorldCollision::cast_camera`], which owns the probe now; the
|
||||
// sphere still sweeps the solid world, which is what it was always for.
|
||||
let hit = collide.cast_camera(head, boom, 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.
|
||||
rig.clipped = hit.is_some();
|
||||
let open = hit.map_or(boom_len, |h| h.distance);
|
||||
let open = hit.unwrap_or(boom_len);
|
||||
// Snap in instantly when geometry intrudes (a wall must never sit between camera and character);
|
||||
// ease back out to the open arm length once it clears — the vanilla snap-close-then-glide-back.
|
||||
rig.collision_distance = if open < rig.collision_distance {
|
||||
|
||||
@@ -88,7 +88,6 @@ pub(super) fn control(
|
||||
speed_capsule: (
|
||||
Res<MoveSpeed>,
|
||||
Res<PlayerCapsule>,
|
||||
Res<CameraProbe>,
|
||||
Res<InspectMode>,
|
||||
Res<crate::ui_script::UiKeyboardCapture>,
|
||||
Res<crate::ui_script::PlayerUiClickConsumed>,
|
||||
@@ -155,19 +154,18 @@ pub(super) fn control(
|
||||
let mouse_motion = &pointer.0;
|
||||
let look_cfg = *pointer.1;
|
||||
let zoom_max = pointer.2.max;
|
||||
let (move_speed, capsule, cam_probe, inspect, ui_capture, click_consumed) = (
|
||||
let (move_speed, capsule, inspect, ui_capture, click_consumed) = (
|
||||
&speed_capsule.0,
|
||||
&speed_capsule.1 .0,
|
||||
&speed_capsule.2 .0,
|
||||
&speed_capsule.2,
|
||||
&speed_capsule.3,
|
||||
&speed_capsule.4,
|
||||
&speed_capsule.5,
|
||||
);
|
||||
let binds = &speed_capsule.6;
|
||||
let view_subject = &speed_capsule.7;
|
||||
let self_guid = speed_capsule.8 .0;
|
||||
let scoped = &speed_capsule.9;
|
||||
let covered = speed_capsule.10.covering();
|
||||
let binds = &speed_capsule.5;
|
||||
let view_subject = &speed_capsule.6;
|
||||
let self_guid = speed_capsule.7 .0;
|
||||
let scoped = &speed_capsule.8;
|
||||
let covered = speed_capsule.9.covering();
|
||||
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
|
||||
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
|
||||
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
|
||||
@@ -545,7 +543,6 @@ pub(super) fn control(
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
&camera::FollowInput {
|
||||
cfg: follow_cfg,
|
||||
face_yaw: player.face_yaw,
|
||||
@@ -1123,7 +1120,6 @@ pub(super) fn control(
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
&follow,
|
||||
&dynamics,
|
||||
);
|
||||
|
||||
@@ -15,8 +15,8 @@ use benilla_world::terrain_stream::SPAWN_XY;
|
||||
use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, NEARCLIP_DEFAULT};
|
||||
|
||||
use super::{
|
||||
CameraControl, CameraProbe, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_COLLISION_RADIUS,
|
||||
CAM_DIST_DEFAULT, CAPSULE_HEIGHT, CAPSULE_RADIUS,
|
||||
CameraControl, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_DIST_DEFAULT, CAPSULE_HEIGHT,
|
||||
CAPSULE_RADIUS,
|
||||
};
|
||||
|
||||
/// Default avatar speed in yards/second — the **VERIFIED** vanilla run speed (`MOVE_RUN` 7.0). Ctrl
|
||||
@@ -70,7 +70,6 @@ pub(super) fn setup_player(
|
||||
CAPSULE_RADIUS,
|
||||
CAPSULE_HEIGHT - 2.0 * CAPSULE_RADIUS,
|
||||
)));
|
||||
commands.insert_resource(CameraProbe(Collider::sphere(CAM_COLLISION_RADIUS)));
|
||||
commands.insert_resource(CameraControl {
|
||||
distance: CAM_DIST_DEFAULT,
|
||||
target_distance: CAM_DIST_DEFAULT,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The UI gamma lane's ONE decode (decision 0254) — the twin of `ffx_glow.wgsl`'s combine, which owns
|
||||
// the world lane's single decode (0161).
|
||||
// the world lane's single decode (0161) — and the client's display-gamma ramp (2182), which rides in
|
||||
// front of it because the value this pass samples is exactly the byte the RAMDAC would have read.
|
||||
//
|
||||
// `ui_quad.wgsl` composites the whole UI in gamma bytes, like the reference's fixed-function
|
||||
// backbuffer. The UI camera's target is `Rgba8UnormSrgb`, so a stored byte round-trips through the
|
||||
@@ -13,6 +14,8 @@
|
||||
|
||||
@group(0) @binding(0) var screen_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var screen_sampler: sampler;
|
||||
// `.x` = the `gamma` CVar. The rest is padding — a uniform struct is 16-byte aligned.
|
||||
@group(0) @binding(2) var<uniform> ramp: vec4<f32>;
|
||||
|
||||
// sRGB → linear (IEC 61966-2-1), the exact inverse of `ui_quad.wgsl`'s `linear_to_srgb`.
|
||||
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
@@ -24,5 +27,16 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
@fragment
|
||||
fn fs_decode(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let ui = textureSample(screen_texture, screen_sampler, in.uv);
|
||||
return vec4<f32>(srgb_to_linear(ui.rgb), ui.a);
|
||||
var rgb = ui.rgb;
|
||||
// The reference's hardware ramp `ramp[i] = pow(i/255, gamma)` (`0x591680`), in its continuous
|
||||
// form: `i/255` IS `rgb` here, so this is the same curve over the same values.
|
||||
//
|
||||
// **The branch is not an optimisation.** It is uniform across the draw, so it costs nothing —
|
||||
// and at the registered default the pass has to be the exact identity it was before this
|
||||
// existed, which `pow` is not: `pow(x, 1.0)` compiles to `exp2(log2(x))` and `log2(0)` is
|
||||
// undefined. The floor inside the taken arm is the same guard for a non-default gamma.
|
||||
if (ramp.x != 1.0) {
|
||||
rgb = pow(max(rgb, vec3<f32>(1e-6)), vec3<f32>(ramp.x));
|
||||
}
|
||||
return vec4<f32>(srgb_to_linear(rgb), ui.a);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ mod logging;
|
||||
mod recruitment;
|
||||
/// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size
|
||||
/// picks are read from at login and written back at logout.
|
||||
mod settings;
|
||||
pub(crate) mod settings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -755,14 +755,48 @@ fn save_chat_looks(
|
||||
flush(&script, &channels, &mut file, exiting);
|
||||
}
|
||||
|
||||
/// The session-end flush — `OnExit(InWorld)`, the reference's chat teardown.
|
||||
fn save_on_session_end(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
channels: Res<super::edit::ChannelState>,
|
||||
mut file: ResMut<ChatWindowFile>,
|
||||
) {
|
||||
let Some(script) = script else { return };
|
||||
flush(&script, &channels, &mut file, true);
|
||||
/// The session-end flush — the reference's chat teardown (`0x499a80` from `0x490c55`), called
|
||||
/// from [`crate::ui_script::end_ui_session`] beside [`crate::cvars::fold_dying_vm_cvars`]:
|
||||
/// **after** the shutdown events, **before** the VM is replaced.
|
||||
///
|
||||
/// **It is not an `OnExit(InWorld)` system, and that is the whole point.** `/reload` rebuilds the
|
||||
/// VM without ever leaving the world (1291), so that edge does not fire for it — and nothing else
|
||||
/// covers the gap, because both halves of the debounce beside it are VM-keyed: [`ChatWindowFile::dirty`] is
|
||||
/// a [`VmMemo`] that resets to `false` with the new VM, and [`restore_chat_looks`] then re-seats
|
||||
/// the look table from the file **on disk**. So a window dragged, resized, recoloured or renamed
|
||||
/// within [`SAVE_QUIET`] of a `/reload` was written nowhere and read straight back stale: the
|
||||
/// player's change was discarded with no error and no trace.
|
||||
///
|
||||
/// `end_ui_session` is the one place every root meets — it is what the `OnExit(InWorld)`
|
||||
/// registration runs, and what `run_pending_reload` calls — so one call here covers the logout,
|
||||
/// the character switch and the reload. It also retires the hand-stated ordering this flush used
|
||||
/// to need: two unconstrained systems on one `OnExit` edge are placed by the executor, and that
|
||||
/// placement was measured (bevy 0.18) to move with nothing but their registration positions.
|
||||
/// Being *inside* the ender is an ordering no arrangement can lose.
|
||||
///
|
||||
/// A session whose UI never loaded writes nothing, by construction rather than by a guard:
|
||||
/// `restored` is false for a VM that never read the character's file, and [`owes_write`] refuses
|
||||
/// on it — which is the same protection against composing the player's file out of a stock table
|
||||
/// that [`ChatWindowFile::dirty`] exists for.
|
||||
pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
|
||||
// A world with no chat-cache state never added the plugin — a test world or a stripped
|
||||
// scenario. Checked up front so the fetch below can be non-optional, exactly as
|
||||
// `fold_dying_vm_cvars` checks its own.
|
||||
if !world.contains_resource::<ChatWindowFile>() {
|
||||
return;
|
||||
}
|
||||
// `resource_scope` rather than a `SystemState`: it lifts the file out for the call, which
|
||||
// leaves the VM and the channel roster as two plain shared borrows of the world — the whole
|
||||
// fetch, with no system machinery and nothing to keep in step with the signature.
|
||||
world.resource_scope(|world, mut file: Mut<ChatWindowFile>| {
|
||||
let (Some(script), Some(channels)) = (
|
||||
world.get_non_send_resource::<UiScript>(),
|
||||
world.get_resource::<super::edit::ChannelState>(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
flush(script, channels, &mut file, true);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
@@ -780,21 +814,11 @@ pub(super) fn plugin(app: &mut App) {
|
||||
.add_systems(
|
||||
Update,
|
||||
save_chat_looks.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
// **Explicitly before the session ender, because the edge is not an ordering.** This
|
||||
// flush composes the file out of the DYING VM, and `end_ui_session` replaces that VM
|
||||
// with a fresh boot one on the same edge. Two unconstrained systems there are placed
|
||||
// by the executor, and that placement was measured (bevy 0.18, five systems on one
|
||||
// `OnExit`) to move with nothing but their registration positions — a VM-reading
|
||||
// saver ran after the exclusive ender in one arrangement and before it in another.
|
||||
// Winning that race by luck is what B353 cost the layout cache one module over; the
|
||||
// layout cache answered it by joining the reference's shutdown tail, which this file
|
||||
// cannot do (its reference is the type-7 chat-cache saver `0x499a80`, not
|
||||
// `0x490bd0`'s tail), so it says the order instead.
|
||||
save_on_session_end.before(crate::ui_script::end_ui_session),
|
||||
);
|
||||
// **The session-end flush is not registered here.** It is called from inside
|
||||
// `ui_script::end_ui_session` ([`fold_dying_vm_chat_cache`]) so that it covers `/reload` — a
|
||||
// VM rebuild that never crosses `OnExit(InWorld)` — as well as the logout edge, and so that
|
||||
// its position relative to the ender is structural instead of a registration-order bet.
|
||||
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
|
||||
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
|
||||
// watcher would lose the last second of drags to the process ending.
|
||||
@@ -1206,4 +1230,115 @@ mod tests {
|
||||
let got = parse("ADDEDVERSION 2\nWINDOW 1\nMESSAGES\nEND\nEND\n", &rows());
|
||||
assert!(got.looks[0].1.messages.is_empty());
|
||||
}
|
||||
|
||||
/// **The `/reload` flush** (the reload half of decision 1290's class): a window renamed in the
|
||||
/// last second before a `ReloadUI()` must reach the player's file.
|
||||
///
|
||||
/// Against the pre-fix shape this fails, and it is worth saying exactly why, because all three
|
||||
/// of the mechanisms that look like they should cover it are VM-keyed and reset together:
|
||||
/// `save_on_session_end` hung on `OnExit(InWorld)`, which `run_pending_reload` never crosses
|
||||
/// (1291); the debounce beside it had not fired, because [`SAVE_QUIET`] had not elapsed; and
|
||||
/// the new VM's [`ChatWindowFile::dirty`] memo came up `false` while [`restore_chat_looks`]
|
||||
/// re-seated the look table from the file **on disk**. So the rename was discarded with no
|
||||
/// error — and the assertion on the rebuilt VM below is the half the player would actually
|
||||
/// see: the window comes back under its old name.
|
||||
#[test]
|
||||
fn a_window_renamed_just_before_a_reload_reaches_the_file() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-chat-reload-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("benilla-config")).expect("hermetic home");
|
||||
let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _home = crate::local_state::test_env::EnvGuard::set(
|
||||
"BENILLA_HOME",
|
||||
tmp.join("benilla-config")
|
||||
.to_str()
|
||||
.expect("utf-8 temp path"),
|
||||
);
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<crate::ui_script::AddOnIdentity>();
|
||||
world.init_resource::<crate::minimap::MinimapZoom>();
|
||||
world.init_resource::<crate::ui_script::ReloadUiPending>();
|
||||
world.init_resource::<super::super::edit::ChannelState>();
|
||||
world.init_resource::<ChatWindowFile>();
|
||||
crate::ui_script::setup_script(&mut world);
|
||||
world.insert_resource(crate::char_select::Roster::with_pending_pick(
|
||||
vec![benilla_protocol::Character {
|
||||
guid: 1,
|
||||
name: "Reloadprobe".into(),
|
||||
race: 1, // Human → Alliance
|
||||
class: 1, // Warrior
|
||||
gender: 0,
|
||||
level: 60,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
zone: 0,
|
||||
map: 0,
|
||||
position: benilla_protocol::wire::Vector3d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
flags: 0,
|
||||
equipment: [benilla_protocol::CharEnumItem::default(); 19],
|
||||
pet_display_id: 0,
|
||||
pet_level: 0,
|
||||
pet_family: 0,
|
||||
}],
|
||||
1,
|
||||
));
|
||||
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
|
||||
|
||||
let path = world
|
||||
.resource::<ChatWindowFile>()
|
||||
.path
|
||||
.clone()
|
||||
.expect("the character's chat cache path is seated by the entry load");
|
||||
|
||||
// The player renames window 1 — a Lua write, exactly what the debounce exists to coalesce.
|
||||
world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(r#"SetChatWindowName(1, "Reloaded")"#)
|
||||
.expect("rename");
|
||||
world
|
||||
.run_system_once(watch_chat_looks)
|
||||
.expect("the watcher arms the debounce");
|
||||
assert!(
|
||||
world.resource::<ChatWindowFile>().last_change.is_some(),
|
||||
"precondition: the rename armed the debounce, so the write is owed but not yet due"
|
||||
);
|
||||
|
||||
// `/reload`, immediately — inside `SAVE_QUIET`, which is the whole window of the bug.
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
world.resource_mut::<crate::ui_script::ReloadUiPending>().0 = true;
|
||||
crate::ui_script::run_pending_reload(&mut world);
|
||||
|
||||
let on_disk = std::fs::read_to_string(&path).expect("the flush wrote the file");
|
||||
assert!(
|
||||
on_disk.contains("Reloaded"),
|
||||
"the rename must survive the reload — the dying VM's cache is folded out by \
|
||||
`fold_dying_vm_chat_cache`; file was:\n{on_disk}"
|
||||
);
|
||||
|
||||
// And the half the player sees: the rebuilt VM read it back.
|
||||
let name = world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.eval::<String>("GetChatWindowInfo(1)")
|
||||
.unwrap_or_default();
|
||||
assert_eq!(
|
||||
name, "Reloaded",
|
||||
"the rebuilt VM restored the renamed window, not the stale one"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,14 +495,42 @@ fn feed_meeting_stone(
|
||||
}
|
||||
}
|
||||
|
||||
/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`
|
||||
/// — once per world session, which is what the reference's run-once byte amounts to.
|
||||
/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`.
|
||||
///
|
||||
/// **Once per UI LIFECYCLE, not once per world session**, and that distinction is the whole bug.
|
||||
/// The reference's run-once byte `[0xb4b424]` is *cleared by the UI teardown* — `0x490bd0` calls
|
||||
/// `0x490a80` at `0x490c20`, which zeroes it at `0x490a8d` — and `UI_Init` (`0x48fbf0`) then calls
|
||||
/// `0x4908c0` at `0x490168` behind the same live-player gate, finds the byte clear, and re-runs
|
||||
/// this entire bring-up: `0x490a14` → `0x4c9f40` → `0x4ca1c0` → `PutUInt32(0x296)` + Send. So a
|
||||
/// `ReloadUI()` **re-asks the server**, and that is what brings the icon back.
|
||||
///
|
||||
/// Gated on `MessageReader<EnteredWorldMessage>`, this leg never ran for a `/reload`, which never
|
||||
/// leaves the world (1291) and so produces no such message. Nothing re-armed
|
||||
/// [`MeetingStone::dirty`] and nothing re-queried, so `IsInMeetingStoneQueue()` and
|
||||
/// `GetMeetingStoneStatusText()` answered nil for the rest of the session — and stock
|
||||
/// `Minimap.xml`'s `MiniMapMeetingStoneFrame` (built `hidden="true"`, shown only by
|
||||
/// `MEETINGSTONE_CHANGED`, whose single firing site image-wide is the `0x295` handler at
|
||||
/// `0x4ca38f`) stayed gone while the player was still queued.
|
||||
///
|
||||
/// **A [`crate::ui_script::VmMemo`] claim IS the reference's gate.** A byte the UI teardown clears
|
||||
/// is exactly "once per VM", and 1290's name for that is `claim` — so this reads as the same
|
||||
/// question the binary asks, rather than as a workaround for the missing message. The one
|
||||
/// round trip during which the icon is genuinely absent is faithful, not a defect: the reference
|
||||
/// has the same gap, because only the server's reply fires the event.
|
||||
///
|
||||
/// The queued area itself is untouched here, matching `[0xb72038]`, which the RE round found is
|
||||
/// referenced six times image-wide and by nothing in either reload closure — it survives, and
|
||||
/// [`MeetingStone::enter_world`]'s `dirty` is what re-pushes it to the fresh VM.
|
||||
fn meeting_stone_enter_world(
|
||||
mut entered: MessageReader<crate::net::EnteredWorldMessage>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut stone: ResMut<MeetingStone>,
|
||||
commands: Res<NetCommands>,
|
||||
mut asked: Local<crate::ui_script::VmMemo<bool>>,
|
||||
) {
|
||||
if entered.read().next().is_none() {
|
||||
let Some(script) = script else {
|
||||
return;
|
||||
};
|
||||
if !asked.claim(&script) {
|
||||
return;
|
||||
}
|
||||
stone.enter_world();
|
||||
@@ -673,7 +701,11 @@ impl Plugin for UiDialogVerbsPlugin {
|
||||
(
|
||||
close_npc_session_out_of_range::<PetUnlearnState>.before(feed_dialog_verbs),
|
||||
feed_dialog_verbs.before(UiInput),
|
||||
meeting_stone_enter_world.before(feed_meeting_stone),
|
||||
// In-world only: the claim is about the VM, but the query is a world
|
||||
// packet, and the boot VM exists at the glue screen too.
|
||||
meeting_stone_enter_world
|
||||
.before(feed_meeting_stone)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
feed_meeting_stone.before(UiInput),
|
||||
drain_latch_verbs.after(UiInput),
|
||||
drain_queue_verbs.after(UiInput),
|
||||
@@ -704,6 +736,74 @@ fn cancel_gate_could_apply(attributes_ex: u32) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **The `/reload` re-query** — the meeting-stone half of 1290's class, and the reference's
|
||||
/// own behaviour rather than an invention of ours.
|
||||
///
|
||||
/// wow-re's §5 trio (3/3 unanimous, every byte re-decoded from the raw image) settled that
|
||||
/// the run-once byte `[0xb4b424]` is cleared by the UI teardown at `0x490a8d` and that
|
||||
/// `UI_Init` re-runs the bring-up and re-sends `CMSG 0x296`. So the gate is once per **VM**,
|
||||
/// not once per world session — and against the old `MessageReader<EnteredWorldMessage>`
|
||||
/// shape this fails on the second VM, which is exactly the reported symptom: the queued
|
||||
/// player's minimap icon never comes back after a `/reload`.
|
||||
///
|
||||
/// It also corrects wow-re's own `meeting-stone-status.md` §6, which asserted the query "is
|
||||
/// sent exactly once per world session … has no other trigger" — true of the entry points it
|
||||
/// enumerated, but it never asked who *clears* the byte.
|
||||
///
|
||||
/// **A registered schedule, not `run_system_once`**: the gate is a `Local<VmMemo<bool>>`, and
|
||||
/// `run_system_once` builds a fresh system — and so a fresh `Local` — on every call, which
|
||||
/// would make the claim look unclaimed every frame (the same trap `ui_loot`'s tests name).
|
||||
#[test]
|
||||
fn a_rebuilt_vm_re_asks_the_server_for_the_meeting_stone_queue() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<MeetingStone>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.add_systems(Update, meeting_stone_enter_world);
|
||||
|
||||
let queries = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
|
||||
rx.try_iter()
|
||||
.filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery))
|
||||
.count()
|
||||
};
|
||||
|
||||
// No VM yet — the glue screen: nothing is asked.
|
||||
app.update();
|
||||
assert_eq!(queries(&rx), 0, "no VM, no bring-up");
|
||||
|
||||
// The first VM: the bring-up runs once, however many frames pass.
|
||||
app.insert_non_send_resource(UiScript::new().expect("VM"));
|
||||
app.update();
|
||||
assert_eq!(queries(&rx), 1, "the first VM asks the server");
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(queries(&rx), 0, "…and does not ask again on later frames");
|
||||
|
||||
// The server answers: the player IS queued, and the host holds that.
|
||||
app.world_mut().resource_mut::<MeetingStone>().area = 1519;
|
||||
app.world_mut().resource_mut::<MeetingStone>().dirty = false;
|
||||
|
||||
// `ReloadUI()`: a fresh VM, and nothing on the wire.
|
||||
app.insert_non_send_resource(UiScript::new().expect("VM"));
|
||||
app.update();
|
||||
assert_eq!(
|
||||
queries(&rx),
|
||||
1,
|
||||
"a rebuilt VM re-asks — the reference's UI teardown clears the run-once byte, so \
|
||||
`UI_Init` re-sends `CMSG 0x296` (0x490a8d / 0x490168)"
|
||||
);
|
||||
|
||||
let stone = app.world().resource::<MeetingStone>();
|
||||
assert!(
|
||||
stone.dirty,
|
||||
"the bring-up re-armed the push, so the fresh VM is told the queued area again"
|
||||
);
|
||||
assert_eq!(
|
||||
stone.area, 1519,
|
||||
"the queued area itself is untouched, matching `[0xb72038]` surviving the reload"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spell 2584's flags off the shipped Spell.dbc: the cancel-aura gate's first two legs.
|
||||
#[test]
|
||||
fn spell_2584_never_trips_the_cancel_gate() {
|
||||
|
||||
@@ -29,9 +29,9 @@ use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
||||
use bevy::render::render_graph::{
|
||||
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
|
||||
};
|
||||
use bevy::render::render_resource::binding_types::{sampler, texture_2d};
|
||||
use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer_sized};
|
||||
use bevy::render::render_resource::*;
|
||||
use bevy::render::renderer::{RenderContext, RenderDevice};
|
||||
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
|
||||
use bevy::render::view::ViewTarget;
|
||||
use bevy::render::{RenderApp, RenderStartup};
|
||||
use bevy::ui_render::graph::NodeUi;
|
||||
@@ -40,9 +40,73 @@ use bevy::ui_render::ui_texture_slice_pipeline::{
|
||||
};
|
||||
use bevy::ui_render::{init_ui_pipeline, UiPipeline};
|
||||
|
||||
/// Marks the camera whose target holds a gamma-composited image awaiting this pass's one decode.
|
||||
#[derive(Component, Clone, Copy, Default, ExtractComponent)]
|
||||
pub(crate) struct UiGammaLane;
|
||||
/// Marks the camera whose target holds a gamma-composited image awaiting this pass's one decode,
|
||||
/// and carries the [`DisplayGamma`] exponent that decode applies on the way through (2182).
|
||||
#[derive(Component, Clone, Copy, ExtractComponent)]
|
||||
pub(crate) struct UiGammaLane {
|
||||
/// The `gamma` CVar's value, ready for the shader. `1.0` is the identity ramp.
|
||||
pub(crate) gamma: f32,
|
||||
}
|
||||
|
||||
impl Default for UiGammaLane {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gamma: DEFAULT_GAMMA,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The display-brightness correction** (decision 2182) — the `gamma` CVar, as the reference
|
||||
/// registers it (`0x402d70`, name `0x82e924`, default string `0x82e92c` = `"1.0"`, flags 0).
|
||||
///
|
||||
/// The reference applies it as an OS **hardware gamma ramp**: its change callback `0x4034d0`
|
||||
/// builds `ramp[i] = __ftol(pow(i · 1/255, gamma) · 65535)` at `0x591680` and hands the 3×256 words
|
||||
/// to `GDI32!SetDeviceGammaRamp` (wow-re `ffxeffects/scratch/whole-frame-grade-verdict.md` §(a);
|
||||
/// the same note proves there is no other whole-frame grade in the client, and that
|
||||
/// `Brightness`/`Contrast` CVars do not exist in the binary at all). That upload is **skipped
|
||||
/// windowed** — `byte[dev+0x20b]` is `CGxFormat +0x07`, which is `gxWindow` — and windowed is every
|
||||
/// mode benilla has (1627), so copying the mechanism byte for byte would ship a slider that never
|
||||
/// moves a pixel.
|
||||
///
|
||||
/// So the ramp goes where a ramp goes when you own the compositor: **the same curve, applied to the
|
||||
/// same values, one stage later.** `i/255` is the framebuffer byte the RAMDAC would have read;
|
||||
/// [`UiGammaNode`] samples exactly that value (the UI lane composites in gamma bytes — the module
|
||||
/// doc above) and raising it to `gamma` before the one decode is the continuous form of the
|
||||
/// reference's 256-entry LUT.
|
||||
#[derive(Resource, Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct DisplayGamma(pub(crate) f32);
|
||||
|
||||
/// The reference's registered `"1.0"` — the identity ramp, and the value the pass is a **no-op**
|
||||
/// at, byte for byte (see [`UiGammaNode`]'s uniform branch).
|
||||
pub(crate) const DEFAULT_GAMMA: f32 = 1.0;
|
||||
|
||||
/// **The clamp is ours, and the reference has none** — `SetGamma(5)` writes `gamma = "-4.000000"`
|
||||
/// there with no arm anywhere to catch it (wow-re `ui/scratch/video-options-verbs.md` §3, with
|
||||
/// `baseMip`'s validating callback `0x689090` as the positive control). The reference can afford
|
||||
/// that because its ramp is a fullscreen-only OS call a player can escape by alt-tabbing; ours is
|
||||
/// the image itself, and `pow(x, 12)` is a black screen with the panel that undoes it somewhere
|
||||
/// inside it. This range is the widest that keeps the client legible enough to reach that panel —
|
||||
/// it spans the stock slider's own `[0.5, 1.5]` four times over, so nothing a player can do from
|
||||
/// the UI ever meets it, and the CVar still keeps whatever truth was written to it (0959's posture:
|
||||
/// the consumer clamps at its own edge, the store does not lie).
|
||||
pub(crate) const GAMMA_RANGE: std::ops::RangeInclusive<f32> = 0.25..=4.0;
|
||||
|
||||
impl Default for DisplayGamma {
|
||||
fn default() -> Self {
|
||||
Self(DEFAULT_GAMMA)
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry the knob onto the camera the pass runs on. Change-detected on the resource, so a settled
|
||||
/// session writes nothing.
|
||||
fn stamp_lane_gamma(gamma: Res<DisplayGamma>, mut lanes: Query<&mut UiGammaLane>) {
|
||||
if !gamma.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut lane in &mut lanes {
|
||||
lane.gamma = gamma.0.clamp(*GAMMA_RANGE.start(), *GAMMA_RANGE.end());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct UiGammaLabel;
|
||||
@@ -52,6 +116,10 @@ struct UiGammaPipeline {
|
||||
layout: BindGroupLayoutDescriptor,
|
||||
sampler: Sampler,
|
||||
decode: CachedRenderPipelineId,
|
||||
/// The ramp exponent's 16-byte uniform, rewritten each frame with a queue write (which lands
|
||||
/// before the graph's submit executes) — the same shape `ffx_glow`'s combine uses. One buffer,
|
||||
/// not one per view: [`UiGammaLane`] is on exactly one camera.
|
||||
ramp: Buffer,
|
||||
}
|
||||
|
||||
fn init_pipeline(
|
||||
@@ -68,6 +136,9 @@ fn init_pipeline(
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
// The ramp exponent, as a `vec4<f32>` — one scalar, but a uniform struct is
|
||||
// 16-byte aligned and a vec4 says so without a padding field to keep in step.
|
||||
uniform_buffer_sized(false, Some(std::num::NonZero::new(16).unwrap())),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -97,10 +168,17 @@ fn init_pipeline(
|
||||
}),
|
||||
..default()
|
||||
});
|
||||
let ramp = render_device.create_buffer(&BufferDescriptor {
|
||||
label: Some("ui_gamma_ramp"),
|
||||
size: 16,
|
||||
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
commands.insert_resource(UiGammaPipeline {
|
||||
layout,
|
||||
sampler,
|
||||
decode,
|
||||
ramp,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,7 +192,7 @@ impl ViewNode for UiGammaNode {
|
||||
&self,
|
||||
_graph: &mut RenderGraphContext,
|
||||
render_context: &mut RenderContext<'w>,
|
||||
(view_target, _): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
(view_target, lane): QueryItem<'w, '_, Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let pipelines = world.resource::<UiGammaPipeline>();
|
||||
@@ -124,12 +202,22 @@ impl ViewNode for UiGammaNode {
|
||||
// which beats dropping the UI entirely.
|
||||
return Ok(());
|
||||
};
|
||||
// The ramp exponent for this frame (2182). `[g, 0, 0, 0]` — the shader reads `.x`.
|
||||
world.resource::<RenderQueue>().write_buffer(
|
||||
&pipelines.ramp,
|
||||
0,
|
||||
bytemuck::cast_slice(&[lane.gamma, 0.0, 0.0, 0.0]),
|
||||
);
|
||||
let post = view_target.post_process_write();
|
||||
let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout);
|
||||
let bind = render_context.render_device().create_bind_group(
|
||||
"ui_gamma_decode",
|
||||
&layout,
|
||||
&BindGroupEntries::sequential((post.source, &pipelines.sampler)),
|
||||
&BindGroupEntries::sequential((
|
||||
post.source,
|
||||
&pipelines.sampler,
|
||||
pipelines.ramp.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
// Its diagnostic span: the journal's `gpu_ui` column counts this decode (2008).
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
@@ -188,7 +276,9 @@ pub(crate) struct UiGammaPlugin;
|
||||
|
||||
impl Plugin for UiGammaPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(ExtractComponentPlugin::<UiGammaLane>::default());
|
||||
app.init_resource::<DisplayGamma>()
|
||||
.add_plugins(ExtractComponentPlugin::<UiGammaLane>::default())
|
||||
.add_systems(Update, stamp_lane_gamma);
|
||||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -633,7 +633,7 @@ fn spawn_ui_camera(mut commands: Commands) {
|
||||
bevy::render::view::Msaa::Off,
|
||||
// The gamma composite lane's mandatory decode (decision 0254) — without it the UI presents
|
||||
// ~2.2× bright, since the quad pass leaves gamma values in the target.
|
||||
crate::ui_gamma::UiGammaLane,
|
||||
crate::ui_gamma::UiGammaLane::default(),
|
||||
// Every Bevy UI tree renders HERE (decision 0541) — the glue screens and the loading screen.
|
||||
// Without the marker, Bevy UI picks the highest-order camera targeting the window, which is
|
||||
// the egui dev overlay (order 2): the glue screens rode the dev camera, outside the gamma
|
||||
|
||||
@@ -668,6 +668,11 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
// that lands in an engine-side store that survives), before the VM is replaced. The next
|
||||
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
|
||||
crate::cvars::fold_dying_vm_cvars(world);
|
||||
// The chat cache, on the same terms and for the same reason (decision NNNN): it composes the
|
||||
// player's file out of the DYING VM, and `/reload` never crosses the `OnExit(InWorld)` edge
|
||||
// its flush used to hang on — so a window moved in the last second before a reload was
|
||||
// written nowhere and re-read stale from disk.
|
||||
crate::ui_chat::settings::fold_dying_vm_chat_cache(world);
|
||||
world.insert_resource(AddOnIdentity(None));
|
||||
|
||||
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
|
||||
|
||||
@@ -2039,6 +2039,12 @@ fn settle(s: &mut UiScript) {
|
||||
fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
// On NAMEPLATES, not the default Controls page: since 2180 seated the four camera toggles and
|
||||
// the two camera-speed sliders, Controls is sixteen rows and overflows its area on its own —
|
||||
// which is the scroll body doing its job, and no longer a control for "nothing overflows".
|
||||
// Nameplates is six rows and fits.
|
||||
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
|
||||
.unwrap();
|
||||
settle(&mut s);
|
||||
|
||||
// Control first: a settled page fits, so there is no scroll and no bar at all — and the body
|
||||
@@ -2047,7 +2053,7 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
|
||||
s.eval::<f32>("return BenillaOptionsFrameContainerScroll:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
0.0,
|
||||
"the Controls page fits its area"
|
||||
"the Nameplates page fits its area"
|
||||
);
|
||||
for f in [
|
||||
"BenillaOptionsFrameContainerScrollBar",
|
||||
@@ -2276,6 +2282,32 @@ fn hover_label(s: &mut UiScript, frame: &str) {
|
||||
s.mouse_move(l + 60.0, (t + b) * 0.5);
|
||||
}
|
||||
|
||||
/// Bring `frame` inside the page's scroll rect, if the page scrolls at all. A page longer than
|
||||
/// its area clips everything past the fold (B217), so a row down there cannot be hovered where its
|
||||
/// rect says it is — the cursor lands outside the scroll frame and hits nothing. Nine of the
|
||||
/// Controls page's sixteen rows and the last of Combat's seventeen live there since 2180.
|
||||
fn scroll_into_view(s: &mut UiScript, frame: &str) {
|
||||
s.resolve();
|
||||
s.run(&format!(
|
||||
"local sf = BenillaOptionsFrameContainerScroll \
|
||||
local range = sf:GetVerticalScrollRange() \
|
||||
if range and range > 0 then \
|
||||
local row = getglobal(\"{frame}\") \
|
||||
local want = sf:GetVerticalScroll() \
|
||||
if row:GetBottom() < sf:GetBottom() then \
|
||||
want = want + (sf:GetBottom() - row:GetBottom()) + 2 \
|
||||
elseif row:GetTop() > sf:GetTop() then \
|
||||
want = want - (row:GetTop() - sf:GetTop()) - 2 \
|
||||
end \
|
||||
if want < 0 then want = 0 end \
|
||||
if want > range then want = range end \
|
||||
sf:SetVerticalScroll(want) \
|
||||
end"
|
||||
))
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// A hovered row raises its 1.12 description, on the era's seat, and drops it on leave — the row
|
||||
/// itself, its checkbox, and (B223's report) the label the cursor actually crosses. The string
|
||||
/// resolves by KEY at hover, so seeding it AFTER the window loaded still paints: that is the
|
||||
@@ -2433,11 +2465,13 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
continue;
|
||||
}
|
||||
// The deliberate exceptions (1639 Render Scale, 1650 Display Mode, 1847 Enable Sound in
|
||||
// Background). None has a 1.12 counterpart whose `OPTION_TOOLTIP_*` could be resolved —
|
||||
// Render Scale has no era row at all, Display Mode's era row was a CHECKBOX whose string
|
||||
// says "Check to…", and 1.12 has no background-sound setting at all (it mutes on its
|
||||
// window-activation event and offers no way out) — and each is a row a player needs a
|
||||
// description for. Each carries one under a `BENILLA_` prefix so the reference's
|
||||
// Background, 2182 Brightness). None has a 1.12 counterpart whose `OPTION_TOOLTIP_*` could
|
||||
// be resolved — Render Scale has no era row at all, Display Mode's era row was a CHECKBOX
|
||||
// whose string says "Check to…", 1.12 has no background-sound setting at all (it mutes on
|
||||
// its window-activation event and offers no way out), and `OPTION_TOOLTIP_GAMMA` spends
|
||||
// its second sentence on "all 21 levels of gray bars to the right", which is the stock
|
||||
// video window's own calibration art and not something this page has — and each is a row a
|
||||
// player needs a description for. Each carries one under a `BENILLA_` prefix so the reference's
|
||||
// namespace stays the reference's, which is exactly what this guard is here to protect.
|
||||
// Everything the guard was built to catch — an invented or typo'd `OPTION_TOOLTIP_` key
|
||||
// that silently resolves to nothing — is untouched: the pairing below is exact, so a
|
||||
@@ -2449,6 +2483,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
|
||||
"AudioRowBackgroundSound",
|
||||
),
|
||||
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
|
||||
];
|
||||
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
|
||||
assert_eq!(row, *want_row, "{row}: not this row's string");
|
||||
@@ -2498,7 +2533,16 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// key OPTION_TOOLTIP_SHOW_TUTORIALS is 1.12's own.
|
||||
// The 30th and 31st CVar rows are the two text filters (2077): Profanity Filter on the
|
||||
// Interface page and Disable Spam Filter on the Chat page, both keys 1.12's own.
|
||||
assert_eq!(checked, 62, "every tipped row carries a live key");
|
||||
// 2180 added fourteen rows, every one of them tipped with the reference's own key: the
|
||||
// four camera toggles and two camera-speed sliders plus Attack on assist, Auto Clear AFK and
|
||||
// Auto Self Cast on Controls, Enhanced Tooltips on Interface, Player Guild Names on
|
||||
// Nameplates, and the damage-number trio on Combat. 62 -> 76.
|
||||
// …and Weather Intensity (2181), on the Graphics page, whose OPTION_TOOLTIP_WEATHER_DETAIL is
|
||||
// 1.12's own and rides its own slider-9 row verbatim. 76 -> 77.
|
||||
// …and Brightness (2182), the FOURTH row whose description is benilla's own rather than 1.12
|
||||
// GlobalStrings — OPTION_TOOLTIP_GAMMA spends its second sentence on the stock window's
|
||||
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
|
||||
assert_eq!(checked, 78, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2564,6 +2608,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
.unwrap();
|
||||
for rkey in rows.split(',') {
|
||||
let row = format!("BenillaOptionsFrameContainerBody{page}{rkey}");
|
||||
scroll_into_view(&mut s, &row);
|
||||
hover_label(&mut s, &row);
|
||||
s.resolve();
|
||||
let tipped: bool = s.eval(&format!("return {row}.tip ~= nil")).unwrap();
|
||||
@@ -2610,7 +2655,14 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
// (1815), the Audio page's fourth checkbox and 1.12's own.
|
||||
// …and Show Tutorials (2077), the Interface page's seventh API row.
|
||||
// …and the two text-filter rows (2077).
|
||||
assert_eq!(raised, 62, "every row but Auto Loot raises a description");
|
||||
// …and 2180's fourteen: Attack on assist, Auto Clear AFK, Auto Self Cast, Follow Terrain,
|
||||
// Head Bob, Auto-Follow Speed, Water Collision, Smart Pivot and Mouse Look Speed on Controls;
|
||||
// Enhanced Tooltips on Interface; Player Guild Names on Nameplates; Show Target Damage,
|
||||
// Periodic Damage and Pet Damage on Combat. Every one of them tipped with the reference's
|
||||
// own key, which is what makes the count move by exactly the number of rows added.
|
||||
// …and Weather Intensity (2181) and Brightness (2182), the Graphics page's fourth and fifth
|
||||
// sliders.
|
||||
assert_eq!(raised, 78, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
@@ -4590,3 +4642,325 @@ fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker
|
||||
"UNBACKED_REFERENCE_CVARS names CVars the reference's table does not: {phantom:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Nothing the reference lets a player change, and this client backs, is out of reach**
|
||||
/// (decision 2180) — the other half of the census above, and the half that was missing.
|
||||
///
|
||||
/// The test above asks the ENGINE's question: is the CVar registered? It was green the entire time
|
||||
/// the four camera toggles had no box on any window a player can open. 2149 built their
|
||||
/// mechanisms and dropped them off `UNBACKED_REFERENCE_CVARS`; 2115 loads the stock window that
|
||||
/// carries their checkboxes deliberately HIDDEN, so addons find real frames and the player never
|
||||
/// sees it; and our own window — the one the ESC menu opens — simply had no rows. Two records were
|
||||
/// individually right and the setting was unreachable, because no check compared them.
|
||||
///
|
||||
/// This asks the UI's question. A CVar that lands on the list below is one the reference lets a
|
||||
/// player change and benilla does not, and it costs a stated reason at the row.
|
||||
#[test]
|
||||
fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
|
||||
let s = harness();
|
||||
|
||||
// Both of the reference's own option tables, exactly as the registration census reads them.
|
||||
let named: Vec<String> = s
|
||||
.eval::<Vec<String>>(
|
||||
"local out = {} \
|
||||
for key, v in pairs(UIOptionsFrameCheckButtons) do \
|
||||
if v.cvar then table.insert(out, v.cvar) end \
|
||||
end \
|
||||
for _, v in ipairs(UIOptionsFrameSliders) do \
|
||||
if v.cvar then table.insert(out, v.cvar) end \
|
||||
end \
|
||||
table.sort(out) \
|
||||
return out",
|
||||
)
|
||||
.expect("read the reference's two option tables");
|
||||
|
||||
// What OUR window can actually move. A row's own `cvar`, plus the `partner` a handful of rows
|
||||
// write beside it (`PetSpellDamage`, `cameraPitchMoveSpeed`) — those are moved by a control
|
||||
// the player uses, which is what "reachable" means here, even though neither is table-named.
|
||||
let ours: std::collections::HashSet<String> = s
|
||||
.eval::<Vec<String>>(
|
||||
"local out = {} \
|
||||
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
|
||||
for _, rkey in ipairs(rows) do \
|
||||
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
|
||||
if row.cvar then table.insert(out, row.cvar) end \
|
||||
if row.partner then table.insert(out, row.partner.cvar) end \
|
||||
end \
|
||||
end \
|
||||
return out",
|
||||
)
|
||||
.expect("read our own window's rows")
|
||||
.into_iter()
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
let registered: std::collections::HashSet<String> = crate::cvars::registered_pairs()
|
||||
.map(|(n, _)| n.to_ascii_lowercase())
|
||||
.collect();
|
||||
let excused: std::collections::HashSet<String> = UNREACHABLE_REFERENCE_CVARS
|
||||
.iter()
|
||||
.map(|(n, _)| n.to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
// Two floors, because the failure this check is worth having is the one where it passes for
|
||||
// the wrong reason: a harness whose stock tables did not load reads no names, and one whose
|
||||
// window did not build reads no rows, and either way `missing` is empty and green.
|
||||
let backed = named
|
||||
.iter()
|
||||
.filter(|c| registered.contains(&c.to_ascii_lowercase()))
|
||||
.count();
|
||||
assert!(
|
||||
backed >= 28,
|
||||
"the reference's tables did not load: only {backed} of their names are registered here"
|
||||
);
|
||||
assert!(
|
||||
ours.len() >= 40,
|
||||
"our own window's rows did not build: only {} carry a cvar",
|
||||
ours.len()
|
||||
);
|
||||
|
||||
let missing: Vec<&str> = named
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let k = c.to_ascii_lowercase();
|
||||
registered.contains(&k) && !ours.contains(&k) && !excused.contains(&k)
|
||||
})
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"these are backed and the reference lets a player change them, and our own options \
|
||||
window has no row for them: {missing:?} — seat a row, or add them to \
|
||||
UNREACHABLE_REFERENCE_CVARS with the reason"
|
||||
);
|
||||
|
||||
// The other direction, the one that keeps the list from rotting: an excuse for a row that now
|
||||
// exists is an excuse describing nothing.
|
||||
let stale: Vec<&str> = UNREACHABLE_REFERENCE_CVARS
|
||||
.iter()
|
||||
.map(|(n, _)| *n)
|
||||
.filter(|n| ours.contains(&n.to_ascii_lowercase()))
|
||||
.collect();
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"these have a row now — drop them from UNREACHABLE_REFERENCE_CVARS: {stale:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **It is empty, and that is the finding, not an omission.** Every CVar the reference's own two
|
||||
/// option tables name is now either unregistered (and on `UNBACKED_REFERENCE_CVARS` above with the
|
||||
/// feature it waits on) or reachable from benilla's own options window. `autointeract` and
|
||||
/// `UnitNamePlayerPVPTitle`, the last two unbacked rows, will each need a row here the day their
|
||||
/// feature lands — which is exactly what the census above will say when it calls them stale.
|
||||
///
|
||||
/// A name arriving here needs a reason of the same shape as the list above's: what the *player*
|
||||
/// cannot do, and why the row is not simply seated.
|
||||
const UNREACHABLE_REFERENCE_CVARS: &[(&str, &str)] = &[];
|
||||
|
||||
/// The four camera toggles (2149's mechanisms, 2179's water-collision rebuild) on the page a
|
||||
/// player can open. Each box opens showing the value the reference registers — two of them "1",
|
||||
/// which is the half that makes this more than cosmetic: on `cameraPivot` and
|
||||
/// `cameraWaterCollision` a player who wants them OFF had no way to say so at all.
|
||||
#[test]
|
||||
fn the_four_camera_toggles_read_their_shipped_defaults_and_write_on_the_click() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"reading the four on open must not write them back"
|
||||
);
|
||||
|
||||
let checked = |s: &mut UiScript, row: &str| -> bool {
|
||||
s.eval::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyControls{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// The registrar's own values: pivot and water collision ON, terrain tilt and head bob OFF.
|
||||
assert!(checked(&mut s, "RowSmartPivot"));
|
||||
assert!(checked(&mut s, "RowWaterCollision"));
|
||||
assert!(!checked(&mut s, "RowFollowTerrain"));
|
||||
assert!(!checked(&mut s, "RowHeadBob"));
|
||||
|
||||
// And the click writes — the first flip of each is the one that could not be expressed before.
|
||||
for (row, cvar, want) in [
|
||||
("RowFollowTerrain", "cameraTerrainTilt", "1"),
|
||||
("RowHeadBob", "cameraBobbing", "1"),
|
||||
("RowWaterCollision", "cameraWaterCollision", "0"),
|
||||
("RowSmartPivot", "cameraPivot", "0"),
|
||||
] {
|
||||
s.run(&format!(
|
||||
"BenillaOptionsFrameContainerBodyControls{row}Check:Click()"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![(cvar.to_string(), want.to_string())],
|
||||
"{row} writes {cvar}"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The partner write** (2180): two rows drive a second CVar that appears in neither option
|
||||
/// table, because `UIOptionsFrame_Save` keeps a special-case arm for each. Without it the control
|
||||
/// is a half-write, and a silent one — `SetCVar` on a name nobody wrote is not an error, it is a
|
||||
/// value that stayed where it was, so the box would read "pet damage off" while pet SPELL damage
|
||||
/// kept floating.
|
||||
#[test]
|
||||
fn the_pet_damage_box_and_the_look_slider_each_write_their_unnamed_twin() {
|
||||
let mut s = combat_harness();
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
|
||||
.unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
|
||||
// Pet Damage off: both pet CVars move, the melee one through the row and the spell one
|
||||
// through the partner (UIOptionsFrame_Save l.334-336).
|
||||
s.run("BenillaOptionsFrameContainerBodyCombatRowPetDamageCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![
|
||||
("PetMeleeDamage".to_string(), "0".to_string()),
|
||||
("PetSpellDamage".to_string(), "0".to_string()),
|
||||
],
|
||||
"one box, both pet knobs"
|
||||
);
|
||||
|
||||
// Mouse Look Speed: the pitch twin at half the yaw value (l.355-356) — which is exactly the
|
||||
// 180/90 pair the two are registered at, so the ratio the reference ships is preserved by
|
||||
// dragging rather than broken by it.
|
||||
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
|
||||
.unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run("BenillaOptionsFrameContainerBodyControlsRowMouseLookSpeedControlSlider:SetValue(240)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![
|
||||
("cameraYawMoveSpeed".to_string(), "240".to_string()),
|
||||
("cameraPitchMoveSpeed".to_string(), "120".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return BenillaOptionsFrameContainerBodyControlsRowMouseLookSpeedControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"240",
|
||||
"a deg/s slider reads out the raw number, not a percent of nothing"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Show Target Damage is a SECOND master on the Combat page, and the floating-text one does not
|
||||
/// own it.** The reference keeps the two dependency blocks apart —
|
||||
/// `UIOptionsFrame_UpdateDependencies` greys 53…69 from CheckButton52 and greys 9 and 11 from 19,
|
||||
/// in two separate `if`s — and collapsing them would tell a player that turning off scrolling
|
||||
/// combat text also turns off damage numbers, which is the opposite of what `CombatDamage` does.
|
||||
#[test]
|
||||
fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_alone() {
|
||||
let mut s = combat_harness();
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
|
||||
.unwrap();
|
||||
let enabled = |s: &mut UiScript, row: &str| -> bool {
|
||||
s.eval::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyCombat{row}Check:IsEnabled() ~= 0"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// `SHOW_COMBAT_TEXT` ships "0", so the page arrives with the floating-text family greyed —
|
||||
// and the damage trio live beside it, which is the whole point of the split.
|
||||
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
|
||||
assert!(!enabled(&mut s, "RowAuras"));
|
||||
assert!(enabled(&mut s, "RowShowDamage"));
|
||||
assert!(enabled(&mut s, "RowPeriodicDamage"));
|
||||
assert!(enabled(&mut s, "RowPetDamage"));
|
||||
|
||||
// The other master: Show Target Damage off greys its two children and nothing else.
|
||||
s.run("BenillaOptionsFrameContainerBodyCombatRowShowDamageCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"CombatDamage\")")
|
||||
.unwrap(),
|
||||
"0"
|
||||
);
|
||||
assert!(!enabled(&mut s, "RowPeriodicDamage"));
|
||||
assert!(!enabled(&mut s, "RowPetDamage"));
|
||||
assert!(
|
||||
enabled(&mut s, "RowShowDamage"),
|
||||
"the master is the way back in"
|
||||
);
|
||||
|
||||
// Waking the floating-text family does not wake them: the two gates are independent.
|
||||
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(enabled(&mut s, "RowAuras"));
|
||||
assert!(
|
||||
!enabled(&mut s, "RowPetDamage"),
|
||||
"scrolling combat text does not own the damage numbers"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The two other dependencies 2180 seated, both the reference's own: Player Guild Names is dead
|
||||
/// while player names are off (l.702-708 — there is no overhead stack for the guild line to be
|
||||
/// the second row of), and Auto-Follow Speed is dead while the following style is Never
|
||||
/// (l.715-719 — there is no follow to set a speed for).
|
||||
#[test]
|
||||
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
|
||||
// Nameplates: `UnitNamePlayer` ships "1", so the child arrives live.
|
||||
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
|
||||
.unwrap();
|
||||
let guild_on = |s: &mut UiScript| -> bool {
|
||||
s.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(guild_on(&mut s));
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(!guild_on(&mut s), "no names, no guild line to gate");
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(guild_on(&mut s));
|
||||
|
||||
// Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never.
|
||||
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
|
||||
.unwrap();
|
||||
let speed_thumb = |s: &mut UiScript| -> bool {
|
||||
s.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyControlsRowAutoFollowSpeedControlSliderThumb\
|
||||
:IsShown()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(speed_thumb(&mut s));
|
||||
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") OptionsPage_Refresh(\"Controls\")")
|
||||
.unwrap();
|
||||
assert!(
|
||||
!speed_thumb(&mut s),
|
||||
"a Never follow leaves the speed groove thumbless, 1.12's own way of saying dead"
|
||||
);
|
||||
// …and the groove refuses the press as well as looking refused, which the reference never
|
||||
// needed: its slider has no track jump to refuse.
|
||||
assert!(!s
|
||||
.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyControlsRowAutoFollowSpeedControlSlider\
|
||||
:IsMouseEnabled()"
|
||||
)
|
||||
.unwrap());
|
||||
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") OptionsPage_Refresh(\"Controls\")")
|
||||
.unwrap();
|
||||
assert!(speed_thumb(&mut s));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -1512,56 +1512,6 @@ 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",
|
||||
|
||||
@@ -2072,6 +2072,119 @@ fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The display-brightness pair** (decision 2182) — `GetGamma`/`SetGamma` and the Graphics page
|
||||
/// row that drives them, held to the reference's own carve.
|
||||
///
|
||||
/// The claim that needs an assertion rather than a comment is the **unit**: `0x4891d0` is FSUBR,
|
||||
/// so `GetGamma()` is `1.0 − gamma` and `SetGamma(v)` writes `gamma := 1.0 − v`. Read as
|
||||
/// "gamma in, gamma out" the pair still composes to the identity and still *looks* right in a
|
||||
/// round-trip test — but the panel would then put `gamma = 0` (`pow(x, 0) = 1`, a white screen)
|
||||
/// at the top of a slider whose top is supposed to be the brightest legible picture. So this
|
||||
/// checks the two ENDS of the reference's own `[-0.5, 0.5]` against the CVar text, not just that
|
||||
/// get and set agree with each other.
|
||||
///
|
||||
/// And the second claim: **there is no clamp**, anywhere, in the reference (the positive control
|
||||
/// is `baseMip`'s validating callback `0x689090`). `SetGamma(5)` writes `"-4.000000"` and the
|
||||
/// store keeps it — benilla's clamp is at the render consumer, where it cannot lie to `GetCVar`.
|
||||
///
|
||||
/// (wow-re `ui/scratch/video-options-verbs.md` §3 and
|
||||
/// `ffxeffects/scratch/whole-frame-grade-verdict.md` §(a), both VERIFIED.)
|
||||
#[test]
|
||||
fn the_display_brightness_pair_speaks_the_reference_slider_unit() {
|
||||
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:#?}");
|
||||
|
||||
// The reference registers `gamma = "1.0"` (our table spells it `"1.000000"`, the spelling
|
||||
// `SetGamma` itself writes — see its row in `crate::cvars`), and 0.0 is what that reads as:
|
||||
// the exact centre of the stock slider.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGamma()").unwrap(),
|
||||
0.0,
|
||||
"a fresh client sits at the centre of the stock slider's [-0.5, 0.5]"
|
||||
);
|
||||
// Both ends, in the CVar's own text — `SStrPrintf(buf, 0x10, "%f", 1.0 - v)`.
|
||||
for (slider, cvar) in [
|
||||
(0.5, "0.500000"),
|
||||
(-0.5, "1.500000"),
|
||||
(0.0, "1.000000"),
|
||||
// No clamp: the reference accepts this and writes a negative gamma.
|
||||
(5.0, "-4.000000"),
|
||||
] {
|
||||
s.eval::<()>(&format!("SetGamma({slider})")).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"gamma\")").unwrap(),
|
||||
cvar,
|
||||
"SetGamma({slider}) writes 1 - v with six decimals"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetGamma()").unwrap(),
|
||||
slider,
|
||||
"…and the getter is its exact inverse"
|
||||
);
|
||||
}
|
||||
// Zero return values, not nil (`eax = 0` at every `ret`). `select` is not in this VM (2171),
|
||||
// so the count is read the way 1.12 Lua reads one: a multiple assignment.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
"local a, b = SetGamma(0) \
|
||||
if a ~= nil or b ~= nil then return 1 end \
|
||||
return 0"
|
||||
)
|
||||
.unwrap(),
|
||||
0,
|
||||
"SetGamma pushes nothing"
|
||||
);
|
||||
// …and it REQUIRES its argument (`0x4891fe`, raising through `0x6f4940`, which never returns).
|
||||
let err = s.eval::<()>("SetGamma()").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Usage: SetGamma(value)"),
|
||||
"the reference's own usage string, verbatim: {err}"
|
||||
);
|
||||
|
||||
// The row: our Graphics page drives the PAIR, not the CVar, which is 1.12's own arrangement
|
||||
// for this one slider — and it reads its bounds off the reference's `OptionsFrameSliders[6]`.
|
||||
s.eval::<()>("SetGamma(0)").unwrap();
|
||||
let row = "BenillaOptionsFrameContainerBodyGraphicsRowBrightness";
|
||||
let bounds = s
|
||||
.eval::<Vec<f64>>(&format!(
|
||||
"local r = getglobal({row:?}) \
|
||||
local sl = getglobal({row:?} .. \"ControlSlider\") \
|
||||
local lo, hi = sl:GetMinMaxValues() \
|
||||
return {{ lo, hi, sl:GetValueStep(), r.numeric }}"
|
||||
))
|
||||
.unwrap();
|
||||
// The step is an f32 on the widget, so it comes back as 0.100000001…; the two bounds and the
|
||||
// numeric flag are exact.
|
||||
assert_eq!(
|
||||
(bounds[0], bounds[1], bounds[3]),
|
||||
(-0.5, 0.5, 1.0),
|
||||
"the reference's slider-6 bounds, on a numeric api row"
|
||||
);
|
||||
assert!(
|
||||
(bounds[2] - 0.1).abs() < 1e-6,
|
||||
"…and its step: {}",
|
||||
bounds[2]
|
||||
);
|
||||
// The readout is the thumb's share of the groove (0..100 with the default at 50), never the
|
||||
// stored offset — "0%" on a brightness control doing nothing wrong is exactly backwards. It is
|
||||
// written by the page's own refresh, so the window has to be up for there to be one.
|
||||
s.eval::<()>(
|
||||
"ShowUIPanel(BenillaOptionsFrame) \
|
||||
BenillaOptionsFrameCategoryListRowGraphics:Click()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!(
|
||||
"return getglobal({row:?} .. \"ControlValue\"):GetText()"
|
||||
))
|
||||
.unwrap(),
|
||||
"50%",
|
||||
);
|
||||
}
|
||||
|
||||
/// **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)` /
|
||||
|
||||
@@ -400,7 +400,19 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// diffuse lobe and the MOLT points are already summed in and cannot be subtracted back out — and
|
||||
/// a caller that reached for the *scene* ambient instead, which is what this replaces, tinted
|
||||
/// every indoor trail with the sky.
|
||||
const CEILING: usize = 179;
|
||||
/// And 179 → 180: `weather::WeatherState`, a PUBLISH — the weather driver's own state, reached by
|
||||
/// `crate::cvars` because one byte of it (`weather_density`) is a **player setting** and 2181 gave
|
||||
/// it its switch. It is the same shape as `clutter::ClutterConfig` two rows of that table up: a
|
||||
/// resource the engine owns and the options window writes exactly one field of, welded to its CVar
|
||||
/// by `registered_defaults_mirror_the_code_truths`. Published as the state rather than as a
|
||||
/// density-only door for the reason the two entries above give in reverse — there is no
|
||||
/// reconstruction to get wrong here, the field is a `u8` the engine reads directly, and a
|
||||
/// one-field wrapper would be a second name for the same byte that the weld test would then have
|
||||
/// to hold in step with both sides. It was already on the far side of the wall as an INSTRUMENT
|
||||
/// item (the `#[cfg(feature = "dev")]` weather panel is its only other namer); what changed is
|
||||
/// that a game module names it now, which is exactly the crossing this gate exists to make
|
||||
/// visible.
|
||||
const CEILING: usize = 180;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -343,6 +343,45 @@ pub(super) fn edge(model: &mut Model, h: FrameHandle, f: impl FnOnce(&mut Button
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a hover edge on frame `h` reach the **base** enter/leave notify — the HIGHLIGHT layer and
|
||||
/// the Lua `<OnEnter>`/`<OnLeave>` script slots — or is it swallowed on the way there?
|
||||
///
|
||||
/// `CSimpleButton::OnEnter 0x779490` and `OnLeave 0x7794e0` both open on the state word, and the
|
||||
/// branch skips the *whole* body except the sound:
|
||||
///
|
||||
/// ```text
|
||||
/// 779493 mov eax,[esi+0x328]
|
||||
/// 779499 test eax,eax
|
||||
/// 77949b je 0x7794b0 ; DISABLED -> skip the base notify AND the label restyle
|
||||
/// 77949d call 0x76b6a0 ; CSimpleFrame::OnEnter — highlight layer on, <OnEnter> at +0x140
|
||||
/// ```
|
||||
///
|
||||
/// — so **a DISABLED Button runs neither script**, and `0x7794b0`'s hover sound is the only thing
|
||||
/// past the branch (wow-re `scratch/button-state-edge-set.md` §3.1, VERIFIED; the base pair's
|
||||
/// script slots `+0x140`/`+0x148` are `ledger.tsv:7944/7945`). Every path that delivers a hover
|
||||
/// edge dispatches through the object's own vtable, so there is no way around the guard: the hover
|
||||
/// walk (`0x766218`–`0x76623c`, `[vt+0x50]` then `[vt+0x4c]`), `SetMouseFocus 0x764dc0`
|
||||
/// (`0x764ddd push 0`), and the hide/removal tail (`0x764cce mov edx,[edi]; push 1;
|
||||
/// call [edx+0x50]`). `CheckButton` inherits both slots; the Hyperlink and NamePlate buttons
|
||||
/// override them and tail straight back into this pair (`0x7cb8a5`), so the guard covers the
|
||||
/// family.
|
||||
///
|
||||
/// The hover **target** still moves — the walk reassigns `[root+0x7c]` *before* it fires either
|
||||
/// notify — so `GetMouseFocus()` answers a disabled button; it is the scripts alone that stay
|
||||
/// quiet. This is what makes AtlasLoot's unset QuickLook buttons safe on the reference: their
|
||||
/// `<OnEnter>` guards with `if this:IsEnabled() then`, which is a NUMBER `1`/`0` and therefore
|
||||
/// always truthy (`scratch/button-enabled-state.md`), so the body would index a nil
|
||||
/// `QuickLooks[n]` — the reference never runs it at all.
|
||||
///
|
||||
/// `None` (the cursor over no frame) and every non-Button kind notify normally.
|
||||
pub(super) fn hover_notify_runs(model: &Model, h: Option<FrameHandle>) -> bool {
|
||||
let Some(h) = h else { return true };
|
||||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Button(bs)) => bs.enabled(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the
|
||||
/// other `ButtonState` writers rather than reaching into the arena from `script::loot`.
|
||||
pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option<u32>) -> mlua::Result<()> {
|
||||
|
||||
@@ -586,7 +586,7 @@ pub const VIDEO_DEFAULT_CVARS: &[&str] = &[
|
||||
"WorldDetail",
|
||||
];
|
||||
|
||||
/// **The Video options window's own engine verbs** — the seven `OptionsFrame.xml` needs that
|
||||
/// **The Video options window's own engine verbs** — the nine `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
|
||||
@@ -594,7 +594,7 @@ pub const VIDEO_DEFAULT_CVARS: &[&str] = &[
|
||||
/// 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
|
||||
/// **Three of the nine 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
|
||||
@@ -603,9 +603,10 @@ pub const VIDEO_DEFAULT_CVARS: &[&str] = &[
|
||||
/// 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.
|
||||
/// are in [`install_world_detail_verbs`]. `GetGamma`/`SetGamma` are the last two: 2177 shipped
|
||||
/// seven and left that pair **loudly absent** under 1203, because a byte-faithful copy would have
|
||||
/// driven a hardware ramp this client never uploads. 2182 built the render feature behind them, so
|
||||
/// they have a real reader now and the window's engine verbs are nine of nine.
|
||||
///
|
||||
/// **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
|
||||
@@ -855,9 +856,80 @@ fn install_video_verbs(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// ── GetGamma / SetGamma ──────────────────────────────────────────────────────────────────
|
||||
// **The display-brightness pair** (2182) — `GetGamma 0x4891c0` and `SetGamma 0x4891f0`, carved
|
||||
// end to end in wow-re `ui/scratch/video-options-verbs.md` §3.
|
||||
//
|
||||
// The one thing about them that is not obvious from the name: **the unit is the SLIDER's
|
||||
// offset, not the CVar's.** `0x4891d0` is `dc 2d`, ModRM reg field 5 = **FSUBR** (`mem − ST(0)`,
|
||||
// not `FSUB`), over `[0x8015b8]` = the f64 `1.0`. So `GetGamma()` answers `1.0 − gamma` and
|
||||
// `SetGamma(v)` writes `gamma := 1.0 − v` — the two compose to the identity, and with the
|
||||
// registered `gamma = "1.0"` the getter answers **0.0**, the exact centre of the stock
|
||||
// slider's `[-0.5, 0.5]`. Reading the pair as "gamma in, gamma out" would put a 1.0 through
|
||||
// `SetGamma` and write `gamma = 0`, i.e. `pow(x, 0) = 1`, a fully white ramp.
|
||||
//
|
||||
// **No clamp exists anywhere in the reference** — not in the binding, not in `CVar::Set`, not
|
||||
// in the change callback, not in the ramp builder — and the absence is earned rather than
|
||||
// assumed: the positive control is `baseMip`'s own callback `0x689090`, which *does* reject
|
||||
// out-of-`[0,1]` with `al = 0`. `SetGamma(5)` is accepted and writes `gamma = "-4.000000"`.
|
||||
// benilla's clamp is at the consumer (`ui_gamma::GAMMA_RANGE`), which is this crate's standing
|
||||
// posture and leaves the store's truth alone.
|
||||
lua.globals().set(
|
||||
"GetGamma",
|
||||
// No argument is read (the getter never calls `lua_gettop`), and one number comes back.
|
||||
lua.create_function(|lua, _: mlua::MultiValue| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let gamma = model
|
||||
.cvars
|
||||
.get(&CVAR_GAMMA.to_ascii_lowercase())
|
||||
.and_then(|slot| slot.value.parse::<f64>().ok())
|
||||
// A VM whose host registered nothing (a bare test kit) answers as if the
|
||||
// registered default were in place, which is 0.0 — the slider's centre. The
|
||||
// reference has no such arm: its `0x63de30` lookup CREATES the record on a miss,
|
||||
// so `[eax+0x24]` is always readable.
|
||||
.unwrap_or(1.0);
|
||||
Ok(1.0 - gamma)
|
||||
})?,
|
||||
)?;
|
||||
lua.globals().set(
|
||||
"SetGamma",
|
||||
lua.create_function(|lua, value: Value| {
|
||||
// `lua_isnumber` (`0x4891fe`): a number, or a string Lua can convert to one. Anything
|
||||
// else raises through `0x6f4940`, which does not return — the trailing `xor eax,eax`
|
||||
// at `0x489215` is dead code.
|
||||
let v = match &value {
|
||||
Value::Integer(i) => *i as f64,
|
||||
Value::Number(n) => *n,
|
||||
Value::String(s) => {
|
||||
match s.to_str().ok().and_then(|s| s.trim().parse::<f64>().ok()) {
|
||||
Some(n) => n,
|
||||
None => return Err(mlua::Error::runtime(USAGE_SET_GAMMA)),
|
||||
}
|
||||
}
|
||||
_ => return Err(mlua::Error::runtime(USAGE_SET_GAMMA)),
|
||||
};
|
||||
// `SStrPrintf(buf, 0x10, "%f", 1.0 - v)` — six decimals into a SIXTEEN-byte buffer, so
|
||||
// a long value is truncated to 15 characters rather than rejected. Reproduced because
|
||||
// the truncated string is what the CVar then holds and what `GetCVar("gamma")` answers.
|
||||
let mut written = format!("{:.6}", 1.0 - v);
|
||||
written.truncate(15);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
write_cvar(&mut model, CVAR_GAMMA, written, None);
|
||||
// Zero return values, not nil (`eax = 0` at every `ret`).
|
||||
Ok(mlua::MultiValue::new())
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reference's own display-gamma CVar — `0x402d70`, name string `0x82e924`, registered `"1.0"`,
|
||||
/// flags 0. Defined in the crate that publishes the two verbs that read and write it, so the
|
||||
/// binding and the name cannot drift apart; the app welds it to its own table.
|
||||
pub const CVAR_GAMMA: &str = "gamma";
|
||||
|
||||
/// `0x8424cc`, verbatim.
|
||||
const USAGE_SET_GAMMA: &str = "Usage: SetGamma(value)";
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -208,6 +208,12 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
let hidden_hover = model
|
||||
.mouseover
|
||||
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
|
||||
// The removal tail is a VIRTUAL dispatch — `0x764cce mov edx,[edi]; push 1;
|
||||
// call [edx+0x50]` — so a Button reaches its own `0x7794e0`, whose DISABLED guard skips
|
||||
// the base leave and with it the `<OnLeave>` script. Hiding a disabled hovered button is
|
||||
// therefore as silent as walking off one ([`super::button::hover_notify_runs`]); the
|
||||
// hover cache and the drag-arm still clear, because those are the *caller's* half.
|
||||
let notified = super::button::hover_notify_runs(&model, hidden_hover);
|
||||
if let Some(m) = hidden_hover {
|
||||
model.mouseover = None;
|
||||
if model.drag.as_ref().is_some_and(|d| d.source == m) {
|
||||
@@ -215,7 +221,9 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
}
|
||||
}
|
||||
model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis);
|
||||
hidden_hover.map(|m| (m, model.frame_id(m)))
|
||||
hidden_hover
|
||||
.filter(|_| notified)
|
||||
.map(|m| (m, model.frame_id(m)))
|
||||
};
|
||||
if let Some((_, oid)) = left {
|
||||
if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) {
|
||||
|
||||
@@ -201,8 +201,9 @@ pub use cursor::{
|
||||
CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
|
||||
};
|
||||
pub use cvars::{
|
||||
MultisampleFormat, ScreenResolution, VideoCaps, CVAR_FRILL_DENSITY, CVAR_NAMEPLATE_ENEMIES,
|
||||
CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS,
|
||||
MultisampleFormat, ScreenResolution, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA,
|
||||
CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS,
|
||||
WORLD_DETAIL_STOPS,
|
||||
};
|
||||
pub use death::{DeathAction, DeathUiState};
|
||||
pub use dressup::DressUpIntent;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! `OnLeave`/`OnEnter` pair (RF-0025's `motion=true` hover boundary) — and, before that,
|
||||
//! advances an armed drag gesture, an in-flight **Slider thumb drag**, and an in-flight
|
||||
//! **frame move** (`StartMoving`, [`super::object`]'s `movable` cluster). All three move within
|
||||
//! one frame, crossing no hover boundary, so they must run before the boundary early-return.
|
||||
//! one frame, crossing no hover boundary, so they must run before the boundary test.
|
||||
//! - [`UiScript::mouse_button`] resolves `OnMouseDown`/`OnMouseUp`, the `OnClick`
|
||||
//! press/release-registration rules ([`button::wants_click`]), the **drag trio** + **world
|
||||
//! drop** below, the **Slider thumb-drag** capture (a left press on a thumb, decision 0250 §5),
|
||||
@@ -269,8 +269,9 @@ impl UiScript {
|
||||
/// `OnEnter(self, motion=true)` on the newly-captured frame (if any). Tracks the current
|
||||
/// mouseover in the model. Also advances an armed [`super::Model::drag`] gesture (decision 0216 §3)
|
||||
/// and an in-flight [`super::Model::moving`] frame ([`super::object`]'s `movable` cluster):
|
||||
/// both run BEFORE the no-boundary-crossed early return below, since dragging within one
|
||||
/// frame crosses no hover boundary at all. Returns the captured frame id (so the app can drive
|
||||
/// both run BEFORE the boundary test below, since dragging within one frame crosses no hover
|
||||
/// boundary at all. A **DISABLED Button** takes the hover — `GetMouseFocus()` answers it — but
|
||||
/// fires neither script ([`button::hover_notify_runs`]). Returns the captured frame id (so the app can drive
|
||||
/// `PointerOverUi`). Handler errors are collected into [`UiScript::errors`], never panicking.
|
||||
pub fn mouse_move(&mut self, x: f32, y: f32) -> Option<u32> {
|
||||
// A drag-active EditBox tracks every move (`0x77a860`): extend the selection to the
|
||||
@@ -278,10 +279,10 @@ impl UiScript {
|
||||
editbox::drag_update(&self.lua, x, y);
|
||||
let new_id = self.hit_test(x, y);
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (old_id, drag_start, boundary_crossed, slider_change, color_change): (
|
||||
let (leave_id, drag_start, enter_id, slider_change, color_change): (
|
||||
Option<u32>,
|
||||
Option<(u32, String)>,
|
||||
bool,
|
||||
Option<u32>,
|
||||
Option<(u32, f32)>,
|
||||
Option<(u32, f64, f64, f64)>,
|
||||
) = {
|
||||
@@ -303,7 +304,7 @@ impl UiScript {
|
||||
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
|
||||
if new_handle == model.mouseover {
|
||||
// no boundary crossed
|
||||
(None, drag_start, false, slider_change, color_change)
|
||||
(None, drag_start, None, slider_change, color_change)
|
||||
} else {
|
||||
// Fire OnLeave only if the frame we're leaving is still live.
|
||||
let old_id = model.mouseover.and_then(|h| {
|
||||
@@ -326,8 +327,18 @@ impl UiScript {
|
||||
// So a press held over a button and walked off it stays PUSHED in the reference —
|
||||
// it is the *drag threshold* (below) that un-presses one, and only for a frame
|
||||
// that registered for drag. Decision 2134.
|
||||
let _ = old_handle;
|
||||
(old_id, drag_start, true, slider_change, color_change)
|
||||
//
|
||||
// **A DISABLED Button swallows its own hover notify** and so runs neither script:
|
||||
// both `0x779490` and `0x7794e0` open `mov eax,[esi+0x328]; test eax,eax; je`,
|
||||
// branching past the base notify that owns the `<OnEnter>`/`<OnLeave>` slots
|
||||
// ([`button::hover_notify_runs`]). The gate is per-side — leaving a button that
|
||||
// was disabled *while* hovered is as silent as entering one — and it is applied
|
||||
// AFTER `model.mouseover` moves, because the reference reassigns the hover target
|
||||
// `[root+0x7c]` before it fires either notify: a disabled button is still the
|
||||
// mouse focus, it just says nothing.
|
||||
let old_id = old_id.filter(|_| button::hover_notify_runs(&model, old_handle));
|
||||
let enter_id = new_id.filter(|_| button::hover_notify_runs(&model, new_handle));
|
||||
(old_id, drag_start, enter_id, slider_change, color_change)
|
||||
}
|
||||
};
|
||||
// The thumb-drag value change fires OnValueChanged (outside the borrow) — the scrollbar's
|
||||
@@ -359,17 +370,14 @@ impl UiScript {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
if !boundary_crossed {
|
||||
return new_id;
|
||||
}
|
||||
if let Some(oid) = old_id {
|
||||
if let Some(oid) = leave_id {
|
||||
if let Err(e) =
|
||||
event::fire_widget_handler(&self.lua, oid, "OnLeave", vec![Value::Boolean(true)])
|
||||
{
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
if let Some(nid) = new_id {
|
||||
if let Some(nid) = enter_id {
|
||||
if let Err(e) =
|
||||
event::fire_widget_handler(&self.lua, nid, "OnEnter", vec![Value::Boolean(true)])
|
||||
{
|
||||
|
||||
@@ -672,6 +672,35 @@ fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The removal tail's own DISABLED case: `0x764cce` dispatches `[edx+0x50]` **virtually**, so a
|
||||
/// Button lands in `0x7794e0` and the guard swallows the leave there too. The `OnHide` half is the
|
||||
/// control — that one is not the hover notify and fires either way.
|
||||
#[test]
|
||||
fn hiding_a_hovered_disabled_button_fires_onhide_but_no_onleave() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
local slot = CreateFrame("Button", "Slot")
|
||||
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(100); slot:EnableMouse(true)
|
||||
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
|
||||
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
|
||||
slot:Disable()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_move(150.0, 150.0);
|
||||
s.run("Slot:Hide()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"hide",
|
||||
"the removal tail reaches the button's own leave notify, which is DISABLED-gated"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The negative: hiding a frame the cursor is NOT over fires no OnLeave (the reference's removal
|
||||
/// tail acts only when the removed frame IS the cached hover).
|
||||
#[test]
|
||||
@@ -835,3 +864,70 @@ fn the_capture_is_per_button() {
|
||||
"the right button captured nothing, so its release dispatches nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A DISABLED Button runs neither `<OnEnter>` nor `<OnLeave>`** — `CSimpleButton::OnEnter
|
||||
/// 0x779490` / `OnLeave 0x7794e0` open `mov eax,[esi+0x328]; test eax,eax; je`, branching past the
|
||||
/// base notify that owns both script slots (wow-re `scratch/button-state-edge-set.md` §3.1). It
|
||||
/// still TAKES the hover, because the walk reassigns `[root+0x7c]` before it fires the notify.
|
||||
///
|
||||
/// The customer is every 1.12 addon that wrote `if this:IsEnabled() then` at the top of an
|
||||
/// `<OnEnter>` and expected it to be false: `IsEnabled()` is the NUMBER `1`/`0`, always truthy, so
|
||||
/// the body runs unless the ENGINE holds it back. AtlasLoot's four QuickLook buttons are the live
|
||||
/// case — unset, they disable themselves in `<OnShow>` and their `<OnEnter>` indexes a nil
|
||||
/// `AtlasLootCharDB.QuickLooks[n]`.
|
||||
#[test]
|
||||
fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
local function mk(name, x)
|
||||
local b = CreateFrame("Button", name)
|
||||
b:SetPoint("BOTTOMLEFT", x, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true)
|
||||
b:SetScript("OnEnter", function(self) table.insert(log, self:GetName()..":enter") end)
|
||||
b:SetScript("OnLeave", function(self) table.insert(log, self:GetName()..":leave") end)
|
||||
return b
|
||||
end
|
||||
Off = mk("Off", 0)
|
||||
On = mk("On", 200)
|
||||
Off:Disable()
|
||||
-- The guard the addons actually wrote, and why it cannot be theirs: a NUMBER, never nil.
|
||||
assert(Off:IsEnabled() == 0 and On:IsEnabled() == 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
let hit = s.mouse_move(50.0, 50.0);
|
||||
assert!(hit.is_some(), "a disabled button still captures the point");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMouseFocus():GetName()")
|
||||
.unwrap(),
|
||||
"Off",
|
||||
"the hover target moves before the notify — a disabled button IS the mouse focus"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"",
|
||||
"entering a disabled button fires nothing"
|
||||
);
|
||||
|
||||
// Leaving it is as silent as entering it, and the enabled neighbour is the control.
|
||||
s.mouse_move(250.0, 50.0);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"On:enter",
|
||||
"no leave from the disabled button; the enabled one notifies normally"
|
||||
);
|
||||
|
||||
// Disabled WHILE hovered: the leave is gated on the state at leave time, not at enter time.
|
||||
s.run("On:Disable()").unwrap();
|
||||
s.mouse_move(400.0, 50.0);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||||
"On:enter",
|
||||
"a button disabled under the cursor swallows its own leave"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -53,6 +53,21 @@ pub(crate) enum CollisionLayer {
|
||||
Liquid,
|
||||
}
|
||||
|
||||
/// **The camera-collision probe radius (yd)** — the sphere [`WorldCollision::cast_camera`] sweeps
|
||||
/// along the boom against the SOLID world.
|
||||
///
|
||||
/// It is the margin kept between the camera and the surface it stops at, so the near plane does
|
||||
/// not poke through the wall; smaller than the player capsule, so the camera threads gaps the body
|
||||
/// cannot. **benilla's own construction** — the reference's camera trace is a bare ray
|
||||
/// (`0x672170`) with no radius anywhere on it.
|
||||
///
|
||||
/// It lives *here*, in the crate that owns the trace, rather than beside the camera that spawns
|
||||
/// the probe, because a fixture that sweeps a different radius than the client ships is an
|
||||
/// instrument reading itself: decision 2179's gate swept `0.15` against a client shipping `0.3`
|
||||
/// and certified a camera that was being slammed onto the character on every level surface swim
|
||||
/// (2185). Nothing may hardcode this number.
|
||||
pub const CAMERA_PROBE_RADIUS: f32 = 0.3;
|
||||
|
||||
/// `CollisionLayers` for a WMO's **walking** collider — member of [`CollisionLayer::Walk`] only, so the
|
||||
/// camera query (which omits `Walk`) never hits it.
|
||||
pub(crate) fn walk_layers() -> CollisionLayers {
|
||||
@@ -200,16 +215,53 @@ 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).
|
||||
/// `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))
|
||||
///
|
||||
/// **The waterline is deliberately not in here** — it rides its own ray
|
||||
/// ([`waterline_ray`](Self::waterline_ray)), for the reason spelled out there. This mask is the
|
||||
/// SOLID world only, and is byte-for-byte the filter it always was.
|
||||
pub(crate) fn camera_filter() -> SpatialQueryFilter {
|
||||
SpatialQueryFilter::from_mask(LayerMask(
|
||||
CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(),
|
||||
))
|
||||
}
|
||||
|
||||
/// **The waterline leg of the camera trace — a RAY, not the probe sphere** (decision 2185).
|
||||
///
|
||||
/// `cameraWaterCollision` is a trace mask (`0x50e5ec` ORs `0xf0000` into the word the solver
|
||||
/// hands its three collision queries), and the query that word rides is a **line segment**:
|
||||
/// `0x672170` takes `(start, end, out, frac, flags)` and carries no radius at all, bottoming
|
||||
/// out in the Möller–Trumbore ray/triangle test at `0x7c2c40` (wow-re
|
||||
/// `camera-water-atomicity.md` §145, `camera-cvar-kernels.md` §276). The reference's whole
|
||||
/// camera trace is that ray; benilla's is a [`CAMERA_PROBE_RADIUS`] sphere, which is **our own
|
||||
/// construction** — the margin that keeps the near plane out of a wall — and it stays, because
|
||||
/// walls are what it is for.
|
||||
///
|
||||
/// It cannot stay for water, because the corridor's clearance is a reference constant sized
|
||||
/// for a ray. Arm A lifts the sweep origin to `surface + 2/9 = 0.2222` yd, and the probe's
|
||||
/// radius is `0.3`: centred on that floor the sphere hangs **78 mm through the water plane
|
||||
/// before the sweep has moved at all**, so a level camera behind a surface swimmer was returned
|
||||
/// a hit at distance zero — the camera slammed onto the character, every frame the boom ran
|
||||
/// flat. Tip it a couple of degrees down and the sweep escapes the plane and the hit vanishes;
|
||||
/// that flip, against a snap-in/ease-out arm, is the "snaps in for a second every couple of
|
||||
/// seconds" a surface swim was reported with.
|
||||
///
|
||||
/// Two-sided, like the shape sweep beside it and for the same reason: a camera crossing the
|
||||
/// surface from underneath has no facing contract to honour, it just must not end up through
|
||||
/// the plane.
|
||||
fn waterline_ray(&self, from: Vec3, movement: Vec3) -> Option<f32> {
|
||||
let Ok(dir) = Dir3::new(movement) else {
|
||||
return None;
|
||||
};
|
||||
self.ms
|
||||
.spatial_query
|
||||
.cast_ray(
|
||||
from,
|
||||
dir,
|
||||
movement.length(),
|
||||
true,
|
||||
&SpatialQueryFilter::from_mask(LayerMask(CollisionLayer::Liquid.to_bits())),
|
||||
)
|
||||
.map(|h| h.distance)
|
||||
}
|
||||
|
||||
/// Sweep `shape` along `movement` against the body's world.
|
||||
@@ -246,32 +298,46 @@ impl WorldCollision<'_, '_> {
|
||||
one_sided::cast_move(&self.ms, shape, from, movement, skin_width, filter)
|
||||
}
|
||||
|
||||
/// Sweep the **camera boom** against the camera's world.
|
||||
/// Sweep the **camera boom** against the camera's world — the distance along `movement` the
|
||||
/// arm is free to reach, or `None` for an unobstructed boom.
|
||||
///
|
||||
/// 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,
|
||||
from: Vec3,
|
||||
rotation: Quat,
|
||||
movement: Vec3,
|
||||
skin_width: f32,
|
||||
liquid: bool,
|
||||
) -> Option<MoveHitData> {
|
||||
self.ms.cast_move(
|
||||
shape,
|
||||
from,
|
||||
rotation,
|
||||
movement,
|
||||
skin_width,
|
||||
&Self::camera_filter(liquid),
|
||||
)
|
||||
/// **Two legs, two shapes, and that is the point** (decision 2185). The solid world is swept
|
||||
/// with the caller's probe sphere, which is benilla's own margin against a wall's near plane;
|
||||
/// the waterline is a bare ray, which is the reference's own geometry and the only shape that
|
||||
/// fits through the clearance the corridor budgets for it — see
|
||||
/// [`waterline_ray`](Self::waterline_ray). `liquid` is `cameraWaterCollision`, straight
|
||||
/// through: with it clear the water leg does not run and this is the query it always was.
|
||||
///
|
||||
/// The nearer of the two wins, which is what one trace carrying both classes would have done.
|
||||
///
|
||||
/// **The probe is this function's, not the caller's.** It used to be threaded in from a
|
||||
/// `CameraProbe` resource in `benilla-app`, along with a rotation that was always the identity
|
||||
/// (a sphere has no orientation) and a skin width that was always zero — three arguments of
|
||||
/// noise around one number that belongs to the trace. That number is [`CAMERA_PROBE_RADIUS`],
|
||||
/// and the whole of decision 2185 is that it must be read in the same place the clearance it
|
||||
/// has to fit through is (`crates/benilla-app/tests/world_api_wall.rs` is what made the point:
|
||||
/// exporting the constant widened the doorway, and owning the probe closes it instead).
|
||||
pub fn cast_camera(&self, from: Vec3, movement: Vec3, liquid: bool) -> Option<f32> {
|
||||
let solid = self
|
||||
.ms
|
||||
.cast_move(
|
||||
&Collider::sphere(CAMERA_PROBE_RADIUS),
|
||||
from,
|
||||
Quat::IDENTITY,
|
||||
movement,
|
||||
0.0,
|
||||
&Self::camera_filter(),
|
||||
)
|
||||
.map(|h| h.distance);
|
||||
let water = liquid.then(|| self.waterline_ray(from, movement)).flatten();
|
||||
match (solid, water) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-sided ray against the body's world.
|
||||
@@ -410,6 +476,7 @@ mod liquid_trace_mask {
|
||||
PhysicsPlugins::new(bevy::app::PostUpdate),
|
||||
));
|
||||
app.init_asset::<Mesh>();
|
||||
app.init_resource::<MoverTraceExclusions>();
|
||||
app.world_mut().spawn((
|
||||
RigidBody::Static,
|
||||
Collider::trimesh(
|
||||
@@ -430,17 +497,24 @@ mod liquid_trace_mask {
|
||||
app
|
||||
}
|
||||
|
||||
/// Drop a small probe from above the surface onto it, through `filter`.
|
||||
fn descend(app: &mut App, filter: SpatialQueryFilter) -> Option<f32> {
|
||||
/// Drop the camera boom from above the surface straight onto it.
|
||||
fn descend_camera(app: &mut App, liquid: bool) -> Option<f32> {
|
||||
app.world_mut()
|
||||
.run_system_once(move |ms: MoveAndSlide| {
|
||||
ms.cast_move(
|
||||
&Collider::sphere(0.1),
|
||||
.run_system_once(move |c: WorldCollision| {
|
||||
c.cast_camera(Vec3::new(0.0, 3.0, 0.0), Vec3::new(0.0, -6.0, 0.0), liquid)
|
||||
})
|
||||
.expect("system runs")
|
||||
}
|
||||
|
||||
/// …and the walking body, which must pass through either way.
|
||||
fn descend_body(app: &mut App) -> Option<f32> {
|
||||
app.world_mut()
|
||||
.run_system_once(move |c: WorldCollision| {
|
||||
c.cast_body(
|
||||
&Collider::sphere(CAMERA_PROBE_RADIUS),
|
||||
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)
|
||||
})
|
||||
@@ -514,25 +588,48 @@ mod liquid_trace_mask {
|
||||
/// 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 (head, boom, len) = boom_from(feet_y, geo, pitch);
|
||||
app.world_mut()
|
||||
.run_system_once(move |c: WorldCollision| {
|
||||
c.cast_camera(head, boom, true).unwrap_or(len)
|
||||
})
|
||||
.expect("system runs")
|
||||
}
|
||||
|
||||
/// The same arm through the query benilla shipped **before** decision 2185: one sphere sweep
|
||||
/// carrying the liquid layer on its own mask, exactly as `camera_filter(true)` built it.
|
||||
///
|
||||
/// It stays as the positive control for both of the tests below, because it is the query in
|
||||
/// which the defect lives — and a gate whose control cannot fail is not a gate.
|
||||
fn open_arm_swept(app: &mut App, feet_y: f32, geo: (f32, f32), pitch: f32) -> f32 {
|
||||
let (head, boom, len) = boom_from(feet_y, geo, pitch);
|
||||
app.world_mut()
|
||||
.run_system_once(move |ms: MoveAndSlide| {
|
||||
ms.cast_move(
|
||||
&Collider::sphere(CAMERA_PROBE_RADIUS),
|
||||
head,
|
||||
Quat::IDENTITY,
|
||||
boom,
|
||||
0.0,
|
||||
&SpatialQueryFilter::from_mask(LayerMask(
|
||||
CollisionLayer::Default.to_bits()
|
||||
| CollisionLayer::Camera.to_bits()
|
||||
| CollisionLayer::Liquid.to_bits(),
|
||||
)),
|
||||
)
|
||||
.map_or(len, |h| h.distance)
|
||||
})
|
||||
.expect("system runs")
|
||||
}
|
||||
|
||||
/// `(sweep origin, boom vector, its length)` for a swimmer at `feet_y` framing at `geo`.
|
||||
fn boom_from(feet_y: f32, geo: (f32, f32), pitch: f32) -> (Vec3, Vec3, 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")
|
||||
(head, boom, boom.length())
|
||||
}
|
||||
|
||||
/// 2170's geometry at a given depth: the framing pivot is the bare swim preset, so it rides the
|
||||
@@ -553,6 +650,7 @@ mod liquid_trace_mask {
|
||||
/// given pitch.
|
||||
fn worst_over_depth(
|
||||
app: &mut App,
|
||||
arm: impl Fn(&mut App, f32, (f32, f32), f32) -> f32,
|
||||
geo: impl Fn(f32) -> (f32, f32),
|
||||
pitch: f32,
|
||||
span: f32,
|
||||
@@ -562,7 +660,7 @@ mod liquid_trace_mask {
|
||||
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);
|
||||
let d = arm(app, feet_y, geo(feet_y), pitch);
|
||||
if let Some(q) = prev {
|
||||
worst = worst.max((d - q).abs());
|
||||
}
|
||||
@@ -595,7 +693,14 @@ mod liquid_trace_mask {
|
||||
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);
|
||||
let control = worst_over_depth(
|
||||
&mut app,
|
||||
open_arm_swept,
|
||||
uncorrected,
|
||||
(-2.0f32).to_radians(),
|
||||
0.30,
|
||||
600,
|
||||
);
|
||||
assert!(
|
||||
control > 5.0,
|
||||
"the positive control must reproduce the regression — half a millimetre of settle \
|
||||
@@ -603,7 +708,14 @@ mod liquid_trace_mask {
|
||||
);
|
||||
// …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);
|
||||
let step = worst_over_depth(
|
||||
&mut app,
|
||||
open_arm,
|
||||
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 \
|
||||
@@ -612,21 +724,77 @@ mod liquid_trace_mask {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The probe that did not fit through its own corridor** (decision 2185).
|
||||
///
|
||||
/// The corridor lifts the camera's sweep origin to `surface + 2/9 = 0.2222` yd. That is a
|
||||
/// **reference** constant, and the reference's trace is a bare ray: `0x672170` takes a start,
|
||||
/// an end, an out-point and a `frac` and carries no radius anywhere, bottoming out in the
|
||||
/// Möller–Trumbore ray/triangle test at `0x7c2c40`. benilla's camera probe is a
|
||||
/// [`CAMERA_PROBE_RADIUS`] = `0.3` yd sphere — **our own** construction, the margin that keeps
|
||||
/// the near plane out of a wall. `0.3 > 0.2222`, so centred on the corridor floor it hangs 78
|
||||
/// mm through the water plane *before the sweep has moved at all*.
|
||||
///
|
||||
/// A surface swimmer's boom runs from that origin to a seat at the same height — the corridor
|
||||
/// pins both to `surface + 2/9` — so a **level** camera sweeps exactly parallel to the water,
|
||||
/// and came back pinned at zero: the camera slammed onto the character. Tip it two degrees
|
||||
/// down and the sphere escapes the plane and the hit vanishes. That flip, against an arm that
|
||||
/// snaps in instantly and eases back out over about a second, is the "snaps in for a second
|
||||
/// every couple of seconds" a surface swim was reported with — and it is the same mechanism
|
||||
/// 2170 was reported for and 2179 could not reach, because the corridor it built is measured
|
||||
/// in a clearance this probe was always too fat for.
|
||||
#[test]
|
||||
fn a_level_boom_behind_a_surface_swimmer_is_not_pinned_to_the_water() {
|
||||
let mut app = flooded_world();
|
||||
let feet_y = -SURFACE_DEPTH;
|
||||
let geo = corrected(feet_y);
|
||||
|
||||
// The control, which must reproduce the defect: the pre-2185 query, level.
|
||||
let control = open_arm_swept(&mut app, feet_y, geo, 0.0);
|
||||
assert!(
|
||||
control < 0.01,
|
||||
"the positive control must reproduce the regression — a level boom swept as a sphere \
|
||||
starts inside the plane and comes back pinned at zero, got {control}"
|
||||
);
|
||||
|
||||
// …and the shipping query, across the band a surface swimmer actually looks through.
|
||||
for deg in [-25.0f32, -10.0, -2.0, -0.5, 0.0, 0.25, 0.5] {
|
||||
let open = open_arm(&mut app, feet_y, geo, deg.to_radians());
|
||||
assert!(
|
||||
open > ZOOM - 0.01,
|
||||
"at {deg} deg the arm came back clipped at {open} — the water is 2/9 yd below a \
|
||||
boom that never descends to it"
|
||||
);
|
||||
}
|
||||
|
||||
// …and the option still does its job when the camera really is aimed under the water:
|
||||
// 2/9 yd of clearance shed at sin(20 deg) crosses the plane 0.65 yd along the boom.
|
||||
let aimed = open_arm(&mut app, feet_y, geo, 20.0f32.to_radians());
|
||||
assert!(
|
||||
(aimed - 0.65).abs() < 0.05,
|
||||
"aimed 20 deg up the boom must still stop ON the surface, got {aimed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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));
|
||||
let on = descend_camera(&mut app, true);
|
||||
assert!(
|
||||
on.is_some_and(|d| (d - 2.9).abs() < 0.05),
|
||||
on.is_some_and(|d| (d - 3.0).abs() < 0.01),
|
||||
"with cameraWaterCollision the boom stops at the surface, got {on:?}"
|
||||
);
|
||||
assert!(
|
||||
on.is_some_and(|d| d > 2.95),
|
||||
"and it stops ON the plane, not a probe radius short of it — the water leg is a ray \
|
||||
(decision 2185), which is what makes the corridor's 2/9 yd of clearance a clearance"
|
||||
);
|
||||
assert_eq!(
|
||||
descend(&mut app, WorldCollision::camera_filter(false)),
|
||||
descend_camera(&mut app, 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()),
|
||||
descend_body(&mut app),
|
||||
None,
|
||||
"and the BODY passes through either way — a swimmer is not stopped by their own water"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user