diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 4fc78c43..c13aafa2 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 @@ }) - + + + -- 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") + + + + + + + -- Vertical Sync: 1.12's own Video Options checkbox -- 5 (gxVSync), over the window's present mode. diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 695cf898..f8214c0f 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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}; diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index c2da87aa..5dff3a2d 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -387,6 +387,96 @@ fn fire_probe_lua( /// line survives a window moving. `WOW_PROBE_DRAG_LUA=""` 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 = 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, + 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, + mut synthetic: ResMut, + time: ProbeClock, + script: Option>, + self_player: Query<(), With>, +) { + 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 { diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 91d3de59..79340f2b 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -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. diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 4713fb1d..e3cb9c70 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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::() - .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::() 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, 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 = 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, 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), + /// 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::(&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 { + 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::() .init_resource::() .init_resource::() @@ -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::().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) + ); } } diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 90276b71..4a23896b 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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() { diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index 5c480eb3..32e29d6e 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -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>, cost: Res, + mesh: Res, script: Option>, mut rec: ResMut, ) { @@ -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=:` will backtrace on a live run. let derives = rows.iter().map(|r| r.cost.derives as f64).sum::() / 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::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::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 { 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::().ok(); let owner = fields[14].clone(); Some(Row { @@ -338,6 +377,7 @@ fn parse_row(line: &str) -> Option { // 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 { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 00569479..9dd088ac 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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() }, diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 5819e6d7..8a28d4c8 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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::)) - .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::() - // 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::() - .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::)) - .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::)), - ) - // `/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::)), - ); + 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::)) + .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::() + // 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::() + .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::)) + .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::)), + ) + // `/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::)), + ); } } diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs index e5324b8e..7148ead3 100644 --- a/crates/benilla-app/src/player/setup.rs +++ b/crates/benilla-app/src/player/setup.rs @@ -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>, world_assets: Option>, + // 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, ) { 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, diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 4475c5bd..405aa6be 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -550,6 +550,8 @@ pub(crate) struct PlayerUiPlugin; impl Plugin for PlayerUiPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() + .init_resource::() .add_plugins(( Material2dPlugin::::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 = static UI_PROBE: std::sync::LazyLock = 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, mut commands: Commands, @@ -931,8 +979,30 @@ fn rebuild_ui_mesh( mut pools: Local, white: Option>, windows: Query<&Window, With>, - hidden: Res, + // 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, + ResMut, + Res, + ), ) { + 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::() .init_asset::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .insert_resource(UiWhiteTexture(Handle::default())) .add_systems(Update, rebuild_ui_mesh); diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index c5393d31..b9f24fed 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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 = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1")) } diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 31e06694..c5cd1734 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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; diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 6abb7f0a..a0422a30 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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 diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 3941812b..eb16750c 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -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 { + 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::().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::() - .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>) { + 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, + mut windows: Query<&mut Window, With>, + mut last: Local>, +) { + 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::() + .unwrap() + .mode = window_mode(DisplayMode::Fullscreen, MonitorSelection::Primary); + + app.update(); + assert!( + matches!( + app.world().entity(win).get::().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::() + .unwrap() + .resolution + .set(3440.0, 1440.0); + app.world_mut().resource_mut::().display = DisplayMode::Windowed; + app.update(); + let w = app.world().entity(win).get::().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::().display = DisplayMode::Fullscreen; + app.update(); + assert!(matches!( + app.world().entity(win).get::().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); + } } diff --git a/crates/benilla-world/src/view.rs b/crates/benilla-world/src/view.rs index 52f8e687..bdac3ce2 100644 --- a/crates/benilla-world/src/view.rs +++ b/crates/benilla-world/src/view.rs @@ -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 = 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 = 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::().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); + } +} diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index a0382faa..a9d37a7e 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -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::() + // 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::() // 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::() diff --git a/crates/benilla-world/src/worldview.rs b/crates/benilla-world/src/worldview.rs index 32ab2475..81e5a613 100644 --- a/crates/benilla-world/src/worldview.rs +++ b/crates/benilla-world/src/worldview.rs @@ -316,18 +316,13 @@ struct ViewCam { speed: f32, } -fn spawn_view_camera(mut commands: Commands) { +fn spawn_view_camera(mut commands: Commands, msaa: Res) { 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,