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:
Sam
2026-08-27 15:16:01 +07:00
co-authored by Claude Fable 5
parent 5b62b89320
commit 67c0c000fb
12 changed files with 982 additions and 375 deletions
+75 -109
View File
@@ -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
+8 -7
View File
@@ -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 => {
+12 -10
View File
@@ -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`.
+22 -8
View File
@@ -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();
+115 -2
View File
@@ -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
+121 -155
View File
@@ -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
+8 -2
View File
@@ -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`).
//!
+173 -27
View File
@@ -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)`.
+39 -27
View File
@@ -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.
+2 -2
View File
@@ -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).
//!
+398 -23
View File
@@ -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]);
+9 -3
View File
@@ -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)