borderless fullscreen ships, and the 4x MSAA nobody chose is off
- the display mode is borderless fullscreen by default, and there is no exclusive mode at all — winit ignores exclusive on Wayland, X11's path cannot restore the desktop after a crash, macOS has none, and WoW itself dropped it in 8.0.1; 1.12's `gxWindow` row keeps its name, polarity and default with "0" redefined as borderless, read before the window exists so it is born in the player's mode (a window that does not fill gamescope's nested output is a documented input break, and the recorded workaround is "set the game to fullscreen"), `gxResolution` remembers the windowed size, and the Graphics page grows 1.12's own Windowed Mode checkbox - the UI camera says `Msaa::Off` out loud, because silence there meant Bevy's default 4×: the world arrives on this camera as an already-resolved quad and everything it draws itself is axis-aligned rects, so four samples bought a 4× colour target, a 4× depth texture nothing ever writes, 4× the fill on every quad and a resolve — for a measured difference of MAE ≤ 0.024, all of it quad borders now drawn at their authored crispness, which is also the reference's hard-edged 8-bit direction - `gxMultisample` is a real player setting, and it ships off — the reference's own default: the reference registers no literal, it formats field 21 of the `VideoHardware.dbc` row the GPU matched, a 193-row table that holds nothing above 2 anywhere and whose every 2004-era id misses a modern card, so the fallback rows answer and all of them hold 1; the setting persists, all three world-lane cameras read it on the reference's [1,16] scale rounded down the way its own retry loop steps, and `$WOW_MSAA` survives session-only - two instruments for the tooltip hunt: `rebuild_ui_mesh` — which sorts, clips and re-splits every quad in the interface on any dirty frame — gets a meter carrying total/sort/split/write beside the script phases whose flat reading had stood as an alibi, and `WOW_PROBE_HOVER` sweeps the real cursor across named frames through the real pointer path, pressing nothing: the hover recorder records what a hand does, and this supplies the hand Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -618,7 +618,10 @@
|
||||
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
|
||||
"RowEnableMusic", "RowEnableAmbience",
|
||||
},
|
||||
Graphics = { "RowUiScale", "RowFarclip", "RowWorldDetail", "RowVerticalSync" },
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
"RowWindowedMode", "RowVerticalSync",
|
||||
},
|
||||
Nameplates = {
|
||||
"RowEnemyPlates", "RowFriendlyPlates",
|
||||
"RowPlayerNames", "RowNpcNames", "RowOwnName",
|
||||
@@ -2268,11 +2271,36 @@
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowVerticalSync" inherits="OptionsCheckboxRowTemplate">
|
||||
<Frame name="$parentRowWindowedMode" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldDetail" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Windowed Mode: 1.12's own Video Options checkbox
|
||||
-- over gxWindow, label and tooltip key both the
|
||||
-- reference's GlobalStrings (WINDOWED_MODE /
|
||||
-- OPTION_TOOLTIP_WINDOWED_MODE).
|
||||
--
|
||||
-- The row is the reference's; UNCHECKED is not.
|
||||
-- There it mode-sets the display at gxResolution;
|
||||
-- here it raises a BORDERLESS fullscreen window,
|
||||
-- and no exclusive mode exists to reach (decision
|
||||
-- 1627 -- Wayland cannot do one at all, and WoW
|
||||
-- itself dropped exclusive fullscreen in 8.0.1).
|
||||
--
|
||||
-- Not deferred, for the same reason as Vertical
|
||||
-- Sync below: the reference row carries
|
||||
-- gxRestart = 1 and ours applies on the click.
|
||||
OptionsRow_OnLoad(self, "gxWindow", "Windowed Mode",
|
||||
"OPTION_TOOLTIP_WINDOWED_MODE")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowVerticalSync" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWindowedMode" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWindowedMode" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Vertical Sync: 1.12's own Video Options checkbox
|
||||
-- 5 (gxVSync), over the window's present mode.
|
||||
|
||||
@@ -122,8 +122,9 @@ pub(crate) use probe_taxi::ProbeTaxiPlugin;
|
||||
pub(crate) use probes::{
|
||||
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
|
||||
JitterMeterPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
|
||||
ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
|
||||
ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
|
||||
ProbeClock, ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeHoverPlugin,
|
||||
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin,
|
||||
UnitVisualsPlugin,
|
||||
};
|
||||
use scenarios::GlueScreen;
|
||||
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
|
||||
|
||||
@@ -387,6 +387,96 @@ fn fire_probe_lua(
|
||||
/// line survives a window moving. `WOW_PROBE_DRAG_LUA="<chunk>"` runs a chunk after each gesture —
|
||||
/// the report channel (`ProbeLog` from [`ProbeLuaPlugin`] is installed by that probe; this one
|
||||
/// prints the chunk's own returned string).
|
||||
/// The HOVER SWEEP (`WOW_PROBE_HOVER="Frame1;Frame2;…"`, `WOW_PROBE_HOVER_AT` seconds, default 14,
|
||||
/// `WOW_PROBE_HOVER_STEP` seconds per frame-to-frame step, default 0.25, looping until exit) —
|
||||
/// the cursor crosses each named frame's centre through the REAL pointer path, pressing nothing.
|
||||
///
|
||||
/// Built because a hover-cost pin was taken with a Lua driver that called `GameTooltip:SetOwner`
|
||||
/// directly, and that driver is not the gesture: it never moves `model.mouseover`, so it skips the
|
||||
/// hit test, `OnEnter`/`OnLeave`, the button's state textures and highlight, and whatever the
|
||||
/// shipped UI hangs off those. The reported symptom was "hovering costs 2 ms" and the instrument
|
||||
/// could not hover. This is the missing half of `WOW_HOVER_LOG`: that one records what a hand on
|
||||
/// the mouse does, and this one supplies the hand.
|
||||
pub(crate) struct ProbeHoverPlugin;
|
||||
|
||||
impl Plugin for ProbeHoverPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let names: Vec<String> = std::env::var("WOW_PROBE_HOVER")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
let at = std::env::var("WOW_PROBE_HOVER_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(14.0);
|
||||
let step = std::env::var("WOW_PROBE_HOVER_STEP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.25);
|
||||
app.insert_resource(ProbeHover {
|
||||
names,
|
||||
at,
|
||||
step,
|
||||
i: 0,
|
||||
next: 0.0,
|
||||
announced: false,
|
||||
})
|
||||
.add_systems(Update, fire_probe_hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeHoverPlugin`]'s state: which name is next, and when.
|
||||
#[derive(Resource)]
|
||||
struct ProbeHover {
|
||||
names: Vec<String>,
|
||||
at: f32,
|
||||
step: f32,
|
||||
i: usize,
|
||||
next: f32,
|
||||
announced: bool,
|
||||
}
|
||||
|
||||
/// Move the cursor onto the next named frame's centre. Loops forever, so a run of any length is a
|
||||
/// steady sweep — the population the recorder splits on.
|
||||
fn fire_probe_hover(
|
||||
mut probe: ResMut<ProbeHover>,
|
||||
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
|
||||
time: ProbeClock,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
self_player: Query<(), With<crate::net::SelfPlayer>>,
|
||||
) {
|
||||
if probe.names.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
if now < probe.at || self_player.is_empty() || now < probe.next {
|
||||
return;
|
||||
}
|
||||
let Some(mut script) = script else { return };
|
||||
probe.next = now + probe.step;
|
||||
synthetic.0 = true;
|
||||
let name = probe.names[probe.i % probe.names.len()].clone();
|
||||
probe.i += 1;
|
||||
match frame_centre(&script, &name) {
|
||||
Some((x, y)) => {
|
||||
if !probe.announced {
|
||||
probe.announced = true;
|
||||
info!(
|
||||
"probe-hover: sweeping {} frame(s) every {:.2}s, first {name} ({x:.0},{y:.0})",
|
||||
probe.names.len(),
|
||||
probe.step
|
||||
);
|
||||
}
|
||||
script.mouse_move(x, y);
|
||||
}
|
||||
// Named but unresolved is worth saying once per pass rather than silently sweeping air —
|
||||
// a sweep over frames that do not exist reads as "hovering is free".
|
||||
None => warn!("probe-hover: {name} has no resolved rect — nothing hovered this step"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ProbeDragPlugin;
|
||||
|
||||
impl Plugin for ProbeDragPlugin {
|
||||
|
||||
@@ -13,7 +13,9 @@ use bevy::prelude::*;
|
||||
mod fx_draw_census;
|
||||
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
|
||||
mod act;
|
||||
pub(crate) use act::{ProbeChatPlugin, ProbeDragPlugin, ProbeKeyPlugin, ProbeLuaPlugin};
|
||||
pub(crate) use act::{
|
||||
ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
|
||||
};
|
||||
|
||||
/// The probe **run** itself — its bounded lifetime, and the window it lives in (level,
|
||||
/// parking, size). Nothing here measures anything; it is the shell every other probe rides.
|
||||
|
||||
+229
-41
@@ -44,7 +44,7 @@ use crate::vplates::VPlateMode;
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
|
||||
use benilla_world::clutter::ClutterConfig;
|
||||
use benilla_world::view::{ViewDistance, FARCLIP_RANGE};
|
||||
use benilla_world::view::{MsaaSetting, ViewDistance, FARCLIP_RANGE, MSAA_RANGE};
|
||||
|
||||
/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page
|
||||
/// actually wires — never ahead of the knob (see the module doc).
|
||||
@@ -208,6 +208,30 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
|
||||
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
|
||||
("gxVSync", "1"),
|
||||
// **Display mode** (decision 1627) — 1.12's own `gxWindow`, the Video Options *Windowed Mode*
|
||||
// checkbox (`WINDOWED_MODE` / `OPTION_TOOLTIP_WINDOWED_MODE` are both in the reference's
|
||||
// GlobalStrings; see `reference/1.12-globals.tsv`). The knob is
|
||||
// [`crate::video::VideoConfig::display`], which the window's `mode` follows.
|
||||
//
|
||||
// Default **"0" = not windowed**, which is the reference's own default and every shipped
|
||||
// game's — but "0" does NOT mean what it means in 1.12. The reference mode-sets the display;
|
||||
// we raise a **borderless** fullscreen window, and ship no exclusive mode at all.
|
||||
// [`crate::video`] carries the three-platform argument for why that is the whole of it (short
|
||||
// version: Wayland cannot do exclusive, X11's XRandR path cannot restore the desktop after a
|
||||
// crash, macOS has no such mode, and WoW itself dropped exclusive fullscreen in 8.0.1).
|
||||
//
|
||||
// Departs from the reference row's `gxRestart = 1` exactly like `gxVSync` above: ours applies
|
||||
// on the click.
|
||||
("gxWindow", "0"),
|
||||
// The **windowed** size, `gxResolution` — 1.12's own CVar name, narrowed to half its job.
|
||||
// There it is the display mode *and* the backbuffer; here it is only what "windowed" means,
|
||||
// because fullscreen is the monitor's own size and we expose no mode list to pick from (the
|
||||
// deviation decision 1092 already records for `GxAspect`, unchanged by 1627).
|
||||
//
|
||||
// A **string** CVar, like it is in the reference — the one row [`apply_to_knobs`] has to match
|
||||
// ahead of its numeric parse. Default is the 1600×900 that was the client's only size before
|
||||
// 1627, so a windowed run is bit-for-bit where it was.
|
||||
("gxResolution", "1600x900"),
|
||||
// The body panes' half-rate render (decision 1444) — **benilla's own CVar**, no 1.12
|
||||
// counterpart: the reference draws its doll inside the main pass (no second view exists to
|
||||
// rate-limit), while our RTT booths (1069) re-run the render graph per pane per frame. "1" =
|
||||
@@ -231,6 +255,24 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// FIRST character and not a "no memory" sentinel — which is exactly why a stock `Config.wtf`
|
||||
// has no such line until you have played somebody other than your first character
|
||||
// (`SaveConfig 0x63d980` skips values equal to their default; `compose_file` does the same).
|
||||
// Multisample antialiasing — 1.12's own `gxMultisample`, registered at `0x63a950` with help
|
||||
// "multisample antialiasing" and flags `3` = registered | **latched**. The knob is
|
||||
// [`benilla_world::view::MsaaSetting`], read once at the world camera's spawn; its doc carries
|
||||
// the full derivation.
|
||||
//
|
||||
// **Default "1" — off — and BYTE-DERIVED, unusually indirectly.** The reference registers no
|
||||
// literal here: the default string is `snprintf("%d")`'d at runtime from field 21 of the
|
||||
// `VideoHardware.dbc` row `DetectHardware` (`0x641260`) matched the GPU to. Across the shipped
|
||||
// 193-row table that field only ever holds 1 (144 rows) or 2 (49 rows), and the three rows the
|
||||
// fallback match can reach all hold 1 — so on any GPU the 2004-era table does not list, which
|
||||
// is every machine this client runs on now, the registered string is "1". A 1 is genuinely no
|
||||
// multisampling on both of its backends, not a one-sample mode. (wow-re §5 cross-check,
|
||||
// 2026-08-26, `system/console/scratch/gxmultisample-default.md`; decision 1629.)
|
||||
//
|
||||
// Latched means a change is PENDING until the next launch — the reference's own callback
|
||||
// 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"),
|
||||
(crate::char_select::CVAR_LAST_CHARACTER, "0"),
|
||||
];
|
||||
|
||||
@@ -302,12 +344,28 @@ impl CvarPersist {
|
||||
/// short enough that a crash loses one gesture, not a session ("write-on-change, debounced").
|
||||
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// The startup fold of `config.toml` into the knob resources ([`load_config`]).
|
||||
///
|
||||
/// A set rather than a bare system because one knob is **read once and never again**: the world
|
||||
/// camera takes its `Msaa` at spawn (decision 1629, the reference's latched `gxMultisample`), so
|
||||
/// `setup_player` must not be able to run before the file has been folded in. Every other knob is
|
||||
/// live-read and does not care.
|
||||
///
|
||||
/// This removes a **race, not an observed bug**. Measured: with the constraint deleted, a
|
||||
/// `gxMultisample = "4"` in `config.toml` still reached the camera — and it did so despite
|
||||
/// `PlayerPlugin` being added *before* `CvarPlugin` (`lib.rs`), i.e. the order that happened to
|
||||
/// hold was the executor's choice out of an unconstrained graph, not insertion order and not
|
||||
/// anything we could point at. The failure it prevents is silent (the player's setting is simply a
|
||||
/// launch late) and would surface as a bug report nobody could reproduce.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct CvarLoad;
|
||||
|
||||
pub(crate) struct CvarPlugin;
|
||||
|
||||
impl Plugin for CvarPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<CvarPersist>()
|
||||
.add_systems(Startup, load_config)
|
||||
.add_systems(Startup, load_config.in_set(CvarLoad))
|
||||
.add_systems(Update, sync_cvars);
|
||||
// **The flush is on the exit edge, not beside its feed** (decision 1528). It used to be
|
||||
// `(sync_cvars, save_config).chain()` in `Update`, which made the "or the app exiting"
|
||||
@@ -332,6 +390,7 @@ pub(crate) struct KnobParams<'w> {
|
||||
sound: ResMut<'w, SoundConfig>,
|
||||
scale: ResMut<'w, UiScaleCvar>,
|
||||
view: ResMut<'w, ViewDistance>,
|
||||
msaa: ResMut<'w, MsaaSetting>,
|
||||
look: ResMut<'w, LookConfig>,
|
||||
click: ResMut<'w, ClickConfig>,
|
||||
loot: ResMut<'w, LootConfig>,
|
||||
@@ -360,6 +419,7 @@ impl KnobParams<'_> {
|
||||
sound: &mut self.sound,
|
||||
scale: &mut self.scale,
|
||||
view: &mut self.view,
|
||||
msaa: &mut self.msaa,
|
||||
look: &mut self.look,
|
||||
click: &mut self.click,
|
||||
loot: &mut self.loot,
|
||||
@@ -383,6 +443,7 @@ struct Knobs<'a> {
|
||||
sound: &'a mut SoundConfig,
|
||||
scale: &'a mut UiScaleCvar,
|
||||
view: &'a mut ViewDistance,
|
||||
msaa: &'a mut MsaaSetting,
|
||||
look: &'a mut LookConfig,
|
||||
click: &'a mut ClickConfig,
|
||||
loot: &'a mut LootConfig,
|
||||
@@ -401,11 +462,25 @@ struct Knobs<'a> {
|
||||
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
|
||||
/// build knows (the caller decides whether that warns or rides through).
|
||||
fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
let key = name.to_ascii_lowercase();
|
||||
// `gxResolution` is a **string** CVar — `"WxH"`, the reference's own spelling — so it has to be
|
||||
// matched ahead of the numeric parse every other row goes through, which would reject it as a
|
||||
// bad value (decision 1627). It is the only such row; if a second one ever lands, this is the
|
||||
// shape it joins, not a second special case somewhere else.
|
||||
if key == "gxresolution" {
|
||||
match crate::video::parse_resolution(value) {
|
||||
Some(size) => knobs.video.windowed = size,
|
||||
// Same posture as the numeric miss below: known key, bad value — consumed, and the
|
||||
// resource keeps its truth.
|
||||
None => warn!("cvar {name}: unparseable value '{value}' ignored"),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let Ok(v) = value.parse::<f32>() else {
|
||||
warn!("cvar {name}: unparseable value '{value}' ignored");
|
||||
return true; // known key, bad value — consumed, resource keeps its truth
|
||||
};
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
match key.as_str() {
|
||||
"mastervolume" => knobs.sound.master = v.clamp(0.0, 1.0),
|
||||
"soundvolume" => knobs.sound.sfx = v.clamp(0.0, 1.0),
|
||||
"musicvolume" => knobs.sound.music = v.clamp(0.0, 1.0),
|
||||
@@ -473,8 +548,18 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
// Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode`
|
||||
// watches the value and pushes it to the window; nothing else reads it.
|
||||
"gxvsync" => knobs.video.vsync = v != 0.0,
|
||||
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
|
||||
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
|
||||
// watches the value and pushes it to the window; nothing else reads it.
|
||||
"gxwindow" => knobs.video.display = crate::video::display_from_flag(v),
|
||||
// The body panes' half-rate render (1444) — a flag like every other checkbox here.
|
||||
"boothhalfrate" => knobs.pane_rate.half = v != 0.0,
|
||||
// Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`.
|
||||
// Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched),
|
||||
// and nothing reads this resource after the world camera's spawn.
|
||||
"gxmultisample" => {
|
||||
knobs.msaa.samples = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end())
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -507,34 +592,34 @@ fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
|
||||
if crate::video::novsync_env() {
|
||||
persist.env_overridden.insert("gxvsync".into());
|
||||
}
|
||||
let Some(path) = crate::local_state::config_path() else {
|
||||
return; // hermetic capture, or no install — session-only state
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
warn!("config: cannot read {}: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cfg: LocalConfig = match toml::from_str(&text) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// `$WOW_WIN`, a capture scenario, or any instrumented run owns the window's geometry for the
|
||||
// session (decision 1627), so the two CVars that would otherwise move it mid-run are
|
||||
// session-only under exactly the same law as the four above.
|
||||
if crate::video::windowed_env() {
|
||||
persist.env_overridden.insert("gxwindow".into());
|
||||
persist.env_overridden.insert("gxresolution".into());
|
||||
}
|
||||
// `$WOW_MSAA` is the multisampling A/B lever (1629), session-only under the same law as every
|
||||
// override above: a value pinned into the file would make a measurement sticky across
|
||||
// relaunches.
|
||||
if std::env::var_os("WOW_MSAA").is_some() {
|
||||
persist.env_overridden.insert("gxmultisample".into());
|
||||
}
|
||||
let cvars = match stored_config() {
|
||||
StoredConfig::Absent => return, // no file, hermetic capture, or no install
|
||||
StoredConfig::Bad(msg) => {
|
||||
// A malformed file is preserved, not clobbered: nothing loads, but nothing saves
|
||||
// over it either until a change actually happens — and the warn names the file.
|
||||
warn!(
|
||||
"config: {} is malformed ({e}) — running on defaults",
|
||||
path.display()
|
||||
);
|
||||
warn!("{msg}");
|
||||
return;
|
||||
}
|
||||
StoredConfig::Table(t) => t,
|
||||
};
|
||||
let known: HashSet<String> = REGISTERED
|
||||
.iter()
|
||||
.map(|(n, _)| n.to_ascii_lowercase())
|
||||
.collect();
|
||||
for (name, value) in &cfg.cvars {
|
||||
for (name, value) in &cvars {
|
||||
let key = name.to_ascii_lowercase();
|
||||
if !known.contains(&key) {
|
||||
warn!("config: unknown cvar '{name}' — preserved, not applied");
|
||||
@@ -546,7 +631,67 @@ fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
|
||||
}
|
||||
apply_to_knobs(name, value, &mut knobs);
|
||||
}
|
||||
persist.file = cfg.cvars;
|
||||
persist.file = cvars;
|
||||
}
|
||||
|
||||
/// What the one read of `config.toml` found.
|
||||
enum StoredConfig {
|
||||
/// No file, no install, or a hermetic capture — every value is its registered default.
|
||||
Absent,
|
||||
/// The file's `[cvars]` table.
|
||||
Table(BTreeMap<String, String>),
|
||||
/// The file is there but unreadable or malformed. The string is what [`load_config`] warns
|
||||
/// with — carried rather than logged, because this read happens before the `App` (and so
|
||||
/// before `LogPlugin`) exists.
|
||||
Bad(String),
|
||||
}
|
||||
|
||||
/// Read `config.toml`.
|
||||
///
|
||||
/// **One parser, two callers at two different times** — [`load_config`] at `Startup`, and the
|
||||
/// primary window literal in [`crate::run`], which has to know `gxWindow`/`gxResolution` *before*
|
||||
/// the window exists ([`crate::video::boot_window_mode`] carries why booting windowed and flipping
|
||||
/// a frame later is not good enough).
|
||||
///
|
||||
/// Deliberately **not** cached in a `OnceLock`, though it was written that way first. Three reads
|
||||
/// of a sub-kilobyte file at process start is not a cost worth a global, and a process-wide cache
|
||||
/// is actively wrong: every test that lays a config down and then runs `load_config` would be
|
||||
/// answered from whatever the *first* test in the binary happened to see, and `local_state`'s home
|
||||
/// law can legitimately move under a run. The thing worth having exactly one of is this function,
|
||||
/// not its result.
|
||||
fn stored_config() -> StoredConfig {
|
||||
let Some(path) = crate::local_state::config_path() else {
|
||||
return StoredConfig::Absent; // hermetic capture, or no install — session-only state
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StoredConfig::Absent,
|
||||
Err(e) => return StoredConfig::Bad(format!("config: cannot read {}: {e}", path.display())),
|
||||
};
|
||||
match toml::from_str::<LocalConfig>(&text) {
|
||||
Ok(cfg) => StoredConfig::Table(cfg.cvars),
|
||||
Err(e) => StoredConfig::Bad(format!(
|
||||
"config: {} is malformed ({e}) — running on defaults",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One CVar as `config.toml` holds it, matched case-insensitively — **before the `App` exists**
|
||||
/// (decision 1627).
|
||||
///
|
||||
/// Every other consumer wants [`CvarPersist::stored`], which answers from the same values once
|
||||
/// they are a resource and stays current across a VM replacement (1291). This one exists for the
|
||||
/// single caller that cannot wait for a resource: the primary window has to be *built* with its
|
||||
/// display mode already resolved.
|
||||
pub(crate) fn boot_cvar(name: &str) -> Option<String> {
|
||||
match stored_config() {
|
||||
StoredConfig::Table(t) => t
|
||||
.into_iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v),
|
||||
StoredConfig::Absent | StoredConfig::Bad(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values,
|
||||
@@ -580,6 +725,7 @@ fn sync_cvars(
|
||||
video,
|
||||
pane_rate,
|
||||
guild_notify,
|
||||
msaa,
|
||||
} = ¶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
|
||||
@@ -595,7 +741,7 @@ fn sync_cvars(
|
||||
);
|
||||
script.register_cvars(REGISTERED.iter().copied());
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 33] = [
|
||||
let session: [(&str, String); 36] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -634,7 +780,18 @@ fn sync_cvars(
|
||||
("minimapZoom", minimap.outdoor.to_string()),
|
||||
("minimapInsideZoom", minimap.inside.to_string()),
|
||||
("gxVSync", flag(video.vsync)),
|
||||
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
|
||||
(
|
||||
"gxWindow",
|
||||
flag(video.display == crate::video::DisplayMode::Windowed),
|
||||
),
|
||||
// The one string-valued row, composed in the reference's spelling.
|
||||
(
|
||||
"gxResolution",
|
||||
format!("{}x{}", video.windowed.x, video.windowed.y),
|
||||
),
|
||||
("boothHalfRate", flag(pane_rate.half)),
|
||||
("gxMultisample", msaa.samples.to_string()),
|
||||
];
|
||||
for (name, value) in session {
|
||||
script.set_cvar_host(name, &value);
|
||||
@@ -824,6 +981,9 @@ mod tests {
|
||||
// ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the
|
||||
// env-less 350 literal (view.rs doc: "Default 350" — the reference's own, 1624).
|
||||
assert_eq!(d["farclip"], 350.0);
|
||||
// Same shape as farclip: `MsaaSetting::default()` reads $WOW_MSAA, so the registered
|
||||
// default mirrors the env-less literal — 1, the reference's own (1629).
|
||||
assert_eq!(d["gxMultisample"], 1.0);
|
||||
// The Controls trio (0961) welds to its knob Defaults the same way.
|
||||
assert_eq!(
|
||||
d["deselectOnClick"] != 0.0,
|
||||
@@ -918,6 +1078,8 @@ mod tests {
|
||||
let mut video = VideoConfig::default();
|
||||
let mut pane_rate = PaneRate::default();
|
||||
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
|
||||
// Literal, not Default: MsaaSetting::default() reads $WOW_MSAA.
|
||||
let mut msaa = MsaaSetting { samples: 1 };
|
||||
let mut knobs = Knobs {
|
||||
sound: &mut sound,
|
||||
scale: &mut scale,
|
||||
@@ -935,6 +1097,7 @@ mod tests {
|
||||
video: &mut video,
|
||||
pane_rate: &mut pane_rate,
|
||||
guild_notify: &mut guild_notify,
|
||||
msaa: &mut msaa,
|
||||
};
|
||||
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
|
||||
assert_eq!(knobs.sound.music, 0.7);
|
||||
@@ -943,6 +1106,14 @@ mod tests {
|
||||
assert_eq!(knobs.sound.master, 1.0);
|
||||
assert!(apply_to_knobs("farclip", "50", &mut knobs));
|
||||
assert_eq!(knobs.view.farclip, *FARCLIP_RANGE.start());
|
||||
// Multisampling clamps to the reference's own [1, 16] and takes an int the way its `atoi`
|
||||
// does — the value reaching the camera is a sample COUNT, where 1 is none (1629).
|
||||
assert!(apply_to_knobs("gxMultisample", "4", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, 4);
|
||||
assert!(apply_to_knobs("gxmultisample", "99", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.end());
|
||||
assert!(apply_to_knobs("gxmultisample", "0", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.start());
|
||||
// Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0).
|
||||
assert!(apply_to_knobs("EnableMusic", "0", &mut knobs));
|
||||
assert!(!knobs.sound.music_enabled);
|
||||
@@ -1114,6 +1285,7 @@ mod tests {
|
||||
.insert_resource(SoundConfig::default())
|
||||
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
|
||||
.insert_resource(ViewDistance { farclip: 350.0 })
|
||||
.insert_resource(MsaaSetting { samples: 1 })
|
||||
.init_resource::<LookConfig>()
|
||||
.init_resource::<ClickConfig>()
|
||||
.init_resource::<LootConfig>()
|
||||
@@ -1295,29 +1467,45 @@ mod tests {
|
||||
let parsed: LocalConfig = toml::from_str(hand).unwrap();
|
||||
assert_eq!(parsed.cvars.get("Farclip").map(String::as_str), Some("500"));
|
||||
}
|
||||
/// **`realmName` is the table's one string-valued CVar, and it defaults EMPTY.**
|
||||
/// **The table's string-valued CVars, named — and each one's default asserted on its own
|
||||
/// terms.**
|
||||
///
|
||||
/// Empty rather than a guess: the value is written from the session's real realm by
|
||||
/// `set_realm_name`, so the default only ever describes a client that has not connected.
|
||||
/// wow-re records `"Last realm connected to"` beside the registration, but that reads like the
|
||||
/// CVar's HELP text rather than its value, and nothing here needs it resolved — `""` is what
|
||||
/// `Ace/AceState.lua:27`'s `ace.trim(GetCVar("realmName"))` handles cleanly, and inventing a
|
||||
/// realm name would be worse than admitting we have none yet.
|
||||
/// Pinned as a closed list so a new string CVar has to come here and think about the numeric
|
||||
/// test above rather than silently widening it. That is exactly what happened at 1627, when
|
||||
/// this test still said "the ONE": `gxResolution` arrived and the list grew by one, on purpose.
|
||||
///
|
||||
/// Pinned as "the ONE" so a second string CVar has to come here and think about the numeric
|
||||
/// test above rather than silently widening it.
|
||||
/// **`realmName` defaults EMPTY** — empty rather than a guess: the value is written from the
|
||||
/// session's real realm by `set_realm_name`, so the default only ever describes a client that
|
||||
/// has not connected. wow-re records `"Last realm connected to"` beside the registration, but
|
||||
/// that reads like the CVar's HELP text rather than its value, and nothing here needs it
|
||||
/// resolved — `""` is what `Ace/AceState.lua:27`'s `ace.trim(GetCVar("realmName"))` handles
|
||||
/// cleanly, and inventing a realm name would be worse than admitting we have none yet.
|
||||
///
|
||||
/// **`gxResolution` defaults to the pre-1627 window** (decision 1627). It is the one row
|
||||
/// [`apply_to_knobs`] matches ahead of its numeric parse, so its default is asserted through
|
||||
/// the same parser the live value goes through — a spelling this table accepts but
|
||||
/// [`crate::video::parse_resolution`] rejects would otherwise ship as a silent fall back to
|
||||
/// `DEFAULT_WINDOWED`.
|
||||
#[test]
|
||||
fn the_only_string_valued_cvar_is_the_realm_and_it_defaults_empty() {
|
||||
let strings: Vec<&str> = REGISTERED
|
||||
fn the_string_valued_cvars_are_the_realm_and_the_windowed_size() {
|
||||
let mut strings: Vec<&str> = REGISTERED
|
||||
.iter()
|
||||
.filter(|(_, v)| v.parse::<f32>().is_err())
|
||||
.map(|(n, _)| *n)
|
||||
.collect();
|
||||
assert_eq!(strings, vec!["realmName"]);
|
||||
let realm = REGISTERED
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "realmName")
|
||||
.map(|(_, v)| *v);
|
||||
assert_eq!(realm, Some(""));
|
||||
strings.sort_unstable(); // the list is the claim, not where the rows sit in the table
|
||||
assert_eq!(strings, vec!["gxResolution", "realmName"]);
|
||||
let default_of = |name: &str| {
|
||||
REGISTERED
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, v)| *v)
|
||||
.expect("registered")
|
||||
};
|
||||
assert_eq!(default_of("realmName"), "");
|
||||
assert_eq!(
|
||||
crate::video::parse_resolution(default_of("gxResolution")),
|
||||
Some(crate::video::DEFAULT_WINDOWED)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ impl Plugin for DevProbesPlugin {
|
||||
if [
|
||||
"WOW_PROBE",
|
||||
"WOW_PROBE_CHAT",
|
||||
"WOW_PROBE_HOVER",
|
||||
"WOW_PROBE_KEY",
|
||||
"WOW_PROBE_LUA",
|
||||
"WOW_PROBE_CHEST",
|
||||
@@ -139,6 +140,12 @@ impl Plugin for DevProbesPlugin {
|
||||
if std::env::var("WOW_PROBE_DRAG").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeDragPlugin);
|
||||
}
|
||||
// The probe-hover sweep: `WOW_PROBE_HOVER="ActionButton1;ActionButton2;…"` crosses each
|
||||
// named frame's centre through the real pointer path, pressing nothing — the "hand on
|
||||
// the mouse" `WOW_HOVER_LOG` was built to record and could not supply itself.
|
||||
if std::env::var("WOW_PROBE_HOVER").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeHoverPlugin);
|
||||
}
|
||||
// The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press
|
||||
// space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`).
|
||||
if std::env::var("WOW_PROBE_KEY").is_ok() {
|
||||
|
||||
@@ -51,6 +51,10 @@ struct Row {
|
||||
frame_ms: f32,
|
||||
cpu_ms: f32,
|
||||
cost: UiFrameCost,
|
||||
/// The RENDER-side half of the same frame (`ui_pass::UiMeshCost`). Added after the 1625 fix
|
||||
/// left the symptom standing: the UI-script phases read flat on a hovered frame while the mesh
|
||||
/// rebuild below them was doing whole-interface work, and this recorder could not see it.
|
||||
mesh: crate::ui_pass::UiMeshCost,
|
||||
/// The tooltip context: `None` = no tooltip up. `(owner frame, line count, first line)`.
|
||||
tip: Option<(String, i64, String)>,
|
||||
}
|
||||
@@ -85,7 +89,8 @@ impl Plugin for HoverLogPlugin {
|
||||
out,
|
||||
"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"
|
||||
tip_first_line,spliced,mesh_us,mesh_sort_us,mesh_split_us,mesh_write_us,\
|
||||
mesh_quads,mesh_runs"
|
||||
);
|
||||
info!("hover log: recording every frame to {path}");
|
||||
app.insert_resource(Recorder {
|
||||
@@ -134,6 +139,7 @@ fn cell(s: &str) -> String {
|
||||
fn record(
|
||||
time: Res<Time<Real>>,
|
||||
cost: Res<UiFrameCost>,
|
||||
mesh: Res<crate::ui_pass::UiMeshCost>,
|
||||
script: Option<NonSend<UiScript>>,
|
||||
mut rec: ResMut<Recorder>,
|
||||
) {
|
||||
@@ -153,7 +159,7 @@ fn record(
|
||||
let (owner, lines, first) = tip
|
||||
.clone()
|
||||
.unwrap_or_else(|| (String::new(), 0, String::new()));
|
||||
let _ = writeln!(
|
||||
let _ = write!(
|
||||
rec.out,
|
||||
"{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{},{}",
|
||||
c.tick,
|
||||
@@ -172,10 +178,16 @@ fn record(
|
||||
cell(&first),
|
||||
c.spliced,
|
||||
);
|
||||
let _ = writeln!(
|
||||
rec.out,
|
||||
",{},{},{},{},{},{}",
|
||||
mesh.total, mesh.sort, mesh.split, mesh.write, mesh.quads, mesh.runs
|
||||
);
|
||||
rec.rows.push(Row {
|
||||
frame_ms,
|
||||
cpu_ms,
|
||||
cost: c,
|
||||
mesh: mesh.clone(),
|
||||
tip,
|
||||
});
|
||||
}
|
||||
@@ -216,6 +228,14 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
|
||||
// The law is zero — anything else is a write site that gave up naming its node, which
|
||||
// `WOW_LAYOUT_DERIVE_TRACE=<secs>:<n>` will backtrace on a live run.
|
||||
let derives = rows.iter().map(|r| r.cost.derives as f64).sum::<f64>() / f64::from(n);
|
||||
// The RENDER half of the same frame. `rebuilt%` is the one to read first: a population that
|
||||
// rebuilds the mesh on every frame is paying whole-interface work per frame, whatever the
|
||||
// script-pass phases say.
|
||||
let mesh_us = |f: fn(&crate::ui_pass::UiMeshCost) -> u128| {
|
||||
rows.iter().map(|r| f(&r.mesh) as f64).sum::<f64>() / f64::from(n)
|
||||
};
|
||||
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);
|
||||
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
|
||||
@@ -228,7 +248,8 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
|
||||
" {label:<34} n={:<6} wall p50={:>6.2} p99={:>6.2} max={:>7.2} cpu p50={:>6.2} \
|
||||
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}%",
|
||||
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}",
|
||||
rows.len(),
|
||||
pct(&wall, 0.50),
|
||||
pct(&wall, 0.99),
|
||||
@@ -246,6 +267,12 @@ fn summarize(label: &str, rows: &[&Row]) -> String {
|
||||
derives,
|
||||
measured,
|
||||
skips,
|
||||
mesh_us(|m| m.total),
|
||||
mesh_us(|m| m.sort),
|
||||
mesh_us(|m| m.split),
|
||||
mesh_us(|m| m.write),
|
||||
rebuilt,
|
||||
mesh_runs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,6 +340,18 @@ fn parse_row(line: &str) -> Option<Row> {
|
||||
if fields.len() < 17 {
|
||||
return None;
|
||||
}
|
||||
// The render-side columns are APPENDED (a pre-1625 recording has none) — absent reads as zero,
|
||||
// the same posture `spliced` took when it was added.
|
||||
let n = |i: usize| fields.get(i).and_then(|f| f.parse().ok()).unwrap_or(0);
|
||||
let mesh = crate::ui_pass::UiMeshCost {
|
||||
rebuilt: n(18) > 0,
|
||||
total: n(18),
|
||||
sort: n(19),
|
||||
split: n(20),
|
||||
write: n(21),
|
||||
quads: n(22) as usize,
|
||||
runs: n(23) as usize,
|
||||
};
|
||||
let num = |i: usize| fields[i].parse::<f64>().ok();
|
||||
let owner = fields[14].clone();
|
||||
Some(Row {
|
||||
@@ -338,6 +377,7 @@ fn parse_row(line: &str) -> Option<Row> {
|
||||
// Appended column (absent in pre-splice recordings — those read back as 0).
|
||||
spliced: fields.get(17).and_then(|f| f.parse().ok()).unwrap_or(0),
|
||||
},
|
||||
mesh,
|
||||
tip: if owner.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -358,6 +358,14 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
|
||||
app.add_plugins(benilla_world::boot::tuned_default_plugins(Window {
|
||||
title: "benilla".into(),
|
||||
// **Born in the player's display mode, not flipped into it** (decision 1627). `gxWindow`
|
||||
// is read straight off `config.toml` here rather than waiting for `Startup`'s
|
||||
// `load_config`, because a launch that opens windowed and goes fullscreen one frame later
|
||||
// is a visible flash on every start — and, under a compositor that only maps a fullscreen
|
||||
// surface 1:1 (gamescope), a first second spent in the exact input state this is meant to
|
||||
// end. Every instrumented run stays windowed regardless (`video::windowed_env`), so the
|
||||
// capture harness, the probe fleet and `$WOW_WIN` are untouched by any of this.
|
||||
mode: video::boot_window_mode(),
|
||||
// UI-fixture captures shrink the window so the docked panel fills the frame — the capture
|
||||
// is the look-pass instrument and the window is its subject. The action bar is the
|
||||
// exception: it spans 1024px + 128px end caps along the screen bottom, so it gets a wide,
|
||||
@@ -416,7 +424,11 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
.unwrap_or(if benilla_world::bgwin::no_pixel_run() {
|
||||
UVec2::new(640, 360)
|
||||
} else {
|
||||
UVec2::new(1600, 900)
|
||||
// The player's `gxResolution` — what "windowed" means for them, and what
|
||||
// leaving fullscreen restores (1627). Its default is the 1600×900 that was
|
||||
// hard-coded here before, and while `mode` above is fullscreen `bevy_winit`
|
||||
// ignores this entirely (it applies an inner size only on the `Windowed` arm).
|
||||
video::boot_windowed_size()
|
||||
})
|
||||
.into()
|
||||
},
|
||||
|
||||
+126
-119
@@ -209,125 +209,132 @@ impl Plugin for PlayerPlugin {
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
);
|
||||
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
|
||||
// The world camera renders only when the world can be seen (decision 0540): in world,
|
||||
// or under the opaque loading screen (whose covered render is what compiles the
|
||||
// world's pipelines before the first visible frame). At the glue screens the fully
|
||||
// streamed world otherwise burns real GPU time behind an opaque fullscreen scene.
|
||||
.add_systems(Update, setup::gate_world_camera)
|
||||
// In capture mode the harness ([`crate::capture`]) pins the camera (and thus the stream
|
||||
// focus), so `control` must not also drive it — gate it off when capturing. In-world
|
||||
// only (decision 0193): at the character-select glue screen the controller must not
|
||||
// grab the cursor, fly the camera, or queue movement sends behind the overlay.
|
||||
.add_systems(
|
||||
Update,
|
||||
control
|
||||
.in_set(PlayerControlSet)
|
||||
.in_set(WorldStage::Input)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The posture setter's queue (the `/sit` family — decision 0881; `control` is the sole
|
||||
// executor, like the sheath queue).
|
||||
.add_message::<StandStateRequest>()
|
||||
// Land-here ([`land`]): the ask, and the re-attach when the server's teleport lands.
|
||||
// Before `control` so the frame that applies the teleport is the frame that takes
|
||||
// third-person control back — `control` reads `detached` after this has cleared it.
|
||||
.add_message::<land::LandHere>()
|
||||
.add_systems(
|
||||
Update,
|
||||
land::land_here
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// (The two scripted probe drivers that used to sit here — `WOW_PROBE_LOOK`'s
|
||||
// mouse-turn and `WOW_PROBE_CAM`'s camera park — are the harness's now, and register
|
||||
// themselves against `control` from there: decision 1174 moved every instrument out of
|
||||
// this module so a player build carries none of them.)
|
||||
// A server-authored spline (Charge/knockback/taxi) driving our own player is mirrored into
|
||||
// `Player` here, *before* `control` reads `pos` to seat the camera and skip input. Same
|
||||
// gates as `control` (not while capturing; in-world only).
|
||||
.add_systems(
|
||||
Update,
|
||||
server_ride::drive_self_ride
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// A session END releases the avatar — a confirmed `/logout`, or a lost session
|
||||
// (decision 1262): the streamed entity is despawned by the net drain either way, and
|
||||
// dropping `active` re-arms the take-control latch for the next login (possibly a
|
||||
// different character). Ungated — the message lands as the state flips.
|
||||
.add_systems(
|
||||
Update,
|
||||
wire_in::release_on_session_end.in_set(WorldStage::Input),
|
||||
)
|
||||
// Which body the client drives at all (decision 1277). Strictly before everything that
|
||||
// reads the marker — the controller, and the collision-height mirror below.
|
||||
.add_systems(
|
||||
Update,
|
||||
embody::maintain_embodiment
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.before(mirror_mover_collision_height),
|
||||
)
|
||||
// Mirror the driven body's collision height onto `Player` for the swim arm (decision
|
||||
// 0645). A *continuous* sync rather than a one-shot at take-control, for the reason the
|
||||
// take-control branch itself records: a cross-map worldport re-streams the entity, so
|
||||
// anything latched on that edge is lost on transfer — and a possession swaps it for a
|
||||
// body of an entirely different size. Before `control`, which is where the swim depth
|
||||
// lines are evaluated.
|
||||
.add_systems(
|
||||
Update,
|
||||
mirror_mover_collision_height
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control),
|
||||
)
|
||||
// The camera shake (B298, decision 1540) lands on the camera AFTER `control` has
|
||||
// seated it: the applier adds its offset to the pose `seat_camera` just wrote, so the
|
||||
// eye it measures the distance falloff against is the un-shaken one. `control` is at
|
||||
// Bevy's 16-param ceiling, so the offset cannot be threaded into it as a resource —
|
||||
// and running after is the better shape anyway.
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::camera_shake::apply_camera_shake
|
||||
.in_set(WorldStage::Input)
|
||||
.after(control)
|
||||
// Gated off in capture mode alongside `control`, and for the same reason a
|
||||
// capture keeps the doodad rail static: a pinned camera that a passing kodo
|
||||
// could nudge is not a regression baseline any more.
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
|
||||
)
|
||||
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
|
||||
// input immediately BEFORE the controller, which folds the flag into its forward axis.
|
||||
// The player's own turn input therefore runs after us and wins, which is exactly what
|
||||
// makes the turn-away cancel reachable.
|
||||
.add_systems(
|
||||
Update,
|
||||
follow::steer_follow
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior
|
||||
// classifier + the appear/despawn fades, so it must run *after* both to win the frame while
|
||||
// fading (and yield to them otherwise). It also writes `Visibility` (the first-person
|
||||
// hide), so it must run after the model-`Visibility` authority
|
||||
// (`debug_panel::apply_model_visibility`) too — otherwise whichever system Bevy's
|
||||
// arbitrary sort ran last would win, and the authority could re-show the body in
|
||||
// first-person. First-person correctness outranks the dev creature-toggle for these few
|
||||
// submeshes. Gated off in capture mode alongside `control` (whose per-frame
|
||||
// `self_fade_alpha` it consumes), so a pinned capture never hides the avatar.
|
||||
.add_systems(
|
||||
Update,
|
||||
apply_self_model_fade
|
||||
.after(benilla_world::interior::classify_entity_interior)
|
||||
.after(benilla_world::model_fade::apply_render_fade)
|
||||
.after(benilla_world::model_render::ModelVisSet)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
|
||||
);
|
||||
app.add_systems(
|
||||
Startup,
|
||||
// AFTER the config fold, not merely after the assets: the camera reads `gxMultisample`
|
||||
// once, at spawn (1629), so a player's setting has to be in the resource by now.
|
||||
setup::setup_player
|
||||
.after(AssetSet::Open)
|
||||
.after(crate::cvars::CvarLoad),
|
||||
)
|
||||
// The world camera renders only when the world can be seen (decision 0540): in world,
|
||||
// or under the opaque loading screen (whose covered render is what compiles the
|
||||
// world's pipelines before the first visible frame). At the glue screens the fully
|
||||
// streamed world otherwise burns real GPU time behind an opaque fullscreen scene.
|
||||
.add_systems(Update, setup::gate_world_camera)
|
||||
// In capture mode the harness ([`crate::capture`]) pins the camera (and thus the stream
|
||||
// focus), so `control` must not also drive it — gate it off when capturing. In-world
|
||||
// only (decision 0193): at the character-select glue screen the controller must not
|
||||
// grab the cursor, fly the camera, or queue movement sends behind the overlay.
|
||||
.add_systems(
|
||||
Update,
|
||||
control
|
||||
.in_set(PlayerControlSet)
|
||||
.in_set(WorldStage::Input)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The posture setter's queue (the `/sit` family — decision 0881; `control` is the sole
|
||||
// executor, like the sheath queue).
|
||||
.add_message::<StandStateRequest>()
|
||||
// Land-here ([`land`]): the ask, and the re-attach when the server's teleport lands.
|
||||
// Before `control` so the frame that applies the teleport is the frame that takes
|
||||
// third-person control back — `control` reads `detached` after this has cleared it.
|
||||
.add_message::<land::LandHere>()
|
||||
.add_systems(
|
||||
Update,
|
||||
land::land_here
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// (The two scripted probe drivers that used to sit here — `WOW_PROBE_LOOK`'s
|
||||
// mouse-turn and `WOW_PROBE_CAM`'s camera park — are the harness's now, and register
|
||||
// themselves against `control` from there: decision 1174 moved every instrument out of
|
||||
// this module so a player build carries none of them.)
|
||||
// A server-authored spline (Charge/knockback/taxi) driving our own player is mirrored into
|
||||
// `Player` here, *before* `control` reads `pos` to seat the camera and skip input. Same
|
||||
// gates as `control` (not while capturing; in-world only).
|
||||
.add_systems(
|
||||
Update,
|
||||
server_ride::drive_self_ride
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// A session END releases the avatar — a confirmed `/logout`, or a lost session
|
||||
// (decision 1262): the streamed entity is despawned by the net drain either way, and
|
||||
// dropping `active` re-arms the take-control latch for the next login (possibly a
|
||||
// different character). Ungated — the message lands as the state flips.
|
||||
.add_systems(
|
||||
Update,
|
||||
wire_in::release_on_session_end.in_set(WorldStage::Input),
|
||||
)
|
||||
// Which body the client drives at all (decision 1277). Strictly before everything that
|
||||
// reads the marker — the controller, and the collision-height mirror below.
|
||||
.add_systems(
|
||||
Update,
|
||||
embody::maintain_embodiment
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.before(mirror_mover_collision_height),
|
||||
)
|
||||
// Mirror the driven body's collision height onto `Player` for the swim arm (decision
|
||||
// 0645). A *continuous* sync rather than a one-shot at take-control, for the reason the
|
||||
// take-control branch itself records: a cross-map worldport re-streams the entity, so
|
||||
// anything latched on that edge is lost on transfer — and a possession swaps it for a
|
||||
// body of an entirely different size. Before `control`, which is where the swim depth
|
||||
// lines are evaluated.
|
||||
.add_systems(
|
||||
Update,
|
||||
mirror_mover_collision_height
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control),
|
||||
)
|
||||
// The camera shake (B298, decision 1540) lands on the camera AFTER `control` has
|
||||
// seated it: the applier adds its offset to the pose `seat_camera` just wrote, so the
|
||||
// eye it measures the distance falloff against is the un-shaken one. `control` is at
|
||||
// Bevy's 16-param ceiling, so the offset cannot be threaded into it as a resource —
|
||||
// and running after is the better shape anyway.
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::camera_shake::apply_camera_shake
|
||||
.in_set(WorldStage::Input)
|
||||
.after(control)
|
||||
// Gated off in capture mode alongside `control`, and for the same reason a
|
||||
// capture keeps the doodad rail static: a pinned camera that a passing kodo
|
||||
// could nudge is not a regression baseline any more.
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
|
||||
)
|
||||
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
|
||||
// input immediately BEFORE the controller, which folds the flag into its forward axis.
|
||||
// The player's own turn input therefore runs after us and wins, which is exactly what
|
||||
// makes the turn-away cancel reachable.
|
||||
.add_systems(
|
||||
Update,
|
||||
follow::steer_follow
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
// The self-avatar zoom-in fade rides the same `MeshTag`/material channel as the interior
|
||||
// classifier + the appear/despawn fades, so it must run *after* both to win the frame while
|
||||
// fading (and yield to them otherwise). It also writes `Visibility` (the first-person
|
||||
// hide), so it must run after the model-`Visibility` authority
|
||||
// (`debug_panel::apply_model_visibility`) too — otherwise whichever system Bevy's
|
||||
// arbitrary sort ran last would win, and the authority could re-show the body in
|
||||
// first-person. First-person correctness outranks the dev creature-toggle for these few
|
||||
// submeshes. Gated off in capture mode alongside `control` (whose per-frame
|
||||
// `self_fade_alpha` it consumes), so a pinned capture never hides the avatar.
|
||||
.add_systems(
|
||||
Update,
|
||||
apply_self_model_fade
|
||||
.after(benilla_world::interior::classify_entity_interior)
|
||||
.after(benilla_world::model_fade::apply_render_fade)
|
||||
.after(benilla_world::model_render::ModelVisSet)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,14 @@ use super::{
|
||||
/// the character controller is in.)
|
||||
const DEFAULT_MOVE_SPEED: f32 = 7.0;
|
||||
|
||||
fn spawn_fallback_camera(commands: &mut Commands) {
|
||||
fn spawn_fallback_camera(commands: &mut Commands, msaa: Msaa) {
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
WorldCamera,
|
||||
// The same level the real camera takes — this one used to name nothing at all, which
|
||||
// (`Camera` requires `Msaa`, defaulting to `Sample4`) meant a data-less free-fly quietly
|
||||
// ran four samples no matter what the player had set. Decision 1629.
|
||||
msaa,
|
||||
Hdr,
|
||||
Tonemapping::None,
|
||||
benilla_world::ffx_glow::FfxGlow::WORLD,
|
||||
@@ -48,6 +52,9 @@ pub(super) fn setup_player(
|
||||
mut commands: Commands,
|
||||
config: Option<Res<RenderConfig>>,
|
||||
world_assets: Option<Res<WorldAssets>>,
|
||||
// The pending `gxMultisample` (decision 1629), already resolved: this system is ordered after
|
||||
// [`crate::cvars::CvarLoad`], so `config.toml` has been folded in before the camera is born.
|
||||
msaa: Res<benilla_world::view::MsaaSetting>,
|
||||
) {
|
||||
let env_speed = std::env::var("WOW_MOVE_SPEED")
|
||||
.ok()
|
||||
@@ -75,7 +82,7 @@ pub(super) fn setup_player(
|
||||
|
||||
// No client data → free-fly an empty scene.
|
||||
let (Some(_), Some(_)) = (config, world_assets) else {
|
||||
spawn_fallback_camera(&mut commands);
|
||||
spawn_fallback_camera(&mut commands, msaa.level());
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -89,15 +96,12 @@ pub(super) fn setup_player(
|
||||
// THE world camera (the portrait booths are further `Camera3d`s — every "where is the viewer"
|
||||
// consumer filters on this marker, never on bare `Camera3d`; see its doc).
|
||||
WorldCamera,
|
||||
// MSAA from $WOW_MSAA (off/0/1 → Off, 2, 4 default, 8). A STARTUP knob, not a live toggle:
|
||||
// switching MSAA at runtime leaves our post-process passes (glow/egui) MSAA-mismatched and
|
||||
// freezes the view, so A/B by restarting. 4× is real GPU fill cost — performance-foundation.md.
|
||||
match std::env::var("WOW_MSAA").ok().as_deref() {
|
||||
Some("off") | Some("0") | Some("1") => bevy::render::view::Msaa::Off,
|
||||
Some("2") => bevy::render::view::Msaa::Sample2,
|
||||
Some("8") => bevy::render::view::Msaa::Sample8,
|
||||
_ => bevy::render::view::Msaa::Sample4,
|
||||
},
|
||||
// The player's `gxMultisample` (decision 1629), read ONCE here and never again — the
|
||||
// reference registers this CVar *latched* and its callback echoes "set pending gxRestart",
|
||||
// so a change is pending until the next launch. Which is also the only thing we could do:
|
||||
// swapping MSAA live leaves our post passes (glow/egui) MSAA-mismatched and freezes the
|
||||
// view. `$WOW_MSAA` still overrides it session-only, through the resource's `Default`.
|
||||
msaa.level(),
|
||||
Projection::from(PerspectiveProjection {
|
||||
far: cam_far,
|
||||
near: CAM_NEAR,
|
||||
|
||||
@@ -550,6 +550,8 @@ pub(crate) struct PlayerUiPlugin;
|
||||
impl Plugin for PlayerUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<UiQuads>()
|
||||
.init_resource::<UiMeshCost>()
|
||||
.init_resource::<crate::ui_script::UiCostWanted>()
|
||||
.add_plugins((
|
||||
Material2dPlugin::<UiQuadMaterial>::default(),
|
||||
// Owned here, not in main.rs: the lane's decode is not optional (see its doc).
|
||||
@@ -590,6 +592,23 @@ fn spawn_ui_camera(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
PlayerUiCamera,
|
||||
Camera2d,
|
||||
// **No MSAA — named, because silence here means 4×.** `bevy_render`'s `CameraPlugin`
|
||||
// registers `Camera` → `Msaa` as a required component (`bevy_render/src/camera.rs:56`) and
|
||||
// `Msaa::default()` is `Sample4`, so a camera that never mentions MSAA does not get "none",
|
||||
// it gets four samples. Every other non-world camera in the tree says `Msaa::Off` out loud
|
||||
// (the portrait booths, the minimap composite); this one simply never did, and paid for it.
|
||||
//
|
||||
// There is nothing here for multisampling to resolve. The world arrives already resolved —
|
||||
// since decision 1603 the world camera renders offscreen and this pass draws the finished
|
||||
// image as its first quad ([`crate::world_backdrop`]), so this camera's samples only
|
||||
// re-average an image whose own MSAA is long since done. What it draws itself is
|
||||
// axis-aligned rects, and the Bevy UI trees riding this camera (decision 0541) antialias
|
||||
// their own edges analytically in-shader. What it *costs* is a full-window 4× sampled
|
||||
// colour texture, a full-window 4× multisampled Core2d depth texture (Bevy sizes that one
|
||||
// at `msaa.samples()` unconditionally — `core_2d::prepare_core_2d_depth_textures` — even
|
||||
// though `AlphaMode2d::Blend` puts every quad in `Transparent2d`, which never writes it),
|
||||
// 4× the fill on every quad, and a resolve. Decision 1628.
|
||||
bevy::render::view::Msaa::Off,
|
||||
// The gamma composite lane's mandatory decode (decision 0254) — without it the UI presents
|
||||
// ~2.2× bright, since the quad pass leaves gamma values in the target.
|
||||
crate::ui_gamma::UiGammaLane,
|
||||
@@ -923,6 +942,35 @@ static UI_DIFF: std::sync::LazyLock<bool> =
|
||||
static UI_PROBE: std::sync::LazyLock<bool> =
|
||||
std::sync::LazyLock::new(|| std::env::var("WOW_UI_PROBE").as_deref() == Ok("1"));
|
||||
|
||||
/// **What a `quads.dirty` frame costs on the RENDER side** — the meter this pass never had
|
||||
/// (decision 1625's residual, found the hard way).
|
||||
///
|
||||
/// `[ui-cost]` and the hover recorder split the UI *script* pass into tick/resolve/measure/extract/
|
||||
/// convert, and stop there. Everything below — sorting every quad in the interface, clipping and
|
||||
/// run-splitting them, and regenerating each run's vertex data — runs in a DIFFERENT system, on
|
||||
/// exactly the frames a tooltip content change dirties the lane, and no instrument had a clock on
|
||||
/// it. So the recorder built for the hover symptom could report "the UI phases are flat" on a frame
|
||||
/// that was doing a full mesh rebuild, which is worse than silence: it reads as an alibi.
|
||||
///
|
||||
/// Published as its own resource rather than a field of `UiFrameCost` because the two systems are
|
||||
/// unordered within `Update` — a field there would be wiped by whichever ran second.
|
||||
#[derive(Resource, Default, Clone)]
|
||||
pub(crate) struct UiMeshCost {
|
||||
/// Did the rebuild actually run this frame, or did the early-out take it?
|
||||
pub(crate) rebuilt: bool,
|
||||
/// Total µs across the whole rebuild.
|
||||
pub(crate) total: u128,
|
||||
/// Collect + stable sort over every quad in both lanes.
|
||||
pub(crate) sort: u128,
|
||||
/// Clip + split into texture-identity runs.
|
||||
pub(crate) split: u128,
|
||||
/// Vertex-data regeneration, mesh writes, material lookups, batch entity churn.
|
||||
pub(crate) write: u128,
|
||||
/// How many quads were sorted, and how many runs came out — the counts behind the µs.
|
||||
pub(crate) quads: usize,
|
||||
pub(crate) runs: usize,
|
||||
}
|
||||
|
||||
fn rebuild_ui_mesh(
|
||||
mut quads: ResMut<UiQuads>,
|
||||
mut commands: Commands,
|
||||
@@ -931,8 +979,30 @@ fn rebuild_ui_mesh(
|
||||
mut pools: Local<BatchPools>,
|
||||
white: Option<Res<UiWhiteTexture>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
hidden: Res<crate::ui_hide::UiHidden>,
|
||||
// The hide binding and the cost meter's pair, bundled (clippy's argument ceiling — the same
|
||||
// reason `stores` is a tuple).
|
||||
mut hide_and_meter: (
|
||||
Res<crate::ui_hide::UiHidden>,
|
||||
ResMut<UiMeshCost>,
|
||||
Res<crate::ui_script::UiCostWanted>,
|
||||
),
|
||||
) {
|
||||
let (hidden, mesh_cost, cost_wanted) =
|
||||
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
|
||||
// The meter is off unless something asked (the hover recorder, `WOW_UI_COST=1`) — an unmetered
|
||||
// rebuild pays one bool test, not six clock reads.
|
||||
let cost_on = cost_wanted.0 || crate::ui_script::extract::ui_cost_enabled();
|
||||
**mesh_cost = UiMeshCost::default();
|
||||
let t_rebuild = cost_on.then(std::time::Instant::now);
|
||||
let mut t_mark = t_rebuild;
|
||||
let mut lap = move || -> u128 {
|
||||
if !cost_on {
|
||||
return 0;
|
||||
}
|
||||
t_mark
|
||||
.replace(std::time::Instant::now())
|
||||
.map_or(0, |t| t.elapsed().as_micros())
|
||||
};
|
||||
let (meshes, materials) = (&mut stores.0, &mut stores.1);
|
||||
let q = quads.as_mut();
|
||||
// TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes
|
||||
@@ -1026,6 +1096,8 @@ fn rebuild_ui_mesh(
|
||||
q.quads.iter().chain(q.overlays.iter()).collect()
|
||||
};
|
||||
sorted.sort_by_key(|q| q.z_key);
|
||||
let n_sorted = sorted.len();
|
||||
let us_sort = lap();
|
||||
// The backdrop is PREPENDED, not sorted in. Giving it a `z_key` would mean picking a number
|
||||
// below every other producer's and trusting all of them to stay above it — and the append
|
||||
// lane's lowest band is already 0 (`overlay_z::WORLD_TEXT`), so there is no room under it
|
||||
@@ -1122,6 +1194,7 @@ 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.
|
||||
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;
|
||||
// materials on live batches survive via the entities' own handle clones and simply re-enter
|
||||
@@ -1327,6 +1400,18 @@ fn rebuild_ui_mesh(
|
||||
pools.meshes.truncate(used);
|
||||
pools.stored.truncate(used);
|
||||
pools.offsets.truncate(used);
|
||||
if cost_on {
|
||||
let us_write = lap();
|
||||
**mesh_cost = UiMeshCost {
|
||||
rebuilt: true,
|
||||
total: t_rebuild.map_or(0, |t| t.elapsed().as_micros()),
|
||||
sort: us_sort,
|
||||
split: us_split,
|
||||
write: us_write,
|
||||
quads: n_sorted,
|
||||
runs: used,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately-overlapping synthetic content proving the sort: 5 z strata × 40 quads each, offset both
|
||||
@@ -1424,6 +1509,8 @@ mod tests {
|
||||
.init_asset::<Mesh>()
|
||||
.init_asset::<UiQuadMaterial>()
|
||||
.init_resource::<UiQuads>()
|
||||
.init_resource::<UiMeshCost>()
|
||||
.init_resource::<crate::ui_script::UiCostWanted>()
|
||||
.init_resource::<crate::ui_hide::UiHidden>()
|
||||
.insert_resource(UiWhiteTexture(Handle::default()))
|
||||
.add_systems(Update, rebuild_ui_mesh);
|
||||
|
||||
@@ -25,7 +25,7 @@ use cooldown::cooldown_quads;
|
||||
/// each phase's wall μs, the quad counts, the layout gate's decision, and whether the diff found
|
||||
/// the produced quads changed. Untraced by design — the campaign grades untraced cpu_ms, and
|
||||
/// `trace_chrome` inflates exactly the fine-grained spans this measures (0718's calibration).
|
||||
fn ui_cost_enabled() -> bool {
|
||||
pub(crate) fn ui_cost_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use benilla_world::schedule::WorldStage;
|
||||
/// because the AddOns screens read the same folder without a VM (decision 1196).
|
||||
pub(crate) mod addons;
|
||||
mod content;
|
||||
mod extract;
|
||||
pub(crate) mod extract;
|
||||
mod input;
|
||||
mod manifest;
|
||||
|
||||
|
||||
@@ -1925,9 +1925,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// 25 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
|
||||
// 26 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; Camera Following Style
|
||||
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's and its Windowed Mode
|
||||
// 1627'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
|
||||
@@ -1937,7 +1938,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, 53, "every tipped row carries a live 1.12 key");
|
||||
assert_eq!(checked, 54, "every tipped row carries a live 1.12 key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2033,15 +2034,15 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
// 25 of the 26 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
|
||||
// 26 of the 27 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; Camera Following Style 1493's; Terrain
|
||||
// Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
|
||||
// Max Camera Distance 1140's; Vertical Sync 1394's and Windowed Mode 1627'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, 53, "every row but Auto Loot has a 1.12 description");
|
||||
assert_eq!(raised, 54, "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
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
//! Player-facing **video settings** — the knobs that reach the window and the presentation path.
|
||||
//!
|
||||
//! Today that is exactly one: **VSync** (`gxVSync`, the Graphics page's *Vertical Sync* row).
|
||||
//! Two, both of them 1.12's own CVars over the Graphics page: **Display Mode** (`gxWindow`, the
|
||||
//! *Windowed Mode* row) and **VSync** (`gxVSync`, the *Vertical Sync* row).
|
||||
//!
|
||||
//! # Display mode — the reference's CVar, deliberately not the reference's meaning (decision 1627)
|
||||
//!
|
||||
//! 1.12 ships a **two-state** display model: `gxWindow "0"` takes the display with an exclusive
|
||||
//! mode-set at `gxResolution`, `gxWindow "1"` runs in a window (with `gxMaximize` for a maximized
|
||||
//! one). We keep the CVar, the row, and the default; we redefine `"0"`.
|
||||
//!
|
||||
//! **`"0"` here means BORDERLESS fullscreen** — a normal window sized to the monitor, flagged
|
||||
//! fullscreen to the compositor (`xdg_toplevel.set_fullscreen` on Wayland,
|
||||
//! `_NET_WM_STATE_FULLSCREEN` on X11, a native fullscreen window on macOS). We ship no exclusive
|
||||
//! mode at all, and that is not a shortcut — it is unavailable or pointless on all three targets:
|
||||
//!
|
||||
//! - **Wayland has no client-side mode-setting**, so there is nothing to implement: winit 0.30.13
|
||||
//! answers `Fullscreen::Exclusive` with ``warn!("`Fullscreen::Exclusive` is ignored on Wayland")``
|
||||
//! and leaves the window as it was (`platform_impl/linux/wayland/window/mod.rs:147,488`).
|
||||
//! - **On X11 it is XRandR mode-setting**, which changes the *desktop's* mode and, as winit's own
|
||||
//! comment says, "does not provide a mechanism to … restore this to the desktop video mode as
|
||||
//! macOS and Windows do" — a crash leaves the player's desktop at our resolution.
|
||||
//! - **On macOS** there is no exclusive mode to take.
|
||||
//!
|
||||
//! And the industry moved: SDL3 deleted `SDL_WINDOW_FULLSCREEN_DESKTOP` and made borderless-desktop
|
||||
//! what a fullscreen window *is* unless you opt into a mode with `SDL_SetWindowFullscreenMode`;
|
||||
//! WoW itself removed exclusive fullscreen in **8.0.1**, leaving Windowed and Windowed
|
||||
//! (Fullscreen). Bevy's own `WindowMode::Fullscreen` arm is a liability besides — it `expect`s a
|
||||
//! monitor at creation and `panic!`s on a live change that cannot resolve one
|
||||
//! (`bevy_winit::winit_windows:91`, `system.rs:333`).
|
||||
//!
|
||||
//! **What this is worth, concretely.** Before 1627 the window was born `WindowMode::Windowed` at a
|
||||
//! hard-coded 1600×900 with nothing able to change it — larger than a Steam Deck's 1280×800 panel,
|
||||
//! and never flagged fullscreen. A window that does not fill gamescope's nested output is a
|
||||
//! documented input break upstream (ValveSoftware/gamescope#1086 — pointer trapped in the nested
|
||||
//! rect, clicks outside it dead; #1209 — tapping the letterbox warps the cursor to centre forever),
|
||||
//! whose recorded workaround is "set the game to fullscreen".
|
||||
//!
|
||||
//! **No `gxRestart`.** 1.12 flags its whole video block restart-required; ours takes effect on the
|
||||
//! click, the same stated departure [`apply_present_mode`] already makes for `gxVSync`.
|
||||
//!
|
||||
//! # VSync
|
||||
//!
|
||||
//! **Why it is a player setting and not a dev toggle.** It briefly lived as a checkbox on the perf
|
||||
//! HUD, where it existed to answer one instrument question — "is the GPU keeping up?" — because a
|
||||
@@ -19,7 +58,7 @@
|
||||
//! takes ~1 s `nextDrawable` stalls — measured, and pinned at [`crate::capture::probe_uncap_mode`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{PresentMode, PrimaryWindow};
|
||||
use bevy::window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode};
|
||||
|
||||
/// `$WOW_NOVSYNC=1` — the session-only measurement override. It wins over the config for the run
|
||||
/// and never reaches `config.toml` (registered in [`crate::cvars`]'s `env_overridden` set), so a
|
||||
@@ -28,17 +67,123 @@ pub(crate) fn novsync_env() -> bool {
|
||||
std::env::var("WOW_NOVSYNC").as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// The video knobs a CVar write can land on. Default = what the client ships: synced, matching the
|
||||
/// primary window's own `PresentMode::default()` (Fifo) — see `lib.rs`'s window literal.
|
||||
/// Does this run **size its own window**, and therefore stay windowed whatever the config says?
|
||||
///
|
||||
/// Three sources, and the first two are named explicitly rather than left to the third because a
|
||||
/// capture that silently went fullscreen would render at the display's resolution instead of the
|
||||
/// scenario's — every visual regression diff in the tree is denominated in the window the scenario
|
||||
/// asks for, so this one must not depend on the machine's panel. The third is the general rule: an
|
||||
/// instrumented run's window is plumbing ([`benilla_world::bgwin`]), and the probe fleet sizes and
|
||||
/// parks it deliberately (decisions 0703/0709/1148).
|
||||
///
|
||||
/// Session-only, exactly like [`novsync_env`]: `gxWindow`/`gxResolution` are registered
|
||||
/// env-overridden while it holds, so the file's value neither reaches the window nor is saved over.
|
||||
pub(crate) fn windowed_env() -> bool {
|
||||
std::env::var_os("WOW_WIN").is_some()
|
||||
|| std::env::var_os("WOW_CAPTURE").is_some()
|
||||
|| std::env::var_os("WOW_CAPTURE_UI").is_some()
|
||||
|| benilla_world::bgwin::background_run()
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub(crate) enum DisplayMode {
|
||||
/// Borderless, filling the monitor. `gxWindow "0"` — the reference's own default value, and
|
||||
/// what every shipped game defaults to.
|
||||
#[default]
|
||||
Fullscreen,
|
||||
/// A window at [`VideoConfig::windowed`]. `gxWindow "1"`.
|
||||
Windowed,
|
||||
}
|
||||
|
||||
/// The windowed size a fresh config gets: the 1600×900 that was the client's only size before
|
||||
/// 1627, so nothing about a windowed run moved when the mode setting landed.
|
||||
pub(crate) const DEFAULT_WINDOWED: UVec2 = UVec2::new(1600, 900);
|
||||
|
||||
/// `gxWindow`'s value → the mode. The reference's own parse for its 0/1 CVars is int + `!= 0`, and
|
||||
/// one function is the whole law so [`crate::cvars`]'s arm and the boot read cannot drift.
|
||||
pub(crate) fn display_from_flag(v: f32) -> DisplayMode {
|
||||
if v != 0.0 {
|
||||
DisplayMode::Windowed
|
||||
} else {
|
||||
DisplayMode::Fullscreen
|
||||
}
|
||||
}
|
||||
|
||||
/// `gxResolution`'s value → a size. The reference's spelling (`"1280x800"`), whose own parse is
|
||||
/// `sscanf("%d%c%d")`; ours is stricter by one thing only — a zero extent is refused rather than
|
||||
/// handed to the windowing system.
|
||||
pub(crate) fn parse_resolution(value: &str) -> Option<UVec2> {
|
||||
let (w, h) = value.split_once(['x', 'X'])?;
|
||||
let size = UVec2::new(w.trim().parse().ok()?, h.trim().parse().ok()?);
|
||||
(size.x > 0 && size.y > 0).then_some(size)
|
||||
}
|
||||
|
||||
/// The `WindowMode` a display mode means, on a given monitor.
|
||||
pub(crate) fn window_mode(display: DisplayMode, monitor: MonitorSelection) -> WindowMode {
|
||||
match display {
|
||||
DisplayMode::Fullscreen => WindowMode::BorderlessFullscreen(monitor),
|
||||
DisplayMode::Windowed => WindowMode::Windowed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode the primary window is **born** in — resolved before the `App` exists, because the
|
||||
/// alternative is worse than the frame it costs. Booting windowed and flipping at `Startup` (where
|
||||
/// [`crate::cvars`]'s `load_config` runs) is a visible flash on every launch, and under a
|
||||
/// compositor that only maps a fullscreen surface 1:1 it is a first second spent in exactly the
|
||||
/// broken input state 1627 exists to end.
|
||||
///
|
||||
/// `MonitorSelection::Primary`, not `Current`: at creation there is no current monitor —
|
||||
/// `bevy_winit::select_monitor` warns and answers `None` for `Current` — and "the monitor the
|
||||
/// window is on" is not a question with an answer before the window exists. A live toggle uses
|
||||
/// `Current`; [`apply_window_mode`] carries why the two never fight.
|
||||
pub(crate) fn boot_window_mode() -> WindowMode {
|
||||
let display = if windowed_env() {
|
||||
DisplayMode::Windowed
|
||||
} else {
|
||||
crate::cvars::boot_cvar("gxWindow")
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.map_or_else(DisplayMode::default, display_from_flag)
|
||||
};
|
||||
window_mode(display, MonitorSelection::Primary)
|
||||
}
|
||||
|
||||
/// The windowed size the primary window is **born** at — `gxResolution`, read at the same pre-`App`
|
||||
/// moment and for the same reason as [`boot_window_mode`]. Ignored by winit while the mode is
|
||||
/// fullscreen (`bevy_winit` applies an inner size only on the `Windowed` arm), and the value
|
||||
/// [`apply_window_mode`] hands back on the way out of it.
|
||||
pub(crate) fn boot_windowed_size() -> UVec2 {
|
||||
crate::cvars::boot_cvar("gxResolution")
|
||||
.and_then(|v| parse_resolution(&v))
|
||||
.unwrap_or(DEFAULT_WINDOWED)
|
||||
}
|
||||
|
||||
/// The video knobs a CVar write can land on. Default = what the client ships: fullscreen, synced,
|
||||
/// matching the primary window's own boot values — see `lib.rs`'s window literal.
|
||||
///
|
||||
/// The **file** is deliberately not read here, only the environment (the posture `vsync` has had
|
||||
/// since 0294): the window literal resolves env-then-file for itself, `load_config` applies the
|
||||
/// file to this resource at `Startup`, and because both read the same key the reconcile is a no-op
|
||||
/// rather than a mode change one frame into the run.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct VideoConfig {
|
||||
pub(crate) vsync: bool,
|
||||
pub(crate) display: DisplayMode,
|
||||
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
|
||||
pub(crate) windowed: UVec2,
|
||||
}
|
||||
|
||||
impl Default for VideoConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vsync: !novsync_env(),
|
||||
display: if windowed_env() {
|
||||
DisplayMode::Windowed
|
||||
} else {
|
||||
DisplayMode::default()
|
||||
},
|
||||
windowed: DEFAULT_WINDOWED,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,10 +209,77 @@ pub(crate) struct VideoPlugin;
|
||||
impl Plugin for VideoPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<VideoConfig>()
|
||||
.add_systems(Update, apply_present_mode);
|
||||
.add_systems(Startup, log_display_session)
|
||||
.add_systems(Update, (apply_present_mode, apply_window_mode));
|
||||
}
|
||||
}
|
||||
|
||||
/// One line at boot naming what the window actually got — and, on a Linux session, what it is
|
||||
/// talking to.
|
||||
///
|
||||
/// **The instrument half of 1627**, and it is not decoration. Every display and input report this
|
||||
/// client has had from a Linux player came from a machine nobody here can run: the Steam Deck
|
||||
/// perf reports behind 1624 and 1626, and the gamescope input report 1627 answers, were all
|
||||
/// diagnosed from prose. `bevy_winit` already logs the monitor, its scale factor and its refresh
|
||||
/// rate at creation; the two things it never says are which **backend** winit picked and whether a
|
||||
/// nested compositor is in the way — and a surprising amount hangs on the first of those.
|
||||
/// `CursorGrabMode::Locked`, which is what mouse-look asks for, is a real pointer lock on Wayland
|
||||
/// and is **rejected outright on X11** (`winit`'s x11 `set_cursor_grab` returns `NotSupported`),
|
||||
/// where `bevy_winit::attempt_grab` quietly falls back to `Confined`. "Which one am I on" should
|
||||
/// never again be a thing we reason about instead of read.
|
||||
///
|
||||
/// Not `#[cfg(feature = "dev")]`: the whole point is that it is in the log a *player* pastes.
|
||||
fn log_display_session(windows: Query<&Window, With<PrimaryWindow>>) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let res = &window.resolution;
|
||||
info!(
|
||||
"video: {:?}, {}x{} logical / {}x{} physical (scale {}){}",
|
||||
window.mode,
|
||||
res.width(),
|
||||
res.height(),
|
||||
res.physical_width(),
|
||||
res.physical_height(),
|
||||
res.scale_factor(),
|
||||
display_session(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The display-server facts worth naming, as a trailing clause. Empty everywhere but a Linux/BSD
|
||||
/// session, where the same `cfg` the Wayland clipboard uses (decision 0702) marks the one platform
|
||||
/// whose windowing backend is a runtime choice rather than the only one there is.
|
||||
fn display_session() -> String {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
|
||||
{
|
||||
let set = |k: &str| std::env::var_os(k).is_some();
|
||||
// Which backend winit took: it prefers Wayland when `WAYLAND_DISPLAY` names a socket and
|
||||
// falls back to X11 (which, under a Wayland compositor, means XWayland). Both features are
|
||||
// on — `bevy`'s `default_platform` carries `x11` *and* `wayland`, so this really is a
|
||||
// runtime pick and not a build-time one.
|
||||
let backend = match (set("WAYLAND_DISPLAY"), set("DISPLAY")) {
|
||||
(true, _) => "wayland",
|
||||
(false, true) => "x11",
|
||||
(false, false) => "none",
|
||||
};
|
||||
// gamescope exports its own socket name to children; the SteamOS session also stamps the
|
||||
// Deck. Named because a nested compositor is the difference between "the window is wrong"
|
||||
// and "the window is right and the compositor is scaling it".
|
||||
let nested = if set("GAMESCOPE_WAYLAND_DISPLAY") {
|
||||
", gamescope"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let deck = if set("SteamDeck") { ", steamdeck" } else { "" };
|
||||
return format!(
|
||||
" [{backend}{nested}{deck}, XDG_SESSION_TYPE={}]",
|
||||
std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "unset".into()),
|
||||
);
|
||||
}
|
||||
#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Push the setting to the window **when the setting moves**, and only then.
|
||||
///
|
||||
/// The value compare is the `rescatter_clutter` pattern (0992), and it is load-bearing for two
|
||||
@@ -103,6 +315,53 @@ fn apply_present_mode(
|
||||
}
|
||||
}
|
||||
|
||||
/// The display-mode half, on the same change-gated shape as [`apply_present_mode`] and for the same
|
||||
/// two reasons — with one wrinkle of its own.
|
||||
///
|
||||
/// **The guard asks whether the window is fullscreen, never on which monitor.** Birth uses
|
||||
/// `MonitorSelection::Primary` (nothing else has an answer yet) and a live toggle uses `Current`
|
||||
/// (fill the monitor the window is actually on), so the two `WindowMode`s that both mean
|
||||
/// "fullscreen" are not equal values, and a `!=` compare would re-assert fullscreen on the first
|
||||
/// frame of every launch. Matching on the variant is the honest question.
|
||||
fn apply_window_mode(
|
||||
cfg: Res<VideoConfig>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
mut last: Local<Option<DisplayMode>>,
|
||||
) {
|
||||
if last.replace(cfg.display) == Some(cfg.display) {
|
||||
return;
|
||||
}
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let want = window_mode(cfg.display, MonitorSelection::Current);
|
||||
let already = matches!(
|
||||
(&window.mode, &want),
|
||||
(WindowMode::Windowed, WindowMode::Windowed)
|
||||
| (
|
||||
WindowMode::BorderlessFullscreen(_),
|
||||
WindowMode::BorderlessFullscreen(_)
|
||||
)
|
||||
);
|
||||
if already {
|
||||
return;
|
||||
}
|
||||
// Leaving fullscreen has to hand the size back, because entering it **overwrote**
|
||||
// `window.resolution` with the monitor's — `bevy_window`'s own documented behaviour for
|
||||
// `BorderlessFullscreen` — so a bare mode flip returns a monitor-sized "window". `gxResolution`
|
||||
// is the record of what windowed means, which is exactly why it persists.
|
||||
//
|
||||
// Both writes land in one frame on purpose: `bevy_winit::changed_windows` applies `mode` before
|
||||
// `resolution` in the same pass, so the inner size is set after the window has left fullscreen.
|
||||
if cfg.display == DisplayMode::Windowed {
|
||||
window
|
||||
.resolution
|
||||
.set(cfg.windowed.x as f32, cfg.windowed.y as f32);
|
||||
}
|
||||
window.mode = want;
|
||||
info!("video: display mode {:?} ({want:?})", cfg.display);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -123,4 +382,107 @@ mod tests {
|
||||
fn uncapped_is_autonovsync_not_immediate() {
|
||||
assert_eq!(present_mode(false), PresentMode::AutoNoVsync);
|
||||
}
|
||||
|
||||
/// The shipped display mode is fullscreen, and fullscreen is **borderless** — the whole point
|
||||
/// of 1627. Welded as an assertion because `WindowMode::Fullscreen` is one word away and is the
|
||||
/// mode that is ignored on Wayland and panics without a monitor.
|
||||
#[test]
|
||||
fn the_default_is_borderless_fullscreen_not_exclusive() {
|
||||
assert_eq!(DisplayMode::default(), DisplayMode::Fullscreen);
|
||||
assert!(matches!(
|
||||
window_mode(DisplayMode::Fullscreen, MonitorSelection::Primary),
|
||||
WindowMode::BorderlessFullscreen(_)
|
||||
));
|
||||
assert_eq!(
|
||||
window_mode(DisplayMode::Windowed, MonitorSelection::Primary),
|
||||
WindowMode::Windowed
|
||||
);
|
||||
}
|
||||
|
||||
/// `gxWindow` reads like every other 0/1 CVar in the tree: int + `!= 0`, with `"1"` the
|
||||
/// *windowed* state — the reference's polarity, which is why the row is "Windowed Mode" and
|
||||
/// not "Fullscreen".
|
||||
#[test]
|
||||
fn gxwindow_one_is_windowed() {
|
||||
assert_eq!(display_from_flag(0.0), DisplayMode::Fullscreen);
|
||||
assert_eq!(display_from_flag(1.0), DisplayMode::Windowed);
|
||||
}
|
||||
|
||||
/// The mode round-trip, driven through the **real system** rather than around it — because the
|
||||
/// two things that are easy to get wrong here are both invisible to a single live run.
|
||||
///
|
||||
/// 1. A window born fullscreen must not be re-asserted on the first frame. Birth carries
|
||||
/// `MonitorSelection::Primary` and a live toggle carries `Current`, so the two `WindowMode`s
|
||||
/// that both mean "fullscreen" are unequal *values*; a `!=` guard would fire every launch.
|
||||
/// 2. Leaving fullscreen must hand `gxResolution` back. Entering it overwrote
|
||||
/// `window.resolution` with the monitor's, so a bare mode flip returns a monitor-sized
|
||||
/// "window" — and on the 3440-wide panel B242/1619 came from, that is not a subtle miss.
|
||||
#[test]
|
||||
fn the_fullscreen_round_trip_keeps_the_monitor_out_of_the_windowed_size() {
|
||||
let mut app = App::new();
|
||||
app.insert_resource(VideoConfig {
|
||||
vsync: true,
|
||||
display: DisplayMode::Fullscreen,
|
||||
windowed: UVec2::new(1024, 768),
|
||||
})
|
||||
.add_systems(Update, apply_window_mode);
|
||||
let win = app
|
||||
.world_mut()
|
||||
.spawn((Window::default(), PrimaryWindow))
|
||||
.id();
|
||||
// Born the way `lib.rs` builds it: already fullscreen, on the boot monitor selection.
|
||||
app.world_mut()
|
||||
.entity_mut(win)
|
||||
.get_mut::<Window>()
|
||||
.unwrap()
|
||||
.mode = window_mode(DisplayMode::Fullscreen, MonitorSelection::Primary);
|
||||
|
||||
app.update();
|
||||
assert!(
|
||||
matches!(
|
||||
app.world().entity(win).get::<Window>().unwrap().mode,
|
||||
WindowMode::BorderlessFullscreen(MonitorSelection::Primary)
|
||||
),
|
||||
"an already-fullscreen window must not be re-asserted onto another monitor selection"
|
||||
);
|
||||
|
||||
// Stand in for the compositor's half of being fullscreen — `bevy_window` documents that
|
||||
// the resolution becomes the monitor's — then leave.
|
||||
app.world_mut()
|
||||
.entity_mut(win)
|
||||
.get_mut::<Window>()
|
||||
.unwrap()
|
||||
.resolution
|
||||
.set(3440.0, 1440.0);
|
||||
app.world_mut().resource_mut::<VideoConfig>().display = DisplayMode::Windowed;
|
||||
app.update();
|
||||
let w = app.world().entity(win).get::<Window>().unwrap();
|
||||
assert_eq!(w.mode, WindowMode::Windowed);
|
||||
assert_eq!(
|
||||
(w.resolution.width(), w.resolution.height()),
|
||||
(1024.0, 768.0),
|
||||
"leaving fullscreen restores gxResolution, never the monitor's size"
|
||||
);
|
||||
|
||||
// And back in, which must reach `Current` — the monitor the window is on by then, not the
|
||||
// one it was born on.
|
||||
app.world_mut().resource_mut::<VideoConfig>().display = DisplayMode::Fullscreen;
|
||||
app.update();
|
||||
assert!(matches!(
|
||||
app.world().entity(win).get::<Window>().unwrap().mode,
|
||||
WindowMode::BorderlessFullscreen(MonitorSelection::Current)
|
||||
));
|
||||
}
|
||||
|
||||
/// `gxResolution` is the reference's `"WxH"` string, and a value that cannot be a window is
|
||||
/// refused rather than passed on.
|
||||
#[test]
|
||||
fn gxresolution_parses_the_reference_spelling() {
|
||||
assert_eq!(parse_resolution("1280x800"), Some(UVec2::new(1280, 800)));
|
||||
assert_eq!(parse_resolution("1600X900"), Some(UVec2::new(1600, 900)));
|
||||
assert_eq!(parse_resolution("1024 x 768"), Some(UVec2::new(1024, 768)));
|
||||
assert_eq!(parse_resolution("0x600"), None);
|
||||
assert_eq!(parse_resolution("1280x"), None);
|
||||
assert_eq!(parse_resolution("fullscreen"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,75 @@ pub struct ViewDistance {
|
||||
/// gone and the window now follows this number, so the headroom went with it (1513).
|
||||
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=777.0;
|
||||
|
||||
/// The world camera's multisampling level — WoW's **`gxMultisample`** CVar.
|
||||
///
|
||||
/// Held on the reference's own scale: a **sample count**, clamped to [`MSAA_RANGE`] (`[1, 16]`,
|
||||
/// the reference's own clamp in the CVar callback at `0x63b250`), where **1 means no
|
||||
/// multisampling at all** — not "one sample of MSAA". Both of the reference's device backends read
|
||||
/// it that way: D3D9 at `0x599899` leaves `pp.MultiSampleType` at `D3DMULTISAMPLE_NONE` for any
|
||||
/// value `<= 1`, and the GL path at `0x59de32` never writes the `WGL_SAMPLE_BUFFERS_ARB` /
|
||||
/// `WGL_SAMPLES_ARB` pair at all — the attribute list simply terminates where they would begin.
|
||||
/// (wow-re `system/console/scratch/gxmultisample-default.md`, §5-verified 2026-08-26.)
|
||||
///
|
||||
/// **Default 1 — off — and that is the reference's own default, not a perf choice dressed up as
|
||||
/// fidelity.** The reference does not register a literal here: `CVar::Register` at `0x63a950` is
|
||||
/// handed a string `snprintf("%d")`'d at runtime from field 21 of the `VideoHardware.dbc` row that
|
||||
/// `DetectHardware` (`0x641260`) matched the GPU to. Across the shipped 193-row table that field
|
||||
/// only ever holds 1 (144 rows) or 2 (49 rows) — nothing higher exists anywhere in it — and all
|
||||
/// three rows reachable by the fallback match hold **1**. Every id in the table is 2004-era, so no
|
||||
/// modern GPU matches a specific row and the fallback is what answers: the string registered on
|
||||
/// any machine this client actually runs on today is `"1"`. Decision 1629.
|
||||
///
|
||||
/// **Latched, like the reference's own flag byte** (`CVar::Register` flags `3` = registered |
|
||||
/// latched; the callback echoes `"set pending gxRestart"`). The value here is the **pending** one:
|
||||
/// changing it persists and `GetCVar` reports it, but the device — here, the camera's `Msaa`
|
||||
/// component — keeps what it was born with until the next launch. That is not a limitation we are
|
||||
/// working around; it is the reference's behaviour, and it happens to be forced on us anyway,
|
||||
/// since swapping MSAA live leaves our post passes MSAA-mismatched and freezes the view.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct MsaaSetting {
|
||||
/// The requested sample count, `[1, 16]`. 1 = no multisampling.
|
||||
pub samples: u32,
|
||||
}
|
||||
|
||||
/// The settable range of [`MsaaSetting::samples`] — the reference's own `atoi`-then-clamp `[1, 16]`
|
||||
/// at `0x63b250`. Shared by the CVar apply and the `$WOW_MSAA` env knob so the two cannot drift.
|
||||
pub const MSAA_RANGE: std::ops::RangeInclusive<u32> = 1..=16;
|
||||
|
||||
impl Default for MsaaSetting {
|
||||
/// `$WOW_MSAA` overrides the default, **session-only** — the A/B lever, the same posture as
|
||||
/// `$WOW_FARCLIP` (a value pinned into `config.toml` would make a measurement sticky). The
|
||||
/// spellings it has always accepted are kept: `off`/`0`/`1` all mean none.
|
||||
fn default() -> Self {
|
||||
let samples = match std::env::var("WOW_MSAA").ok().as_deref() {
|
||||
Some("off") => Some(1),
|
||||
Some(v) => v.parse::<u32>().ok(),
|
||||
None => None,
|
||||
}
|
||||
.map_or(1, |v| v.clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end()));
|
||||
Self { samples }
|
||||
}
|
||||
}
|
||||
|
||||
impl MsaaSetting {
|
||||
/// The sample count as a Bevy [`Msaa`](bevy::render::view::Msaa) level.
|
||||
///
|
||||
/// wgpu can only express 1/2/4/8, while the reference's range runs to 16, so a request lands on
|
||||
/// the **largest expressible level at or below it**. That direction is the reference's own: its
|
||||
/// device-init retry loop (`0x63b380`) steps the sample count *down* — `-= 2`, floored at 1 —
|
||||
/// and only once it bottoms out does it start giving up depth and colour bits. Nothing anywhere
|
||||
/// steps it up, so rounding down can never hand a player more than they asked for.
|
||||
pub fn level(self) -> bevy::render::view::Msaa {
|
||||
use bevy::render::view::Msaa;
|
||||
match self.samples {
|
||||
0..=1 => Msaa::Off,
|
||||
2..=3 => Msaa::Sample2,
|
||||
4..=7 => Msaa::Sample4,
|
||||
_ => Msaa::Sample8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -370,3 +439,40 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
use super::*;
|
||||
use bevy::render::view::Msaa;
|
||||
|
||||
/// The env-less default is **off**, and off is spelled 1 (the reference's scale), not 0.
|
||||
/// Welded to the `gxMultisample` registration in `benilla-app`'s `cvars.rs` by its own test.
|
||||
#[test]
|
||||
fn the_default_is_the_references_one_sample_which_means_none() {
|
||||
// Not `MsaaSetting::default()` — a test run under `$WOW_MSAA` would read the env and this
|
||||
// is a claim about the literal.
|
||||
let literal = MsaaSetting { samples: 1 };
|
||||
assert_eq!(literal.level(), Msaa::Off);
|
||||
assert_eq!(*MSAA_RANGE.start(), 1);
|
||||
assert_eq!(*MSAA_RANGE.end(), 16);
|
||||
}
|
||||
|
||||
/// 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 a_sample_count_rounds_down_never_up() {
|
||||
let level = |n| MsaaSetting { samples: n }.level();
|
||||
assert_eq!(level(1), Msaa::Off);
|
||||
assert_eq!(level(2), Msaa::Sample2);
|
||||
assert_eq!(level(3), Msaa::Sample2);
|
||||
assert_eq!(level(4), Msaa::Sample4);
|
||||
assert_eq!(level(7), Msaa::Sample4);
|
||||
assert_eq!(level(8), Msaa::Sample8);
|
||||
// The reference clamps at 16; anything that reaches here is already in range, and the top
|
||||
// of that range is still only eight samples of real hardware.
|
||||
assert_eq!(level(16), Msaa::Sample8);
|
||||
// 0 cannot arrive through the clamp, but the mapping must not panic if it ever does.
|
||||
assert_eq!(level(0), Msaa::Off);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,9 @@ impl Plugin for WorldFoundation {
|
||||
// The faithful view distance (`farclip`) — one source of truth for the wall + the
|
||||
// per-object cull (and, post-split, the stream radius). See `view.rs`.
|
||||
.init_resource::<crate::view::ViewDistance>()
|
||||
// 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>()
|
||||
// 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>()
|
||||
|
||||
@@ -316,18 +316,13 @@ struct ViewCam {
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
fn spawn_view_camera(mut commands: Commands) {
|
||||
fn spawn_view_camera(mut commands: Commands, msaa: Res<crate::view::MsaaSetting>) {
|
||||
let start = wow_to_bevy([VIEW_START.0, VIEW_START.1, 100.0]);
|
||||
let far = crate::view::CAM_FAR;
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
crate::view::WorldCamera,
|
||||
match std::env::var("WOW_MSAA").ok().as_deref() {
|
||||
Some("off") | Some("0") | Some("1") => bevy::render::view::Msaa::Off,
|
||||
Some("2") => bevy::render::view::Msaa::Sample2,
|
||||
Some("8") => bevy::render::view::Msaa::Sample8,
|
||||
_ => bevy::render::view::Msaa::Sample4,
|
||||
},
|
||||
msaa.level(),
|
||||
Projection::from(PerspectiveProjection {
|
||||
far,
|
||||
near: NEAR,
|
||||
|
||||
Reference in New Issue
Block a user