action-bar hover stops re-deriving, and multisampling asks the GPU

- hovering the action bars re-derived the whole layout graph and hovering bags did not: `GameTooltip:SetOwner(owner, "ANCHOR_NONE")` dropped the tooltip's anchors through the conservative whole-graph derive, and with `UberTooltips` at its shipped default every action-bar button routes through that arm while a bag slot anchors `ANCHOR_RIGHT` and never does — the arm now retargets to the empty target set, and derive frames fall from 15.9 % of hovered frames to 0.2 %
- `gxMultisample` clamps to what the GPU actually offers: Apple GPUs stop at 4x, so a typed 8 was a wgpu validation panic on frame one — the clamp intersects the supported counts of every format the client multisamples into and steps the request down the way the reference's device-init retry loop does, with a warning that says so
- the Multisampling dropdown lands on the Graphics page with the device's own stops, built from the same enumeration that clamps a typed value on the reference's three bindings (`GetMultisampleFormats`, `GetCurrentMultisampleFormat`, `SetMultisampleFormat`) — a fixed Off/2x/4x/8x ladder would put a crash on the menu of every GPU that stops at 4
- capture hardening: a run whose `$WOW_WIN` the display clamped is refused in ~2 s instead of producing a wrong-sized shot to diff, a capture never reads the OS pointer (a cursor resting over the window arms a hover and a tooltip that a cursor an inch to the left does not), and the stall sampler writes into `benilla-config` instead of a hand-built `~/Library/Logs` path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:13:27 +07:00
co-authored by Claude Fable 5
parent 17a47acd71
commit bf7201f4d3
18 changed files with 820 additions and 33 deletions
+54 -1
View File
@@ -620,7 +620,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
"RowWindowedMode", "RowVerticalSync",
"RowWindowedMode", "RowVerticalSync", "RowMultisample",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -2312,6 +2312,59 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMultisample" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" 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`). The text is
-- MULTISAMPLING_FORMAT_STRING verbatim, inlined
-- rather than read off the global so the row does
-- not depend on GlobalStrings being loaded.
for i = 1, table.getn(fmt), 3 do
table.insert(stops, {
text = format("%d-bit color %d-bit depth %dx multisample",
fmt[i], fmt[i + 1], fmt[i + 2]),
value = tostring(fmt[i + 2]),
})
end
OptionsDropdownRow_Setup(self, stops)
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The COMBAT page (decision 1134) — the first page whose rows
+39
View File
@@ -273,6 +273,21 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// echoes "set pending gxRestart" — so this row persists and `GetCVar` answers it, while the
// camera keeps what it was born with. `$WOW_MSAA` overrides it session-only, below.
("gxMultisample", "1"),
// The multisample triple's other two thirds. The reference's Video dropdown formats all three
// into one row (`MULTISAMPLING_FORMAT_STRING` = "%d-bit color %d-bit depth %dx multisample")
// and `GetCurrentMultisampleFormat 0x48c580` looks up all three by name to find which row is
// selected — so without these registered that lookup can never match and the dropdown would
// sit on entry 1 forever.
//
// **They describe, they do not steer.** benilla does not offer a colour or depth format to
// choose: every format `benilla_world::view::MsaaFormats` publishes carries the same pair,
// derived from the swapchain format and `Depth32Float`. `SetMultisampleFormat` writes them
// from the chosen entry exactly like `0x48c640` does, which is a no-op in value and the right
// shape to keep. The defaults here are the literals that pair matches on every target we ship;
// if a target ever disagrees the dropdown's own row wins, because it is written from the live
// enumeration.
("gxColorBits", "32"),
("gxDepthBits", "32"),
(crate::char_select::CVAR_LAST_CHARACTER, "0"),
];
@@ -391,6 +406,7 @@ pub(crate) struct KnobParams<'w> {
scale: ResMut<'w, UiScaleCvar>,
view: ResMut<'w, ViewDistance>,
msaa: ResMut<'w, MsaaSetting>,
msaa_formats: Res<'w, benilla_world::view::MsaaFormats>,
look: ResMut<'w, LookConfig>,
click: ResMut<'w, ClickConfig>,
loot: ResMut<'w, LootConfig>,
@@ -726,6 +742,7 @@ fn sync_cvars(
pane_rate,
guild_notify,
msaa,
msaa_formats,
} = &params;
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
@@ -740,6 +757,23 @@ fn sync_cvars(
.map(|(k, v)| (k.clone(), v.clone())),
);
script.register_cvars(REGISTERED.iter().copied());
// The Video dropdown's menu — what this device actually accepts, enumerated once at
// `finish()` by `view::MsaaSupportPlugin` (decision 1631) and handed over whole. Pushed
// here rather than owned by the VM because the list is a fact about the render adapter,
// which `benilla-ui` has no way to ask and should not grow one.
script.set_multisample_formats(
msaa_formats
.formats
.iter()
.map(
|&(color_bits, depth_bits, samples)| benilla_ui::script::MultisampleFormat {
color_bits,
depth_bits,
samples,
},
)
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 36] = [
("MasterVolume", sound.master.to_string()),
@@ -1286,6 +1320,11 @@ mod tests {
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
.insert_resource(ViewDistance { farclip: 350.0 })
.insert_resource(MsaaSetting { samples: 1 })
// The device menu the Video dropdown reads. A real-shaped list, not empty: these
// tests exercise `GetCurrentMultisampleFormat`'s lookup, which needs rows to find.
.insert_resource(benilla_world::view::MsaaFormats {
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
})
.init_resource::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
+9 -4
View File
@@ -90,7 +90,7 @@ impl Plugin for HoverLogPlugin {
"frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\
diff_us,quads,solves,derives,skipped,measured,measured_texts,tip_owner,tip_lines,\
tip_first_line,spliced,mesh_us,mesh_sort_us,mesh_split_us,mesh_write_us,\
mesh_quads,mesh_runs"
mesh_quads,mesh_runs,mesh_rewrites"
);
info!("hover log: recording every frame to {path}");
app.insert_resource(Recorder {
@@ -180,8 +180,8 @@ fn record(
);
let _ = writeln!(
rec.out,
",{},{},{},{},{},{}",
mesh.total, mesh.sort, mesh.split, mesh.write, mesh.quads, mesh.runs
",{},{},{},{},{},{},{}",
mesh.total, mesh.sort, mesh.split, mesh.write, mesh.quads, mesh.runs, mesh.rewrites
);
rec.rows.push(Row {
frame_ms,
@@ -236,6 +236,9 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
};
let rebuilt = 100.0 * rows.iter().filter(|r| r.mesh.rebuilt).count() as f32 / n;
let mesh_runs = rows.iter().map(|r| r.mesh.runs as f64).sum::<f64>() / f64::from(n);
// The number that reaches Bevy: a rewritten batch re-extracts, and `rewrites` tracking `runs`
// means the skip gate is being defeated for every batch at once (decision 1632).
let mesh_rw = rows.iter().map(|r| r.mesh.rewrites as f64).sum::<f64>() / f64::from(n);
let skips = 100.0 * rows.iter().filter(|r| r.cost.skipped).count() as f32 / n;
// Counted against the DROPPED threshold, not the raw budget: synced wall time rails at the
// display's present grant and jitters around it, so counting frames over 16.7 ms mostly counts
@@ -249,7 +252,7 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
p99={:>6.2} dropped={:>5.1}%\n ui μs: tick={:>6.0} resolve={:>6.0} \
measure={:>5.0} extract={:>6.0} convert={:>6.0} diff={:>5.0}\n solves/frame={:.2} \
derives/frame={:.2} reshaped/frame={:.2} extract-gate-skipped={:.0}%\n mesh μs: \
total={:>6.0} sort={:>5.0} split={:>5.0} write={:>6.0} rebuilt={:.0}% runs={:.0}",
total={:>6.0} sort={:>5.0} split={:>5.0} write={:>6.0} rebuilt={:.0}% runs={:.0} rewrites={:.1}",
rows.len(),
pct(&wall, 0.50),
pct(&wall, 0.99),
@@ -273,6 +276,7 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
mesh_us(|m| m.write),
rebuilt,
mesh_runs,
mesh_rw,
)
}
@@ -351,6 +355,7 @@ fn parse_row(line: &str) -> Option<Row> {
write: n(21),
quads: n(22) as usize,
runs: n(23) as usize,
rewrites: n(24) as usize,
};
let num = |i: usize| fields[i].parse::<f64>().ok();
let owner = fields[14].clone();
+2 -10
View File
@@ -377,10 +377,7 @@ pub fn run(build: BuildId) -> AppExit {
// `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI scenarios (a
// scale-dependent text bug looks fine at the scenario's default size and truncates at
// fullscreen heights).
if let Some(win) = std::env::var("WOW_WIN").ok().and_then(|v| {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
}) {
if let Some(win) = video::requested_window_size() {
win
} else {
match std::env::var("WOW_CAPTURE").as_deref() {
@@ -408,12 +405,7 @@ pub fn run(build: BuildId) -> AppExit {
// self-amplify at 4K where the 1024-era reference diluted them; matching the era's
// pixel density (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
// that term. Also the knob for any future era-resolution comparison.
std::env::var("WOW_WIN")
.ok()
.and_then(|v| {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
})
video::requested_window_size()
// A run that reads no pixels gets a SMALL window. It is held `AlwaysOnTop` for its
// whole life so it can never be occluded into the ~1 fps throttle
// (`capture::ProbeFocusPlugin`, decision 0906) — at the full default that meant
+21
View File
@@ -268,6 +268,27 @@ pub(crate) fn screenshots_dir() -> Option<PathBuf> {
home().map(|h| h.join("Screenshots"))
}
/// `benilla-config/Diagnostics/` — where the stuck-thread self-sampler drops its profiles
/// ([`crate::perf::stall`]).
///
/// **This was `~/Library/Logs/benilla/` until 2026-08-27, hand-built from `$HOME`** — a platform
/// log directory, which is the exact shape the one-folder rule names as forbidden ("never a
/// hand-built path, never a platform config dir"). It had been that way since decision 0713, and it
/// went unnoticed because the sampler only writes when something is already wrong, so the stray
/// folder appeared on the bad days and nobody was looking at paths on a bad day. It surfaced while
/// proving out the MSAA clamp (1631): three probe runs stalled, and the stall lines named a
/// directory that had no business existing.
///
/// It resolves through [`home`] like every other resident, which costs the sampler its capture
/// runs (`home` answers `None` there, deliberately) and is the right trade: a `WOW_CAPTURE` run is
/// bounded by the harness timeout and already has the probe backstop's `_exit(0)`, while the runs
/// this instrument was built for — the director's, and the long probe rounds — keep it. The
/// alternative, an ungated second accessor, would put "which paths are exempt from hermetic?" back
/// into someone's head, which is what the single rule exists to prevent.
pub(crate) fn diagnostics_dir() -> Option<PathBuf> {
home().map(|h| h.join("Diagnostics"))
}
/// Make an arbitrary realm/character name safe as one path component: anything outside
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
/// escape the folder or collide with the path separator.
+9 -4
View File
@@ -5,7 +5,7 @@
//! each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
//! the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
//! backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
//! the hang may own. Samples land in `~/Library/Logs/benilla/` and the path prints on stderr
//! the hang may own. Samples land in `benilla-config/Diagnostics/` and the path prints on stderr
//! (the tracing subscriber may already be gone during teardown).
//!
//! After a teardown sample, an [`EXIT_KILL_MS`] backstop `_exit(0)` ends the wedged process —
@@ -68,11 +68,15 @@ pub(super) fn plugin(app: &mut App) {
if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
return;
}
let Ok(home) = std::env::var("HOME") else {
// Through `local_state`, never a hand-built path — the one-folder rule. This used to read
// `$HOME` and write `~/Library/Logs/benilla/`; see `local_state::diagnostics_dir`. `None` means
// a hermetic capture run, which gets no sampler.
let Some(dir) = crate::local_state::diagnostics_dir() else {
return;
};
let dir = std::path::PathBuf::from(home).join("Library/Logs/benilla");
let _ = std::fs::create_dir_all(&dir);
// NOT created here: an empty `Diagnostics/` on every single run would be this instrument
// advertising itself in the player's folder for the ~always-case where nothing ever stalls.
// The watchdog creates it at the moment it actually has a sample to write.
START
.set(Instant::now())
.expect("stall_sample plugin built twice");
@@ -143,6 +147,7 @@ fn watchdog(dir: &std::path::Path) {
{
taken += 1;
last_sample_ms = now;
let _ = std::fs::create_dir_all(dir);
let file = dir.join(format!(
"stall-{}{}.txt",
std::process::id(),
+17
View File
@@ -969,6 +969,12 @@ pub(crate) struct UiMeshCost {
/// How many quads were sorted, and how many runs came out — the counts behind the µs.
pub(crate) quads: usize,
pub(crate) runs: usize,
/// How many pooled batch meshes were REWRITTEN this rebuild, rather than left alone or moved
/// by a translation-only nudge (1361's skip gate). This is the number that reaches Bevy: each
/// rewrite re-extracts in `RenderExtractApp`, which is where a hover's real cost turned out to
/// live (decision 1632). `rewrites == runs` every frame means the gate is being defeated for
/// every batch at once, which is what a z coupled to the run count did.
pub(crate) rewrites: usize,
}
fn rebuild_ui_mesh(
@@ -1194,6 +1200,14 @@ fn rebuild_ui_mesh(
// `Transparent2d` phase by ascending mesh z, so later runs drawing on top is exactly "higher z").
// Spread runs across a z window comfortably inside the camera's default near/far (±1000) regardless
// of run count, so this never depends on how many runs a given frame happens to produce.
//
// NB (decision 1632): this z DOES move when the run count moves, and `translation_from` bails on
// `z_bits` first — so it looks like a hover (which adds a run: the ButtonHilight is additive with
// its own texture and can never merge) would defeat 1361's skip gate for every batch at once.
// It was tried, with a constant denominator, and MEASURED: no change to the hover cost, because
// the gate is not in fact being defeated — `mesh_rewrites` reads 2.2 of 96.6 runs on a hover
// sweep. Left exactly as it was; the note is here so the next reader does not re-run the
// experiment.
let us_split = lap();
let run_count = runs.len().max(1) as f32;
// An unbounded material key set (a window resize moves every mask rect) resets the cache;
@@ -1203,6 +1217,7 @@ fn rebuild_ui_mesh(
pools.materials.clear();
}
let mut used = 0usize;
let mut n_rewrites = 0usize;
for (i, run) in runs.into_iter().enumerate() {
if run.indices.is_empty() {
continue;
@@ -1281,6 +1296,7 @@ fn rebuild_ui_mesh(
used += 1;
continue;
}
n_rewrites += 1;
// Pan-gate miss diagnostic (`WOW_UI_DIFF=1`, ≤3 lines/s so a startup burst can't
// exhaust it): names the check that sent this slot to the rewrite path.
if *UI_DIFF {
@@ -1410,6 +1426,7 @@ fn rebuild_ui_mesh(
write: us_write,
quads: n_sorted,
runs: used,
rewrites: n_rewrites,
};
}
}
+7 -1
View File
@@ -36,6 +36,9 @@ pub(super) struct PointerFeed<'w> {
/// cursor must not fight it. Same arm as `hidden` below, for the same reason: the mouse half
/// of this pass is not ours this frame.
synthetic: Res<'w, super::SyntheticPointer>,
/// A capture owns the pointer for its whole run ([`super::CapturePointerPinned`]) — the OS
/// cursor belongs to whoever is at the keyboard and must not reach the shot.
capture_pinned: Res<'w, super::CapturePointerPinned>,
}
impl PointerFeed<'_> {
@@ -94,7 +97,10 @@ pub(super) fn feed_ui_input(
// WHOLE mouse half out — including the else-arm below, which is the half that matters: a
// `pointer_left_window` between the synthetic press and the synthetic release would disarm
// the gesture the probe is in the middle of.
let synthetic = pointer.synthetic.0;
// The two reasons the OS pointer is not ours this frame: a probe is driving a gesture through
// the real pointer path, or this is a capture (where it is never ours). Same treatment — skip
// the mouse half whole, touch nothing — so they read as one condition here.
let synthetic = pointer.synthetic.0 || pointer.capture_pinned.0;
let (hover, click_consumed, payload_held) = (
&mut pointer.hover,
&mut pointer.click_consumed,
+30
View File
@@ -78,6 +78,31 @@ pub(crate) struct PointerOverUi(pub(crate) bool);
#[derive(Resource, Default)]
pub(crate) struct SyntheticPointer(pub(crate) bool);
/// **A capture never reads the OS pointer** — set for the whole life of a `$WOW_CAPTURE` /
/// `$WOW_CAPTURE_UI` run, and never cleared.
///
/// [`input::feed_ui_input`] treats this exactly like [`SyntheticPointer`]: the mouse half of the
/// pass is skipped entirely, touching nothing. The keyboard half is untouched.
///
/// **Why it is not just `SyntheticPointer`.** They are different statements. That one means "a
/// probe is driving a gesture *right now*", and the drag probe clears it when its gesture ends
/// (`capture::probes::act`) — so borrowing it would re-expose the real cursor for the rest of the
/// run, which is the opposite of the guarantee wanted here.
///
/// **Why it exists at all.** The mouse feed reads `window.cursor_position()`, so a UI capture's
/// pixels depended on where the person at the keyboard had left their mouse: a cursor resting over
/// the window arms a hover and a tooltip that a cursor an inch to the left does not. The old
/// defence was an assumption written in a comment — *"probes park the cursor outside"* — with
/// nothing enforcing it. On 2026-08-26 a `ui-tooltip` A/B came back with an MAE of 5.275 against
/// 0.020 for every other UI scenario; that particular anomaly turned out to be a rebuilt-between-
/// legs mistake, and three attempts to reproduce a cursor-driven one all failed, because
/// `CGWarpMouseCursorPosition` does not synthesise the events winit needs. So the hazard was left
/// on the record as **suspected, unproven** — and this closes it by construction instead, which
/// costs nothing and does not require ever winning that argument. A capture that cannot read the
/// pointer cannot be perturbed by it.
#[derive(Resource, Default)]
pub(crate) struct CapturePointerPinned(pub(crate) bool);
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
@@ -297,6 +322,11 @@ impl Plugin for UiScriptPlugin {
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
.init_resource::<SyntheticPointer>()
// Not `init_resource`: the value IS the answer, read once from the env at build.
.insert_resource(CapturePointerPinned(
std::env::var_os("WOW_CAPTURE").is_some()
|| std::env::var_os("WOW_CAPTURE_UI").is_some(),
))
.init_resource::<EguiPointerOver>()
.init_resource::<InspectMode>()
.init_resource::<PlayerUiHover>()
@@ -1096,6 +1096,91 @@ 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 — the first stop, shown in the
// reference's own MULTISAMPLING_FORMAT_STRING wording.
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
)
.unwrap(),
"32-bit color 32-bit depth 1x multisample"
);
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(),
"32-bit color 32-bit depth 4x multisample"
);
// 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(),
"32-bit color 32-bit depth 4x multisample"
);
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).
@@ -1925,10 +2010,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 26 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
// 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 and its Windowed Mode
// 1627's; Camera Following Style
// 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
// 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
@@ -1938,7 +2023,7 @@ 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.
assert_eq!(checked, 54, "every tipped row carries a live 1.12 key");
assert_eq!(checked, 55, "every tipped row carries a live 1.12 key");
assert_eq!(
untipped,
vec![
@@ -2034,15 +2119,16 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 26 of the 27 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
// 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 and Windowed Mode 1627's; Camera Following
// Max Camera Distance 1140's; Vertical Sync 1394's, Windowed 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).
assert_eq!(raised, 54, "every row but Auto Loot has a 1.12 description");
assert_eq!(raised, 55, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -643,3 +643,73 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
roster on every slot the cursor passes over (decisions 1388, 1625)."
);
}
/// **The action-bar hover law** (decision 1630, ledger B06): sweeping the cursor across ACTION BAR
/// buttons must cost **zero** derivations of the layout graph.
///
/// The two guards above drive `SetOwner(button, "ANCHOR_RIGHT")` — the bag slot's idiom. The bars
/// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes
/// through `GameTooltip_SetDefaultAnchor` (`ActionBar.xml:510`, and the stance/pet/bonus bars the
/// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors.
///
/// That drop took the conservative touch, so it re-derived the whole graph on every button the
/// cursor crossed — and on nothing else, which is why it survived two rounds of fixing and a
/// live probe: the director's own recording is what named it, every derive frame owned by a
/// `MultiBarBottomLeftButton*` or `BonusActionButton*`. The lesson worth keeping is in the guards
/// as much as the fix: a hover guard that only drives one of two anchor idioms is testing the
/// idiom, not the gesture.
#[test]
fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
let mut s = settled_default_ui();
s.run(
r#"
for i = 1, 12 do
local b = CreateFrame("Button", "BarOwner" .. i)
b:SetPoint("CENTER", 0, 0); b:SetSize(36, 36)
end
bar_n = 0
-- `GameTooltip_SetDefaultAnchor`'s body, which is what every action button actually runs:
-- own the tooltip WITHOUT an anchor, then point it by hand.
function bar_hover()
bar_n = bar_n + 1
local owner = getglobal("BarOwner" .. (math.mod(bar_n, 12) + 1))
GameTooltip:SetOwner(owner, "ANCHOR_NONE")
GameTooltip:ClearAllPoints()
GameTooltip:SetPoint("BOTTOMRIGHT", "UIParent", "BOTTOMRIGHT", -70, 80)
GameTooltip:AddLine("Spell " .. bar_n, 1, 1, 1)
if math.mod(bar_n, 2) == 0 then
GameTooltip:AddLine("Blasts the enemy for 20 damage.", 1, 1, 1, 1)
else
GameTooltip:AddLine("Instant", 1, 1, 1)
end
GameTooltip:Show()
end
"#,
)
.unwrap();
// Positive control across the births and the line pool's growth — both structural.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bar_hover()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating twelve buttons must derive the graph — zero here makes the assertion vacuous."
);
let derives_before = s.layout_derivations();
for _ in 0..24 {
s.run("bar_hover()").unwrap();
app_frame(&mut s);
}
let derives = s.layout_derivations() - derives_before;
assert_eq!(
derives, 0,
"24 action-bar hovers derived the layout graph {derives} times. `SetOwner`'s ANCHOR_NONE \
arm drops the tooltip's anchors, which is a retarget to the EMPTY target set and names \
its node like any other (decision 1630, extending 1625)."
);
}
+66 -1
View File
@@ -85,6 +85,15 @@ pub(crate) fn windowed_env() -> bool {
|| benilla_world::bgwin::background_run()
}
/// `$WOW_WIN=WxH` in **logical** px — the one parser, so the window that is *asked for* and the
/// window that is *checked* can never drift. It was spelled twice inline in `lib.rs` (once for the
/// UI-fixture arm, once for the world arm) and is now spelled here once and read there.
pub(crate) fn requested_window_size() -> Option<UVec2> {
let v = std::env::var("WOW_WIN").ok()?;
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
}
/// The display modes benilla ships. **Two, and neither is the reference's mode-setting
/// fullscreen** — the module doc says why.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
@@ -209,11 +218,67 @@ pub(crate) struct VideoPlugin;
impl Plugin for VideoPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<VideoConfig>()
.add_systems(Startup, log_display_session)
.add_systems(Startup, (log_display_session, check_window_pinned).chain())
.add_systems(Update, (apply_present_mode, apply_window_mode));
}
}
/// **Did the window actually get the size `$WOW_WIN` asked for?** Refuse the run if not.
///
/// The window manager is free to clamp a requested inner size to the display, and macOS does. The
/// failure is silent and it invalidates comparisons: on 2026-08-26 an MSAA A/B produced one leg at
/// 3200x1800 and the other at 3024x1800, because the director had the laptop panel on overnight
/// instead of the external monitor, and the window landed on a smaller screen. Nothing said so.
/// `benilla-visual` refused the pair with `image size mismatch` — the right outcome by luck, from a
/// tool three steps downstream that could only report the symptom, and a full A/B cycle was spent
/// getting there.
///
/// Measured, not assumed: `WOW_WIN=4000x3000` on this machine yields `1920x1048 logical` — clamped
/// to the display the window happened to open on, minus its chrome.
///
/// **Fatal under a capture, a warning otherwise**, and the split is the point. Every visual
/// regression diff in the tree is denominated in the window the scenario asks for
/// ([`windowed_env`]'s doc says so), so a capture at the wrong size is not a worse capture, it is
/// an invalid one that will be diffed against a valid one. A non-capture run with `$WOW_WIN` is
/// somebody looking at something; a clamp there is surprising, not wrong, so it says so and
/// carries on.
fn check_window_pinned(
windows: Query<&Window, With<PrimaryWindow>>,
mut exit: MessageWriter<AppExit>,
) {
let Some(want) = requested_window_size() else {
return;
};
let Ok(window) = windows.single() else {
return;
};
let got = UVec2::new(
window.resolution.width() as u32,
window.resolution.height() as u32,
);
if got == want {
return;
}
let capturing =
std::env::var_os("WOW_CAPTURE").is_some() || std::env::var_os("WOW_CAPTURE_UI").is_some();
if !capturing {
warn!(
"window: asked for {}x{} logical, got {}x{} — the window manager clamped it to the \
display. Harmless here; it would invalidate a capture.",
want.x, want.y, got.x, got.y
);
return;
}
error!(
"window: REFUSING this capture — asked for {}x{} logical, got {}x{}. The window manager \
clamped the request to the display this window opened on, so the image would not be the \
size the scenario is denominated in and any diff against it would be meaningless. Use a \
size that fits the current display (or move the window to a bigger one) and re-run.",
want.x, want.y, got.x, got.y
);
exit.write(AppExit::error());
}
/// One line at boot naming what the window actually got — and, on a Linux session, what it is
/// talking to.
///
+191
View File
@@ -38,6 +38,23 @@ pub(crate) struct CvarSlot {
pub default: String,
}
/// One device-supported multisample format, as the Video options dropdown shows it.
///
/// The reference's distilled triple (`[0xb4b444]`, count `[0xb4b440]`, built by `0x48c3e0`), and
/// the shape `MULTISAMPLING_FORMAT_STRING` formats: `"%d-bit color %d-bit depth %dx multisample"`.
///
/// `samples == 1` is the encoding of **no multisampling**, not "one sample of it" — the convention
/// both of the reference's enumerators normalise to (`0x58b8b1`'s jump table maps
/// `D3DMULTISAMPLE_NONE → 1`; the GL path at `0x58d5fa`/`0x58d5fe`/`0x58d605` turns a failed
/// `WGL_SAMPLES_ARB` query or a value of 0 into 1), and the same convention the device paths test
/// with `> 1`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MultisampleFormat {
pub color_bits: u32,
pub depth_bits: u32,
pub samples: u32,
}
impl super::UiScript {
/// Hand registration the config file's persisted values (decision 1291): name → value, keys
/// lowercased here. Set **before** any `register_cvars` / addon `RegisterCVar` runs in this
@@ -128,6 +145,18 @@ impl super::UiScript {
pub fn set_cvar_engine(&mut self, name: &str, value: &str) {
set_from_engine(&mut self.model_mut(), name, value.to_string());
}
/// Publish the multisample formats this run's device actually accepts — what the Video
/// options dropdown offers, in the order it offers them.
///
/// The host's half of the reference's `0x48c3e0`: there, a D3D `CheckDeviceMultiSampleType`
/// sweep over the nine-entry candidate table `0x85a83c` or a GL `wglGetPixelFormatAttribivARB`
/// sweep over every pixel format; here, `benilla_world::view::supported_sample_counts` asking
/// wgpu the same question. Pushed rather than pulled because the VM lives in `benilla-ui`,
/// which has no render adapter and should not grow one.
pub fn set_multisample_formats(&mut self, formats: Vec<MultisampleFormat>) {
self.model_mut().multisample_formats = formats;
}
}
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
@@ -230,6 +259,80 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// ── The Video options Multisampling dropdown ──────────────────────────────────────────────
// Three bindings over one list, exactly as `OptionsFrame.lua` consumes them: `_OnLoad` seeds
// the selection with `GetCurrentMultisampleFormat()`, `_Initialize` walks
// `GetMultisampleFormats()` three varargs at a time building the menu, and the Okay handler
// (l.240) calls `SetMultisampleFormat(UIDropDownMenu_GetSelectedID(...))`. Identities and
// behaviour from wow-re `system/console/scratch/gxmultisample-default.md` §7 — registration
// table `0x83de68`, records `0x83e2c0`/`0x83e2c8`/`0x83e2d0`.
lua.globals().set(
"GetMultisampleFormats",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// Flat, three per entry — `0x48c360` "pushes all three fields of every entry", and the
// Lua walks `for i=1, arg.n, 3`. A device with nothing to offer returns nothing, and
// the loop runs zero times: an empty dropdown, not a fabricated one.
let mut out = mlua::MultiValue::new();
for f in &model.multisample_formats {
out.push_back(Value::Number(f.color_bits as f64));
out.push_back(Value::Number(f.depth_bits as f64));
out.push_back(Value::Number(f.samples as f64));
}
Ok(out)
})?,
)?;
lua.globals().set(
"GetCurrentMultisampleFormat",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let cvar = |n: &str| model.cvars.get(n).and_then(|s| s.value.parse::<u32>().ok());
let (Some(color), Some(depth), Some(samples)) = (
cvar("gxcolorbits"),
cvar("gxdepthbits"),
cvar("gxmultisample"),
) else {
return Ok(1.0f64);
};
// **1-based, and 1.0 on no match** — `0x48c580` "returns the 1-based index of the
// matching triple, or 1.0 on no match". Not 0 and not nil: `_OnLoad` feeds this
// straight to `UIDropDownMenu_SetSelectedID`, so a miss has to name a real row.
let idx = model
.multisample_formats
.iter()
.position(|f| {
f.color_bits == color && f.depth_bits == depth && f.samples == samples
})
.map_or(1.0, |i| (i + 1) as f64);
Ok(idx)
})?,
)?;
lua.globals().set(
"SetMultisampleFormat",
lua.create_function(|lua, id: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// 1-based from `UIDropDownMenu_GetSelectedID`; anything off the end is ignored rather
// than clamped, because a clamp would silently apply a format the player did not pick.
let Some(f) = id
.filter(|v| *v >= 1.0)
.and_then(|v| model.multisample_formats.get(v as usize - 1).copied())
else {
return Ok(());
};
// All three, from the chosen entry — `0x48c640` "writes those three CVars from the
// chosen entry". Through `set_from_engine` so each ride the host's change queue and
// the config file is marked dirty, the same route a Lua `SetCVar` takes.
set_from_engine(&mut model, "gxColorBits", f.color_bits.to_string());
set_from_engine(&mut model, "gxDepthBits", f.depth_bits.to_string());
set_from_engine(&mut model, "gxMultisample", f.samples.to_string());
// The reference then sets `gxRestart` (`0x842978`, `0x63ce00`). We deliberately do not
// register that CVar: `gxMultisample` is latched here too (decision 1629 — the camera
// reads its sample count once, at spawn), so "applies at next launch" is already the
// behaviour and a second flag saying so would be a flag nothing reads.
Ok(())
})?,
)?;
lua.globals().set(
"SetCVar",
lua.create_function(|lua, args: mlua::MultiValue| {
@@ -280,6 +383,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::MultisampleFormat;
use crate::script::UiScript;
fn script_with_volume() -> UiScript {
@@ -444,6 +548,93 @@ mod tests {
);
}
/// The Video options Multisampling dropdown, driven exactly as `OptionsFrame.lua` drives it:
/// `_Initialize` walks `GetMultisampleFormats()` three varargs at a time, `_OnLoad` seeds the
/// selection from `GetCurrentMultisampleFormat()`, and the Okay handler (l.240) writes back
/// through `SetMultisampleFormat(id)`.
#[test]
fn the_multisample_dropdown_round_trips_through_the_three_bindings() {
let mut s = UiScript::new().unwrap();
s.register_cvars([
("gxColorBits", "32"),
("gxDepthBits", "32"),
("gxMultisample", "1"),
]);
s.set_multisample_formats(vec![
MultisampleFormat {
color_bits: 32,
depth_bits: 32,
samples: 1,
},
MultisampleFormat {
color_bits: 32,
depth_bits: 32,
samples: 2,
},
MultisampleFormat {
color_bits: 32,
depth_bits: 32,
samples: 4,
},
]);
// Flat triples, in order — the shape `for i=1, arg.n, 3` walks.
let flat: Vec<f64> = s.eval(r#"return { GetMultisampleFormats() }"#).unwrap();
assert_eq!(
flat,
vec![32.0, 32.0, 1.0, 32.0, 32.0, 2.0, 32.0, 32.0, 4.0],
"three fields per entry, entries in offer order"
);
// 1-BASED: the default `gxMultisample "1"` is the FIRST row, not the zeroth.
assert_eq!(
s.eval::<f64>("return GetCurrentMultisampleFormat()")
.unwrap(),
1.0
);
// Pick 4x — the third row — and all three CVars move together, as `0x48c640` writes them.
s.eval::<()>("SetMultisampleFormat(3)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetCVar("gxMultisample")"#)
.unwrap(),
"4"
);
assert_eq!(
s.eval::<String>(r#"return GetCVar("gxColorBits")"#)
.unwrap(),
"32"
);
assert_eq!(
s.eval::<f64>("return GetCurrentMultisampleFormat()")
.unwrap(),
3.0,
"the selection round-trips: what Set wrote, GetCurrent finds"
);
// The host sees the write, so the config file is dirtied — the same route a Lua SetCVar
// takes. Without this the player's choice would live only until they quit.
let changed: Vec<String> = s
.take_cvar_changes()
.into_iter()
.map(|(n, v)| format!("{n}={v}"))
.collect();
assert!(
changed.contains(&"gxMultisample=4".to_string()),
"expected gxMultisample on the change queue, got {changed:?}"
);
// Off the end is ignored, never clamped: a clamp would silently apply a format the player
// did not choose.
s.eval::<()>("SetMultisampleFormat(99)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetCVar("gxMultisample")"#)
.unwrap(),
"4",
"an out-of-range id must leave the selection alone"
);
}
/// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
/// PLAYER_ENTERING_WORLD, so nil became `gsub(nil, ...)` and took the whole family down.
///
+1
View File
@@ -162,6 +162,7 @@ pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::MultisampleFormat;
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
+13
View File
@@ -541,6 +541,18 @@ pub(crate) struct Model {
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
pub(crate) cvars_warned: HashSet<String>,
/// The device-enumerated multisample formats the Video options dropdown offers, in the order
/// it offers them — pushed by the host ([`super::UiScript::set_multisample_formats`]) from what
/// the render adapter actually accepts, and read by `GetMultisampleFormats` /
/// `GetCurrentMultisampleFormat` / `SetMultisampleFormat`.
///
/// In the reference this is the distilled `{colorBits, depthBits, multisample}` triple list at
/// `[0xb4b444]` (count `[0xb4b440]`), built by `0x48c3e0` from the D3D
/// `CheckDeviceMultiSampleType` sweep or the GL `wglGetPixelFormatAttribivARB` sweep. Empty
/// until the host pushes it — a VM with no device behind it (every extract test) offers no
/// formats rather than inventing some.
pub(crate) multisample_formats: Vec<super::cvars::MultisampleFormat>,
/// The globals `RegisterForSave` declared, in registration order — the saved-variables set the
/// host writes out at logout/exit and re-executes at load (decision 1128, [`super::saved`]).
pub(crate) saved_names: Vec<String>,
@@ -1436,6 +1448,7 @@ impl Model {
cvars_saved_base: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
multisample_formats: Vec::new(),
saved_names: Vec::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
+16 -5
View File
@@ -86,15 +86,26 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
None if anchor.eq_ignore_ascii_case("ANCHOR_NONE") => {
// The caller points it (ClearAllPoints+SetPoint) — drop ours now so a
// stale owner anchor never wins the frame the caller forgets to.
//
// Dropping every anchor is a retarget to the EMPTY target set, and it
// names its node like any other (decision 1625). It matters here more
// than anywhere: this arm is `GameTooltip_SetDefaultAnchor`'s, which is
// the path EVERY action-bar hover takes (`ActionBar.xml:510`, the
// `UberTooltips` default; the stance, pet and bonus bars the same) —
// while a bag slot anchors straight to its button and never comes
// through here. So a conservative touch on this line was a whole-graph
// derivation on every action button the cursor crossed, and on nothing
// else, which is exactly the shape the director's recorder reported (decision 1630):
// every derive frame owned by a MultiBar/BonusAction button.
let dropped = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
input.anchors.clear();
true
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
}
_ => false,
_ => None,
};
if dropped {
model.touch_layout();
if let Some(old_targets) = dropped {
let old_targets: Vec<u32> = old_targets;
model.touch_layout_retarget_frame(h, &old_targets, &[]);
}
}
None => {} // ANCHOR_PRESERVE
+177
View File
@@ -183,6 +183,150 @@ impl MsaaSetting {
}
}
/// The sample counts this run's GPU actually accepts — the guard between a player's
/// `gxMultisample` and a wgpu validation error.
///
/// **This closes a hazard [`MsaaSetting`] itself opened.** Decision 1629 made the sample count a
/// real setting a player can type, and nothing between the CVar and the GPU asked whether the GPU
/// could do it. Bevy does not ask either — `bevy_render`'s `prepare_view_targets` writes
/// `sample_count: msaa.samples()` into the descriptor unchecked (`view/mod.rs:1142`). wgpu *does*:
/// `wgpu-core` tests the count against the format's `sample_count_supported` and fails
/// `CreateTextureError::InvalidSampleCount` (`device/resource.rs:1316`), then tests it again on
/// every pipeline rendering into that texture (`:3882`, `:3961`, `:4013`). A validation error on a
/// texture rebuilt every frame is not a graceful degrade; it is the client dying on a value the
/// player was invited to type. `gxMultisample 8` on a device that stops at 4x is the live case.
///
/// **Stepping down is the reference's own answer, not a defensive invention.** Its device-init
/// retry loop at `0x63b380` walks the sample count *down* (`-= 2`, floored at 1) until the device
/// accepts a mode, and only once it bottoms out does it begin surrendering depth and colour bits.
/// A request the hardware cannot meet becoming the nearest one it can is therefore the faithful
/// behaviour; refusing to boot is what would be unfaithful.
///
/// The three formats are every one we multisample into: the world camera's off-screen colour target
/// (`Rgba16Float` — `benilla_app::world_backdrop`), `Core3d`'s depth (`CORE_3D_DEPTH_FORMAT` =
/// `Depth32Float`), and the swapchain format the world viewer and the fallback camera render
/// straight into. A count is granted only if **all three** take it: they are separate capability
/// bits in wgpu, and a device may well offer 8x colour alongside 4x depth.
fn supported_sample_counts(adapter: &bevy::render::renderer::RenderAdapter) -> Vec<u32> {
use bevy::image::BevyDefault as _;
use bevy::render::render_resource::TextureFormat;
let formats = [
TextureFormat::Rgba16Float,
TextureFormat::Depth32Float,
TextureFormat::bevy_default(),
];
MSAA_RANGE
.clone()
.filter(|&n| {
formats.iter().all(|f| {
adapter
.get_texture_format_features(*f)
.flags
.sample_count_supported(n)
})
})
.collect()
}
/// The largest count in `supported` at or below `requested`, floored at 1 — no multisampling, which
/// no device can refuse. The step-*down* direction is the reference's; see
/// [`supported_sample_counts`].
pub fn clamp_to_supported(requested: u32, supported: &[u32]) -> u32 {
supported
.iter()
.copied()
.filter(|&n| n <= requested)
.max()
.unwrap_or(1)
}
/// The multisample formats this run's device accepts, as the Video options dropdown offers them —
/// `(color_bits, depth_bits, samples)`, ascending.
///
/// The reference's distilled triple list (`[0xb4b444]`, count `[0xb4b440]`, built by `0x48c3e0` out
/// of the D3D `CheckDeviceMultiSampleType` sweep or the GL `wglGetPixelFormatAttribivARB` sweep) —
/// here, whatever wgpu says it will take. Filled by [`MsaaSupportPlugin`] from the same enumeration
/// that does the clamping, so the list a player picks from and the ceiling a typed value is clamped
/// to cannot disagree.
///
/// Empty until the adapter exists (and forever, headless): an empty dropdown is the honest answer
/// for a run with no device, and the Lua walks it zero times.
#[derive(Resource, Default, Clone, Debug)]
pub struct MsaaFormats {
pub formats: Vec<(u32, u32, u32)>,
}
/// Colour and depth bit counts for the dropdown's label, derived from the formats we actually
/// render into rather than typed: `MULTISAMPLING_FORMAT_STRING` is
/// `"%d-bit color %d-bit depth %dx multisample"`, and a number in it should be true.
///
/// The colour figure is the **swapchain**'s, not the world camera's off-screen `Rgba16Float`
/// target. What the dropdown is describing is the mode the window is presented in — the thing the
/// reference's enumerators were asking the device about — and quoting 64 there would be describing
/// an internal buffer no player chose.
fn dropdown_bit_depths() -> (u32, u32) {
use bevy::image::BevyDefault as _;
use bevy::render::render_resource::TextureFormat;
let bits = |f: TextureFormat| f.block_copy_size(None).unwrap_or(4) * 8;
(
bits(TextureFormat::bevy_default()),
bits(TextureFormat::Depth32Float),
)
}
/// Clamps [`MsaaSetting`] to what this run's GPU accepts, once, before anything spawns a camera.
///
/// **All the work is in `finish`, deliberately** — the same reason as
/// `benilla_assets::gpu_blp::BlpGpuSupportPlugin`: `RenderAdapter` is inserted into the main world
/// by `RenderPlugin::finish` (`bevy_render/src/lib.rs:419`), so it does not exist at `build` time
/// and never exists at all in a headless app. Every plugin's `finish` completes before the runner's
/// first update, and the cameras read `MsaaSetting` in `Startup` (1629's latch), so this is both
/// the earliest honest moment and comfortably early enough.
///
/// Headless — no adapter, or no setting — changes nothing: such a run builds no swapchain to be
/// wrong about.
pub struct MsaaSupportPlugin;
impl Plugin for MsaaSupportPlugin {
fn build(&self, _app: &mut App) {}
fn finish(&self, app: &mut App) {
// Scoped so the adapter borrow ends before the resource is written.
let supported = match app
.world()
.get_resource::<bevy::render::renderer::RenderAdapter>()
{
Some(adapter) => supported_sample_counts(adapter),
None => return,
};
// Published whether or not anything needs clamping: this is the dropdown's whole menu.
let (color_bits, depth_bits) = dropdown_bit_depths();
app.insert_resource(MsaaFormats {
formats: supported
.iter()
.map(|&s| (color_bits, depth_bits, s))
.collect(),
});
let Some(requested) = app.world().get_resource::<MsaaSetting>().map(|m| m.samples) else {
return;
};
let granted = clamp_to_supported(requested, &supported);
if granted == requested {
debug!("msaa: {requested}x accepted (this GPU offers {supported:?})");
return;
}
// At `warn`, not `debug`: the player asked for something and did not get it, and this is
// the only place that fact exists. A line that survives only in a debug build cannot answer
// "why won't my setting stick?" from a player's log.
warn!(
"msaa: this GPU does not offer {requested}x — using {granted}x (it offers {supported:?})"
);
app.world_mut().resource_mut::<MsaaSetting>().samples = granted;
}
}
/// The world camera's projection far plane in yards — the **horizon** plane, far beyond `farclip`
/// on purpose so the coarse WDL ring draws behind the wall (decision 0684; the reference's own
/// `horizonfarclip` is a second plane floored at `farclip + 528`, default 2112). One number, not a
@@ -460,6 +604,39 @@ mod msaa_tests {
/// A request lands on the largest level wgpu can express at or below it — the direction the
/// reference's own device-init retry loop steps (`0x63b380`, `-= 2` floored at 1). Never up:
/// asking for 3 must not silently buy 4 samples' worth of bandwidth.
#[test]
fn an_unsupported_count_steps_down_to_the_nearest_the_device_offers() {
// The live hazard: a player types 8 on a device that stops at 4x. Stepping down is the
// reference's own retry direction (`0x63b380`), so this is faithful, not just safe.
let upto4 = [1, 2, 4];
assert_eq!(clamp_to_supported(8, &upto4), 4);
assert_eq!(clamp_to_supported(16, &upto4), 4);
assert_eq!(clamp_to_supported(4, &upto4), 4);
assert_eq!(clamp_to_supported(3, &upto4), 2);
assert_eq!(clamp_to_supported(1, &upto4), 1);
}
#[test]
fn a_supported_count_is_never_stepped_up_and_never_touched() {
let all = [1, 2, 4, 8, 16];
for n in all {
assert_eq!(clamp_to_supported(n, &all), n, "{n}x should pass through");
}
// Between two offered levels it still rounds DOWN — the direction is the whole point.
assert_eq!(clamp_to_supported(6, &all), 4);
assert_eq!(clamp_to_supported(15, &all), 8);
}
#[test]
fn a_device_that_offers_nothing_still_boots_at_one_sample() {
// `supported_sample_counts` returning empty means some format refused even 1x, which should
// be impossible — but the floor is 1 (no multisampling) rather than 0 or a panic, because a
// client that will not start is strictly worse than one that starts unaliased.
assert_eq!(clamp_to_supported(8, &[]), 1);
assert_eq!(clamp_to_supported(1, &[]), 1);
assert_eq!(MsaaSetting { samples: 1 }.level(), Msaa::Off);
}
#[test]
fn a_sample_count_rounds_down_never_up() {
let level = |n| MsaaSetting { samples: n }.level();
@@ -245,6 +245,11 @@ impl Plugin for WorldFoundation {
// The world camera's multisampling level (`gxMultisample`, decision 1629) — read
// ONCE, at the camera's spawn, because the reference's own flag says latched.
.init_resource::<crate::view::MsaaSetting>()
.init_resource::<crate::view::MsaaFormats>()
// ...and the clamp that keeps it inside what this GPU will actually accept. Runs in
// plugin `finish()`, so it lands between the render adapter appearing and `Startup`
// reading the setting — see `view::MsaaSupportPlugin`.
.add_plugins(crate::view::MsaaSupportPlugin)
// The viewer's body (wire (a)'s kinematics half) — defaulted by the engine so a
// program with no avatar leaves it empty and every reader takes its no-body branch.
.init_resource::<crate::view::Viewer>()