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