diff --git a/crates/fpsmaster_app/Cargo.toml b/crates/fpsmaster_app/Cargo.toml index 465d91a..2bbd168 100644 --- a/crates/fpsmaster_app/Cargo.toml +++ b/crates/fpsmaster_app/Cargo.toml @@ -1,5 +1,7 @@ [package] name = "fpsmaster_app" +# Product version shown in-game (title screen / window / F3), sourced at compile +# time via CARGO_PKG_VERSION — the single source of truth for the display version. version = "1.0.0" edition.workspace = true rust-version.workspace = true diff --git a/crates/fpsmaster_app/src/frame_profiler.rs b/crates/fpsmaster_app/src/frame_profiler.rs index 501d89e..ce809b3 100644 --- a/crates/fpsmaster_app/src/frame_profiler.rs +++ b/crates/fpsmaster_app/src/frame_profiler.rs @@ -38,6 +38,9 @@ pub struct FrameProfiler { ema_ms: f32, /// Last completed window summary, served to the F3 overlay. latest: PhaseSummary, + /// The just-completed frame's per-phase durations, kept after `cur` is + /// cleared so a per-frame consumer (the F10 perf capture) can sample them. + last_frame_phases: BTreeMap<&'static str, Duration>, } impl FrameProfiler { @@ -53,6 +56,7 @@ impl FrameProfiler { last_flush: now, ema_ms: 0.0, latest: PhaseSummary::default(), + last_frame_phases: BTreeMap::new(), } } @@ -62,6 +66,13 @@ impl FrameProfiler { &self.latest } + /// The per-phase CPU durations of the frame that just closed (via + /// [`Self::end_frame`]). Unlike [`Self::latest`] this is a single frame, not + /// a windowed average, so the F10 capture can build its own statistics. + pub fn last_frame_phases(&self) -> &BTreeMap<&'static str, Duration> { + &self.last_frame_phases + } + /// Record one phase's duration for the current frame. Overwrites, so each /// phase is recorded at most once per frame. Always accumulated (cheap) so the /// F3 readout works even without the log flag. @@ -103,6 +114,9 @@ impl FrameProfiler { self.frame_sum += cpu; self.frame_max = self.frame_max.max(cpu); self.frames += 1; + // Hand the closed frame's phases to any per-frame consumer before we + // reset `cur` for the next frame. + std::mem::swap(&mut self.last_frame_phases, &mut self.cur); self.cur.clear(); if now.duration_since(self.last_flush) >= Duration::from_secs(1) { diff --git a/crates/fpsmaster_app/src/gui/ingame.rs b/crates/fpsmaster_app/src/gui/ingame.rs index 2f66da7..3f20525 100644 --- a/crates/fpsmaster_app/src/gui/ingame.rs +++ b/crates/fpsmaster_app/src/gui/ingame.rs @@ -239,7 +239,7 @@ fn draw_debug_overlay( let grounded = if info.on_ground { "yes" } else { "no" }; let left = [ - format!("FPSMaster Extreme {fps:.0} fps"), + format!("{} {fps:.0} fps", crate::version::title()), format!("XYZ: {fx:.3} / {fy:.3} / {fz:.3}"), format!("Block: {bx} {by} {bz} (grounded: {grounded})"), format!("Chunk: {rx} {ry} {rz} in {cx} {cz}"), diff --git a/crates/fpsmaster_app/src/gui/main_menu.rs b/crates/fpsmaster_app/src/gui/main_menu.rs index 2d1f495..8735b7c 100644 --- a/crates/fpsmaster_app/src/gui/main_menu.rs +++ b/crates/fpsmaster_app/src/gui/main_menu.rs @@ -15,11 +15,17 @@ use super::{draw_default_background, DrawCtx, GuiAction, GuiScreen, ScreenCtx}; #[derive(Default)] pub struct GuiMainMenu { buttons: Vec, + /// A random line from the vanilla `texts/splashes.txt`, chosen once when the + /// title screen is (re)opened — vanilla re-rolls it in `GuiMainMenu.initGui`. + splash: Option, } impl GuiMainMenu { pub fn new() -> Self { - Self::default() + Self { + splash: pick_splash(), + ..Self::default() + } } /// Vanilla `initGui` + `addSingleplayerMultiplayerButtons`. @@ -71,30 +77,42 @@ impl GuiScreen for GuiMainMenu { } let s = ctx.scale; + let cx = ctx.width / 2; - // Custom MINECRAFT logo (single image, 1364×214). Drawn 256 GUI px wide, - // aspect-preserved (~40 GUI px tall), centered horizontally near the top. - const LOGO_SRC_W: u32 = 1364; - const LOGO_SRC_H: u32 = 214; - let logo_w = 256 * s; - let logo_h = logo_w * LOGO_SRC_H as i32 / LOGO_SRC_W as i32; - let logo_x = (ctx.width - logo_w) / 2; + // Vanilla MINECRAFT logo from gui/title/minecraft.png: two 155×44 halves + // (rows at v=0 and v=45 of the 256×256 sheet) blitted side by side, exactly + // like vanilla `GuiMainMenu` (`j = width/2 - 137`, logo at y=30). let logo_y = 30 * s; + let j = cx - 137 * s; + ui.image(UiRect::new(j, logo_y, 155 * s, 44 * s), GuiTexture::Title, 0, 0, 155, 44); ui.image( - UiRect::new(logo_x, logo_y, logo_w, logo_h), + UiRect::new(j + 155 * s, logo_y, 155 * s, 44 * s), GuiTexture::Title, 0, - 0, - LOGO_SRC_W, - LOGO_SRC_H, + 45, + 155, + 44, ); + // Splash text from the vanilla texts/splashes.txt, yellow with a shadow near + // the logo's lower-right (vanilla anchors it at width/2+90, tilted ~20° and + // pulsing — the tilt/pulse need rotated, fractionally-scaled glyphs the UI + // layer has no primitive for, so it renders upright at the GUI scale). + if let Some(splash) = &self.splash { + let splash_w = fpsmaster_render::text_width(splash, s); + let splash_x = (cx + 88 * s - splash_w / 2) + .min(ctx.width - splash_w - 2 * s) + .max(2 * s); + let splash_y = logo_y + 34 * s; + ui.text_shadowed(splash_x, splash_y, s, super::TEXT_YELLOW, splash); + } + // Brand subtitle under the logo: "FPSMaster Extreme", centered. { - let brand = "FPSMaster Extreme"; + let brand = crate::version::PRODUCT_NAME; let brand_w = fpsmaster_render::text_width(brand, s); let brand_x = (ctx.width - brand_w) / 2; - let brand_y = logo_y + logo_h + 2 * s; + let brand_y = logo_y + 46 * s; ui.text_shadowed(brand_x, brand_y, s, super::TEXT_YELLOW, brand); } @@ -104,7 +122,13 @@ impl GuiScreen for GuiMainMenu { } // Version string: bottom-left, white with shadow. - ui.text_shadowed(2 * s, ctx.height - 10 * s, s, super::TEXT_WHITE, "FPSMaster Extreme 1.8.9"); + ui.text_shadowed( + 2 * s, + ctx.height - 10 * s, + s, + super::TEXT_WHITE, + crate::version::title(), + ); } fn wants_panorama(&self) -> bool { @@ -133,3 +157,27 @@ impl GuiScreen for GuiMainMenu { Vec::new() } } + +/// The vanilla splash file, one splash per line, under the active assets. +const SPLASHES_ASSET: &str = "assets/minecraft/texts/splashes.txt"; + +/// Pick a random splash line from the vanilla [`SPLASHES_ASSET`], or `None` when +/// the asset is unavailable (no vanilla assets extracted). Blank lines are +/// dropped; the choice is seeded from the wall clock so it varies per visit, +/// matching vanilla's per-`initGui` re-roll. +fn pick_splash() -> Option { + let text = fpsmaster_render::read_asset_string(SPLASHES_ASSET)?; + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { + return None; + } + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + Some(lines[(seed % lines.len() as u128) as usize].to_owned()) +} diff --git a/crates/fpsmaster_app/src/gui/mod.rs b/crates/fpsmaster_app/src/gui/mod.rs index 270e564..8a5f379 100644 --- a/crates/fpsmaster_app/src/gui/mod.rs +++ b/crates/fpsmaster_app/src/gui/mod.rs @@ -244,10 +244,25 @@ pub struct ScreenCtx<'a> { pub mouse: (f64, f64), } +/// The active GUI-scale preference (vanilla `guiScale`: `0` = Auto, `1..` = a +/// fixed cap), published once per frame from [`Settings`] by the render loop. +/// Every menu and HUD reads the scale through the ambient [`gui_scale`] helper — +/// which is a pure function of the window size — so the preference lives here as +/// frame-global state rather than being threaded through dozens of draw calls. +static GUI_SCALE_PREF: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Publish the user's GUI-scale preference for this frame. Call before any +/// screen or HUD draw so [`gui_scale`] resolves against the current setting. +pub fn set_gui_scale_pref(pref: u32) { + GUI_SCALE_PREF.store(pref, std::sync::atomic::Ordering::Relaxed); +} + /// GUI pixel scale shared by every screen and the HUD (same formula the /// renderer rasterizes the UI buffer at, so all layout snaps to GUI pixels). +/// Honors the GUI-scale preference set via [`set_gui_scale_pref`]. pub fn gui_scale(width: i32, height: i32) -> i32 { - fpsmaster_render::gui_pixel_scale(width.max(1) as u32, height.max(1) as u32) as i32 + let pref = GUI_SCALE_PREF.load(std::sync::atomic::Ordering::Relaxed); + fpsmaster_render::gui_pixel_scale_capped(width.max(1) as u32, height.max(1) as u32, pref) as i32 } // ─── Shared drawing helpers ────────────────────────────────────────────────── diff --git a/crates/fpsmaster_app/src/gui/options.rs b/crates/fpsmaster_app/src/gui/options.rs index 2c294f3..aae2825 100644 --- a/crates/fpsmaster_app/src/gui/options.rs +++ b/crates/fpsmaster_app/src/gui/options.rs @@ -100,6 +100,7 @@ pub struct GuiOptions { controls_btn: Option, resource_packs_btn: Option, language_btn: Option, + gui_scale_btn: Option, sound_btn: Option, done: Option, dragging: Option, @@ -134,8 +135,11 @@ impl GuiOptions { Some(GuiButton::at_px(x, top + 72 * s, 98 * s, s, tr("options.resourcepack"))); self.sound_btn = Some(GuiButton::at_px(x + 102 * s, top + 72 * s, 98 * s, s, tr("options.sounds"))); + // Language + GUI Scale share a row (left/right halves). GUI Scale is a + // cycle button (Auto/1/2/…), so its label is set at draw time. self.language_btn = - Some(GuiButton::at_px(x, top + 96 * s, 200 * s, s, tr("options.language"))); + Some(GuiButton::at_px(x, top + 96 * s, 98 * s, s, tr("options.language"))); + self.gui_scale_btn = Some(GuiButton::at_px(x + 102 * s, top + 96 * s, 98 * s, s, "")); // A gap above Done, echoing vanilla's separated bottom button. self.done = Some(GuiButton::at_px(x, top + 132 * s, 200 * s, s, tr("gui.done"))); } @@ -161,6 +165,7 @@ impl GuiScreen for GuiOptions { self.resource_packs_btn.as_ref(), self.sound_btn.as_ref(), self.language_btn.as_ref(), + self.gui_scale_btn.as_ref(), self.done.as_ref(), ] .into_iter() @@ -203,6 +208,11 @@ impl GuiScreen for GuiOptions { if let Some(b) = &self.language_btn { b.draw(ui, s, ctx.mouse, ctx.mouse_down); } + if let Some(gui_scale) = &mut self.gui_scale_btn { + gui_scale.label = + format!("{}: {}", tr("options.guiScale"), ctx.settings.clone().gui_scale_label()); + gui_scale.draw(ui, s, ctx.mouse, ctx.mouse_down); + } if let Some(done) = &self.done { done.draw(ui, s, ctx.mouse, ctx.mouse_down); } @@ -257,6 +267,13 @@ impl GuiScreen for GuiOptions { ))), ]; } + if self.gui_scale_btn.as_ref().is_some_and(|b| b.clicked(x, y)) { + // Cycle Auto → 1 → … → Auto. The new scale is picked up next frame + // when the render loop re-publishes the preference, so every menu and + // the HUD rescale together. + ctx.settings.cycle_gui_scale(); + return vec![GuiAction::SaveSettings]; + } if self.done.as_ref().is_some_and(|b| b.clicked(x, y)) { return vec![ GuiAction::SaveSettings, diff --git a/crates/fpsmaster_app/src/main.rs b/crates/fpsmaster_app/src/main.rs index 7ec4370..ddc7550 100644 --- a/crates/fpsmaster_app/src/main.rs +++ b/crates/fpsmaster_app/src/main.rs @@ -7,6 +7,7 @@ mod gui; mod item_renderer; mod network; mod particle; +mod perf_capture; mod player_list; mod scoreboard; mod skin; @@ -15,6 +16,7 @@ mod servers; mod settings; mod sound; mod text_input; +mod version; // The i18n engine lives in `fpsmaster_render` (it owns asset access and is shared // with item/block name localization); re-export it so the rest of the app keeps @@ -248,6 +250,8 @@ struct WinitApp { /// Per-frame CPU phase profiler (active only with `--profile-frames` / /// `RUST_LOG=frame_profile=debug`). profiler: frame_profiler::FrameProfiler, + /// Interactive render profiler toggled with F10 (`Some` while capturing). + perf_capture: Option, window_shown: bool, /// The window is fully hidden/occluded (backgrounded, minimized, covered). /// While set, rendering is skipped — see the note in `about_to_wait`. @@ -304,6 +308,7 @@ impl WinitApp { smoke_profile, pass_bench, profiler: frame_profiler::FrameProfiler::new(now), + perf_capture: None, window_shown: false, occluded: false, } @@ -320,7 +325,7 @@ impl ApplicationHandler for WinitApp { None => LogicalSize::new(1280.0, 720.0).into(), }; let attrs = Window::default_attributes() - .with_title("FPSMaster Extreme - Rust Minecraft Client") + .with_title(format!("{} - Rust Minecraft Client", version::title())) .with_inner_size(initial_size) .with_visible(false); let window = Arc::new(event_loop.create_window(attrs).expect("create window")); @@ -446,7 +451,7 @@ impl ApplicationHandler for WinitApp { app.ext.register(Box::new(fpsmaster_ext::dev::DemoMod::new())); } // Load `.js` (and, later, native) mods from `mods/` next to the working - // directory. F10 reloads them at runtime. + // directory. Shift+F10 reloads them at runtime. let loaded = app.ext.load_mods(std::path::Path::new("mods")); apply_disabled_mods(&mut app); if !loaded.is_empty() { @@ -490,14 +495,22 @@ impl ApplicationHandler for WinitApp { } } WindowEvent::KeyboardInput { event, .. } => { - // F10: hot-reload `mods/` (dev convenience; never consumed by mods). + // F10: toggle the interactive render profiler (start/stop a + // capture). Shift+F10 keeps the dev-only `mods/` hot-reload. if event.state == ElementState::Pressed && !event.repeat && matches!(event.physical_key, PhysicalKey::Code(KeyCode::F10)) { - let loaded = app.ext.reload_mods(std::path::Path::new("mods")); - apply_disabled_mods(app); - log::info!("[ext] reloaded {} mod(s): {:?}", loaded.len(), loaded); + if self.modifiers.shift_key() { + let loaded = app.ext.reload_mods(std::path::Path::new("mods")); + apply_disabled_mods(app); + log::info!("[ext] reloaded {} mod(s): {:?}", loaded.len(), loaded); + } else if let Some(capture) = self.perf_capture.take() { + log::info!("{}", capture.finish(Instant::now())); + } else { + self.perf_capture = Some(perf_capture::PerfCapture::new(Instant::now())); + log::info!("[perf] capture started — press F10 again to stop"); + } } if event.state == ElementState::Pressed && !event.repeat @@ -961,6 +974,12 @@ impl ApplicationHandler for WinitApp { renderer.set_debug_tris(&[]); app.ext_lines_on = false; } + // While a perf capture runs, feed the HUD indicator (secs, frames) and + // force GPU timing on so the samples carry a real `gpu_us`. + let capture_status = self + .perf_capture + .as_ref() + .map(|c| (c.elapsed(Instant::now()), c.frames())); render_frame( renderer, app, @@ -974,6 +993,7 @@ impl ApplicationHandler for WinitApp { self.mouse_down_left, self.f3_debug, self.smoke_profile.is_some() || self.pass_bench.is_some(), + capture_status, &mut self.profiler, ); if let Some(profile) = self.smoke_profile.as_mut() { @@ -982,6 +1002,13 @@ impl ApplicationHandler for WinitApp { if let Some(bench) = self.pass_bench.as_mut() { bench.record(renderer.last_stats(), Instant::now()); } + if let Some(capture) = self.perf_capture.as_mut() { + capture.record( + renderer.last_stats(), + self.profiler.last_frame_phases(), + Instant::now(), + ); + } // Adaptive resolution: drive the world render scale off the occlusion-proof // GPU frame time toward the target budget. Steps by 0.05 at most once a // second (each change reallocates the offscreen targets), within @@ -2327,6 +2354,29 @@ fn build_inventory_preview( })); } +/// Top-centre "● REC PERF" banner shown while an F10 capture is running, so the +/// user always knows sampling is live (menus and F1-hidden HUD included). +fn draw_capture_indicator( + ui: &mut fpsmaster_render::UiFrame, + width: i32, + scale: i32, + secs: f32, + frames: usize, +) { + use fpsmaster_render::{text_height, text_width, UiColor, UiRect}; + let text = format!("\u{25CF} REC PERF {secs:.1}s {frames} frames"); + let pad = 2 * scale; + let w = text_width(&text, scale); + let h = text_height(scale); + let x = (width - w) / 2; + let y = pad; + ui.rect( + UiRect::new(x - pad, y - scale, w + 2 * pad, h + 2 * scale), + UiColor::rgba(0, 0, 0, 170), + ); + ui.text_shadowed(x, y, scale, UiColor::rgba(255, 80, 80, 255), text); +} + /// Render one frame: world, entities + first-person hand, HUD and the open /// screen. Driven from `AboutToWait` so the frame rate is paced by our own /// vsync/FPS-cap logic instead of macOS Core Animation throttling. @@ -2344,6 +2394,8 @@ fn render_frame( mouse_down: bool, f3_debug: bool, smoke_active: bool, + // `Some((elapsed_secs, frames))` while an F10 perf capture is running. + capture_status: Option<(f32, usize)>, profiler: &mut frame_profiler::FrameProfiler, ) { let now = Instant::now(); @@ -2351,9 +2403,15 @@ fn render_frame( fps_counter.tick(now); *last_frame = now; + // Publish the GUI-scale preference for this frame so every menu/HUD draw + // (and the inventory preview below) resolves `gui::gui_scale` against it. + gui::set_gui_scale_pref(app.settings.gui_scale); + // The GPU-time readback costs ~0.04 ms/frame, so only measure it when its // number is actually shown: the F3 overlay, or a scripted benchmark run. - renderer.set_gpu_timing(f3_debug || smoke_active || app.settings.adaptive_resolution); + renderer.set_gpu_timing( + f3_debug || smoke_active || capture_status.is_some() || app.settings.adaptive_resolution, + ); // Render-distance safety net: free the GPU meshes of columns that drifted // out of view (keeping their block data) so resident VRAM stays bounded even @@ -2775,6 +2833,12 @@ fn render_frame( screen.draw(&mut ui, &ctx); } + // F10 perf-capture indicator, drawn on top of everything so it stays visible + // through open menus and with the HUD hidden. + if let Some((secs, frames)) = capture_status { + draw_capture_indicator(&mut ui, width, gui::gui_scale(width, height), secs, frames); + } + // Drive the day/night sky and lightmap from the world clock (interpolated // for smooth motion between ticks). renderer.set_world_time(app.game.world_time(tick_alpha)); diff --git a/crates/fpsmaster_app/src/perf_capture.rs b/crates/fpsmaster_app/src/perf_capture.rs new file mode 100644 index 0000000..031cac0 --- /dev/null +++ b/crates/fpsmaster_app/src/perf_capture.rs @@ -0,0 +1,260 @@ +//! Interactive render profiler, toggled in-game with **F10**. +//! +//! Press F10 once to begin sampling every rendered frame; press it again to +//! stop. On stop it prints a statistical summary to the log (frame-time +//! percentiles, GPU/CPU pass costs, the per-subsystem CPU breakdown and draw +//! counts) and writes the raw per-frame rows to a `perf_capture_.csv` so the +//! numbers can be graphed / diffed offline while optimizing. +//! +//! Unlike the `--profile-frames` log target (a passive once-a-second summary) +//! this captures a bounded, on-demand window that you drive by hand — walk into +//! the scene you want to measure, tap F10, do the thing, tap F10 again. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use fpsmaster_render::RenderStats; + +/// One rendered frame's measurements. +struct Sample { + /// Wall-clock interval since the previously sampled frame, microseconds. + frame_us: u32, + stats: RenderStats, + /// This frame's per-phase CPU cost (microseconds), from the frame profiler. + phases: BTreeMap<&'static str, u32>, +} + +/// A live capture session. Created when F10 starts a capture, consumed by +/// [`PerfCapture::finish`] when F10 stops it. +pub struct PerfCapture { + start: Instant, + last: Instant, + samples: Vec, +} + +impl PerfCapture { + pub fn new(now: Instant) -> Self { + Self { start: now, last: now, samples: Vec::new() } + } + + /// Seconds elapsed since the capture started (for the on-screen indicator). + pub fn elapsed(&self, now: Instant) -> f32 { + (now - self.start).as_secs_f32() + } + + /// Frames sampled so far (for the on-screen indicator). + pub fn frames(&self) -> usize { + self.samples.len() + } + + /// Record the just-rendered frame. `stats` is `renderer.last_stats()` and + /// `phases` is `profiler.last_frame_phases()` — the CPU phase durations of + /// the frame that just closed. + pub fn record( + &mut self, + stats: RenderStats, + phases: &BTreeMap<&'static str, Duration>, + now: Instant, + ) { + // Cap the interval so a single hitch (alt-tab, a GC-style stall) doesn't + // blow the totals; 100 ms == 10 fps is already deep into "spike". + let frame_us = (now - self.last).as_micros().min(100_000) as u32; + self.last = now; + let phases = phases + .iter() + .map(|(k, v)| (*k, v.as_micros() as u32)) + .collect(); + self.samples.push(Sample { frame_us, stats, phases }); + } + + /// Close the capture: log a summary and write the per-frame CSV. Returns a + /// short human-readable status line for the caller to surface (log/toast), + /// e.g. `"perf: 512 frames, 143 fps avg -> perf_capture_1720000000.csv"`. + pub fn finish(self, now: Instant) -> String { + let n = self.samples.len(); + let secs = (now - self.start).as_secs_f32(); + if n == 0 { + log::info!("[perf] capture stopped with no frames sampled"); + return "perf: no frames captured".to_owned(); + } + + // Frame-time percentiles (sorted copy) + the 1%-low fps (average of the + // slowest 1% of frames, the number that "feels" like stutter). + let mut ft: Vec = + self.samples.iter().map(|s| s.frame_us as f32 / 1000.0).collect(); + ft.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let pct = |q: f32| ft[((ft.len() as f32 * q) as usize).min(ft.len() - 1)]; + let (min_ms, max_ms) = (ft[0], ft[ft.len() - 1]); + let avg_ms = ft.iter().sum::() / n as f32; + let avg_fps = if secs > 0.0 { n as f32 / secs } else { 0.0 }; + let low1_n = (n / 100).max(1); + let low1_ms = ft[n - low1_n..].iter().sum::() / low1_n as f32; + let low1_fps = if low1_ms > 0.0 { 1000.0 / low1_ms } else { 0.0 }; + + // Per-field averages over the whole capture. + let mut sum = Accum::default(); + for s in &self.samples { + sum.add(&s.stats); + } + let a = |v: u64| v as f64 / n as f64; + + // CPU phase aggregation: avg & max ms per subsystem, ordered by avg desc. + let names: BTreeSet<&'static str> = + self.samples.iter().flat_map(|s| s.phases.keys().copied()).collect(); + let mut phase_stats: Vec<(&'static str, f64, f64)> = names + .iter() + .map(|&name| { + let mut total = 0u64; + let mut max = 0u32; + for s in &self.samples { + let v = s.phases.get(name).copied().unwrap_or(0); + total += v as u64; + max = max.max(v); + } + (name, total as f64 / n as f64 / 1000.0, max as f64 / 1000.0) + }) + .collect(); + phase_stats.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap()); + + let mut report = String::new(); + let _ = writeln!(report, "===== perf capture: {n} frames over {secs:.1}s ====="); + let _ = writeln!( + report, + " fps avg {avg_fps:5.0} 1%-low {low1_fps:5.0}", + ); + let _ = writeln!( + report, + " frame ms min {min_ms:5.2} avg {avg_ms:5.2} p50 {:5.2} p95 {:5.2} p99 {:5.2} max {max_ms:5.2}", + pct(0.50), + pct(0.95), + pct(0.99), + ); + let _ = writeln!( + report, + " gpu ms avg {:5.2} (0 = adapter has no timestamp query)", + a(sum.gpu_us) / 1000.0, + ); + let _ = writeln!( + report, + " cpu pass avg us prepare {:5.0} encode {:5.0} submit {:5.0} present {:5.0} acquire {:5.0}", + a(sum.prepare_us), + a(sum.encode_us), + a(sum.submit_us), + a(sum.present_us), + a(sum.acquire_us), + ); + let _ = writeln!( + report, + " draws avg {:5.0} visible chunks {:5.0} tris {:.0}", + a(sum.draw_calls), + a(sum.visible_chunks), + a(sum.chunk_indices) / 3.0, + ); + report.push_str(" cpu phases (avg/max ms, worst first):\n"); + for (name, avg, max) in &phase_stats { + if *max >= 0.01 { + let _ = writeln!(report, " {name:<12} {avg:6.3} / {max:6.3}"); + } + } + // Trim the trailing newline so the log block is tight. + for line in report.trim_end().lines() { + log::info!("{line}"); + } + + let csv_path = self.write_csv(&phase_stats); + match &csv_path { + Ok(path) => log::info!("[perf] per-frame data written to {path}"), + Err(e) => log::warn!("[perf] could not write CSV: {e}"), + } + + format!( + "perf: {n} frames, {avg_fps:.0} fps avg, 1%-low {low1_fps:.0}{}", + match csv_path { + Ok(path) => format!(" -> {path}"), + Err(_) => String::new(), + } + ) + } + + /// Write one row per sampled frame. Columns: the fixed render metrics, then + /// one column per observed CPU phase (in the same order as the log summary). + fn write_csv( + &self, + phase_stats: &[(&'static str, f64, f64)], + ) -> std::io::Result { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let path = format!("perf_capture_{ts}.csv"); + + let phase_cols: Vec<&'static str> = phase_stats.iter().map(|(n, _, _)| *n).collect(); + let mut out = String::new(); + out.push_str("frame,frame_ms,gpu_us,acquire_us,prepare_us,encode_us,submit_us,present_us,draw_calls,visible_chunks,tris"); + for name in &phase_cols { + out.push(','); + out.push_str(name); + out.push_str("_us"); + } + out.push('\n'); + + for (i, s) in self.samples.iter().enumerate() { + let st = &s.stats; + let _ = write!( + out, + "{i},{:.3},{},{},{},{},{},{},{},{},{}", + s.frame_us as f32 / 1000.0, + st.gpu_us, + st.acquire_us, + st.prepare_us, + st.encode_us, + st.submit_us, + st.present_us, + st.draw_calls, + st.visible_chunks, + st.chunk_indices / 3, + ); + for name in &phase_cols { + let _ = write!(out, ",{}", s.phases.get(name).copied().unwrap_or(0)); + } + out.push('\n'); + } + + std::fs::write(&path, out)?; + // Report the absolute path when we can resolve it, else the relative one. + Ok(std::fs::canonicalize(&path) + .ok() + .and_then(|p| p.to_str().map(str::to_owned)) + .unwrap_or(path)) + } +} + +/// Running u64 sums of the `RenderStats` fields, to sidestep u32 overflow over a +/// long capture (draw counts × thousands of frames). +#[derive(Default)] +struct Accum { + gpu_us: u64, + acquire_us: u64, + prepare_us: u64, + encode_us: u64, + submit_us: u64, + present_us: u64, + draw_calls: u64, + visible_chunks: u64, + chunk_indices: u64, +} + +impl Accum { + fn add(&mut self, s: &RenderStats) { + self.gpu_us += s.gpu_us as u64; + self.acquire_us += s.acquire_us as u64; + self.prepare_us += s.prepare_us as u64; + self.encode_us += s.encode_us as u64; + self.submit_us += s.submit_us as u64; + self.present_us += s.present_us as u64; + self.draw_calls += s.draw_calls as u64; + self.visible_chunks += s.visible_chunks as u64; + self.chunk_indices += s.chunk_indices as u64; + } +} diff --git a/crates/fpsmaster_app/src/settings.rs b/crates/fpsmaster_app/src/settings.rs index 471273f..3537e89 100644 --- a/crates/fpsmaster_app/src/settings.rs +++ b/crates/fpsmaster_app/src/settings.rs @@ -68,6 +68,11 @@ pub struct Settings { /// compositor and lets the display hardware scale a lower mode — cheap present /// at a full-screen image). Off = windowed. pub fullscreen: bool, + /// GUI scale (vanilla "GUI Scale"): `0` = Auto (the largest scale that fits), + /// `1..=GUI_SCALE_MAX` = a fixed upper cap on the pixel scale of every menu + /// and HUD. The window-fit rule still bounds it, so a fixed value never blows + /// the UI up past what the window can hold. + pub gui_scale: u32, /// Shader pack master switch: per-pixel directional sun + ambient lighting. pub shaders: bool, /// Sun shadow map (only active while `shaders` is on). @@ -155,6 +160,10 @@ pub const RENDER_DIST_MIN: u32 = 2; pub const RENDER_DIST_MAX: u32 = 32; /// Highest selectable mipmap level (16px tiles → mips 0..4). pub const MIPMAP_MAX: u32 = 4; +/// Highest fixed GUI scale the option cycles to (`0` = Auto, then `1..=4`). +/// Matches the useful vanilla range (Small/Normal/Large) plus one step for +/// high-DPI displays where Auto already resolves to 4+. +pub const GUI_SCALE_MAX: u32 = 4; /// Highest ray-tracing quality preset index (Low = 0, Medium = 1, High = 2, /// Path Traced = 3, the experimental full path tracer). pub const RT_QUALITY_MAX: u32 = 3; @@ -183,6 +192,7 @@ impl Default for Settings { mipmap_levels: MIPMAP_MAX, resolution: None, fullscreen: false, + gui_scale: 0, shaders: false, shader_shadows: true, shader_specular: true, @@ -327,6 +337,11 @@ impl Settings { s.fullscreen = v; } } + "gui_scale" => { + if let Ok(v) = val.parse() { + s.gui_scale = v; + } + } "shaders" => { if let Ok(v) = val.parse() { s.shaders = v; @@ -487,6 +502,7 @@ impl Settings { s.render_scale = s.render_scale.clamp(RENDER_SCALE_MIN, 1.0); s.render_distance = s.render_distance.clamp(RENDER_DIST_MIN, RENDER_DIST_MAX); s.mipmap_levels = s.mipmap_levels.min(MIPMAP_MAX); + s.gui_scale = s.gui_scale.min(GUI_SCALE_MAX); s.rt_quality = s.rt_quality.min(RT_QUALITY_MAX); s.dlss_quality = s.dlss_quality.min(DLSS_QUALITY_MAX); s.brightness = s.brightness.clamp(BRIGHTNESS_MIN, BRIGHTNESS_MAX); @@ -547,6 +563,7 @@ impl Settings { self.old_animations, self.resource_pack.as_deref().unwrap_or(""), ); + text.push_str(&format!("gui_scale={}\n", self.gui_scale)); text.push_str(&format!("taa={}\n", self.taa)); text.push_str(&format!("ray_tracing={}\n", self.ray_tracing)); text.push_str(&format!("rt_quality={}\n", self.rt_quality)); @@ -735,6 +752,24 @@ impl Settings { } } + /// Advance the GUI scale, wrapping `Auto(0) → 1 → … → GUI_SCALE_MAX → Auto`. + pub fn cycle_gui_scale(&mut self) { + self.gui_scale = if self.gui_scale >= GUI_SCALE_MAX { + 0 + } else { + self.gui_scale + 1 + }; + } + + /// Value label for the GUI-scale button: `Auto` at 0, else the number. + pub fn gui_scale_label(self) -> String { + if self.gui_scale == 0 { + crate::i18n::tr("options.guiScale.auto") + } else { + self.gui_scale.to_string() + } + } + /// Cycle through [`RESOLUTION_PRESETS`] (Native → 1080p → … → 540p → Native). pub fn cycle_resolution(&mut self) { let idx = RESOLUTION_PRESETS @@ -1135,6 +1170,7 @@ mod tests { original.render_scale = 0.7; original.fancy_graphics = false; original.mipmap_levels = 2; + original.gui_scale = 3; original.brightness = 0.55; original.old_animations = true; original.ray_tracing = true; @@ -1152,6 +1188,7 @@ mod tests { assert!((loaded.render_scale - 0.7).abs() < 1e-6); assert!(!loaded.fancy_graphics); assert_eq!(loaded.mipmap_levels, 2); + assert_eq!(loaded.gui_scale, 3); assert!((loaded.brightness - 0.55).abs() < 1e-6); assert!(loaded.old_animations); assert!(loaded.ray_tracing); diff --git a/crates/fpsmaster_app/src/version.rs b/crates/fpsmaster_app/src/version.rs new file mode 100644 index 0000000..d2e1cdf --- /dev/null +++ b/crates/fpsmaster_app/src/version.rs @@ -0,0 +1,39 @@ +//! The single source of truth for the product name and version shown anywhere +//! in the UI (title screen, window title, F3 overlay). The version tracks the +//! `fpsmaster_app` crate version — a semantic `major.minor.patch` string — via +//! `CARGO_PKG_VERSION`, so a release only has to bump `Cargo.toml`. + +/// Product / brand name. +pub const PRODUCT_NAME: &str = "FPSMaster Extreme"; + +/// Semantic product version (`major.minor.patch`), sourced from the crate +/// version in `Cargo.toml`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The brand + version label shown to the player, e.g. `FPSMaster Extreme 1.0.0`. +pub fn title() -> String { + format!("{PRODUCT_NAME} {VERSION}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_is_semantic_major_minor_patch() { + let parts: Vec<&str> = VERSION.split('.').collect(); + assert_eq!(parts.len(), 3, "version must be major.minor.patch: {VERSION}"); + for p in parts { + assert!( + p.parse::().is_ok(), + "version component {p:?} is not numeric in {VERSION}" + ); + } + } + + #[test] + fn title_combines_name_and_version() { + assert_eq!(title(), format!("{PRODUCT_NAME} {VERSION}")); + assert!(title().starts_with("FPSMaster Extreme ")); + } +} diff --git a/crates/fpsmaster_ext/src/lib.rs b/crates/fpsmaster_ext/src/lib.rs index 61bb01f..f53edb7 100644 --- a/crates/fpsmaster_ext/src/lib.rs +++ b/crates/fpsmaster_ext/src/lib.rs @@ -472,7 +472,6 @@ mod tests { "chat_alert", "block_tint", "preset_demo", - "scaffold_demo", "render_demo", ] { assert!(loaded.contains(&id.to_string()), "example mod '{id}' failed to load"); diff --git a/crates/fpsmaster_render/src/embedded/title_logo.png b/crates/fpsmaster_render/src/embedded/title_logo.png deleted file mode 100644 index c17fed8..0000000 Binary files a/crates/fpsmaster_render/src/embedded/title_logo.png and /dev/null differ diff --git a/crates/fpsmaster_render/src/lang/fpsmaster.zh_cn.lang b/crates/fpsmaster_render/src/lang/fpsmaster.zh_cn.lang index daab560..6f841d5 100644 --- a/crates/fpsmaster_render/src/lang/fpsmaster.zh_cn.lang +++ b/crates/fpsmaster_render/src/lang/fpsmaster.zh_cn.lang @@ -108,3 +108,7 @@ soundCategory.hostile=敌对生物 soundCategory.neutral=友好生物 soundCategory.player=玩家 soundCategory.ambient=环境 + +# 界面缩放(原版键;因我们的中文仅靠此叠加层提供,故在此翻译) +options.guiScale=界面缩放 +options.guiScale.auto=自动 diff --git a/crates/fpsmaster_render/src/lib.rs b/crates/fpsmaster_render/src/lib.rs index 71e7eb4..92914e9 100644 --- a/crates/fpsmaster_render/src/lib.rs +++ b/crates/fpsmaster_render/src/lib.rs @@ -38,6 +38,6 @@ pub use texture::{ list_lang_codes, read_asset_string, }; pub use ui::{ - gui_pixel_scale, text_height, text_width, GuiAtlas, GuiTexture, UiBatch, UiColor, UiFrame, - UiGeometry, UiRect, UiTextureId, UiVertex, + gui_pixel_scale, gui_pixel_scale_capped, text_height, text_width, GuiAtlas, GuiTexture, + UiBatch, UiColor, UiFrame, UiGeometry, UiRect, UiTextureId, UiVertex, }; diff --git a/crates/fpsmaster_render/src/renderer/gpu.rs b/crates/fpsmaster_render/src/renderer/gpu.rs index f845878..a662792 100644 --- a/crates/fpsmaster_render/src/renderer/gpu.rs +++ b/crates/fpsmaster_render/src/renderer/gpu.rs @@ -470,6 +470,22 @@ pub(super) fn pick_present_mode(present_modes: &[wgpu::PresentMode], vsync: bool } } +/// How many frames the surface may keep in flight, chosen per present mode. +/// +/// Fifo (vsync) wants 2: a queued frame absorbs an occasional frame-time spike so +/// the vsync cadence doesn't hitch. The unsynced modes want 1: with a single +/// in-flight frame the drawable back-pressure paces presentation to roughly the +/// display rate (the render loop blocks acquiring the next drawable), which stops +/// Immediate from scanning out multiple half-drawn frames per refresh — i.e. the +/// tearing that reads as GUI "flicker". It also gives the lowest input latency, +/// which is the whole point of running vsync off. +pub(super) fn frame_latency_for(mode: wgpu::PresentMode) -> u32 { + match mode { + wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed => 2, + _ => 1, + } +} + // Depth32Float (not Depth24Plus) so the post pass can sample the world depth for // depth-of-field and motion blur (Depth24Plus is not reliably loadable). pub(super) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; diff --git a/crates/fpsmaster_render/src/renderer/mod.rs b/crates/fpsmaster_render/src/renderer/mod.rs index ba43e57..089df91 100644 --- a/crates/fpsmaster_render/src/renderer/mod.rs +++ b/crates/fpsmaster_render/src/renderer/mod.rs @@ -646,6 +646,11 @@ pub struct InventoryPreview<'a> { } pub struct Renderer { + /// The window we present to. Held so we can call `pre_present_notify()` right + /// before `frame.present()` — on macOS this hands the drawable to the + /// compositor in sync with the display refresh, without it static frames can + /// flicker (a screen recorder forcing continuous composite masks the tell). + window: Arc, surface: wgpu::Surface<'static>, device: wgpu::Device, queue: wgpu::Queue, @@ -1262,6 +1267,9 @@ fn request_standard_adapter( impl Renderer { pub fn new(window: Arc) -> Result { let size = window.inner_size(); + // Kept for `pre_present_notify()` at present time (the adapter helpers + // below consume the original `window`). + let present_window = window.clone(); // Honour WGPU_BACKEND / WGPU_ADAPTER_NAME etc. so the backend can be picked // from the environment (e.g. WGPU_BACKEND=dx12 for per-backend testing). let instance_descriptor = @@ -1408,11 +1416,23 @@ impl Renderer { let present_modes = caps.present_modes.clone(); log::info!("surface present modes available: {present_modes:?}"); let present_mode = pick_present_mode(&present_modes, false); - let alpha_mode = caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto); + // Prefer an OPAQUE surface: a game window is fully opaque, and the UI pass + // alpha-blends onto the swapchain, which can leave the drawable's alpha + // channel < 1 in UI regions. If the window server treats the drawable as + // premultiplied it then composites those pixels against the desktop, and + // the macOS direct-scanout path (used only when nothing is capturing the + // screen) renders that inconsistently — static UI flickers, yet a screen + // recorder (which forces compositing) hides it. Opaque ignores the alpha + // channel entirely, so the window is stable regardless of the path. + log::info!("surface alpha modes available: {:?}", caps.alpha_modes); + let alpha_mode = if caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::Opaque) { + wgpu::CompositeAlphaMode::Opaque + } else { + caps.alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto) + }; let config = wgpu::SurfaceConfiguration { // COPY_SRC additionally allows the FPSMASTER_DUMP_FRAME debug hook to // read back the post-processed swapchain image. @@ -1427,7 +1447,11 @@ impl Renderer { present_mode, alpha_mode, view_formats: vec![], - desired_maximum_frame_latency: 2, + // Per present mode: 1 for the unsynced modes (drawable back-pressure + // paces Immediate to the refresh, killing the tearing that reads as + // GUI flicker, at the lowest latency); 2 for Fifo (a spare frame keeps + // vsync from hitching on a spike). Kept in sync in `set_vsync`. + desired_maximum_frame_latency: frame_latency_for(present_mode), }; surface.configure(&device, &config); @@ -5118,6 +5142,7 @@ impl Renderer { }); let mut renderer = Self { + window: present_window, surface, device, queue, @@ -6684,11 +6709,22 @@ impl Renderer { } /// Write the post-pass parameters (bloom + grade) for the current off-screen - /// size and bloom toggle. + /// size and bloom toggle, gated by the live shader-master state. fn update_post_params(&self) { + let params = self.compute_post_params(self.shaders_enabled); + self.queue + .write_buffer(&self.post_params_buffer, 0, bytemuck::cast_slice(¶ms)); + } + + /// The 16-float post-pass parameter block for a given shader-master state. + /// With `shaders_on == false` it is the passthrough set (no tone-map/grade + /// and every effect zeroed), which the title-screen panorama uses so the + /// background image is shown raw — untouched by the shader toggle or any + /// shader effect (see [`render_inner`]). + fn compute_post_params(&self, shaders_on: bool) -> [f32; 16] { let (w, h) = self.scaled_dims(); // All post effects are gated by the master shader toggle. - let sh = self.shaders_enabled; + let sh = shaders_on; let on = |b: bool, v: f32| if sh && b { v } else { 0.0 }; // p: bloom threshold, bloom intensity, texel.x, texel.y // q: exposure, saturation, contrast, bloom-enabled @@ -6698,7 +6734,7 @@ impl Renderer { // soft-knee bright-pass) for a cinematic highlight glow, plus a touch of extra // saturation + contrast. Exposure stays slightly under so sunlit blocks keep // texture detail (under-exposure + blooming highlights reads as drama). - let params = [ + [ 0.9f32, 0.72, 1.0 / w as f32, @@ -6733,9 +6769,7 @@ impl Renderer { // s.z: add the volumetric in-scatter target. on(self.volumetric_enabled, 1.0), 0.0, - ]; - self.queue - .write_buffer(&self.post_params_buffer, 0, bytemuck::cast_slice(¶ms)); + ] } pub fn aspect(&self) -> f32 { @@ -6747,8 +6781,14 @@ impl Renderer { /// only when the effective present mode actually changes. pub fn set_vsync(&mut self, vsync: bool) { let desired = pick_present_mode(&self.present_modes, vsync); - if self.config.present_mode != desired { + let desired_latency = frame_latency_for(desired); + if self.config.present_mode != desired + || self.config.desired_maximum_frame_latency != desired_latency + { self.config.present_mode = desired; + // Fifo wants a deeper queue (spike tolerance); the unsynced modes want + // a shallow one (pacing + latency). See `frame_latency_for`. + self.config.desired_maximum_frame_latency = desired_latency; self.surface.configure(&self.device, &self.config); } log::info!( @@ -9036,6 +9076,16 @@ impl Renderer { // When the title screen requests the panorama, upload its rotation // uniforms; the pass below draws it instead of the sky+world. let panorama_active = panorama_timer.is_some() && self.panorama.is_some(); + // The title-screen panorama is the raw vanilla background: force the post + // pass into its shaders-off passthrough while it's active so neither the + // shader toggle nor any shader effect (tone-map/grade/bloom/vignette/dof/ + // chromatic/motion-blur/volumetric) alters it. World frames re-write the + // settings-derived params, so this self-corrects on the next world frame. + { + let params = self.compute_post_params(self.shaders_enabled && !panorama_active); + self.queue + .write_buffer(&self.post_params_buffer, 0, bytemuck::cast_slice(¶ms)); + } if let (Some(timer), Some(pan)) = (panorama_timer, &self.panorama) { // Vanilla: pitch = sin(timer/400)*25+20 degrees, yaw = -timer*0.1 degrees. let pitch = ((timer / 400.0).sin() * 25.0 + 20.0).to_radians(); @@ -10708,6 +10758,11 @@ impl Renderer { } let t_present = Instant::now(); + // Tell the windowing system a present is imminent so it can schedule the + // drawable against the display refresh. On macOS this keeps presentation + // in sync with the compositor; skipping it lets static frames flicker + // (and a screen recorder, which forces continuous composite, hides it). + self.window.pre_present_notify(); frame.present(); let present_us = t_present.elapsed().as_micros() as u32; diff --git a/crates/fpsmaster_render/src/ui.rs b/crates/fpsmaster_render/src/ui.rs index c5ef41a..6545b7a 100644 --- a/crates/fpsmaster_render/src/ui.rs +++ b/crates/fpsmaster_render/src/ui.rs @@ -16,7 +16,8 @@ pub enum GuiTexture { Inventory, /// gui/options_background.png — the tiled dirt menu background. OptionsBackground, - /// Custom single-image MINECRAFT title logo bundled with the binary. + /// gui/title/minecraft.png — the vanilla MINECRAFT title logo (256×256 sheet, + /// blitted from two 155×44 halves, matching vanilla `GuiMainMenu`). Title, /// gui/container/generic_54.png — the chest window (1..6 rows, blitted in /// two parts) and the fallback for unmodelled container types. @@ -78,10 +79,8 @@ impl GuiAtlas { icons: crate::texture::load_gui_image("icons"), inventory: crate::texture::load_gui_image("container/inventory"), options_background: crate::texture::load_gui_image("options_background"), - // Custom single-image MINECRAFT logo bundled with the binary. - title: image::load_from_memory(include_bytes!("embedded/title_logo.png")) - .ok() - .map(|img| img.to_rgba8()), + // Vanilla MINECRAFT logo from the active assets (gui/title/minecraft.png). + title: crate::texture::load_gui_image("title/minecraft"), chest: crate::texture::load_gui_image("container/generic_54"), dispenser: crate::texture::load_gui_image("container/dispenser"), hopper: crate::texture::load_gui_image("container/hopper"), @@ -940,8 +939,19 @@ impl<'a> Tess<'a> { /// The GUI pixel scale for a window of `width`×`height` (vanilla /// `ScaledResolution` auto gui-scale with `guiScale=0`). pub fn gui_pixel_scale(width: u32, height: u32) -> u32 { + gui_pixel_scale_capped(width, height, 0) +} + +/// The GUI pixel scale for a window of `width`×`height` under a user GUI-scale +/// preference, matching vanilla `ScaledResolution`: `gui_scale` `0` means "Auto" +/// (the largest scale that still fits a 320×240 layout), while `1..` is a fixed +/// upper cap (Small/Normal/Large/…). The window-fit conditions still bound the +/// result, so a fixed cap never forces a UI too large for the window — it only +/// ever holds the scale *lower* than Auto would pick. +pub fn gui_pixel_scale_capped(width: u32, height: u32, gui_scale: u32) -> u32 { + let cap = if gui_scale == 0 { 1000 } else { gui_scale }; let mut scale = 1u32; - while scale < 1000 + while scale < cap && width / (scale + 1) >= 320 && height / (scale + 1) >= 240 { @@ -975,6 +985,22 @@ fn item_swatch_color(id: i16) -> UiColor { mod tests { use super::*; + #[test] + fn gui_scale_auto_matches_uncapped_and_cap_bounds_it() { + // 1080p: Auto (0) resolves to 4, and the plain helper agrees. + assert_eq!(gui_pixel_scale_capped(1920, 1080, 0), 4); + assert_eq!(gui_pixel_scale(1920, 1080), 4); + // A fixed cap only ever holds the scale lower than Auto. + assert_eq!(gui_pixel_scale_capped(1920, 1080, 1), 1); + assert_eq!(gui_pixel_scale_capped(1920, 1080, 2), 2); + // 720p only fits 3, so a cap of 4 still resolves to 3 (never oversized). + assert_eq!(gui_pixel_scale_capped(1280, 720, 0), 3); + assert_eq!(gui_pixel_scale_capped(1280, 720, 4), 3); + // Tiny window: scale is at least 1 whatever the cap. + assert_eq!(gui_pixel_scale_capped(200, 200, 0), 1); + assert_eq!(gui_pixel_scale_capped(200, 200, 3), 1); + } + #[test] fn gradient_rect_emits_one_white_quad_with_top_and_bottom_colors() { let mut frame = UiFrame::new();