the world redraws on resize, and terrain takes its DXT blocks
- the world froze on every resize or graphics change: the backdrop was resized by writing a fresh image through the existing handle, and a mutated `Image` is a whole new GPU texture while a material's bind group is captured once and never re-captured — so the camera drew into a texture the UI never sampled, and the last frame stretched over the window forever; a resized world target is now a NEW asset (publish, retire the old one, and the UI pass forgets materials for removed images so a window drag does not leak a 46 MB target per frame), and a resized capture now matches the native render bit for bit - terrain tile layer arrays take the DXT blocks when the tile's whole layer set allows it — one array has one format, so eligibility is the tile's, not a texture's — and the install's own census says narrow is enough: 87 % of Azeroth's tiles and 92 % of Kalimdor's pass, layer bytes 1.57 GB → 0.63 and 2.23 → 0.76; the decode-tax comment is corrected on the way past — decoding costs 8x DXT1's bytes and 4x DXT3/DXT5's, not 4x and 2x - Environment Detail is the reference's slider, not a dropdown — row 27 of 1.12's own slider table, with the readout keeping the stop names because a bare "1" means nothing to a player — and the shipped ground cover comes off High to Medium: the x3 clutter density had been matched to a configured install, and Medium is the nearest stop no sparser than what a fresh install draws on capable hardware - Display Mode is modern Classic's two-entry dropdown — Windowed (Fullscreen) / Windowed, a boolean at Blizzard's own source, where the three-mode list is pre-8.0.1 folklore — riding `gxWindow` so saved configs keep meaning, with the label-against-value polarity pinned by the row's first test; the Multisampling row comes off the Graphics page for now (the CVar, the device clamp and the bindings all stay): with alpha-to-coverage set nowhere, MSAA touches polygon silhouettes only, and render scale on the same page measures four times the visual change Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -609,7 +609,7 @@
|
||||
-- CENTER-80, child rows +15 indent in the small font). Era rows with no 1.12 CVar +
|
||||
-- benilla knob behind them (Enable Sound=Sound_EnableAllSound, Dialog, output device,
|
||||
-- channels…) do not ship — the 0950 honest-tree rule applied to settings.
|
||||
-- The one options string that is NOT the reference's (decision 1639). Every other
|
||||
-- The options strings that are NOT the reference's (1639, 1650). Every other
|
||||
-- tooltip on this window resolves an OPTION_TOOLTIP_* out of 1.12's GlobalStrings, and a
|
||||
-- benilla row with no counterpart there has so far simply gone mute (the nameplate pair).
|
||||
-- Render Scale cannot afford to: the director's own reaction to the dial was "not really
|
||||
@@ -622,6 +622,17 @@
|
||||
.. "Below 100% trades sharpness for frame rate; above 100% is supersampling, which "
|
||||
.. "smooths edges at the cost of frame rate. Applies when you click Apply."
|
||||
|
||||
-- Display Mode (1650). 1.12's own OPTION_TOOLTIP_WINDOWED_MODE cannot ride this row: it
|
||||
-- is written for the checkbox it belonged to ("Check to play in a non-fullscreen
|
||||
-- window..."), and its second half describes a desktop-gamma consequence of an exclusive
|
||||
-- mode-set we do not do. The row also has to answer the question a player arrives with —
|
||||
-- "where is Fullscreen?" — which no reference string was ever written to answer.
|
||||
BENILLA_TOOLTIP_DISPLAY_MODE =
|
||||
"Windowed (Fullscreen) fills the screen with a borderless window; Windowed runs in "
|
||||
.. "an ordinary one.\n\n"
|
||||
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
|
||||
.. "modern Classic offers these same two."
|
||||
|
||||
OPTIONS_PAGE_ROWS = {
|
||||
Controls = {
|
||||
"RowStickyTarget", "RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
|
||||
@@ -633,7 +644,7 @@
|
||||
},
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
"RowWindowedMode", "RowVerticalSync", "RowRenderScale", "RowMultisample",
|
||||
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
|
||||
},
|
||||
Nameplates = {
|
||||
"RowEnemyPlates", "RowFriendlyPlates",
|
||||
@@ -855,9 +866,15 @@
|
||||
-- refreshing guard: SetMinMaxValues re-clamps the held value and fires OnValueChanged,
|
||||
-- and that clamp is wiring, not a player gesture — without the guard every load would
|
||||
-- SetCVar the template's 0 clamped into the new range.
|
||||
function OptionsSliderRow_Setup(row, min, max, step, fmt)
|
||||
function OptionsSliderRow_Setup(row, min, max, step, fmt, stops)
|
||||
local slider = getglobal(row:GetName() .. "ControlSlider")
|
||||
row.fmt = fmt
|
||||
-- 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
|
||||
-- all — it labelled the two ENDS — but every slider row in this kit carries a readout,
|
||||
-- and "1" is the one thing that seat could say that means nothing to a player.
|
||||
row.stops = stops
|
||||
row.refreshing = 1
|
||||
slider:SetMinMaxValues(min, max)
|
||||
slider:SetValueStep(step)
|
||||
@@ -958,6 +975,15 @@
|
||||
if row.fmt == "raw" then
|
||||
return string.format("%d", math.floor(value + 0.5))
|
||||
end
|
||||
-- A named ladder reads out its stop's name (see OptionsSliderRow_Setup). The index is
|
||||
-- clamped, not wrapped: the slider itself has already clamped a config value from
|
||||
-- outside the range, so this only ever guards a stops table shorter than the grid.
|
||||
if row.stops then
|
||||
local i = math.floor(value + 0.5) + 1
|
||||
if i < 1 then i = 1 end
|
||||
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 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".
|
||||
@@ -2235,10 +2261,11 @@
|
||||
retired by the director in 0961 and back by their call in
|
||||
1513, now that the one number moves the far-clip wall AND how
|
||||
much terrain is resident, as it does in the reference.
|
||||
Environment Detail (0992) is the same table's WorldDetail row
|
||||
Environment Detail is the same table's WorldDetail row
|
||||
(0..2 step 1, label ENVIRONMENT_DETAIL) over the clutter-density
|
||||
knob, worn as the dropdown row — the shape call is the
|
||||
template's comment. -->
|
||||
knob. 0992 wore it as a dropdown; 1649 puts it back on the
|
||||
SLIDER the reference actually uses, keeping 0992's stop names
|
||||
in the readout seat. -->
|
||||
<Frame name="$parentGraphics" hidden="true">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
@@ -2272,7 +2299,7 @@
|
||||
OptionsSliderRow_Setup(self, 177, 777, 60, "raw")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWorldDetail" inherits="OptionsDropdownRowTemplate">
|
||||
<Frame name="$parentRowWorldDetail" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFarclip" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFarclip" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
@@ -2283,42 +2310,54 @@
|
||||
-- The stop names are the era preset ladder's (its
|
||||
-- quality dropdowns say Low/Medium/High); 1.12's
|
||||
-- slider had no stop labels to quote.
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = "Low", value = "0" },
|
||||
{ text = "Medium", value = "1" },
|
||||
{ text = "High", value = "2" },
|
||||
})
|
||||
OptionsSliderRow_Setup(self, 0, 2, 1, nil,
|
||||
{ "Low", "Medium", "High" })
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWindowedMode" inherits="OptionsCheckboxRowTemplate">
|
||||
<Frame name="$parentRowDisplayMode" inherits="OptionsDropdownRowTemplate">
|
||||
<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>
|
||||
-- Windowed Mode: 1.12's own Video Options checkbox
|
||||
-- over gxWindow, label and tooltip key both the
|
||||
-- reference's GlobalStrings (WINDOWED_MODE /
|
||||
-- OPTION_TOOLTIP_WINDOWED_MODE).
|
||||
-- Display Mode (1650): MODERN CLASSIC's row, not
|
||||
-- 1.12's, because 1627 already gave us modern
|
||||
-- Classic's two states and a checkbox could only
|
||||
-- name one of them.
|
||||
--
|
||||
-- The row is the reference's; UNCHECKED is not.
|
||||
-- There it mode-sets the display at gxResolution;
|
||||
-- here it raises a BORDERLESS fullscreen window,
|
||||
-- and no exclusive mode exists to reach (decision
|
||||
-- 1627 -- Wayland cannot do one at all, and WoW
|
||||
-- itself dropped exclusive fullscreen in 8.0.1).
|
||||
-- Blizzard_SettingsDefinitions_Shared/Graphics.lua
|
||||
-- (live, classic AND classic_era, all identical):
|
||||
-- a BOOLEAN proxy rendered as a dropdown, with
|
||||
-- exactly two entries -- container:Add(true,
|
||||
-- VIDEO_OPTIONS_WINDOWED_FULLSCREEN) and
|
||||
-- container:Add(false, VIDEO_OPTIONS_WINDOWED).
|
||||
-- There is no Fullscreen entry anywhere in it;
|
||||
-- 8.0.1 removed the exclusive mode those clients
|
||||
-- had, which is the same reason 1627 ships none.
|
||||
--
|
||||
-- It rides gxWindow, OUR persisted CVar, not the
|
||||
-- gxMaximize the modern client uses -- that client
|
||||
-- deleted gxWindow entirely and had to move; we
|
||||
-- did not, and repointing would orphan every
|
||||
-- config.toml already holding the row for nothing.
|
||||
-- Polarity is gxWindow's own: "0" is not-windowed,
|
||||
-- which 1627 redefined as the borderless window.
|
||||
--
|
||||
-- Not deferred, for the same reason as Vertical
|
||||
-- Sync below: the reference row carries
|
||||
-- gxRestart = 1 and ours applies on the click.
|
||||
OptionsRow_OnLoad(self, "gxWindow", "Windowed Mode",
|
||||
"OPTION_TOOLTIP_WINDOWED_MODE")
|
||||
OptionsRow_OnLoad(self, "gxWindow", "Display Mode",
|
||||
"BENILLA_TOOLTIP_DISPLAY_MODE")
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = "Windowed (Fullscreen)", value = "0" },
|
||||
{ text = "Windowed", value = "1" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowVerticalSync" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWindowedMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWindowedMode" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowDisplayMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowDisplayMode" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Vertical Sync: 1.12's own Video Options checkbox
|
||||
@@ -2340,12 +2379,15 @@
|
||||
-- Render Scale (decision 1639) -- benilla's own
|
||||
-- row, no 1.12 counterpart: the reference renders
|
||||
-- straight to the backbuffer and has no second
|
||||
-- image to size. Seated directly above
|
||||
-- Multisampling because the two are the page's
|
||||
-- antialiasing pair and a player weighing one
|
||||
-- against the other should see them together --
|
||||
-- and because above 100% this row IS antialiasing,
|
||||
-- on a client whose gxMultisample ships off (1629).
|
||||
-- image to size. It CLOSES the page, and it is
|
||||
-- the only antialiasing control on it: the
|
||||
-- reference's own Multisampling row was pulled
|
||||
-- 2026-08-27 (decision 1648, the director's call).
|
||||
-- gxMultisample survives as a CVar and $WOW_MSAA
|
||||
-- -- what went is the row, because multisampling
|
||||
-- cannot touch the alpha-tested cutouts that carry
|
||||
-- most of this game's visible aliasing, and above
|
||||
-- 100% this row can.
|
||||
--
|
||||
-- 50-200%, narrower than the CVar's own clamp
|
||||
-- (RENDER_SCALE_RANGE, 0.25-4.0). That clamp stops
|
||||
@@ -2367,82 +2409,6 @@
|
||||
self.deferred = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowMultisample" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Multisampling: 1.12's own Video Options dropdown
|
||||
-- (OptionsFrameMultiSampleDropDown, id 4), over
|
||||
-- gxMultisample. Label and tooltip key are the
|
||||
-- reference's GlobalStrings (MULTISAMPLE =
|
||||
-- "Multisampling" / OPTION_TOOLTIP_MULTISAMPLING).
|
||||
--
|
||||
-- The STOPS ARE THE DEVICE'S, not a fixed ladder:
|
||||
-- built here from GetMultisampleFormats(), which
|
||||
-- is what this GPU actually accepts (decision
|
||||
-- 1631's enumeration, the same one that clamps a
|
||||
-- typed gxMultisample). That is the reference's
|
||||
-- design too -- its list comes from a D3D
|
||||
-- CheckDeviceMultiSampleType sweep or a GL
|
||||
-- wglGetPixelFormatAttribivARB sweep -- and it is
|
||||
-- why the row cannot offer a mode that would kill
|
||||
-- the client. An Apple GPU shows three stops
|
||||
-- (1x/2x/4x); a machine with 8x shows four.
|
||||
--
|
||||
-- NOT marked deferred. That flag stages a value
|
||||
-- until Apply (uiScale's mid-drag rescale is what
|
||||
-- it exists for); this row writes live like every
|
||||
-- other. What it cannot do is take effect live:
|
||||
-- gxMultisample is LATCHED (decision 1629 -- the
|
||||
-- camera reads its sample count once, at spawn),
|
||||
-- exactly as the reference's own row is, whose
|
||||
-- callback echoes "set pending gxRestart". The
|
||||
-- CVar moves and persists on the click; the
|
||||
-- picture changes at the next launch.
|
||||
OptionsRow_OnLoad(self, "gxMultisample", "Multisampling",
|
||||
"OPTION_TOOLTIP_MULTISAMPLING")
|
||||
local stops = {}
|
||||
local fmt = { GetMultisampleFormats() }
|
||||
-- Three varargs per entry, the walk OptionsFrame.lua
|
||||
-- l.354 does (`for i=1, arg.n, 3`) -- and then we
|
||||
-- use ONE of the three.
|
||||
--
|
||||
-- 1.12 formats all three (MULTISAMPLING_FORMAT_
|
||||
-- STRING = "%d-bit color %d-bit depth %dx
|
||||
-- multisample") because in 2004 they VARIED: its
|
||||
-- D3D CheckDeviceMultiSampleType sweep really did
|
||||
-- enumerate 16-bit and 32-bit colour modes, so the
|
||||
-- colour and depth figures were part of the
|
||||
-- choice. Ours cannot vary -- every entry carries
|
||||
-- the one pair `view::dropdown_bit_depths` returns,
|
||||
-- the swapchain's format and Depth32Float, fixed
|
||||
-- in our code and not by luck of hardware. Quoting
|
||||
-- them on every row would reproduce the FORMAT of
|
||||
-- a fact we no longer have, and it overflowed the
|
||||
-- capsule doing it (the director's 08-27 shot).
|
||||
-- The full triple is on the record in 1632; the
|
||||
-- row shows what a player is actually choosing.
|
||||
--
|
||||
-- "Off", not "1x": 1 is the encoding of NO
|
||||
-- multisampling, not one sample of it -- VERIFIED
|
||||
-- at 0x59989b (D3D leaves MultiSampleType
|
||||
-- D3DMULTISAMPLE_NONE) and 0x59de35 (GL never
|
||||
-- emits the SAMPLE_BUFFERS/SAMPLES pair). Short
|
||||
-- values are this page's own grammar too: the
|
||||
-- label carries the noun and the capsule carries
|
||||
-- the value ("Environment Detail [ High ]").
|
||||
for i = 1, table.getn(fmt), 3 do
|
||||
local n = fmt[i + 2]
|
||||
table.insert(stops, {
|
||||
text = (n > 1) and (n .. "x") or "Off",
|
||||
value = tostring(n),
|
||||
})
|
||||
end
|
||||
OptionsDropdownRow_Setup(self, stops)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- The COMBAT page (decision 1134) — the first page whose rows
|
||||
|
||||
@@ -1024,19 +1024,20 @@ pub(super) fn seed_ui_fixture(
|
||||
warn!("capture: ui-color-picker seed failed: {e}");
|
||||
}
|
||||
}
|
||||
UiFixture::OptionsWorldDetail => {
|
||||
UiFixture::OptionsDropdownList => {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// The dropdown list open (0992), same posture as the page fixtures: real CVar set,
|
||||
// the live open-select-toggle path. The list's width settles from its OnUpdate a
|
||||
// frame later (the kit's WIDTH SETTLE law) — inside the capture's settle frames.
|
||||
// The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same
|
||||
// posture as the page fixtures: real CVar set, the live open-select-toggle path. The
|
||||
// list's width settles from its OnUpdate a frame later (the kit's WIDTH SETTLE law) —
|
||||
// inside the capture's settle frames.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowGraphics:Click(); \
|
||||
OptionsFrameContainerBodyGraphicsRowWorldDetailDropdownButton:Click()",
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
|
||||
OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()",
|
||||
) {
|
||||
warn!("capture: ui-options-worlddetail seed failed: {e}");
|
||||
warn!("capture: ui-options-dropdown seed failed: {e}");
|
||||
}
|
||||
}
|
||||
UiFixture::KeyBindings => {
|
||||
|
||||
@@ -128,9 +128,9 @@ pub(super) enum UiFixture {
|
||||
/// Rows read the CVar registration defaults (hermetic capture = no config file), so the
|
||||
/// pixels move only with the window, the atlas seam, or a registered default.
|
||||
OptionsAudio,
|
||||
/// The Options window ON THE GRAPHICS PAGE (decision 0959; farclip row retired 0961;
|
||||
/// Environment Detail joined 0992) — uiScale at its 0.64..1.0 panel range with the percent
|
||||
/// readout, and the dropdown row's closed capsule (the 1.12 kit art) reading "High". Rows
|
||||
/// The Options window ON THE GRAPHICS PAGE (decision 0959; farclip row retired 0961 and back
|
||||
/// 1513; Environment Detail joined 0992, a slider since 1649) — uiScale at its 0.64..1.0 panel
|
||||
/// range with the percent readout, and three slider grooves under it. Rows
|
||||
/// read the CVar registration defaults (hermetic capture = no config file), so the pixels
|
||||
/// move only with the window, the atlas seam, or a registered default.
|
||||
OptionsGraphics,
|
||||
@@ -144,10 +144,12 @@ pub(super) enum UiFixture {
|
||||
/// known colour so the markers sit somewhere checkable rather than at the default white's
|
||||
/// centre. The one fixture whose subject is pixels this client computes.
|
||||
ColorPicker,
|
||||
/// The Graphics page with the Environment Detail MENU OPEN (0992) — the dropdown-list look
|
||||
/// The Controls page with the Camera Following Style MENU OPEN — the dropdown-list look
|
||||
/// instrument: the shared DropDownList1 at the window's effective scale (the kit's uiScale
|
||||
/// correction), three entries with High checked, the kit's dialog backdrop.
|
||||
OptionsWorldDetail,
|
||||
/// correction), three entries with the stored one checked, the kit's dialog backdrop. It was
|
||||
/// Environment Detail's list (0992) until 1649 put that row back on the reference's slider;
|
||||
/// the instrument follows the shape, not the row, so it moved to a row that still has a list.
|
||||
OptionsDropdownList,
|
||||
/// The Options window MID-SEARCH (decision 0984) — the results-view look instrument: the
|
||||
/// "volume" query reflows the four live volume sliders under the clickable Audio head
|
||||
/// (GameFontNormalLarge), title "Search Results", Defaults hidden, the clear-X shown in
|
||||
@@ -1077,15 +1079,15 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::ColorPicker),
|
||||
},
|
||||
// The Graphics page with the Environment Detail dropdown OPEN (0992). Run with
|
||||
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-worlddetail`.
|
||||
// The Controls page with the Camera Following Style dropdown OPEN (0992, re-seated 1649). Run
|
||||
// with `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-dropdown`.
|
||||
Scenario {
|
||||
name: "ui-options-worlddetail",
|
||||
name: "ui-options-dropdown",
|
||||
map: MAP_AZEROTH,
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::OptionsWorldDetail),
|
||||
ui: Some(UiFixture::OptionsDropdownList),
|
||||
},
|
||||
// The options window's Keybindings page, Movement expanded (1008). Run with
|
||||
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
|
||||
|
||||
@@ -114,8 +114,18 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
(crate::vplates::CVAR_FRIENDS, "0"),
|
||||
// World detail (0992): 1.12's video-panel var (the ENVIRONMENT_DETAIL slider, 0..2) over
|
||||
// the clutter-density knob — 0 is the client's bare frillDensity baseline (×1 = 16 visits),
|
||||
// each step +1×; the "2" default IS ClutterConfig's shipped ×3 (the reference's High).
|
||||
("WorldDetail", "2"),
|
||||
// each step +1×, so 0/1/2 are the 16/32/48 `SetWorldDetail` itself writes.
|
||||
//
|
||||
// **"1", not "2" (1649).** This shipped at High because that is the panel's top stop, not
|
||||
// because the reference runs there. It does not: `frillDensity` registers at **16**, and on a
|
||||
// first launch `hwDetect` overwrites it from `VideoHardware.dbc` — **24** on any D3D9-class
|
||||
// part (fallback row 170) and **8** on the weakest (row 168). Both sit BELOW this panel's
|
||||
// Medium, and the fresh-install 24 is not on a stop at all: the reference's own slider cannot
|
||||
// express what its hardware detection chose. So every stop we could pick is a divergence, and
|
||||
// High was the most expensive one available — 3x the registered default and 2x what a fresh
|
||||
// install actually draws. Medium is the nearest stop that is still no sparser than the
|
||||
// reference's own fresh install, which is the side to err on for a knob about ground cover.
|
||||
("WorldDetail", "1"),
|
||||
// Mouse Sensitivity (1140): 1.12's own `mousespeed` slider (UIOptionsFrameSliders, 0.5..1.5
|
||||
// step 0.05), a MULTIPLIER over the camera's own per-pixel rate — which was a frozen constant
|
||||
// until this row. Default "1" is the shipped feel exactly, welded to LookConfig::default().
|
||||
@@ -209,10 +219,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
|
||||
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
|
||||
("gxVSync", "1"),
|
||||
// **Display mode** (decision 1627) — 1.12's own `gxWindow`, the Video Options *Windowed Mode*
|
||||
// checkbox (`WINDOWED_MODE` / `OPTION_TOOLTIP_WINDOWED_MODE` are both in the reference's
|
||||
// GlobalStrings; see `reference/1.12-globals.tsv`). The knob is
|
||||
// [`crate::video::VideoConfig::display`], which the window's `mode` follows.
|
||||
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
|
||||
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
|
||||
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
|
||||
// `VIDEO_OPTIONS_WINDOWED_FULLSCREEN` and `VIDEO_OPTIONS_WINDOWED`, and nothing else), and a
|
||||
// checkbox could only name one of them. The knob is [`crate::video::VideoConfig::display`],
|
||||
// which the window's `mode` follows.
|
||||
//
|
||||
// Default **"0" = not windowed**, which is the reference's own default and every shipped
|
||||
// game's — but "0" does NOT mean what it means in 1.12. The reference mode-sets the display;
|
||||
@@ -1207,8 +1219,10 @@ mod tests {
|
||||
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
|
||||
assert!(plates.enemies && !plates.friends, "the shipped boot pair");
|
||||
// ClutterConfig::default() reads $WOW_CLUTTER_DENSITY; the registered default mirrors
|
||||
// the env-less ×3 literal (clutter.rs: "Default ×3 = High") on the panel's 0..2 scale.
|
||||
assert_eq!(d["WorldDetail"], 2.0);
|
||||
// the env-less ×2 literal (clutter.rs: "Default ×2 = Medium", 1649) on the panel's 0..2
|
||||
// scale. The weld is the point: the CVar's default and the engine's must be the same
|
||||
// ground cover, or a fresh config writes a row the world does not agree with.
|
||||
assert_eq!(d["WorldDetail"], 1.0);
|
||||
// The bubble pair (1139) welds to BubbleConfig's defaults — including the "1" that
|
||||
// deliberately disagrees with the binary's registered `ChatBubblesParty` "0" (0598).
|
||||
let bubbles = BubbleConfig::default();
|
||||
|
||||
@@ -929,6 +929,14 @@ fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) {
|
||||
/// texture-identity runs, and write the runs into the pooled batches ([`BatchPools`]). See the
|
||||
/// module doc for the full approach (painter's order within/across runs) and its batching
|
||||
/// consequence.
|
||||
/// The two asset stores [`rebuild_ui_mesh`] writes, plus the image-removal stream its material
|
||||
/// cache has to hear — one bundle, because the rebuild is already at clippy's argument ceiling.
|
||||
type RebuildStores<'w, 's> = (
|
||||
ResMut<'w, Assets<Mesh>>,
|
||||
ResMut<'w, Assets<UiQuadMaterial>>,
|
||||
MessageReader<'w, 's, AssetEvent<Image>>,
|
||||
);
|
||||
|
||||
fn ui_mesh_frozen() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_FREEZE_UI_MESH").is_some())
|
||||
@@ -980,8 +988,7 @@ pub(crate) struct UiMeshCost {
|
||||
fn rebuild_ui_mesh(
|
||||
mut quads: ResMut<UiQuads>,
|
||||
mut commands: Commands,
|
||||
// The two asset stores the rebuild writes, bundled (clippy's argument ceiling).
|
||||
mut stores: (ResMut<Assets<Mesh>>, ResMut<Assets<UiQuadMaterial>>),
|
||||
mut stores: RebuildStores,
|
||||
mut pools: Local<BatchPools>,
|
||||
white: Option<Res<UiWhiteTexture>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
@@ -993,6 +1000,29 @@ fn rebuild_ui_mesh(
|
||||
Res<crate::ui_script::UiCostWanted>,
|
||||
),
|
||||
) {
|
||||
// **Forget the materials of images that no longer exist** — first, before any early return,
|
||||
// because a missed read is a leak rather than a stale frame.
|
||||
//
|
||||
// A cached material holds a strong `Handle<Image>`, and its PREPARED form holds the whole GPU
|
||||
// texture behind a bind group Bevy never re-prepares. So an entry keyed on a retired asset
|
||||
// pins that texture for as long as the cache holds the key — and the world backdrop retires a
|
||||
// full-window `Rgba16Float` image (46 MB at 3200×1800) on every resize and every render-scale
|
||||
// change, which is once a frame while a window is being dragged (decision 1647).
|
||||
//
|
||||
// `Removed` only, not `Unused`: `Unused` fires when the last strong handle drops, and this
|
||||
// cache IS a strong handle, so for a cached texture it can never fire. The producer removes
|
||||
// the asset explicitly (`world_backdrop::track_render_size`) precisely so this can hear it.
|
||||
let retired: Vec<AssetId<Image>> = stores
|
||||
.2
|
||||
.read()
|
||||
.filter_map(|e| match e {
|
||||
AssetEvent::Removed { id } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !retired.is_empty() {
|
||||
pools.materials.retain(|key, _| !retired.contains(&key.0));
|
||||
}
|
||||
let (hidden, mesh_cost, cost_wanted) =
|
||||
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
|
||||
// The meter is off unless something asked (the hover recorder, `WOW_UI_COST=1`) — an unmetered
|
||||
@@ -1525,6 +1555,9 @@ mod tests {
|
||||
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
|
||||
.init_asset::<Mesh>()
|
||||
.init_asset::<UiQuadMaterial>()
|
||||
// The pass binds images and listens for their removal (the material cache sweep), so
|
||||
// an app without the Image asset is an under-provisioned harness, not a lighter one.
|
||||
.init_asset::<Image>()
|
||||
.init_resource::<UiQuads>()
|
||||
.init_resource::<UiMeshCost>()
|
||||
.init_resource::<crate::ui_script::UiCostWanted>()
|
||||
@@ -1604,6 +1637,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A removed image takes its cached material with it** (decision 1647).
|
||||
///
|
||||
/// The cache holds a strong `Handle<Image>` per entry and its prepared form holds the whole GPU
|
||||
/// texture behind a bind group Bevy never re-prepares — so an entry keyed on an asset that no
|
||||
/// longer exists pins that texture indefinitely. Nothing here notices until something retires
|
||||
/// images in bulk, and the world backdrop does exactly that: one full-window `Rgba16Float`
|
||||
/// image per resize, which is once a frame while a window is being dragged.
|
||||
///
|
||||
/// The removal is seen a few frames late by construction (the loop below names the three
|
||||
/// lags). That is fine for memory hygiene and would NOT be fine for correctness — which is
|
||||
/// exactly why the correctness half of 1647 is a new `AssetId` rather than an invalidation.
|
||||
#[test]
|
||||
fn a_removed_image_does_not_keep_its_material_alive() {
|
||||
let mut app = rebuild_app();
|
||||
let art = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Image>>()
|
||||
.add(Image::new_fill(
|
||||
Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
&[255; 4],
|
||||
TextureFormat::Rgba8Unorm,
|
||||
RenderAssetUsages::default(),
|
||||
));
|
||||
let art_id = art.id();
|
||||
{
|
||||
let mut q = app.world_mut().resource_mut::<UiQuads>();
|
||||
q.quads.push(UiQuad {
|
||||
rect: Rect::new(0.0, 0.0, 32.0, 32.0),
|
||||
texture: Some(art.clone()),
|
||||
..default()
|
||||
});
|
||||
q.dirty = true;
|
||||
}
|
||||
app.update();
|
||||
assert!(
|
||||
names_image(&app, art_id),
|
||||
"the textured quad must have produced a material naming that image"
|
||||
);
|
||||
|
||||
// Retire it exactly the way the world backdrop retires a resized target: drop every quad
|
||||
// that names it, then remove the asset. Only the cache is left holding it.
|
||||
drop(art);
|
||||
{
|
||||
let mut q = app.world_mut().resource_mut::<UiQuads>();
|
||||
q.quads.retain(|quad| quad.texture.is_none());
|
||||
q.dirty = true;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<Assets<Image>>()
|
||||
.remove(art_id);
|
||||
// Three separate one-frame lags stand between the removal and an observably dead material:
|
||||
// `Assets::asset_events` writes the `Removed` message in `PostUpdate` while the rebuild
|
||||
// reads it in `Update`; the retired batch entity's own handle drops at that frame's command
|
||||
// flush; and a dropped handle is only reclaimed by `track_assets` in the NEXT `PreUpdate`.
|
||||
// None of that matters for the freeze — the correctness half is a new AssetId, not an
|
||||
// invalidation — so the loop simply spends the frames rather than pretending to be exact.
|
||||
for _ in 0..4 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
assert!(
|
||||
!names_image(&app, art_id),
|
||||
"a material for a removed image is dead weight: it pins the image AND the GPU \
|
||||
texture behind its prepared bind group for as long as the cache holds the key"
|
||||
);
|
||||
}
|
||||
|
||||
/// Does any live `UiQuadMaterial` still name this image?
|
||||
fn names_image(app: &App, id: AssetId<Image>) -> bool {
|
||||
app.world()
|
||||
.resource::<Assets<UiQuadMaterial>>()
|
||||
.iter()
|
||||
.any(|(_, m)| m.texture.as_ref().is_some_and(|t| t.id() == id))
|
||||
}
|
||||
|
||||
/// **TOGGLEUI hides the UI, not the world.** Since the world arrives as this pass's own
|
||||
/// backdrop quad ([`crate::world_backdrop`]), the dark path can no longer mean "retire every
|
||||
/// batch" — that would black the screen, which is the exact inverse of what the binding is
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! select/hover wash mechanism, the working era search; 0985: the provenance split those
|
||||
//! cite; 0989: the directed cuts — steppers and the corner X gone, the whole bar live via
|
||||
//! the engine's track-press law, the search box at the era's verbatim seat; 0992: the
|
||||
//! dropdown row shape on the 1.12 kit — Environment Detail — and the Nameplates page's
|
||||
//! dropdown row shape on the 1.12 kit — Camera Following Style — and the Nameplates page's
|
||||
//! three UnitName* rows; 1476: the ground dim — a black fill over the 0.6 tile, seated clear
|
||||
//! of the rope's ink, because a 60% veil is not a page you can read over bright terrain).
|
||||
//!
|
||||
@@ -1015,6 +1015,67 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The **Display Mode** row (1650) — modern Classic's own control, which 1627 had already given us
|
||||
/// the two states for. Blizzard's `Blizzard_SettingsDefinitions_Shared/Graphics.lua` (identical on
|
||||
/// live, classic and classic_era) builds a boolean proxy as a dropdown with exactly two entries,
|
||||
/// `VIDEO_OPTIONS_WINDOWED_FULLSCREEN` and `VIDEO_OPTIONS_WINDOWED`, and no Fullscreen entry at all.
|
||||
///
|
||||
/// **The polarity is the thing worth pinning.** `gxWindow` is 1.12's CVar and keeps 1.12's sense —
|
||||
/// `"1"` is WINDOWED — so the FIRST entry, the borderless one this client defaults to, is the CVar's
|
||||
/// `"0"`. A row whose label and value run opposite ways is exactly the kind that gets silently
|
||||
/// inverted by a later edit, and until this test there was no coverage of the row at all.
|
||||
#[test]
|
||||
fn the_display_mode_dropdown_maps_its_entries_to_the_gx_window_polarity() {
|
||||
const ROW: &str = "OptionsFrameContainerBodyGraphicsRowDisplayMode";
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("gxWindow", "0");
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowGraphics:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
|
||||
.unwrap(),
|
||||
"Display Mode"
|
||||
);
|
||||
// "0" is NOT windowed, which 1627 redefined as the borderless fullscreen window.
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
|
||||
.unwrap(),
|
||||
"Windowed (Fullscreen)"
|
||||
);
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"reading the table on select must not write it back"
|
||||
);
|
||||
|
||||
// Two entries, and only two — the modern list has no Fullscreen row, because 8.0.1 removed
|
||||
// the exclusive mode those clients had and 1627 ships none for its own platform reasons.
|
||||
s.run(&format!("{ROW}DropdownButton:Click()")).unwrap();
|
||||
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
|
||||
2.0
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// Picking Windowed writes the CVar's "1", closes the list, and repaints the capsule.
|
||||
s.run("DropDownList1Button2:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("gxWindow".to_string(), "1".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
|
||||
.unwrap(),
|
||||
"Windowed"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Vertical Sync** (1394) — the Graphics page's third row, and the first option in this window
|
||||
/// that reaches the *window* rather than a gameplay or UI knob. It is 1.12's own Video Options
|
||||
/// checkbox 5 (`gxVSync`), which lived on the perf HUD as a dev checkbox until it turned out to be
|
||||
@@ -1065,13 +1126,13 @@ fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The dropdown row (0992, the first): the capsule reads the CVar on select (text from the
|
||||
/// row's own entries), the capsule button opens the shared list at the OWNER's effective scale
|
||||
/// (the kit's 0992 uiScale correction — the window rides SetScale) with the current value's
|
||||
/// entry checked, and an entry click writes the CVar, repaints the capsule, and closes the
|
||||
/// list on the kit's own law.
|
||||
/// Environment Detail is the reference's own slider (1649): 0..2 step 1 over `WorldDetail`, with
|
||||
/// 0992's Low/Medium/High names kept in the readout seat — a groove whose readout says "1" tells a
|
||||
/// player nothing. Dragging writes the CVar; a value from outside the range shows the nearest stop
|
||||
/// and writes nothing back (0959's out-of-range law).
|
||||
#[test]
|
||||
fn the_world_detail_dropdown_writes_the_cvar_and_the_capsule_follows() {
|
||||
fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
|
||||
const ROW: &str = "OptionsFrameContainerBodyGraphicsRowWorldDetail";
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("WorldDetail", "0");
|
||||
let mut s = harness_on(s);
|
||||
@@ -1079,68 +1140,57 @@ fn the_world_detail_dropdown_writes_the_cvar_and_the_capsule_follows() {
|
||||
s.run("OptionsFrameCategoryListRowGraphics:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowWorldDetailDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Low"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowWorldDetailLabel:GetText()")
|
||||
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
|
||||
.unwrap(),
|
||||
"Environment Detail"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"Low"
|
||||
);
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"reading the table on select must not write it back"
|
||||
);
|
||||
|
||||
// The capsule button opens the list: three entries, the stored value's row checked, and
|
||||
// the shared list wearing the window's effective scale (0.78 era scale, checkFit capped).
|
||||
s.run("OptionsFrameContainerBodyGraphicsRowWorldDetailDropdownButton:Click()")
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
|
||||
3.0
|
||||
);
|
||||
// The reference's grid, verbatim: three stops, one apart (OptionsFrame.lua l.27).
|
||||
assert!(s
|
||||
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(!s
|
||||
.eval::<bool>("return DropDownList1Button3Check:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return math.abs(DropDownList1:GetScale() - OptionsFrame:GetScale()) < 0.0001"
|
||||
)
|
||||
.eval::<bool>(&format!(
|
||||
"local lo, hi = {ROW}ControlSlider:GetMinMaxValues() \
|
||||
return lo == 0 and hi == 2 and {ROW}ControlSlider:GetValueStep() == 1"
|
||||
))
|
||||
.unwrap());
|
||||
|
||||
// Clicking High: the write queues, the capsule repaints, the list closes.
|
||||
s.run("DropDownList1Button3:Click()").unwrap();
|
||||
// A drag to the top stop: the write queues and the readout names it.
|
||||
s.run(&format!("{ROW}ControlSlider:SetValue(2)")).unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("WorldDetail".to_string(), "2".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowWorldDetailDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"High"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
|
||||
|
||||
// An off-grid value (an env A/B: the hermetic capture's clutter-off session seeds "-1")
|
||||
// Off-grid input snaps to the stop grid (era obeyStepOnDrag), naming the stop it landed on.
|
||||
s.run(&format!("{ROW}ControlSlider:SetValue(0.6)")).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"Medium"
|
||||
);
|
||||
|
||||
// An out-of-range value (an env A/B: the hermetic capture's clutter-off session seeds "-1")
|
||||
// displays the NEAREST stop — 0959's out-of-range law — and writes nothing back.
|
||||
s.set_cvar_host("WorldDetail", "-1");
|
||||
s.take_cvar_changes();
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click(); OptionsFrameCategoryListRowGraphics:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowWorldDetailDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"Low"
|
||||
);
|
||||
assert!(
|
||||
@@ -1150,92 +1200,6 @@ fn the_world_detail_dropdown_writes_the_cvar_and_the_capsule_follows() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Graphics page's **Multisampling** row (decision 1631): its stops are enumerated from the
|
||||
/// DEVICE, not a fixed ladder, and picking one writes the latched `gxMultisample`.
|
||||
///
|
||||
/// The device list is seeded Apple-shaped — 1x/2x/4x and no 8x — because that is what this
|
||||
/// project's own machine reports, and because a fixed ladder offering 8x there is precisely the
|
||||
/// crash 1631 exists to prevent. A machine with 8x would show four rows off the same code.
|
||||
#[test]
|
||||
fn the_multisampling_row_offers_exactly_what_the_device_supports() {
|
||||
// No explicit `register_cvars` here: `audio_harness` registers `cvars::REGISTERED` whole, and
|
||||
// the three multisample rows live in it. That is deliberate — if they are ever dropped from
|
||||
// the registry this test fails rather than papering over it with its own registration.
|
||||
let mut s = audio_harness();
|
||||
s.set_multisample_formats(vec![
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 1,
|
||||
},
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 2,
|
||||
},
|
||||
benilla_ui::script::MultisampleFormat {
|
||||
color_bits: 32,
|
||||
depth_bits: 32,
|
||||
samples: 4,
|
||||
},
|
||||
]);
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowMultisampleLabel:GetText()")
|
||||
.unwrap(),
|
||||
"Multisampling",
|
||||
"the reference's own MULTISAMPLE global"
|
||||
);
|
||||
// "1" is the registered default and means NO multisampling, so the first stop reads "Off"
|
||||
// rather than "1x" (1635). The label carries the noun; the capsule carries the value.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Off",
|
||||
"1 is the encoding of NO multisampling, not one sample of it"
|
||||
);
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"reading the table on select must not write it back"
|
||||
);
|
||||
|
||||
// Three stops, because the device offered three. This is the assertion that would fail if the
|
||||
// row ever went back to a hardcoded ladder.
|
||||
s.run("OptionsFrameContainerBodyGraphicsRowMultisampleDropdownButton:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
|
||||
3.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||||
.unwrap(),
|
||||
"4x"
|
||||
);
|
||||
|
||||
// Picking 4x writes the CVar — live and persisted, even though the picture waits for the next
|
||||
// launch (the 1629 latch; the camera takes its sample count at spawn).
|
||||
s.run("DropDownList1Button3:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("gxMultisample".to_string(), "4".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"4x"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Nameplates page (0992 — live at last): the three 1.12 UnitName* checkbox rows read the
|
||||
/// table on select and write their flags on the interface panel's own click kit (checked →
|
||||
/// CheckBoxOn; these rows carry no soundQuirk).
|
||||
@@ -1391,9 +1355,9 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Render Scale** (decision 1639) — benilla's own Graphics row, seated directly above
|
||||
/// Multisampling because above 100 % it *is* antialiasing, on a client whose `gxMultisample`
|
||||
/// ships off (1629).
|
||||
/// **Render Scale** (decision 1639) — benilla's own Graphics row, and since 1648 the page's ONLY
|
||||
/// antialiasing control: the reference's own Multisampling row was pulled on the director's call,
|
||||
/// leaving `gxMultisample` reachable as a CVar and `$WOW_MSAA` but off the page.
|
||||
///
|
||||
/// Three things this pins, each of which was a real choice:
|
||||
///
|
||||
@@ -2133,18 +2097,20 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
untipped.push(row.to_string());
|
||||
continue;
|
||||
}
|
||||
// The one deliberate exception (decision 1639). Render Scale has no 1.12 counterpart
|
||||
// and so no `OPTION_TOOLTIP_*` to resolve, but it is the row that most needs a
|
||||
// description — its first reviewer said outright they could not tell what it did. It
|
||||
// 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 by the carve-out.
|
||||
if key == "BENILLA_TOOLTIP_RENDER_SCALE" {
|
||||
assert_eq!(
|
||||
row, "GraphicsRowRenderScale",
|
||||
"{row}: not this row's string"
|
||||
);
|
||||
// The deliberate exceptions (1639 Render Scale, 1650 Display Mode). Neither has a 1.12
|
||||
// counterpart whose `OPTION_TOOLTIP_*` could be resolved — Render Scale has no era row at
|
||||
// all, and Display Mode's era row was a CHECKBOX whose string says "Check to…" — and both
|
||||
// are rows 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 `BENILLA_` key on the wrong row still fails.
|
||||
const BENILLA_OWNED: &[(&str, &str)] = &[
|
||||
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
|
||||
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
|
||||
];
|
||||
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
|
||||
assert_eq!(row, *want_row, "{row}: not this row's string");
|
||||
let text: String = s.eval(&format!("return {key}")).unwrap();
|
||||
assert!(!text.is_empty(), "{row}: {key} resolves to nothing");
|
||||
checked += 1;
|
||||
@@ -2167,8 +2133,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
}
|
||||
// 27 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
|
||||
// Information + Guild Member Alert are 1589's; Status Bar Text, Mouse Sensitivity
|
||||
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's, its Windowed Mode
|
||||
// 1627's and its Multisampling 1632's; Camera Following Style
|
||||
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's, its Display Mode
|
||||
// 1627's (a dropdown since 1650) and its Multisampling 1632's; Camera Following Style
|
||||
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
|
||||
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
|
||||
// lock 1136, Always Show
|
||||
@@ -2178,9 +2144,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
|
||||
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
|
||||
// their other entries ride the same census as the selection moves.
|
||||
// 55 of the 56 are 1.12's own; the 56th is Render Scale, whose description is benilla's
|
||||
// (1639) and whose carve-out is above.
|
||||
assert_eq!(checked, 56, "every tipped row carries a live key");
|
||||
// 53 of the 55 are 1.12's own; the other two are Render Scale (1639) and Display Mode
|
||||
// (1650), whose descriptions are benilla's and whose carve-out is above.
|
||||
assert_eq!(checked, 55, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2278,16 +2244,16 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
}
|
||||
// 27 of the 28 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
|
||||
// Loot Information + Guild Member Alert 1589's; Status Bar Text, Mouse Sensitivity and
|
||||
// Max Camera Distance 1140's; Vertical Sync 1394's, Windowed Mode 1627's and Multisampling
|
||||
// Max Camera Distance 1140's; Vertical Sync 1394's, Display Mode 1627's and Multisampling
|
||||
// 1632's; Camera Following
|
||||
// Style 1493's; Terrain Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
|
||||
// page's 6 (1136, + Buff Durations 1139, + the target-of-target pair 1576), Action Bars' 2
|
||||
// (the lock 1136, Always Show
|
||||
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
|
||||
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
|
||||
// …plus the Graphics page's Render Scale (1639), the one row whose description is
|
||||
// benilla's own rather than a 1.12 GlobalString — see the tooltip-key guard above.
|
||||
assert_eq!(raised, 56, "every row but Auto Loot raises a description");
|
||||
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650), the two rows whose
|
||||
// descriptions are benilla's own rather than 1.12 GlobalStrings — see the guard above.
|
||||
assert_eq!(raised, 55, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Player-facing **video settings** — the knobs that reach the window and the presentation path.
|
||||
//!
|
||||
//! Two, both of them 1.12's own CVars over the Graphics page: **Display Mode** (`gxWindow`, the
|
||||
//! *Windowed Mode* row) and **VSync** (`gxVSync`, the *Vertical Sync* row).
|
||||
//! *Display Mode* row) and **VSync** (`gxVSync`, the *Vertical Sync* row).
|
||||
//!
|
||||
//! # Display mode — the reference's CVar, deliberately not the reference's meaning (decision 1627)
|
||||
//!
|
||||
@@ -25,7 +25,13 @@
|
||||
//! And the industry moved: SDL3 deleted `SDL_WINDOW_FULLSCREEN_DESKTOP` and made borderless-desktop
|
||||
//! what a fullscreen window *is* unless you opt into a mode with `SDL_SetWindowFullscreenMode`;
|
||||
//! WoW itself removed exclusive fullscreen in **8.0.1**, leaving Windowed and Windowed
|
||||
//! (Fullscreen). Bevy's own `WindowMode::Fullscreen` arm is a liability besides — it `expect`s a
|
||||
//! (Fullscreen) — VERIFIED at the source, not from the patch notes: Blizzard's own
|
||||
//! `Blizzard_SettingsDefinitions_Shared/Graphics.lua` (live, `classic` and `classic_era`, all
|
||||
//! identical) registers Display Mode as a **boolean** proxy and builds its dropdown from exactly
|
||||
//! two entries, `VIDEO_OPTIONS_WINDOWED_FULLSCREEN` and `VIDEO_OPTIONS_WINDOWED`. **Our two states
|
||||
//! are modern Classic's two states**, which is why 1650 wears them as that client's dropdown
|
||||
//! rather than 1.12's checkbox. (Modern hangs the boolean on `gxMaximize`, having deleted
|
||||
//! `gxWindow` outright; we keep `gxWindow`, which is the CVar our configs already persist.) Bevy's own `WindowMode::Fullscreen` arm is a liability besides — it `expect`s a
|
||||
//! monitor at creation and `panic!`s on a live change that cannot resolve one
|
||||
//! (`bevy_winit::winit_windows:91`, `system.rs:333`).
|
||||
//!
|
||||
|
||||
@@ -195,10 +195,11 @@ pub(crate) struct WorldBackdrop {
|
||||
pub(crate) image: Handle<Image>,
|
||||
/// The size the image was last built at, in physical px — the resize gate.
|
||||
size: UVec2,
|
||||
/// The scale factor the camera's target was last stamped with — the re-stamp gate, and the
|
||||
/// number that keeps every logical↔physical conversion on the world camera honest. See
|
||||
/// [`retarget_world_camera`].
|
||||
scale_factor: f32,
|
||||
/// What the world camera's target was last stamped with: **which image**, and the scale factor
|
||||
/// that went with it. Both halves gate the re-stamp — the factor because a stale one is B169,
|
||||
/// the image because a rebuilt backdrop is a NEW asset (see [`track_render_size`]) and a camera
|
||||
/// left aiming at the retired one renders into nothing. `None` until the first stamp.
|
||||
stamped: Option<(AssetId<Image>, f32)>,
|
||||
}
|
||||
|
||||
impl WorldBackdrop {
|
||||
@@ -252,9 +253,9 @@ fn setup_backdrop(
|
||||
commands.insert_resource(WorldBackdrop {
|
||||
image,
|
||||
size,
|
||||
// Deliberately not the window's: `retarget_world_camera` stamps the real one and must see
|
||||
// a mismatch on its first run, before any camera exists to point at the image.
|
||||
scale_factor: f32::NAN,
|
||||
// Nothing stamped yet, so `retarget_world_camera`'s first run always fires — before any
|
||||
// camera exists to point at the image.
|
||||
stamped: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,6 +266,45 @@ fn setup_backdrop(
|
||||
///
|
||||
/// Runs before the stamp (the plugin's `.chain()`), so the factor is always computed against the
|
||||
/// image that now exists.
|
||||
///
|
||||
/// ## The rebuild publishes a NEW asset — it does not write through the old handle (decision 1647)
|
||||
///
|
||||
/// It used to do exactly that (`*images.get_mut(&handle) = new_backdrop_image(size)`), and **the
|
||||
/// world froze**: change any graphics setting or resize the window while in the world and the 3D
|
||||
/// stopped dead at the frame of the change, stretched over the new window, while the interface
|
||||
/// carried on. That is the director's report of 2026-08-27, and it reproduces on the bench (see the
|
||||
/// decision record's measurement).
|
||||
///
|
||||
/// The mechanism is a **texture-identity lie**, and it is Bevy's prepared-bind-group model meeting
|
||||
/// our material cache:
|
||||
///
|
||||
/// - Mutating an `Image` behind its handle makes a whole new GPU texture — `GpuImage`'s
|
||||
/// `prepare_asset` (`bevy_render/texture/gpu_image.rs`) calls `create_texture` afresh on every
|
||||
/// `AssetEvent::Modified`.
|
||||
/// - A material's bind group, by contrast, is captured **once**: `PreparedMaterial2d::prepare_asset`
|
||||
/// (`bevy_sprite_render/mesh2d/material.rs`) calls `as_bind_group` when the *material* asset is
|
||||
/// added or modified, and nothing re-prepares it when a texture it named is re-created.
|
||||
/// - [`crate::ui_pass`] keys its material cache on `AssetId<Image>`. Same id ⇒ same material ⇒ the
|
||||
/// same bind group, still holding the `TextureView` of the texture we just threw away.
|
||||
///
|
||||
/// So the world camera drew into the new texture and the UI kept sampling the old one, forever.
|
||||
///
|
||||
/// A new handle fixes it at the identity rather than by invalidating a cache: **`AssetId<Image>` is
|
||||
/// the tree's name for a GPU texture, so a new GPU texture gets a new name.** Every consumer then
|
||||
/// reacts on its own — the cache misses and builds a fresh material, [`emit_backdrop_quad`]'s quad
|
||||
/// differs and flags a rebuild, and [`retarget_world_camera`] re-points the camera.
|
||||
///
|
||||
/// It is also the only version that is safe against prepare *order*. `Material2dPlugin` registers
|
||||
/// `RenderAssetPlugin::<PreparedMaterial2d<M>>::default()` — `AFTER = ()`, i.e. **not** ordered
|
||||
/// after `prepare_assets::<GpuImage>` — so the "touch the material so its bind group rebuilds"
|
||||
/// remedy (which `benilla_world::clouds` uses, and whose comment already named this whole hazard)
|
||||
/// can re-bind the *previous* `GpuImage` if the material happens to prepare first. A fresh
|
||||
/// `AssetId` cannot: it either finds its own `GpuImage` or `as_bind_group` returns
|
||||
/// `RetryNextUpdate` and Bevy retries next frame.
|
||||
///
|
||||
/// The retired asset is **removed**, not merely dropped, because the stale cache entry is still
|
||||
/// holding a strong handle to it; `ui_pass`'s rebuild forgets materials whose image was removed,
|
||||
/// which is what keeps a window drag from retiring tens of megabytes a frame into a live cache.
|
||||
fn track_render_size(
|
||||
mut backdrop: ResMut<WorldBackdrop>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
@@ -282,22 +322,20 @@ fn track_render_size(
|
||||
if size == backdrop.size {
|
||||
return;
|
||||
}
|
||||
let handle = backdrop.image.clone();
|
||||
if let Some(image) = images.get_mut(&handle) {
|
||||
*image = new_backdrop_image(size);
|
||||
backdrop.size = size;
|
||||
// Said out loud on every change, because a measurement taken at the wrong scale looks
|
||||
// exactly like a measurement: the pixels the GPU is actually being asked for are the one
|
||||
// term no probe line could infer from the window.
|
||||
info!(
|
||||
"render scale {:.3}: world renders at {}x{} into a {}x{} window",
|
||||
scale.0,
|
||||
size.x,
|
||||
size.y,
|
||||
window.physical_width(),
|
||||
window.physical_height()
|
||||
);
|
||||
}
|
||||
let retired = std::mem::replace(&mut backdrop.image, images.add(new_backdrop_image(size)));
|
||||
images.remove(&retired);
|
||||
backdrop.size = size;
|
||||
// Said out loud on every change, because a measurement taken at the wrong scale looks
|
||||
// exactly like a measurement: the pixels the GPU is actually being asked for are the one
|
||||
// term no probe line could infer from the window.
|
||||
info!(
|
||||
"render scale {:.3}: world renders at {}x{} into a {}x{} window",
|
||||
scale.0,
|
||||
size.x,
|
||||
size.y,
|
||||
window.physical_width(),
|
||||
window.physical_height()
|
||||
);
|
||||
}
|
||||
|
||||
/// Point the world camera at the backdrop instead of the swapchain — **carrying the window's own
|
||||
@@ -354,13 +392,15 @@ fn retarget_world_camera(
|
||||
// The image's real size, not the requested one: `track_render_size` ran first and may have
|
||||
// clamped, and the factor must describe the texture that exists.
|
||||
let scale_factor = window_factor * backdrop.size.x as f32 / px.x.max(1) as f32;
|
||||
// `!=` on the stored NAN seed is true, so the first run always stamps.
|
||||
#[allow(clippy::float_cmp)]
|
||||
let moved = scale_factor != backdrop.scale_factor;
|
||||
// **The image as well as the factor.** A rebuild retires the old asset and publishes a new one
|
||||
// ([`track_render_size`]), and on a pure window resize the factor does NOT move — so a
|
||||
// factor-only gate would leave every world camera aiming at an asset that no longer exists.
|
||||
let want = (backdrop.image.id(), scale_factor);
|
||||
let moved = backdrop.stamped != Some(want);
|
||||
if !moved && added.is_empty() {
|
||||
return;
|
||||
}
|
||||
backdrop.scale_factor = scale_factor;
|
||||
backdrop.stamped = Some(want);
|
||||
let bias = MipBias(mip_bias(scale_factor / window_factor));
|
||||
for (entity, mut current) in &mut cameras {
|
||||
*current = RenderTarget::Image(bevy::camera::ImageRenderTarget {
|
||||
@@ -614,6 +654,112 @@ mod tests {
|
||||
assert_eq!(mip_bias(4.0), 0.0);
|
||||
}
|
||||
|
||||
/// **A rebuild publishes a NEW image, and the camera follows it onto the new one** — the
|
||||
/// 2026-08-27 world-freeze (decision 1647).
|
||||
///
|
||||
/// Writing the new size through the OLD handle is what froze the world: `ui_pass` keys its
|
||||
/// material cache on `AssetId<Image>`, and Bevy prepares a material's bind group once, so the
|
||||
/// same id handed back the same bind group — still holding the `TextureView` of the texture
|
||||
/// that had just been thrown away. The world camera drew into the new texture; the UI kept
|
||||
/// sampling the dead one, forever.
|
||||
///
|
||||
/// Three assertions, because the fix has three halves and any one of them alone is still broken:
|
||||
/// the id must MOVE (or the cache cannot tell), the old asset must be GONE (or the retired
|
||||
/// image is pinned by whatever still names it), and the camera must be re-stamped onto the new
|
||||
/// handle — that last one is the half a factor-only gate misses, since a pure window resize
|
||||
/// does not move the scale factor at all.
|
||||
#[test]
|
||||
fn a_rebuilt_backdrop_is_a_new_asset_and_the_camera_follows_it() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
|
||||
.init_asset::<Image>();
|
||||
let mut w = Window::default();
|
||||
w.resolution.set_scale_factor(2.0);
|
||||
app.world_mut().spawn((w, PrimaryWindow));
|
||||
app.insert_resource(RenderScale(1.0))
|
||||
.add_systems(Startup, setup_backdrop)
|
||||
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
|
||||
let cam = app
|
||||
.world_mut()
|
||||
.spawn((WorldCamera, RenderTarget::default()))
|
||||
.id();
|
||||
app.update();
|
||||
let first = app.world().resource::<WorldBackdrop>().image.id();
|
||||
|
||||
// The director's own path: move the render scale while the world is up.
|
||||
app.insert_resource(RenderScale(0.5));
|
||||
app.update();
|
||||
|
||||
let second = app.world().resource::<WorldBackdrop>().image.id();
|
||||
assert_ne!(
|
||||
first, second,
|
||||
"a rebuilt backdrop is a NEW GPU texture, so it must be a new AssetId — \
|
||||
the same id hands `ui_pass` back a bind group pointing at the retired texture"
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<Assets<Image>>().contains(first),
|
||||
"the retired image must be removed, not merely dropped: a cached material still \
|
||||
holds a strong handle to it"
|
||||
);
|
||||
match app.world().entity(cam).get::<RenderTarget>() {
|
||||
Some(RenderTarget::Image(t)) => assert_eq!(
|
||||
t.handle.id(),
|
||||
second,
|
||||
"the camera must be re-stamped onto the new image"
|
||||
),
|
||||
_ => panic!("the world camera must target the backdrop image"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure WINDOW resize moves the image without moving the scale factor — the case a
|
||||
/// factor-only re-stamp gate silently misses, leaving the camera aimed at a removed asset.
|
||||
#[test]
|
||||
fn a_window_resize_restamps_the_camera_even_though_the_factor_does_not_move() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
|
||||
.init_asset::<Image>();
|
||||
let mut w = Window::default();
|
||||
w.resolution.set_scale_factor(2.0);
|
||||
let win = app.world_mut().spawn((w, PrimaryWindow)).id();
|
||||
app.insert_resource(RenderScale(1.0))
|
||||
.add_systems(Startup, setup_backdrop)
|
||||
.add_systems(Update, (track_render_size, retarget_world_camera).chain());
|
||||
let cam = app
|
||||
.world_mut()
|
||||
.spawn((WorldCamera, RenderTarget::default()))
|
||||
.id();
|
||||
app.update();
|
||||
let before = match app.world().entity(cam).get::<RenderTarget>() {
|
||||
Some(RenderTarget::Image(t)) => (t.handle.id(), t.scale_factor),
|
||||
_ => panic!("the world camera must target the backdrop image"),
|
||||
};
|
||||
|
||||
app.world_mut()
|
||||
.entity_mut(win)
|
||||
.get_mut::<Window>()
|
||||
.expect("the primary window")
|
||||
.resolution
|
||||
.set(640.0, 400.0);
|
||||
app.update();
|
||||
|
||||
let after = match app.world().entity(cam).get::<RenderTarget>() {
|
||||
Some(RenderTarget::Image(t)) => (t.handle.id(), t.scale_factor),
|
||||
_ => panic!("the world camera must target the backdrop image"),
|
||||
};
|
||||
assert_ne!(before.0, after.0, "the resize must re-point the camera");
|
||||
assert!(
|
||||
(before.1 - after.1).abs() < 1e-6,
|
||||
"at scale 1.0 the factor is the window's own and does not move on a resize — \
|
||||
which is exactly why the re-stamp cannot be gated on it alone: {} vs {}",
|
||||
before.1,
|
||||
after.1
|
||||
);
|
||||
assert!(
|
||||
app.world().resource::<Assets<Image>>().contains(after.0),
|
||||
"the camera must point at an image that still exists"
|
||||
);
|
||||
}
|
||||
|
||||
/// End to end on a live `App`: a half-scale world on a 2× display renders into a half-size
|
||||
/// image, stamps a factor of 1.0 — `image / factor` is still the window's logical size — and
|
||||
/// carries `MipBias(-1)`.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{
|
||||
adt_to_tile_mesh, blp_bytes_to_mip_chain, ChunkMesh, Doodad, WmoInstance, ALPHA_MAP_SIZE,
|
||||
adt_to_tile_mesh, blp_bytes_to_native_chain, ChunkMesh, Doodad, WmoInstance, ALPHA_MAP_SIZE,
|
||||
SHADOW_MAP_SIZE,
|
||||
};
|
||||
use bevy::asset::io::Reader;
|
||||
@@ -37,8 +37,7 @@ use bevy::reflect::TypePath;
|
||||
|
||||
use crate::coords::wow_to_bevy;
|
||||
use crate::terrain::{
|
||||
alpha_array_image, chain_to_layer, layer_array_image, shadow_array_image, solid_layer_chain,
|
||||
LayerTexture, LAYER_TEX_SIZE,
|
||||
alpha_array_image, layer_array_image, pack_layers, shadow_array_image, RawLayer, LAYER_TEX_SIZE,
|
||||
};
|
||||
|
||||
/// A loaded ADT terrain tile: the decoded chunks + per-chunk shading indices + the tile's three
|
||||
@@ -167,10 +166,10 @@ impl AssetLoader for AdtLoader {
|
||||
let tile = adt_to_tile_mesh(&bytes).map_err(to_io)?;
|
||||
|
||||
// Layer array: a solid-green fallback at index 0, then each unique referenced layer texture.
|
||||
let fallback = solid_layer_chain([107, 133, 82, 0]);
|
||||
let layer_mips = fallback.mip_count;
|
||||
let mut layer_buf = fallback.chain;
|
||||
let mut layer_count = 1u32;
|
||||
// The layers are collected in their **authored** form and packed only once the tile is fully
|
||||
// read — one `texture_2d_array` has one format, so whether this tile's DXT blocks go up
|
||||
// untouched is a question about the whole set (`terrain::pack_layers`, decision 1646).
|
||||
let mut layers: Vec<RawLayer> = Vec::new();
|
||||
let mut layer_index: HashMap<String, u32> = HashMap::new();
|
||||
|
||||
let mut alpha_buf: Vec<u8> = Vec::new();
|
||||
@@ -198,11 +197,12 @@ impl AssetLoader for AdtLoader {
|
||||
let key = normalize_path(name);
|
||||
li[slot] = if let Some(&i) = layer_index.get(&key) {
|
||||
i
|
||||
} else if let Some(layer) = decode_layer(ctx, &key).await {
|
||||
layer_buf.extend_from_slice(&layer.chain);
|
||||
layer_index.insert(key, layer_count);
|
||||
layer_count += 1;
|
||||
layer_count - 1
|
||||
} else if let Some(layer) = read_layer(ctx, &key).await {
|
||||
// +1: index 0 is the fallback layer, which is not in `layers`.
|
||||
let index = layers.len() as u32 + 1;
|
||||
layers.push(layer);
|
||||
layer_index.insert(key, index);
|
||||
index
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -244,7 +244,19 @@ impl AssetLoader for AdtLoader {
|
||||
shadow_count = 1;
|
||||
}
|
||||
|
||||
let layer_array = layer_array_image(LAYER_TEX_SIZE, layer_count, layer_mips, layer_buf);
|
||||
let layer_count = layers.len() as u32 + 1;
|
||||
let packed = pack_layers([107, 133, 82, 0], layers);
|
||||
// Which lane this tile took, and what it cost. Per-tile rather than aggregated because the
|
||||
// question after a perf report is always "which tiles fell back, and where am I standing" —
|
||||
// and one greppable format name per tile answers it from a player's log with no machinery
|
||||
// (`grep -c Bc2RgbaUnorm` against `grep -c Rgba8Unorm`) (1646).
|
||||
debug!(
|
||||
"adt {}: layer array {layer_count} x {:?}, {} KiB",
|
||||
ctx.path(),
|
||||
packed.format,
|
||||
packed.data.len() >> 10
|
||||
);
|
||||
let layer_array = layer_array_image(LAYER_TEX_SIZE, layer_count, packed);
|
||||
let alpha_array = alpha_array_image(ALPHA_MAP_SIZE, alpha_count, alpha_buf);
|
||||
let shadow_array = shadow_array_image(SHADOW_MAP_SIZE, shadow_count, shadow_buf);
|
||||
|
||||
@@ -264,27 +276,27 @@ impl AssetLoader for AdtLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a layer texture into the packed array form. Prefers the `_s` specular variant (sheen mask
|
||||
/// in alpha); else falls back to the base BLP with alpha forced to 0 (matte — no sheen). `key` is a
|
||||
/// Read a layer texture in its authored form. Prefers the `_s` specular variant (sheen mask in
|
||||
/// alpha); else falls back to the base BLP, flagged `matte` so no sheen rides it. `key` is a
|
||||
/// normalized internal path.
|
||||
async fn decode_layer(ctx: &mut LoadContext<'_>, key: &str) -> Option<LayerTexture> {
|
||||
///
|
||||
/// **Native, not decoded**: the blocks are kept here so [`pack_layers`] still has the choice. A
|
||||
/// Raw1/Raw3 BLP has no block form and comes back decoded anyway, which is why the caller never has
|
||||
/// to ask — it is [`benilla_formats::BlpMipChain::texels`] that says what arrived.
|
||||
async fn read_layer(ctx: &mut LoadContext<'_>, key: &str) -> Option<RawLayer> {
|
||||
if let Some(spec) = key.strip_suffix(".blp").map(|stem| format!("{stem}_s.blp")) {
|
||||
if let Ok(bytes) = ctx.read_asset_bytes(mpq_url(&spec)).await {
|
||||
if let Ok(chain) = blp_bytes_to_mip_chain(&bytes) {
|
||||
return Some(chain_to_layer(chain, LAYER_TEX_SIZE));
|
||||
if let Ok(chain) = blp_bytes_to_native_chain(&bytes) {
|
||||
return Some(RawLayer {
|
||||
chain,
|
||||
matte: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let bytes = ctx.read_asset_bytes(mpq_url(key)).await.ok()?;
|
||||
let chain = blp_bytes_to_mip_chain(&bytes).ok()?;
|
||||
let mut layer = chain_to_layer(chain, LAYER_TEX_SIZE);
|
||||
layer
|
||||
.chain
|
||||
.iter_mut()
|
||||
.skip(3)
|
||||
.step_by(4)
|
||||
.for_each(|a| *a = 0); // matte: no sheen mask
|
||||
Some(layer)
|
||||
let chain = blp_bytes_to_native_chain(&bytes).ok()?;
|
||||
Some(RawLayer { chain, matte: true })
|
||||
}
|
||||
|
||||
/// An internal (`\`/lowercase) path → an `mpq://` URL.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! those blocks verbatim — `glCompressedTexImage2DARB`, with the software DXT decoder existing only
|
||||
//! as a 16-bit-device fallback (wow-re `system/image/image.md`: *"raw passthrough — device eats
|
||||
//! DXT"*, and `system/models/scratch/wmo-texture-sampling.md` for the per-level upload formats).
|
||||
//! benilla used to decode every one of them to `Rgba8Unorm` on the CPU and upload that: **4x the
|
||||
//! bytes of DXT1, 2x of DXT3/DXT5**, on the whole resident working set and on every texture fetch
|
||||
//! benilla used to decode every one of them to `Rgba8Unorm` on the CPU and upload that: **8x the
|
||||
//! bytes of DXT1, 4x of DXT3/DXT5**, on the whole resident working set and on every texture fetch
|
||||
//! that misses the cache. On a bandwidth-starved integrated GPU that is the frame's dominant cost,
|
||||
//! and it is the divergence Liho's Steam Deck reading exposed (decision 1626).
|
||||
//!
|
||||
|
||||
@@ -6,8 +6,26 @@
|
||||
//! pyramid is laid in **verbatim** — never CPU-regenerated by averaging texels in gamma byte space,
|
||||
//! which darkens mid-tones and cools foliage. Off-Elwynn BLPs authored below [`LAYER_TEX_SIZE`] fill
|
||||
//! the top mips by `NEAREST` upsample (texel replication — gamma-safe, no averaging).
|
||||
//!
|
||||
//! # The array's format is a property of the TILE, not of any one texture
|
||||
//!
|
||||
//! `Tileset\` is ~98 % S3TC, and 1626 handed the blocks straight to the GPU for model and WMO
|
||||
//! albedo — but not here, because this lane resamples and stomps texels on the CPU. [`pack_layers`]
|
||||
//! is what unblocks it: it holds every layer in its **authored** form until the whole tile has been
|
||||
//! read, then asks one question of the set — *can all of these go up as blocks, together?* One
|
||||
//! `texture_2d_array` has exactly one format and one mip-0 size, so the answer is all-or-nothing per
|
||||
//! tile, and the fallback is the decode this lane always did.
|
||||
//!
|
||||
//! **The rule earns its narrowness from the install** (censused across all 2 275 textured ADTs):
|
||||
//! a tile passes when every chosen layer shares one block form, is natively [`LAYER_TEX_SIZE`]² with
|
||||
//! a full [`LAYER_MIP_COUNT`]-level chain, and needs no matte stomp. That is **87 % of Azeroth's
|
||||
//! tiles and 92 % of Kalimdor's**, worth 2.5x / 2.9x off their layer bytes. The two obvious
|
||||
//! extensions were measured and are deliberately **not** built: doing the matte stomp in block space
|
||||
//! (legal on Bc2/Bc3 — zero the alpha half) buys **0** further tiles, and a block-space power-of-two
|
||||
//! NEAREST upsample buys **8**. What is left out is left out because it is worth nothing, not
|
||||
//! because it is hard.
|
||||
|
||||
use benilla_formats::BlpMipChain;
|
||||
use benilla_formats::{BlpMipChain, BlpTexels};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
|
||||
use bevy::prelude::*;
|
||||
@@ -30,6 +48,17 @@ fn color_format() -> TextureFormat {
|
||||
TextureFormat::Rgba8Unorm
|
||||
}
|
||||
|
||||
/// The block form's albedo format — the same **non-sRGB** gamma-byte lane [`color_format`] is in
|
||||
/// (0161). `Bc*RgbaUnormSrgb` would linearize on sample and is the one wrong answer here.
|
||||
fn block_color_format(texels: BlpTexels) -> TextureFormat {
|
||||
match texels {
|
||||
BlpTexels::Bc1 => TextureFormat::Bc1RgbaUnorm,
|
||||
BlpTexels::Bc2 => TextureFormat::Bc2RgbaUnorm,
|
||||
BlpTexels::Bc3 => TextureFormat::Bc3RgbaUnorm,
|
||||
BlpTexels::Rgba8Unorm => TextureFormat::Rgba8Unorm,
|
||||
}
|
||||
}
|
||||
|
||||
/// One ground texture packed for the per-tile array: its authored mip chain at [`LAYER_TEX_SIZE`]²,
|
||||
/// concatenated mip-major (level 0 first), `mip_count` levels.
|
||||
pub(crate) struct LayerTexture {
|
||||
@@ -43,11 +72,11 @@ pub(crate) struct LayerTexture {
|
||||
/// smaller mips slotting into the deeper array levels; when its chain runs out the last authored mip
|
||||
/// is reused (NEAREST-resampled if the dims still differ).
|
||||
pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture {
|
||||
// **This lane cannot take DXT blocks and must not be handed them.** It NEAREST-resamples levels
|
||||
// to a uniform array size and its caller stomps the alpha byte for the matte case — both are
|
||||
// per-texel CPU writes, which is exactly what a block form does not have. So terrain layers
|
||||
// stay decoded while model/WMO albedo passes through (decision 1626); the day this lane wants
|
||||
// the same win, the resample and the alpha stomp are what have to go first.
|
||||
// **This function cannot take DXT blocks.** It NEAREST-resamples levels to a uniform array size,
|
||||
// and [`pack_layers`] stomps the alpha byte after it for the matte case — both are per-texel CPU
|
||||
// writes, which is exactly what a block form does not have. That is why the passthrough is a
|
||||
// decision taken *before* this point, in [`pack_layers`], rather than a flag threaded through
|
||||
// here: a chain that reaches this function has already been ruled out of it (1646).
|
||||
debug_assert!(
|
||||
blp.is_rgba8(),
|
||||
"terrain layer packing indexes texels; it needs a decoded chain"
|
||||
@@ -81,6 +110,171 @@ pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture
|
||||
}
|
||||
}
|
||||
|
||||
/// One of a tile's ground textures as it was read, **before** the tile has decided what form its
|
||||
/// layer array takes — held in its authored form (DXT blocks kept) because that decision belongs to
|
||||
/// the whole set, not to any one texture. See [`pack_layers`].
|
||||
pub(crate) struct RawLayer {
|
||||
/// The BLP's authored chain, blocks intact ([`benilla_formats::blp_bytes_to_native_chain`]).
|
||||
pub(crate) chain: BlpMipChain,
|
||||
/// No `_s` specular variant existed for this layer, so its sheen mask must read 0 (matte). On
|
||||
/// the decoded path that is an alpha stomp; on the block path it is why the tile is not eligible.
|
||||
pub(crate) matte: bool,
|
||||
}
|
||||
|
||||
/// A tile's layer array, packed: LayerMajor bytes plus the format and level count that describe them.
|
||||
pub(crate) struct PackedLayers {
|
||||
pub(crate) data: Vec<u8>,
|
||||
pub(crate) format: TextureFormat,
|
||||
pub(crate) mip_count: u32,
|
||||
}
|
||||
|
||||
/// The block form this whole tile's layer array can be uploaded in, or `None` if it must be decoded.
|
||||
///
|
||||
/// Every clause is a property one `texture_2d_array` cannot vary across its layers, plus the two
|
||||
/// CPU texel writes this lane used to do unconditionally:
|
||||
///
|
||||
/// - **one block form** — an array has one `TextureFormat`, and `Bc1`/`Bc2` are different widths;
|
||||
/// - **natively [`LAYER_TEX_SIZE`]²** — an array has one mip-0 size, and NEAREST-resampling a level
|
||||
/// to reach it is a per-texel write blocks do not support;
|
||||
/// - **a full [`LAYER_MIP_COUNT`]-level chain, every level exactly the size its form implies** — the
|
||||
/// levels are laid in verbatim, so a short chain would have to repeat one at the wrong size (the
|
||||
/// same resample again), and a level whose byte count disagreed with the format would corrupt
|
||||
/// every layer after it rather than fail. `decode_native` pads to whole blocks, so this always
|
||||
/// holds — it is checked in **release** anyway, because the one bug 1626 shipped was bytes
|
||||
/// uploaded under a format that implied a different count, and a `debug_assert` cannot catch that
|
||||
/// in a player's build;
|
||||
/// - **no matte layer** — zeroing the sheen mask is a per-texel alpha write. (It *is* expressible in
|
||||
/// Bc2/Bc3 block space — memset the alpha half — and that was measured to buy **zero** further
|
||||
/// tiles, because a layer with no `_s` variant is a `Bc1` base and its tile is mixed-format anyway.)
|
||||
///
|
||||
/// [`crate::gpu_blp::bc_supported`] gates the whole thing, so `WOW_NO_BC=1` and a device without BC
|
||||
/// both land on the decoded path exactly as before — and so does every headless test, which is what
|
||||
/// keeps the pixel-reading ones honest.
|
||||
fn passthrough_form(layers: &[RawLayer], bc_supported: bool) -> Option<BlpTexels> {
|
||||
if !bc_supported {
|
||||
return None;
|
||||
}
|
||||
let form = layers.first()?.chain.texels;
|
||||
let uniform = form.is_block_compressed()
|
||||
&& layers.iter().all(|l| {
|
||||
l.chain.texels == form
|
||||
&& !l.matte
|
||||
&& l.chain.width == LAYER_TEX_SIZE
|
||||
&& l.chain.height == LAYER_TEX_SIZE
|
||||
&& l.chain.mips.len() >= LAYER_MIP_COUNT as usize
|
||||
&& l.chain.mips[..LAYER_MIP_COUNT as usize]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, level)| level.len() == form_level_bytes(form, i as u32))
|
||||
});
|
||||
uniform.then_some(form)
|
||||
}
|
||||
|
||||
/// Pack a tile's ground textures into its layer array — blocks straight through when the whole set
|
||||
/// allows it ([`passthrough_form`]), else the decode-resample-stomp path this lane always took.
|
||||
///
|
||||
/// `fallback` is layer 0's solid colour: the "this texture would not load" placeholder every tile
|
||||
/// carries at index 0, which is why it has to be synthesised in whichever form the tile picked.
|
||||
pub(crate) fn pack_layers(fallback: [u8; 4], layers: Vec<RawLayer>) -> PackedLayers {
|
||||
pack(fallback, layers, crate::gpu_blp::bc_supported())
|
||||
}
|
||||
|
||||
/// [`pack_layers`] with the device fact passed in rather than read from the process — so the block
|
||||
/// path is exercisable in a headless test, where [`crate::gpu_blp::bc_supported`] is always false
|
||||
/// and would otherwise leave the whole passthrough untested.
|
||||
fn pack(fallback: [u8; 4], layers: Vec<RawLayer>, bc_supported: bool) -> PackedLayers {
|
||||
if let Some(form) = passthrough_form(&layers, bc_supported) {
|
||||
let per_layer: usize = (0..LAYER_MIP_COUNT)
|
||||
.map(|i| form_level_bytes(form, i))
|
||||
.sum();
|
||||
let mut data = Vec::with_capacity((layers.len() + 1) * per_layer);
|
||||
data.extend_from_slice(&solid_block_chain(form, fallback));
|
||||
for layer in &layers {
|
||||
// A straight copy: `passthrough_form` has already established that each of these levels
|
||||
// is exactly what `format` implies, so the upload's own slicing cannot disagree.
|
||||
for level in layer.chain.mips.iter().take(LAYER_MIP_COUNT as usize) {
|
||||
data.extend_from_slice(level);
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(data.len(), (layers.len() + 1) * per_layer);
|
||||
return PackedLayers {
|
||||
data,
|
||||
format: block_color_format(form),
|
||||
mip_count: LAYER_MIP_COUNT,
|
||||
};
|
||||
}
|
||||
|
||||
let fb = solid_layer_chain(fallback);
|
||||
let mip_count = fb.mip_count;
|
||||
let mut data = fb.chain;
|
||||
for layer in layers {
|
||||
let matte = layer.matte;
|
||||
// `into_rgba8` is free for a chain that was never blocks and is the decode we were trying to
|
||||
// avoid for one that was — but it cannot fail, so this path is always available.
|
||||
let mut packed = chain_to_layer(layer.chain.into_rgba8(), LAYER_TEX_SIZE);
|
||||
if matte {
|
||||
packed
|
||||
.chain
|
||||
.iter_mut()
|
||||
.skip(3)
|
||||
.step_by(4)
|
||||
.for_each(|a| *a = 0);
|
||||
}
|
||||
data.extend_from_slice(&packed.chain);
|
||||
}
|
||||
PackedLayers {
|
||||
data,
|
||||
format: color_format(),
|
||||
mip_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes one array level occupies per layer in `form` — the arithmetic the eligibility check, the
|
||||
/// packer and the fallback synthesiser all have to agree on, stated once.
|
||||
fn form_level_bytes(form: BlpTexels, level: u32) -> usize {
|
||||
let w = (LAYER_TEX_SIZE >> level).max(1);
|
||||
form.level_bytes(w, w)
|
||||
}
|
||||
|
||||
/// One solid-colour 4x4 block in `form`.
|
||||
///
|
||||
/// The colour half is a DXT1 colour block with `c0` = the colour, `c1` = 0 and every 2-bit index 0,
|
||||
/// so all 16 texels read `c0` — quantised to RGB565, which is what a solid colour costs in any block
|
||||
/// form. `Bc2`/`Bc3` carry the alpha exactly (explicit 4-bit / a constant 8-bit endpoint pair).
|
||||
///
|
||||
/// **`Bc1` cannot carry alpha at all**, so a `Bc1` tile's placeholder samples as opaque and would
|
||||
/// take the sun sheen the `Rgba8Unorm` placeholder (alpha 0) suppresses. That is accepted, not
|
||||
/// overlooked: layer 0 is only ever sampled when a BLP named by MTEX fails to read, which no healthy
|
||||
/// install does — the census resolved all 1 048 `Tileset\` names — and it is a placeholder either way.
|
||||
fn solid_block(form: BlpTexels, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let c = ((rgba[0] as u16 >> 3) << 11) | ((rgba[1] as u16 >> 2) << 5) | (rgba[2] as u16 >> 3);
|
||||
let color = [c as u8, (c >> 8) as u8, 0, 0, 0, 0, 0, 0];
|
||||
let mut block = match form {
|
||||
// DXT3: eight bytes of explicit 4-bit alpha, two texels per byte.
|
||||
BlpTexels::Bc2 => vec![(rgba[3] >> 4) * 0x11; 8],
|
||||
// DXT5: `a0 == a1` and every 3-bit index 0, so every texel reads `a0` under either mode.
|
||||
BlpTexels::Bc3 => vec![rgba[3], rgba[3], 0, 0, 0, 0, 0, 0],
|
||||
BlpTexels::Bc1 | BlpTexels::Rgba8Unorm => Vec::new(),
|
||||
};
|
||||
block.extend_from_slice(&color);
|
||||
block
|
||||
}
|
||||
|
||||
/// A solid-colour block chain at [`LAYER_TEX_SIZE`]² with [`LAYER_MIP_COUNT`] levels — the block-form
|
||||
/// twin of [`solid_layer_chain`], for layer 0 of a passthrough array.
|
||||
fn solid_block_chain(form: BlpTexels, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let block = solid_block(form, rgba);
|
||||
let mut out = Vec::new();
|
||||
for level in 0..LAYER_MIP_COUNT {
|
||||
// Levels below 4x4 still occupy one whole block — `form_level_bytes` rounds up, which is
|
||||
// exactly what the authored chains next to this one do at their 2x2 tail.
|
||||
for _ in 0..(form_level_bytes(form, level) / block.len()) {
|
||||
out.extend_from_slice(&block);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A solid-colour layer chain at [`LAYER_TEX_SIZE`]² with [`LAYER_MIP_COUNT`] levels — the per-tile
|
||||
/// array's fallback layer 0 (a solid colour downsamples to itself, so every mip is the same colour).
|
||||
pub(crate) fn solid_layer_chain(rgba: [u8; 4]) -> LayerTexture {
|
||||
@@ -125,17 +319,15 @@ pub(crate) fn extend_nearest(out: &mut Vec<u8>, src: &[u8], sw: u32, sh: u32, dw
|
||||
|
||||
/// Stack the tile's ground textures into a repeating, mipmapped `2d_array`. `rgba` holds each layer's
|
||||
/// full `mip_levels`-deep chain (LayerMajor order); `layer_count` layers at `size²` mip 0.
|
||||
pub(crate) fn layer_array_image(
|
||||
size: u32,
|
||||
layer_count: u32,
|
||||
mip_levels: u32,
|
||||
rgba: Vec<u8>,
|
||||
) -> Image {
|
||||
// Image::new asserts mip-0 length; seed a correctly-sized buffer, then swap in the full chain.
|
||||
let mip0_len = (size * size * 4 * layer_count) as usize;
|
||||
let mut image = array_image(size, layer_count, vec![0u8; mip0_len], color_format());
|
||||
image.data = Some(rgba);
|
||||
image.texture_descriptor.mip_level_count = mip_levels;
|
||||
pub(crate) fn layer_array_image(size: u32, layer_count: u32, packed: PackedLayers) -> Image {
|
||||
let PackedLayers {
|
||||
data,
|
||||
format,
|
||||
mip_count,
|
||||
} = packed;
|
||||
let mut image = array_image(size, layer_count, format);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = mip_count;
|
||||
image.data_order = TextureDataOrder::LayerMajor;
|
||||
// Ground is the surface the filter policy costs the most on — a tiling texture at a grazing
|
||||
// angle is the anisotropy axis's worst case, and it fills most of the frame. The reference
|
||||
@@ -158,13 +350,17 @@ pub(crate) fn layer_array_image(
|
||||
/// Stack `layer_count` per-chunk alpha maps (each `size²`, concatenated) into a linear `2d_array` —
|
||||
/// the tile's blend weights (R/G/B = layers 1/2/3 opacity). Non-colour, so `Rgba8Unorm`.
|
||||
pub(crate) fn alpha_array_image(size: u32, layer_count: u32, rgba: Vec<u8>) -> Image {
|
||||
array_image(size, layer_count, rgba, TextureFormat::Rgba8Unorm)
|
||||
let mut image = array_image(size, layer_count, TextureFormat::Rgba8Unorm);
|
||||
image.data = Some(rgba);
|
||||
image
|
||||
}
|
||||
|
||||
/// Stack `layer_count` MCSH shadow maps (each `size²`, one byte/texel) into a single-channel
|
||||
/// `2d_array` — WoW's pre-baked static terrain shadows (`R` = shadowed, 0 = lit).
|
||||
pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec<u8>) -> Image {
|
||||
array_image(size, layer_count, data, TextureFormat::R8Unorm)
|
||||
let mut image = array_image(size, layer_count, TextureFormat::R8Unorm);
|
||||
image.data = Some(data);
|
||||
image
|
||||
}
|
||||
|
||||
/// Build a `2d_array` `Image` from concatenated per-layer bytes, with a `D2Array` view so the
|
||||
@@ -175,15 +371,18 @@ pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec<u8>) ->
|
||||
/// material as handles; the minimap, collider, clutter all derive from other sources). The
|
||||
/// default usage cloned every array on landing AND kept a main-world copy resident per tile —
|
||||
/// pure cost on both the B181 landing frame and the resident-set memory.
|
||||
fn array_image(size: u32, layer_count: u32, data: Vec<u8>, format: TextureFormat) -> Image {
|
||||
let mut image = Image::new(
|
||||
fn array_image(size: u32, layer_count: u32, format: TextureFormat) -> Image {
|
||||
// `new_uninit`, not `new`: `Image::new`'s length assert is written for uncompressed formats
|
||||
// (it skips itself when `pixel_size()` errors, which is every BC format) and it would force a
|
||||
// mip-0-sized dummy allocation on every tile just to be thrown away. The caller sets `data`.
|
||||
// Same reasoning, same call, as the model lane's `world_art_image` (`blp.rs`).
|
||||
let mut image = Image::new_uninit(
|
||||
Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: layer_count,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
data,
|
||||
format,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
@@ -249,12 +448,188 @@ mod tests {
|
||||
#[test]
|
||||
fn layer_array_image_has_the_expected_shape() {
|
||||
let bytes = vec![0u8; 3 * mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT)];
|
||||
let img = layer_array_image(LAYER_TEX_SIZE, 3, LAYER_MIP_COUNT, bytes);
|
||||
let img = layer_array_image(
|
||||
LAYER_TEX_SIZE,
|
||||
3,
|
||||
PackedLayers {
|
||||
data: bytes,
|
||||
format: color_format(),
|
||||
mip_count: LAYER_MIP_COUNT,
|
||||
},
|
||||
);
|
||||
assert_eq!(img.texture_descriptor.size.width, LAYER_TEX_SIZE);
|
||||
assert_eq!(img.texture_descriptor.size.depth_or_array_layers, 3);
|
||||
assert_eq!(img.texture_descriptor.mip_level_count, LAYER_MIP_COUNT);
|
||||
}
|
||||
|
||||
/// A synthetic block chain: `mips` authored levels at `size²` in `form`, padded to whole blocks
|
||||
/// exactly as `benilla_blp::decode_native` delivers them.
|
||||
fn block_chain(form: BlpTexels, size: u32, mips: u32) -> BlpMipChain {
|
||||
let levels = (0..mips)
|
||||
.map(|i| {
|
||||
let w = (size >> i).max(1);
|
||||
vec![0u8; form.level_bytes(w, w)]
|
||||
})
|
||||
.collect();
|
||||
BlpMipChain {
|
||||
texels: form,
|
||||
width: size,
|
||||
height: size,
|
||||
mips: levels,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(form: BlpTexels, size: u32, mips: u32, matte: bool) -> RawLayer {
|
||||
RawLayer {
|
||||
chain: block_chain(form, size, mips),
|
||||
matte,
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole eligibility rule, clause by clause — each `None` here is a tile that keeps paying
|
||||
/// 4 bytes a texel, so each one is worth stating.
|
||||
#[test]
|
||||
fn a_tile_passes_its_blocks_through_only_when_every_clause_holds() {
|
||||
let ok = || {
|
||||
vec![
|
||||
raw(BlpTexels::Bc2, 256, 8, false),
|
||||
raw(BlpTexels::Bc2, 256, 8, false),
|
||||
]
|
||||
};
|
||||
assert_eq!(passthrough_form(&ok(), true), Some(BlpTexels::Bc2));
|
||||
|
||||
// No device support (every headless test, and `WOW_NO_BC=1`).
|
||||
assert_eq!(passthrough_form(&ok(), false), None);
|
||||
// Mixed block forms — one array, one format. This is the common mixed tile: a `_s` variant
|
||||
// is DXT3 while a base without one is DXT1 (413 of 416 pairs in the install).
|
||||
let mixed = vec![
|
||||
raw(BlpTexels::Bc1, 256, 8, false),
|
||||
raw(BlpTexels::Bc2, 256, 8, false),
|
||||
];
|
||||
assert_eq!(passthrough_form(&mixed, true), None);
|
||||
// Not natively 256² — reaching the array size is a NEAREST resample, a per-texel write.
|
||||
let small = vec![raw(BlpTexels::Bc2, 128, 7, false)];
|
||||
assert_eq!(passthrough_form(&small, true), None);
|
||||
// A short chain would have to repeat a level at the wrong size — the same resample.
|
||||
let short = vec![raw(BlpTexels::Bc2, 256, 5, false)];
|
||||
assert_eq!(passthrough_form(&short, true), None);
|
||||
// A matte layer needs its alpha stomped to 0, which is a per-texel write.
|
||||
let matte = vec![raw(BlpTexels::Bc2, 256, 8, true)];
|
||||
assert_eq!(passthrough_form(&matte, true), None);
|
||||
// Raw1/Raw3 tilesets have no block form at all.
|
||||
let raw1 = vec![RawLayer {
|
||||
chain: synthetic_chain(256, 8),
|
||||
matte: false,
|
||||
}];
|
||||
assert_eq!(passthrough_form(&raw1, true), None);
|
||||
// A tile with no layers at all is the fallback-only tile — nothing to pass through.
|
||||
assert_eq!(passthrough_form(&[], true), None);
|
||||
}
|
||||
|
||||
/// The bug 1626 shipped for twenty minutes was blocks uploaded under a format that implied a
|
||||
/// different byte count. This asserts the pairing directly, for every block form: the packed
|
||||
/// buffer must be exactly what `format` says a `LAYER_TEX_SIZE²` × `mip_count` × `layers` array
|
||||
/// holds — nothing else can be sliced out of it correctly.
|
||||
#[test]
|
||||
fn a_packed_block_array_is_exactly_the_size_its_format_implies() {
|
||||
for form in [BlpTexels::Bc1, BlpTexels::Bc2, BlpTexels::Bc3] {
|
||||
let layers = vec![raw(form, 256, 8, false), raw(form, 256, 8, false)];
|
||||
// A level that lies about its size must not reach the packer: it would corrupt every
|
||||
// layer after it in the array rather than fail (1626's own twenty-minute bug).
|
||||
let mut short = raw(form, 256, 8, false);
|
||||
short.chain.mips[3].truncate(4);
|
||||
assert_eq!(passthrough_form(&[short], true), None, "{form:?}");
|
||||
let packed = pack([107, 133, 82, 0], layers, true);
|
||||
assert_eq!(packed.format, block_color_format(form));
|
||||
assert_eq!(packed.mip_count, LAYER_MIP_COUNT);
|
||||
let per_layer: usize = (0..LAYER_MIP_COUNT)
|
||||
.map(|i| form_level_bytes(form, i))
|
||||
.sum();
|
||||
// 3 layers: the synthesised fallback at index 0, then the two real ones.
|
||||
assert_eq!(packed.data.len(), 3 * per_layer, "{form:?}");
|
||||
// And the point of the whole change, pinned: DXT1 is 0.5 bytes a texel and DXT3/DXT5
|
||||
// are 1, against `Rgba8Unorm`'s 4. (The sub-4x4 tail mips cost a whole block in either
|
||||
// form, which is the entire 0.01 the ratio falls short of the texel arithmetic.)
|
||||
let decoded = 3 * mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT);
|
||||
let ratio = decoded as f64 / packed.data.len() as f64;
|
||||
let want = if form == BlpTexels::Bc1 { 8.0 } else { 4.0 };
|
||||
assert!(
|
||||
(ratio - want).abs() < 0.01,
|
||||
"{form:?} packs {ratio:.3}x smaller than the decoded array, expected {want}x"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer 0 is synthesised, not read, so it is the one part of a passthrough array whose bytes we
|
||||
/// author ourselves — decode them back and check they are the colour we asked for.
|
||||
#[test]
|
||||
fn the_synthesised_fallback_block_decodes_to_its_colour() {
|
||||
let rgba = [107u8, 133, 82, 0];
|
||||
for form in [BlpTexels::Bc1, BlpTexels::Bc2, BlpTexels::Bc3] {
|
||||
let chain = solid_block_chain(form, rgba);
|
||||
let per_layer: usize = (0..LAYER_MIP_COUNT)
|
||||
.map(|i| form_level_bytes(form, i))
|
||||
.sum();
|
||||
assert_eq!(chain.len(), per_layer, "{form:?}");
|
||||
let top = BlpMipChain {
|
||||
texels: form,
|
||||
width: LAYER_TEX_SIZE,
|
||||
height: LAYER_TEX_SIZE,
|
||||
mips: vec![chain[..form.level_bytes(LAYER_TEX_SIZE, LAYER_TEX_SIZE)].to_vec()],
|
||||
}
|
||||
.into_rgba8()
|
||||
.mips
|
||||
.remove(0);
|
||||
// RGB565 is what a solid colour costs in any block form; assert within one quantisation
|
||||
// step per channel rather than pretending the round trip is exact.
|
||||
for px in top.chunks_exact(4) {
|
||||
for (got, want) in px[..3].iter().zip(&rgba[..3]) {
|
||||
assert!(
|
||||
got.abs_diff(*want) <= 8,
|
||||
"{form:?}: {got} is not {want} within an RGB565 step"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Alpha is the sheen mask: `Bc2`/`Bc3` carry the requested 0; `Bc1` has no alpha channel
|
||||
// and samples opaque, which is the documented limitation of the placeholder.
|
||||
let alpha_ok = match form {
|
||||
BlpTexels::Bc1 => true,
|
||||
_ => top.chunks_exact(4).all(|px| px[3] == rgba[3]),
|
||||
};
|
||||
assert!(alpha_ok, "{form:?} must carry the fallback's alpha");
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded path must still be byte-for-byte what this lane always produced — including the
|
||||
/// matte stomp, which moved out of the ADT loader and into `pack`.
|
||||
#[test]
|
||||
fn the_decoded_path_still_stomps_a_matte_layers_alpha() {
|
||||
let layers = vec![
|
||||
RawLayer {
|
||||
chain: synthetic_chain(256, 8),
|
||||
matte: true,
|
||||
},
|
||||
RawLayer {
|
||||
chain: synthetic_chain(256, 8),
|
||||
matte: false,
|
||||
},
|
||||
];
|
||||
let packed = pack([107, 133, 82, 0], layers, true);
|
||||
assert_eq!(packed.format, color_format());
|
||||
let per_layer = mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT);
|
||||
assert_eq!(packed.data.len(), 3 * per_layer);
|
||||
let matte = &packed.data[per_layer..2 * per_layer];
|
||||
assert!(
|
||||
matte.iter().skip(3).step_by(4).all(|&a| a == 0),
|
||||
"a layer with no `_s` variant must carry a zero sheen mask"
|
||||
);
|
||||
let sheened = &packed.data[2 * per_layer..];
|
||||
assert!(
|
||||
sheened.iter().skip(3).step_by(4).all(|&a| a == 200),
|
||||
"a layer that found its `_s` variant keeps the authored mask"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_and_shadow_arrays_have_the_expected_shape_and_format() {
|
||||
let alpha = alpha_array_image(64, 2, vec![0u8; 2 * 64 * 64 * 4]);
|
||||
|
||||
@@ -139,9 +139,15 @@ impl Default for ClutterConfig {
|
||||
fn default() -> Self {
|
||||
let env = |k: &str| std::env::var(k).ok().and_then(|s| s.parse::<f32>().ok());
|
||||
Self {
|
||||
// Default ×3 = frillDensity 48 = the client's High world-detail preset, which matches the
|
||||
// reference (the user's reference runs High). ×1 would be the CVar's bare default (16/Low).
|
||||
density: env("WOW_CLUTTER_DENSITY").unwrap_or(3.0).max(0.0),
|
||||
// Default ×2 = frillDensity 32 = the panel's Medium (1649). It was ×3/High, matched to
|
||||
// the director's OWN reference install, which runs High — a setting on their machine,
|
||||
// not a default of the game's: `frillDensity` registers at 16, and a first launch has
|
||||
// `hwDetect` overwrite it from `VideoHardware.dbc` to 24 on any D3D9-class part and 8
|
||||
// on the weakest. Both are below Medium, and 24 is not on a panel stop at all, so every
|
||||
// stop is a divergence; High was simply the dearest one, and this is alpha-tested
|
||||
// overdraw on the ground — the thing a bandwidth-bound part has least of. Medium is the
|
||||
// nearest stop no sparser than a fresh install, and the row is one drag from High.
|
||||
density: env("WOW_CLUTTER_DENSITY").unwrap_or(2.0).max(0.0),
|
||||
scale: env("WOW_CLUTTER_SCALE").unwrap_or(1.0).max(0.01),
|
||||
alpha_ref: env("WOW_CLUTTER_ALPHA")
|
||||
.unwrap_or(DETAIL_DOODAD_ALPHA_REF)
|
||||
|
||||
Reference in New Issue
Block a user