From d23c059684794fdf87365840166d65a80b8bd644 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Sat, 4 Jul 2026 09:19:35 -0700 Subject: [PATCH 1/3] fix(render): stop GUI tearing on vsync-off; bundle in-progress UI work The reported "GUI flicker / stale pixels" was screen tearing, not a cache bug. vsync-off selects the Immediate present mode, and the faster GPU-quad UI (from the CPU-rasterize -> quad refactor) pushes frames well past the refresh, so the display scans out torn drawables -- visible only on the crisp, static GUI (masked entirely by screen recording, which forces compositing at the refresh). Fix: choose the surface frame latency per present mode (gpu::frame_latency_for) -- 1 for the unsynced modes so drawable back-pressure paces Immediate to the refresh at the lowest latency, 2 for Fifo so a spare frame keeps vsync from hitching on a spike. Applied at init and kept in sync on vsync toggle. Plus present hygiene: window.pre_present_notify() before present and an opaque surface. Also included (already in the working tree): user GUI-scale option (guiScale Auto/1..4, published once per frame), vanilla title logo + splash text, a version module, and the title-screen panorama post-pass passthrough. --- crates/fpsmaster_app/Cargo.toml | 2 + crates/fpsmaster_app/src/gui/ingame.rs | 2 +- crates/fpsmaster_app/src/gui/main_menu.rs | 78 +++++++++++++---- crates/fpsmaster_app/src/gui/mod.rs | 17 +++- crates/fpsmaster_app/src/gui/options.rs | 19 +++- crates/fpsmaster_app/src/main.rs | 7 +- crates/fpsmaster_app/src/settings.rs | 37 ++++++++ crates/fpsmaster_app/src/version.rs | 39 +++++++++ .../src/embedded/title_logo.png | Bin 40126 -> 0 bytes .../src/lang/fpsmaster.zh_cn.lang | 4 + crates/fpsmaster_render/src/lib.rs | 4 +- crates/fpsmaster_render/src/renderer/gpu.rs | 16 ++++ crates/fpsmaster_render/src/renderer/mod.rs | 81 +++++++++++++++--- crates/fpsmaster_render/src/ui.rs | 38 ++++++-- 14 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 crates/fpsmaster_app/src/version.rs delete mode 100644 crates/fpsmaster_render/src/embedded/title_logo.png 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/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..1bb1b02 100644 --- a/crates/fpsmaster_app/src/main.rs +++ b/crates/fpsmaster_app/src/main.rs @@ -15,6 +15,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 @@ -320,7 +321,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")); @@ -2351,6 +2352,10 @@ 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); 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_render/src/embedded/title_logo.png b/crates/fpsmaster_render/src/embedded/title_logo.png deleted file mode 100644 index c17fed8cae280234dc99672139e617b0076bfa7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40126 zcmZ6ybyQnT)IN$^OL2D#?ph?cTY%tD9EwA+;$Ga{+6E25wP>NGxD|JIFYa*j{=V<7 zD}UsioV9W$v-ixlXFn4I(on?4BF92NK)_a3lG8>&Kn26U&tjm#uRu9W5CQ@%g0h@6 z`16NjQ}hj@(j#nz?ZrRnrOf94vP$n~@qV|cYWtZaFJ^}fF$Q78 z>g=3yy!m?!!cr2zEx(Ewv6e<@{npJAr52<1*&NU6o)*bm66hvr)cGP-W%6PjrcT0b zvhj?K{6?shi}^G-%RkF&m^@SlD~>m5;@=09N{f@t>k1%H4QeZKu~J^s2r-vgEdY*c z6Tk-+=eCOxo$JNmL5fFUFpov3G^@S@E|T&>$g!zJ#E%7-^N8P8r>13d6mVk#94}_f zmW>|qzJKNZ9KX1>*W83V%MK85I?4!WM0Y}`7nm0wQ4DrxCqq;S!81o8h2PK+cd;4V ztj!CtcPpJhzmyiQYmDAOM^LV&Y{qRI)EZd~HjH72~Tx+y16ihC5?`xCXiwN-2Lt z!C%OiM|h&R`$gFo^ZyJ&iueuJ{FJBo2U+?*x^C;-muV^G%>5s~#hDd<=2#6d@gQko zfJO+!H5&qXk>aH?F%WXFB(u9j;1384-T$MZbpj;9N8!DHmNJ4JsyS->{9f~JAnGGQ z$Ak|qi2+kR2_C#iFpTVKN!@z-M0$PsydAg_x(N~gIn(}H?)m>shDFI{gUeR~5k*b7 zu{vzfnp}NcTT)7P3bw6n-tb3Golk?~7e+R%c^ivQR)#ED{(t(!8^8*6FvPpV_@Af$ zQ8L~?Y{XrhVZVIDa!HhKEnB_>7Y80D^?jql5BQ2K6;{2e@gbN$ZevF4I&Q?9=G-2(wnG+MgcgYBe}+yVPjAHp)BON|GcFJZn>Y zs&%82r?})nm=LPnx><^;sBXzeqB$D+d}o#vJGUC3({4&)=Js>m>UO2+aw*sSVta72 zjlyNpg?dXHU3A4)`k<%3gyaLQ_mZ>z$I-1#ZcaOUsjxnZdP_!s+IAas0+{DH<1qNA{_#K#=oyCvoxE-5e|V&DH(!pN^`sxn99dD=zebgB{IoMS3ltEl(HXSuvW z&?6dgn>4=77R>ZoLkxMi?^tLoaEWwLswtx5AI{h_g*IX8(S8 z^v>v;Do+p|4b9unNq9Qd>fzAlnvq}Sdx^S-*GsQQ7bQ&qwk$kEueWgO?OEn5sgOD! zmkZUQ%?=YX;tw_G@``Vi>NF*vIS7|Vg_fp8M@;RLV@^B|=cV(#`VKbdt&-eP-_0V2 zcGp_;^wu$Z$#1j7{?4Cjvw7owm7*9;pdpS}@QuGEv>Q;hc*v%mOjh?Ow9-~N-XGzx z!X=YtpBy+>;6oga)pzZnkpKCoamQgH$g0deVkfz_LWGp+!C+(X%UCfbZ0SQXMs{#9mLPnfdR@1~h@b3jcsk_}FH z1Te$G6Ft+m2#1(uk*n{>e^|>3^xQ!rGJ=m>cQzB6g)4eX-`TjBd;M;XLx;wHpVER@ zmT9R2uH2a-!@PBd{+`A3XM=QQ#wF-vWrzqcwx~ZNCS=29K@;VxOD8HEHBn1rq?DaJ&q-@3Ulcx*V8XAVykzCGqF4Z2V+cuOi< z>*7aq_59v697)=nWsh-j*w^{p>awrZ>H@De{ZuF>1iuC~Q4-&uTl0E9p>Xb46p3C@ zJ&W}>vF)hM((+jEgCn4a4t+NIFHTKOGo1A5JQL_lb z5SC+rE}M@k^KvPcDmjJ&?@>;ZT?>|d&jRcGq#5}s&!d8er&6M5*b)yWd*7QF&I_dut@{c<}{r}Y)wA2uxHu_0LvLyltu*ciY@ zP*tpEvhEGh?etG-1%P#U*Gv@RxA9Yd%*I6oeJ=5Zv(Z8uxLf3Mrdew664!v|>zxAF z8`__u5Rxs-C9??``_|`gq#yNPrn|H2wq$hg;+{xHSS3)XKVM;%u=Mco5gB>iR&`D~ zQf4_xAT6Cx2X?a$*{Y~4@ZB2V)E8lc3;gM)?YOj%dT0r&id0@2=f6dro*r>PyeD?_ z+107qDCUxXSL_7qNL*A*3l-z$g1_-xQCSn+8eM2D`~q=3KBNdYwtS4`O`FC9ZyzYeWq=meJn`?zO0vKPfo;`U0Xw-p}tMZDM# zJZ~biV$Wl>EO$_mAbuy3TveTEiD}<3oVxl~S$b6`%9HIcy`6hegC!_3B(XQ@w)&!o zAMMCG$Nv))MdrGW*^)hEUnbk`_hhKlz%UK7Bwlo`f9k+AzLC$$>=eX$HQV+(@c1T_cEu%iK<@@!zK84x9}(_lQ`%2{Um zH)8JsXzOdUBi>#WqZ%D-MQM6RGgSxH%5nhAqu}{i>+zy~)?i;4rtaed{}HhT#>}U} zaLk!X|KaKf+TZt-_tMO4y(#6%QJA{z@E^=ZRGGr8*&46IJjOSZLJfU#<*zMftLtr< zPv$r#`8rE^AVz=m>^(lLVsjBVxNCiO--R}WCa`cHA;tn`Ojkc!JxErLn*!>=^goJ# zTrJ{u9S^%pn}YXmRCBeCwLGLUDrMo5boGXkrySH%p-G}0Rw{ChA04!&o~Ap=KxG=~ zTvb-;P^r{;@Wy`aPivbB$;HUaLuDx1DgE=`arF7_j()W_QJ}_(W)NHtSD$~*V(N6F z+rA8Gh;!yJYfn8cp+p93n>J#36{rQ!7{=WO6y72zF@RR{v+_z@^Sov(VtiXzAR`X< zJ{Sy9!BZL^WoC9>`f*`@d3XLq;!L7UJKv64fcOk1td&(#?M$t*eQx z8}>HPIIc(?DLFsB==dsNcfJ-mF(C{9FNA?HXP^VT6d1buy)4=pm@}Tv;;|rIrhcco zZ=PD4lSQ})kH6$f(1tB!zMjMufv=t6DX3*Jfzw~D!5!@KG51%Ve_-KTtnQvp*bLUE zHu@nH^;#tTLGqOs?rY(s1YJFCjolWtSw=iqNNis|D0yM%Z1;fcmE!4ke!GGSh8xXg zrK170bROH`%@we{J9~(5I1u4dgg!XS!?b`F6XhZKoHsoY4mQX0SMx#yu#5v?V~5WB z%%$GBH$Nv_-Yq2wd1_dLpJ_=8Iot#?0+7o#?;bUv4tVx|9l2cFz|nZR!%Xpd*bI8$ zycEW$X5=hPOj8*mDYOeDu3n#OEt2DT7RkfYiw?*qBBc3ftPOo3^WpxVlZrfM{ZOpX z{fjzp05~mE?8ToF2^L}COQr^`H$_BZyCLu)DLsiej7lp5R~C(nmpJwwj=?rRJ^oUl zxTrg+^cx!U+pJ#Iw&z_+!csXMf>xUGF8YtM`c-uZV3j2JW}l0D>YE`_c#}-^>YJ!n zXAS|(Ad?QPX?;WpAI02SUtjOduh?&}0vTU$xP)BycG>$y=TVer&Sh7%LV0}n_-#k= zUjzP`shrH_zX{ViRN1_dz@9M5%Xu8M@U3g zbI&11iZxeF@U%g@!PnU5KU8Z#x3xeA8WNZ%xo}XxM*=ow=+}u+WEnx8SMx#eK9BKn z+2qZULv$M-W$} zJCT&ysuC~#dXW6(S5Q}jrY1?0LX}1v4Mm8Y)6ZXM;;xmoZ?UzTjBGinn6=1jxCpHN z-OV|=MoJ=};WBR=@d;U6>Q6JcTM%Tx1!y4Phgh;j7#MglU4&!m{vI2!(AwH4xruxG z!NE>OuzoI(7A);cE8sZ*9(Z$W{<79;x97I)mR64Yccm=-(8m`?F|uxWXa7Npo##AL z|4Pq&;avBk>`L47zMIuW;dOiaa~MklS%YmE%y`n)tnPyLS7A02_^mU#nA0P3R7~B% zP|6pJkHiJ74QdZPiyb^%;3A%$^p8J(J#6L%%egjI>Y6|-xB$M&IcmjeQ((k@yOZXC z4ShUyR*pDCITE$*r)vIDt!^*y@k}-e-<-MaU<@eX+3*R-h*Y)IthlRW_O!6~l>~WP zQ+Q?i*{`lC^!Z@6BGvWuIET%W!zv`C8mF;vXc9R39rb?XkRJl`bP}jEHeGcy4d@lj zG5B=9l8Tm*3p9vq4)@TI?A<8(V_OX2%tJ)>j~#)@lB2!x@GYu(q(k!{^heE{xmaV% zc)3mx;OJ*el)J*F81dV>c&O%c3CNmQQR9Iew(nF<21ku;DBf(HYH;o}?-X6NoU1|g zh6=r&%eDP;K%>=rMutjIq#OqsD0T2LFH(?DsW05mPjYHGX3OlTLD@pKbECD>by_DW z3zQtuvWv2L1@qpT3qTGj$U+a?0oX+omQIIO;W3nd=II97)Z)usS_&~7v+80VSfDDa z?eW;&Z|_-LRiJH8S`&)?e<$8M@aGa1V0kUbKo9rTGnivXu9;+JuC^q`rNNm{y|E%q zi7G%gWIBpQbGEw5ehN?+xFocAKQ;DQ;YZq3_3y_EujlVX-M(a%2PKpBJr&zYNsn3T zWUxHX!<;YC;PT9q-#~*B8+0z$7Io+D9qz5tiKTe;yAf%Dmm|W{izsdoc!s#B*30)@ z+;4^6F_Rw@hfPbp7oNiS{>SwmoRn&|Egy+Q3VvhgX29bGC)Xnun-U)+wo8UJntfcF zO;3T@iUA_(aYI@6abkQ?#e31{bqMoBg@~UI0>elT&90__ukUXxU>|&ehY#5yv56`i zUMDTL%bjK?sq78!PichI*sjcpazT;Ux8WD>Oi2iq@%+wY9k`pK4scS4_P%YMK9|R< zx6I@%VyCMRmjA2|@qmpV8Kfq(#wWyC(P0;?OS5r6$m4-<)AKKz5a8lx#>n@du<^ps zRr6$nVg#k1;p8QJ8l*Xdb z$SpK=D@!yclZY=ht&>qO+eJWS(I%QWk3_=0;4+^Qi?p%RAN)4!9-K8OKpSUXLbo|wbDi{jra!Z= zX!Qy=e14$!vWHn(Y{w>Pr?#FEYOWl+A-AeEbBN~^7vo=AE7@&=CL7T=(|z{%4HGid3L%% zYZ$s!`{4aL^i$8<(-D7~Ai0so?Uvx_{qI4I3U-8TPRLSn%giTI!c9?+I^*8AFW^4F zA|F@h&~bImMJg+>Fup2CbuPVXyiCk2tB~LHYyY}Y;@nygb2HA?Y-+>CspU&*#CU2|5mRZRbv)ml?{bY`dl*yWc^|D7p$@Hx$;DNE!;DqfTkNbhDOcq4 zl~KnuP%g9}bw5bB7XaHXE?RKkn!mm`$^xN|(27p^$Ow)#XM+R{=~f1YBVZKI2Fjc& zLd66HSSPmhvY$1fVN}_tqYpQmQRQs!appn7OUc-s$!9{{=(Yw^0%^kHMZ&Qvvd~n? z>4F@lE#LLArTn0U=buqDGnC8=b_@ZvSTlF@${rMKnqMQ%GK9?WO9ECG>_k+dd_u|* z4x*P)T;)Snf^A(0Q_|1W!&B1{4vxGsg1$LPqBmz3MoCDMRvYwWH$H?&UJT&dO$=Q^ zKNqYs|IZ{R`t@-Uhh#2DMbMdum@`Ecm#&rO*phAIP?uzy8RB*b-`JC_r-O6 z!QAxUWud2a!(;3l9)S|pVp_86BbVIxZrf(Uykuxvu#I9EcLc0Fc&GlXT&y}^*v>&# z4Z7QJY4k%q3WSOxQ;>C?dInlpm1aEA$Zg0#K8 zGoF~fA-}D|$`UpxmCOB2J0vF;<@rk>J_)zKXy-!TZHjl(uQ8P^G?L79r+)r?)Wcg~ z(J4vZsql2md%;_+@4KdNU4+AZk8E`k?>DDka{5zE6ERG1F-u+o@T%_7FJMZ&OcvmfXJPhZ(@Nb8Gse#|!FGvt+ z!LQyi4^*&g4Bh^{fM8DKviG)JSnLb`&1o>^EQ;oS&Ba*q6oi_u7FfCDe<1;b-R#1a zk6gPLRzWSsK$X|$4eFV|8*d`&vMayBU{O360zU3#Zn@F!k}AKeOZ~kRRBv$gZ>+|O z<>gJ`TF>oXDSsU{r)Ypj?LuW{QX9p1@x<+#dqqRuF}-xrtCIR-vvWS+t$P1}uU)~Z z$U>#7ST-*ikrt_5U$)O$H(>$0A$869ir2!-9Q&=^ywOJe2@CR8t9c6OIf@VKfBmlk z>)Ast^lkiJ*gui4?_)Nes0C(K016!(odt?Coji_4q}e}iJ0>nu1`Zq6_Or0%M)OKp zI-V9OHZ$g$!rvC`Q55LAq2^!}vx)MieECvYNXAqArWC^ETJH-<_<;fd7oGDqQH_v- zsg{;pmj(|$ep)T&hs2TzbHpizVCst7<`)5x#P#!xg)(mj`?Q3}f1gVzYJTFu2!LPdSIdLO6&jjh>Slq@O;Bn+aI5!GyvlB|-90RX6xE zT!7z&{%>#U!#@P+j%2^*cU{S~_qT|DVhnDxuWFhjb{zhuj_E1t_WJziRP2N$liS!< zXKG;a;@9-S=ID=A0Q-33cjdIQXl0$!Y(kynoPq>xuL96aQ2gP`&mUNy;|F@ds?g?Q zI&e+rF1h2W1*ZgGeRxN0(7(9dh{O64m$dSKv@=h9ycYeAt?JUAm(PJK zfdOvQ5x2dycnqe;e%b{;U;IL$)D8;JHt{o(nA(lL?BK^y;X=Dx$Asa!CWPYvaCOwt z3vwhCqVm<0reqyWE)Ryu&6Hvy1^1UEa;qZ&(7AU;U$l$ttdS!pVgyW`6*@%~=qIWQ zO(f;liznr;-u#OkBkDT@j`ik&sDxTZ4&AAjeTdfRoZ863E632KrED+o%YFA}+c!)x z?3;gSR|rr*T@BjDu;11*zO5VZ$`5f6?ODHkeI{@4uQgdUu)vI;L~qgN7SV(z2AjR5 zvY4Q=%7MF16c zW;qq7VZB_y?a7ZOqLwEJ?v<5TY;%$3j`-wjD+zAVB#ss-P26@r+MPbLAS%7?i<8Q~ za}-ak(#vrP)kf!;p;Ft_=ABvpQwB~P9nx76ZH}b{DGfWKG%91JV59!$I4z9=LT3gu zORzQDN9_J*|C>WARD@q2i*gtEl@_-}Ay^aTyAv+XO0~^(xP8n&^`RIu{WO8#j+a@n zx{Qo8Gp*l6YrZQAI`ocHYc&Th;8|yhd6tp0o?8|`7G zMmz@4=Yza_@)L4CQbUaBd$_ue@#{9u=DcPZeSapNF4p{f)(}6xYFG@!v37{z5PkY9 zE4OF*QfQiaLbgDXa7s5nb@OwJda0df>LwB|DI0A&QVq4B&GWs{aeZfAS&VcSPN^D! zt98I}7hyu|eQo7OGgQ{SN{?+mh{NaQH7%JYY2>;OmkETGPIDf=y0&^6!Gwtcv z&ondU1l}I6jW>#%A6@KhF4c4iaP{vZ%`3;9QLVo1-MaLDpHvChBbf5^Ncw8oSGkbV z3k52nqPD0(EirpONyXvql;wG6HQld$el$Jr6Pp=2xl906Q3JK($t1PNXv|P82zYG{ znTHM^6`(=q*TS2Fc4#bH1c190pS740zE6ksaa_20PA#EYhC+5i*L1NdS&;F7`&Ay(c`};vM z_Jpl6*&|a8_1T08Tv6;|G*Q0vTAtRTsQ~??qOcySI6_aE<_Jo|z~}nw^EWDg6A$4N z+QT@b=~9Bi|IGzh@oQ-JC}IWHVm=(@oTlmnymh_`+f!>Xfla@)F95cxP7{1qiCo$6 zVHQF=ZO`2zkGt(>@(gi8s_B;)x`wh}a7f+|2Nao@kU|bG7DZ}ybXk|%VhUkh4 z=%-d%OtAQoe*l37`t$Y0|0Fk8z?m&~=$ksLQ)N`s_|7%))l3d^-7^MFN?AbR3mutO zkn~WSDltk3ybC+RmYu9a0VvJ$Z!(%L4$Irpa%~UX*2hkWc$`_Gm z;PJe}sFMO_7v}~rg1?bn6|#w6Z)Xm*-v%4ho%)Hz%YM zOjY|S$CDgDQ-Y*DGlxP$8uW5+=X{+fU~idW7BbU+?aQbBh=%XwAj+K3NvExfJgzIF z``98$@X5iWzPt#k7nch@Xz4nNh$Sa?&@O%6dqA>{O*%?pa?s1e^blTs1o)J z$&z!xR3gFDJ=q7(32%do6eCd}F!u8Nwp(}=nj1m#R8aV8@}Yy2hKRi|MQgzgMt$!S z_|k{_#iDcT;R`{yCMhJ>=TVCeT&DhwYqpt#KGGHdVc%JMz2}DU&|uDd4ZJS=6@vSC z-cO^yc_eB9t^e6eW_-@_XwTw5lE#Az`=_dc8+zLn!6H64C#+-qDX_A9Q9HG2XXA`* zi%ug+Fhngw(m1so_#J)8=aVZ^%tOo-VvLS-=AK}R(eEjFnQLKFE3*4p$2P;xA@qo; z@Fbv1%f;aJ?Qi~H+BJY%MV9gcd^|}AtIEnuDl^yj#zYk?92O&O zPY7?(zVW0|&nAgWew+ue%3SY$wY*4YMfz~QLzvJ#{2EAvs>uR*5CF<}X(ZBv{ck$o zL~1GDF)NpH88h@z3^a2fIAE~05>$|PLzHAwgy%Qe?UDFq=rX$CagMLGV1sk|`Q>|y3$r?7j zRaJK$yspzz9m5Iu;P_!k7C@`z6$AcTlli}kX5n`S)X%iKh+)(!8>MB|hLF$4^Q0J| zwKzt=Zroq}UC+{v_R}i?gPVmfa(XkpvjmKWEw9t{|T8WCmZJAYW1ZGvNqfl4-J^DlN3= z`C@A>mP>&#lVa6Z*u>5ZUi#t8iD0L8==W4ddLBD;(}JxDl_}n!zbE(NAyWRT!wyqj z%&^*JqS-qUF-G#oR1oODCR)<2XIlG|wge0r>qw6`+F^RR?dr8Wx9o(14UY890=*n( z0|-o0{VcmWZ#+d+0tYb2A~}PVF`r29)bpf+xc#MPtA5(6IzX+0eG))3qNis)w)sOY z&h+5${t(5Vkz%Eak&3HBkbTLy0=_BiX4qm9;iWx)djZFoI8#^6W2&*dS}5*{i0V&5Cs{V<*dO8hjZ4dk8b!bTiRDNo zOKB{Ys}q*+m`d-YB(Ga1oXdbC#A&ClZ-?2HB3W$g8K@oR>X0(=o{$p2Y>oy5r$6p*C0T783fF$t zx7eDYh?}9{hmfPq3d8X#g(QAv@NLB*htOi}`@Vo)A!FBs)SBXdI^*x;2w=bZp<&KG zft>=^IpY$~;E8JIJQZ&rm=_M&JH}rFqFx+kKTV!E%%1xIB+jh+{>y)nPk()ljkGCw z;#H(!O3aXqwJz$ywN)i-(<_YcRV9vNip%<_?>r^y3VVZwLb4a~{Y7|fQJ66CfS)Nv z)PooeE=m}owoj$5il;#z(|-~0Rg#G;YpVO%+SHIkQOLtKzZ7&#Ux44Z~7 z1cSXq#8~-vxBSKXegEtS#A*WAPIJ07ck@f&U@8+*kSOM?ciyZIfvM+}kNZ1}O_0l0 z8&DOg8TH-=pq+xYj|IV6{i5f|rYH620o$TC7YU$1*Le&? z3b7d!oMZ$o;!L=t8#XsTRs%JOV1A#*2w*nNgm&!8v~97vgRea2IS67II2FpISuZXX z<8D^pmcY8T+V3z1N>sBPnyR#l6lRml0e&A@yXey@JftwwCKrt#{#9(%{8=|PI2Q)! z2*&>2V#ufLZ#ZGQm2D)^tvUHu~s5?I` zPieY8eEtA_J?N-5@?%t-1B4YH-rIfCs>x1E5>xoG?|1Kc#ZX~FY#mEPDVZo1rWQj~ zvI?`Y*lUI`9IeR;ZjQdf^s-c2*{i$UZQAn$eo17ft40cvOL+fAIY04PgLq^Tz^ZGI zXQaZgEBw{O-Zg#;u6`ud147GoXvn=voQj(P2#<-i=2`#9@Z ze}jxCoR+B1yG{)f24z)Z$=P43fXMUs^WfFgm=j@xs?d)tgWx*@2U)&}-p1#X#YXQJ zNRP{2KKCBtQ`8!Y)0>MkwPJG8@3#?$|Ad!U<;dn0IxF?P&6?e`(ffQjW-|W;+D*eA<3CA)57nRL~Rw zvy3>b84gYege-bhhNuNr7)i>^pm!aik?CezGqZ(H>Q;um^us%1dZF~%rGZPch4wm^ zI1bGZZ?jtmF_eCf;HI>x@v{*{gQL}&=yLX@7hYC{Td3|X2Xr*!6nBzXh^*hM72}5- zAVG|n-2aN5j$7x~V_FDT4xv(DAmvPMwN;>+EOoPzg+^^#fdP1Z)uI(l+bmAY*OB5w zL!KClQwh?wYl-4l>wfA*o_AhXs?;fm_@xmJlYm(GX0O_3tSgQ@i#Q~AtX4+%{3kWV1)gW6IYQ~3; zbDaDKDU>Reo=YE7;p8amt1vuR!gdc)MOCxWC%`ykpJu=}Ox;Leun!9^tcQp((tzVk zGeNc5HAwv#t9#5#a&B4}doe4{0QDIMgYn*NlCY$X1TbB`rB|q zsYkYvOP@_a+vG5TBo7rr_z!JRk}Bl-5uwDnvGI_L6%vQZ3F#3I(`ccS3v&}% zcKHfB7BYg=>Te z%if+QTWqdCdSW~A!&&7@0rH%!iA0fSJM_w=l#|)_V)mw96PhKAU}1+jCN^*x~}D*Yn>k^-BP|5Bc__C5K42S-B_s#Q8p;isDUpet6cZuF;l{j~8OKi9@m6 znPU)I+P%1|ZyC(Ma7sFyMWlfKh@l5xMm#c-nc`=ND##^d zCz?WQ=CJ3=eKg)N@mdMPYsjJ{<;{2(<~@+dHNb4tus`TYh+amD01|u@}?-Adxmt=l1sM$J(Y9wMjpg6joxV++P`#i^jf#eZ>Qu z78lK997ec14o@;d3H)T=h))2#0|n50s)~mP8LjfJhIQ+~8e)R-lGRUv)Y*5x6gR_y zP0AhfQFBs~9*+u1Qn=6lCx>)MdUTb2Tb9>rAyi>Vzb>jQ6;onQ^B@JiD2 z>Nh{|U35~wgK1f_?GI8wt=s?1rzJ%=v|>M>`gAqy4w!#Bvt>RXONJO__$otzGm?1h zpNkn;TYsM0$OsN1MVQM7k`;YNd4!#Rp23G**y-eAYn5Nfg2Az3H)~R<(B=SyDkdGP z(?89q9yGHVE4PwzBrgATN8$#v>|~l$4oheX*f4c995BS6S`4?p8gYu08-<+-{9pjM zNB>dHyP-2b`6S@Rxj}of%y`Y#LWTM<02lX^Io*@q99pK?{uDpK_H+voyqgFbq!D5m8smR0L)Jg^y?FaELoNz)CS5&Z zasuK6J3^sh(jyGq7hNqSy{~00Zd9JaMkIh8c5Z|_^Fhd6aV#z=2w+~k$THW@NkBas z14LzbI}`N0nCI7LiJEs-y!8S?7}p#G?1>^ONaJUMu6})0ldOL**HpgLxq$L#g$UZf z!ywX&uJSLd*MAdJX!JyjRiarH``IP&M~`}U2qXP34UN#dwhJ|AF4gVXpZ;gv5!?nX zUDhO(zU!D&EM}Id!Qa*b>L<(S%s?`EFHANWLD!ZeIcU8zV2P;j>+V<$-lc=XA)%2! zYH|-ydY;^T*p{=^ry(Uo{S2K(a}qY9D07`l;V);2DhI>LIeY^dLNU0g{XMdwT+&LN zW9y*ldzqK|Aa;EA>R0$p+)`4XnO*s7YpPNKe=rbMi4VcDW_w~Q;JN%FD*v-Q@yw2dcJ0**bwcz|%iMf+#CZD2dNM>}Mt${ZgM`m%_?6ti+pb1(IxMe0x@} ztmL?b`yKeO#ffsCwauG8%!%8W!rG6oN0{WB40U`FQpU7kO|PX^x$YE}huY(wg?etU zDMNh6xxwERt{`UX-)>ik45p8)UGF#F_3^0?q8_8qm;LSz zJ{+s!ci(VcNNqLlmM8Rxo^7}#ypCvHvfoEEJ9f&H`{|kMeyvG zDG{`jpwn?}(eFx{0uS=t5o|ke^}09!;3LbhmE`uHL6&Xd!T;{oO( zdzF9ge?0m6q%ntlBjNG*_%cGPUg(;s)&mY{ie5G8d&0)lsR7`Gc9@?$=6dj~Di9xr zON2bZYY~Gv(*`x)gYu}E-ukOQ*V5VolUYiG(s4+8N45Ic!?sY=c1l-ECjr% zeWln(d6T5zpxD{Lm4tmdv7RTwqZYBUwx0W^V-@X8oJjGvBn=dlx3`F{eJo2U=CMAt zO%m;9Y0g{N4te4>4j)h789S31vh|7wWae|5{;Wri(-MI^)Ju;qeiMAl0K7;#6lR39 zDOOk0OVicK+`*RtzyQZ_ixBFI#q81^y1n9SFF#X$0Nz!V`b0d0Xr+Ag1K58&Uz5e{ z4kBzI`y+lr;OWLe{n%y1%)ZS!KtuC5C(oVCo%Da=1zk;Br#*dbRE9hP=4}o02?Kas zw4?$-F(i!~sg&FFA;f3dv6qeIF(d`+9gMH^Gy2hidtea8466>ju~5|<4*;|7ZirhH zJIG4E=TNYaD?IusO{$;tAt3I4YWo*k(fbI`#Et|U0kprB1@CLW zc1+A}*ELps-qM(UqGD)~y{;?E0re*27Yca4n=5Ci5cfZ0m2?~OA2rn| zz!6o`m-y#=GdVhPuSb_G3=bzlrbjKW%>~VPKDP&hfi=+x9J393BRc708csCtdEvCH zFT$B0G1zCkn7{IBH!!iWcIO@L%rrY2?F=bT(t&-a(w%$_;=d$P>ehhfFlTt;vq9?3 zRUvZHFRuS2H}tvh;rus4-t(d)S!ok}IK?N%p?;?3@#>NrtgDpXezu{AZ?lOKK#R(v z+1vUe9UiFPw1GksWTL4>)2w#wy+f@_v#ZgQgzmeA1g9!wJozGP!HZziRJ9e2R9Jp0 z-p6@p%6z5QlJ&26rc5qVHejBZ*p|_yF;!&G&I5^4AB+E^OXoc?V@43$UYN}%jR92{ z3gl3>IG+_>HT2K&#Q%=o>4-1EYHXlAsx&&clF_CA3r;PiaBAUI)_=O%k~tsE^4w-p zF0!V34SH7Rbqz{zcUL9mCSD7;Owf7{Oh$qUU0;WxLJWk~%)>KY15e}a6+Z9LHP}x# z90}PReaM__HaF|=jjCdeGcn9m?SC;{8~$DQ8h0g09TMEr|AMWk^%X#Pz9 zYMLo!dF-B+{P(NW$d&rUOzTFzWGF1~oR>MDrN#_XlGA*WD+@!H#;NmCI_k*Vr)gxM z|HW&ya5(EpyvS~Q`<3900_!oKT zS5}(8p6{!e7{GID(JV4vkOFw8UGgjA7jk^qRGDT?;CdweN4x06kTDoxZn8IIbvYMpBzgUJR*ymdhd2^cmRO7hY^cbQq;ED@_ z_aV+9-O-r3%RFJNsJo=GD3^#MZ+w;7BG?FC20Mo%9Qfc~3}97E`zyi$p;$0afM|5~ zu@lK@kpA`2H|1_a?2=_{JJ$_=OY%Zvj;7eXoO|1oyCq7141z3Q#0HXLs7X>S#7)6;ZT(Kbc5)hi z2H@&%?}(QL9t?Pgp$1Kzry+nb^6Zu+=Z*tO#$O+as?fL%{w2h_P|>kqV%`YrdC5=b zlKX7RRxogmTR%+9MCov3`p_v~u1go*N^bgtx4N%>&29|5Zo&D8ch;!@H($%5;HjDa z$_@Jd`-^ylFV+}SQ&ExqgbXxFd*Vrmd5hHz_eHc%fp_#t3m`s7n*;b?O7PX&UlDBN z^#OL!`(ys|CvyoIul?v0%g})M*i_K91yH|n6`5CUceTFg+*QuQ}P_gloqa<(O;{lcdHy4q8pUT=?FPD?6fSs;Fsm`H_S zQw<9bDgxL(HJsiAv8L&xCjUig+j995^|uO*-fMnf(@M|nm3CQHDJ9_FFxY+4P)B!o zERAwG;Tc}-E~?u~>QRVR@X2@{_CDO&^&QIGYe6T!-`8@5Q<2KC?}d_7Hj+ioenVex zOnOAl!SaEA`fnLSVn~}-`*3+Jm@ak|uw7wO%^@>B!9$1ssHb zgXc(t9Mh(m+8FGtPCW-beF-SK!*34!*TVOS-_Z!F1YD}0YZ$1=c-4%xzlmnqF8)#4 z7ESSp$#nz!s-YW23CZH6%TkSi;dro6-UoNQHncT&_rD0HG!(#_d4BftWk}RBuVj^& zZ|tm4F5(arKLBp2P!C4n1~A5Wq}&!C)gKwH6*Z=fCbd?$%{_Ypjp zyjo8+kVQ7Hi108EzOx5qC*Q|}a=#Q#7p5LO_gdU5iV@v1V=wD z6*(0t|D9b8jn6p)<_olDs=Wo!1Vnx^)}{qJ-(nLuGoT_8Lj1M_^-KSvaPc{!@I)Lo zlJWn6e~tuGV&l!btcV)hjvQuh(&Pi@ZugzRqaXZ_xkA7@>8>d2t0St_Z9+s$`EYJ! zXs0Ay(h~*cBexR6CX4R2QvHH`b!yv3n%m&pjI3l(tq4PMFp68_%f|oB1*k=l&F%v_ zq2yE!bye-jBd;WUD^GZ+RsKEZqXs2b#3q0lHK4;UwL4-{AtYwnv7kukw3bz0B-@)L z?O%0oE~?My_}tB^$(PdH=ugQhbO_;VK5uk&i+F z(03?JWbcY>0Gt<-W0#$-x2?~inFh12I0+%&y|J&a=OtBdIE)Qy$@C<6eKV9ul0Sprrwxl0zzXBu-XL zx&$%xmT1qmCx9o#UnVOpp*_w2B*CnSy`Gou20{OcI&cBi54|{|56SkE3hG&HvZ(xD z{>TcMPJkQ;mlM*p+ttNkP05#)v6~vOGQjzKq)#L60&WWg28zhh$X8ydM@2aTFnxWj zZ=SYbWX==k_y(dDu#mHM<6u)(h26@$fKddGFTxEyzHPDHqtI&BZIx(gEINFgmS`{> z_8L`?j%aP4ZwcV$%Zm^cG|ILu*nH%bx)UV$`tEFjDo^dJ4KtR*8?tPNQjJc=H?fir zQTU_ZbA0l@+$9-n#v(o|ji%fEp@Wp_ZTziZZN|nwR0`{Ky8)@{-}dZeD$jOX8I(V- zvJ#5|`R;C$&Tx!WgT*v~VVp^4lS6zht^5fO7FY=BY|YS8eA+b&b0Sjs9+Sz(FTl`^ zMyoM{oIk5KNc*E57*E!w6UjR=p&t&hD=*^Iv(95XS3{|jBM3ge6jUf_PVmt$;BC7V z2ey*=k^D%$%7PQ~=FsgCp(L8g#dV4gQVGmeV<9*(HSy z5NFOItSmZ}3u@QT7Kz!aH`0TH_6NVJrLU^V#IA2s{f{-ybP-x@f)I)AG4{kjAU8eDhe^6 z=sH(QU-@R)lDEKS>)p65PRg8zDU;>0xUb}SEr$#XEUX1SUN);33;1H%X#dB|7ln(j zAViakerU$poq}x$13Y`WvPo z@_FIy_bq)X)?-ckJ2c(BtO%R;Z&9)^W*BscK0jkQgp`l_iOCai>&Z%8e(--nGkQl> zZ5(F<90egfKgR55V>Cll5<9Y;;eo>r3=IT!4&`NLnXHo8$5+VV0+kAM?@z)r->4tgIb5y0 z5RIr{#n!Y4K4531;nhr*p3@c61a@bHXYweqS0Mbg!){%oR@|W?XL*EZq6vE0LS2de z)zx95;6Wd$89^oB*VHx$DdiCvKGoTQ3C-%6NopK<(Z;bf-*8aU6nVcY3I&ug$}@y! zY-0GoepC_QG+@5??-e$kNlFm>2h+*yYkv#SjYwa(UFy5_5M0gi&=9%F1i)U!~%Z1o`DBg zP8=FB0PsLq(0~IK33D>?0gnaTqaM}W;6s<{lb(xRKwxFzvQGmsL&n2K_qfC$X2Hn> zh!&znN{-1N1JN5==eQ8fs4ubTX*57<;qx(ne&MgP8(7bd1w7T069_DbPck$&cDw07 zg@szN%WSn<&e!Ne{|v05XqOG)me6u>GV(eo|GVTsmQ1$PsYxvn=AI$+#w;A$;g5}! zP#s$4bvJeyvRMfCGssY}@~TF1;jB>*8uNd{ZHND5WI*FoOA=8?nAP{akZg0yht%l% z?@@zVxuxlLla|4TZs(s@U!(aZQThdzuMS-T#a{N;IbcQ(w^B>>4-@k6eCNv&m4lQH zSUpUihh!z)K)&e`5_{qymhW~~KB)TgV$7@bm9K(VK zmcpBmiYcgbn_bw`#)C_VhR!#g_NXSJyjCYx2j+#LU;TZhPpE_MI{>3`%>P?vtZNpm z$_psnzebfnt8)%#t?N$lO?0%;^L6RfW|Fr?q~<$m=6TimJOJ5?KC6&@)om5MTKAWy z6JBI}9eQkbS`q@xEKkDCQ_-nkuOrk$YBm_a8jK}*KD6i%uCvkL0}jp|bVxQnH(-|y zj21{{>?|ZL*VyxEEQAI~7F9o)YnjdeBJu9SOCj|kJ5O@HlYEh{Om4~ix#(gr z2i45x9@r5YNtuF#3`pw&)B%gB%7KJ$|2m+S}ybM{OTgKQiD7H9p@^!eR{`})&6EXVENB%DnY-k zp70C0QU9Cs?5tF*nqp_3iA`TGULiLreW7-(KN0A|aIj$2#c@pGV90G5+T3Fn!s>oc z)x!YxZSD`qdu3$V>H)hEBT z9CLJfxkz>$esyIwZ7va7&Kait;I@A8YOQRcmJ#uOt^vBW_WsB<6W_Fbp2I`^CAAow zrs6~U-&XutuR*E?O+Mf}cCCJNxG5cIFDo}x@&1NEL+$gSaiJ$@&g|^dgtl=}`-$Np z$CqNM7ZWD$4f_3*X%aK!G^y{dtfjGE>Pi+pi4yxbRcemqx)^^f`cLU6dh#-ugH@^X z?F%+^NZ22b2ceHuFLlwTNE3$Vs*}F1YG<~JzDl-KzpEh!&f75uzyi~wDjR_TL5b5Z zpc)|ntH%(;XwE3cTr>qfO97!zErqh7 zv6L8BU;FIm)pl7KK~AlX?g#LJN@E)x;x2&&I8fmznQQ5j3}D`lBg^V-5`P%Vob4;t z($zVG548?Pvk3FKet@n@1N9jt(g%!loV)f zjrWZ9=B>1pIU8kQf~Z1(nZ{^owFOfGQbykmz!L%EVM_`(~H-ACiIN zXHeF5x!CVeXFp|lzI=EWPgO4nfX;6*NRD%`jRF?1>9*!ze)gWi~q(E1k}x6ixjd=N+F_X$p7W z`B6)urF3fnX6%>0~Bc~4n0C}Mw3#BV>GG!6gnw3&qh0C@@0 z#Qvg!tnHT7lg%C^;hz)Ezm!RrzPWdMx=Nez3^1=nl;rs6l@umDG24Lw45&v{fboV?n zoLNoB{cO@^BQ!*Mry>?Yfz5QV#jXoch_*J4)Il(Rv3)9t5!$~}I}c?!*5v+B^sFR- zLN*w#=e?;2)iWt=SF;X;jSbu)D&GplMQa2YD-x83o^({syKMrVXf>y78JNfEua=mo ziWpE>xwp>q4{8K*pM4Aj`Fh{zOW)M+Kib?>ZZs^N!a|koE7|Kc3X z5$ot4*%Rd$11hIPWC18tTwCWawDsb_U=b&Qy9XcHz8TiS-y>zO!A#oPx)O!xi>*qeGQrJMjvUyPXsR_ z$wTN*QEDISRB<^M!44^%p1Y33P0g9?=ePVoB?FJSVcO}6IpmY8UpgCzcz^EwVvbN5 zmT<{O2&V_~mVQ}9$vy&#qh1|V@21N zvt^UwV;LBY^2^D+!EhZdl0qVX&zCX~3;?a2t)$LA*mQmpoqEs2_&VVF**QN)J#l}nBVl^U4>{hCIRio-xLH70*=n+$F4`-=#a7@luO0I(9KaHKP|+*x(Avxe zKRu7XeYjeiOR?Y#FDNyiHvF^GBHx>8=3j2)1Z}Sva5qR_gUGobyU!B;Sapvj(Oi43 zYUXkLGh{c1b=JPQSkKFASYiEcQJjPw1JbB=x;?ixtULF$fmmglowEYHEcn&G&Tm7U z$cE78DCd10I1n@wtC?&j^9AHC+4WppOER)$_L+(@o%HXubGPe&J0q`U+C{I6a^K_@ z0ZnDLUR%lJWH8=zb?muiOZLaIXF4;87s?c0)cIBC9yfIokD7)kg-9|7BkwHUcNJ3q zIA-!#zSK(N(i|F3r+oaak~v#$h3Fj(wLqzVzt9~CdvioZrUdf{L5JM9N_H{Y$tz7u zo=dxJ!9?r3hBgL+J_h3j!)dL~YI)mx&xGyF8xvbd1avopHF(6+)XHBO=RwpQ=`Q!|hOb42|g^ z5M?b43~14+xtU+a3I2!(Bb#hihjJNTcO;8i+X{z5(yZmS0~YF2=E< z$(GuZm?pHEfB%5Im~rn@uPj=ahK+UBVYsRsf=0M+m^2U*FPibfd{iiLz#E*%YsHWG zl3EV(Nea86i}Z7!Wgh_P!{M%P1C5#T4BPYm+Hm<@-jG`UDXnzu!BRNr4k^tq@?99z z!87ebhooZ6bUpc+_+0jp4hy4Rf@#tXhmWxGB1{;2IuR(P)r4!Ln=F30PG$}0n#ph7 zG_r*yqj!k(hzxH5mA&KpXWbnUVUyDN}$3YCGgKBVb_RUvfX_4$s< z5}jZ;uO?ke6gngL1EMzv@K{b7gaQ73QE4G2u>Z}C-Ps6lM;RAzF1deRsf5?gzw6nl zCpTsRo5|t9Wr}$?C3a{Prc+;1=hl`y9Y#u9h=-QM>QkuF=}4=_s&Hp(%3cWZ09BIx zH__kk3Vyt(*}=C*)y6Ec#(6<|^>$BTN|8@LWNf^IJm^d1777?o!#(^|#fN#ZFQc9bhVtzT%PA-x1QCHPeP#^@AP z&zE69rhj9@J+TxLMgK+^gRf+nqPu)a5D{%Vr>Q^nF`e0`y}NxqWr&);v3m)%zz^wueo}onjx1#j{7*hjE^l>>1Yq**0if`e*F}LNM22ti z;HR!=qO7rx!C!VNCKiktWuhgBsGbbGg2zlb1W0@XKr@CI@o6(Tp$K)9ECAo9{p_4D z?-d7#Azoep=qD6N_xdVxH30+!AiDt^7ea;4Ot_jzs){%G;CqfgkEzf0j0z=IZGmXe z-#)Z{KTqtQQPQ%1-t;exKe&;?#i9M9f*D0}!FfQ5t?ADGXDqcIJ?gNNXCQN4KgBp- zRYG?xlNnuU;2{8nphq+wM7hMRyz-{Tq>w2jqv8VOrv%PKD-i#;x`Os781z_R&6mz2Zb2=lE+@XzDK99 z4r96Z`Za+#zo`osLhK3yLd2sVZ+DZ)nkn)yF_jkPH=ejjB1w6b7IJunm4IF?aAxm} zu#5p5ppb1U#vmm64a6F000{<6ooqkeof=dYuzWN0=g1SjP>yfR0mw7T;pQ9&-N^@)avl-pdCvts!9%AL zm?QfV`riCRzm+3E=3{B6-nzml<)f6RB$fTTFJKozE?|eB1pXctc<(R%sh}Wm9l)X8 zf7s~K5W;svcL5;6YqMafPngh-$oefFDGPX}Jh)nCV3<@n!SZ!QQ$Gc#>I{!E6$i$Q z#!P^Rx=t#PTXOv@O4wha;K&yE^{?qLAgx1)Vs&uLW?pGB^-T2LpAj}W;;?Bmz!?Hc zf?k@;C}(Fd^<}Lg8hJQ5UB%?;m-|z=3>Z?lOyH<)=}Elp)Ijx_Xy!ck@X~&P5K_Ef zYa0&4I_VO>FhKOi+{Hat8U!;V8?2CSku{hb0I(DI8$R&wpe`8q2)?XQd-sYduBJseF6LJ0ZGGq zrqO71n354{1gPz{CbF%nS~kc`79akUXnk;QcBuRvSzl_*ib%>N4y0jdb?y`;OKDW& z7OoFhEv|`PFYiqCa+;n$vjo@!&o9`nqX3a0EEaFborxly4V%tY0tW6?v&YdI>0Kyd z(41GFu1csU9=vd5fCq=@C?0P<$`m)d4LO59>BdtOokLlI*s=x_zDbF#=@Zb8&iWXV zsjJ?Fp0;r!7QBQX=H9twvJY6vtHMqF)^GNN!LQ_(JfNzj{-_ZjW zoBeYHB_r5CuwZxlbvKo9u2iv_aPhYuW0&IP=c9$Jx+O#0m8aSdcYUN`(vKFd7vq}M zFCy9s>GKS^XP&S9T1rC6gHvZ?wr#i)g;zipTdRxda046X)u|3I&Ge-(mz^50)XH+X zbkE62wDc9GG>IZ}?J3!TDCI>mLU=JrlCR(BH7+AwIxzbnQ$SOWgYoQ)+e{pY*TB6?nmg*8_Wjugym(YTFbupAEu>I&MM z!M$ohGzc3;*s<`(<&NJ2&QGSk`!RQ?xRE*c3u$x?fA)93u{th7Qg59LV~~F&#!41n zie?{%BbSz;&lm^{#opw$q`I6SDPu~xY_Zon=4b>vjF9|Neq?u=-SNsmd=v?_JEO$4yIWsXxb4FLW)ax_=&YSB zwR^G-tn=oz0z2My{vxstnxy-ykvGKTzX)t{({gu>{-@pUrc_tF;+R2$;`dutE&6Y= zBqcwBvd6N+Cl$|aXF~2-Y!<$yB8y}m2NL*4oV~!OErPuqZa^6kw|;t<O2FfDIQXoP$Y}j;UE{w_4kjSW7Lyp;~_fb0{hMG4MFW z0@k+JkS*m@io32}&!Zbrn_p=Elg@J_OL-O|^DLYFngA z7mf?`8NW-j2I$R*nz7h=13=d3$7dloGiVCT&VUcSd)3G)Glee(>izGyZ+>R*wqttm zVD$hcCsQfb(`Op##{+wgEqQ^R0Iv2Z)tM&jDbm^im28QZiHeF1aY*qLPRi%K)Nx-O zlYdWtkb)+c-0d|g z;ZGg)>$Z!a41sR@sxQg_U1}LGZCaHu)|D1oY->;(vR?Zw zDKO|`8NkWkDdO{&3`s%37@5HxID*uEUl!Zz|jt>xk;7VvWqOOXyPEykbTO=!g@i0F0&6 zQO8(36%1-Q5(LDZ3mo7uPf+CQN+ls)(Sdhhw5k8Yp9AkdSuX}2tYT912(*6I`dB$m ze&ggl+teoui9Tw-7`MF2qVAimk6!(hMs2~nmb=%$$Zi%n#^Em*J5Y!xlvB}c_Nrt; z{gGyFjp(8C!ou@37$07x*-h;&Rd{|AhXE-tRmO)4-&rZKCE4&K;PLj8U;s*KAhHY3 z?BjEjk_TeSdrY+@%oHK)ihgn%k6ov#*JiWon9Dwk^2^>=($N}ZVbfw4JCvz}Y`Jt# zpP0iri1H{rzKb%GC9{<};#QCqrai6WLgY<3G~QPem{fx8GSm)fuTFo;0;&Qjrif@a zQ>qub1N<=7xc=X6cX4b^y{R~uKoDRsd=)mlnKla4W(u2B)DcbbBLC_!dH^n7^&yFm z#F)V|-$BX~-wRR64N!qF>jL`8CC85iTGe^|I+7ez|DP7%2VEu=QLnk_<(s51o$Ed+ zAYyy430sO_zpyDmAzL6POC_WjoS@Dv8zAm}v@8j?dHL*MUYW#;ZT5?NjDYy%%PW|k zU0$h%d!ZssPn8)bU$+v$AWn>(cSZdvaY4S6B-7x{D-zPb??NfDx>W~gpim|ry;LSI zOv>6$s#d*X*3L@R&*W|`cEWI zw|<3FTu90Q3Ip7=?srLw*8SrTs?guQ3IuYck0*(-NOLm#3VXg!J#y|cGfY@Iqo@q^ z(}qNOn6e~kUS>E6!>TsbB85MB>^t&w&|a$-383mFK{Hq2Fek1=lK+k?iU&J$f2+Q8 z&@+@9!s#j7-CQyoRb^8?S=jq^DKR-B4<)gfd~}Hj6s;BHd}fXCR`Ve#XdrtPZ?bGP z?zLclb>_iK=7r}>mVEqq;$q{$ZBaZRswv=OK0^sUNETV9!l3uCI-W9=Td%#AgXxqd zx8HIRwSVKidYQaT$>yxM}0Rien-?+}F;9fK}M%=B4#${O*;3GQ2vnRUY^FU|$bU;k;V!Ic5}xf89yWPE@0iN!MW89beAZO=ISOS*epX zte6c|ote<--SN+Z)4@21@Bu0fJsKNXy!6}A3%7xXPfItv$Sgv%1ix)57O*^!F4yH> zQR^mc?oCZdMF#uSYKWA3egeh5$vKLuxnR2u*zMh;!C(aYUJ7(q$gs)3q_WUlNALVf z;Ir(_9tA?!Yrg(_oyZ79y^{&ox*#_;d*w4tR&b{0M>K=_)UA@2Hd-uYTJ^3$z?k^K}|?kiz$Y_CW5{ zuaewj&gb7WP3$Q-a_1G$5LFA#SE_FvmhwN+R5o5CAJ`fg(1c{)D8O{$5VG=r`o>|= zIhOLriHV{0Mu0iX<9NwFeWh++pcgPc8nIRaCN&7D9h^4Wbs=OolKZhrj9jqRcASAT zvYAdzF4={;cs8;5b;QjYz|BiM4IoID$j38$&jqlDn6Rt77{7t2Vj~A0eHRXW1ic~} zId(W`Q5dy?tiZ5_R}A)Sk+?lN@D9RmUpIHwKh6QDMzB{KQvH7gJJr)5R;xW=UwD2L-{o0B}fUCbg#`+d}9@PKHonSJ553s>&ibvAHM- zPYMvZEfCAh`btZ3{{E0Ht^@-DX7j8nl2ZJxBMOo|VpF9{La?FV6_z4XvEj2AfyXFr2*@j|KHa{U76#V%A}ffQ*PLV#*G z(L#|oP?&|Ash15;{2N%}Ai$=nk5P3L^Ey)M8psZ6TkjB_ufai7aI+dYz_`B^zvDTmM8O=J$fd) zia{#Po-FkpkPU!<`idU~SD@=wvL0j{D_dIN{Idxul-$O#3IAlyVpGi(toqIbhR!7u zNMnAFKkG)IGn=ZzHma@+GAtzl{Y>9@>v89{puR{9NX);vkX|_?A)mzY z>PChRqN)x$-8SF#XWB`Ryz5nd7ZYTj148{^yE+Fa)?p8)G44XWCCXM?7e%RV`5SoA zOOpAaVsR~fSR<2P{%_l^RE@YV1C;HEDeSGpn6k#8IPZ4g?QrPZ;;}S)FjkSTB0ju< zs;{7`l5W0TL4|@`U&VIy_iwzujcw#SiYsRpjJE$-U;&jU?Twb4#?$F`X8-xz9O8{> z@>GgYp>ZmcT)pl9E;2(hm~h*>%+RiAi*0uE0WLn*WZJDKk=L1q%xm^Q8nI`Zb1ylz zISyXN?E9!%iVZY}C?GE?woE-)wPv?%Mc4*7$W%g{*l-5$8@Gv_JRRkx_TFpRpJ+jw z7Hl^%MtC6&du1pA>O#y-fzZc+kX^fSxxRxI#7XnZ@I67`1@A7sD-{l>jly61x9z>! zbYMR|yx?s|hfc6iesEgWj6h~PDxJnn3)S3nVN$GZ<$QiJ=?BE^NBzCT@aHnM2tJqv z5+84=yi~Pa!`1Ep8&xAR(#Lg$SQ(OFIJcvUnD<9PlCTO`Fbgm|lXJr%!q zSv-7utF7F_vT!S6l*sd@`A6=2(6`^e=38%zzin8Nmqe%o?_9H}-lq+iCt8)==dvHU z08lS9sji*X925t@qmW-9kQqmy%?{MHAWRuX<{^*r|v=Et8hXD+-RqnhOcNs3=)QscZ zISZ#NtP;xc!PQ_GLPN$-gmsI$9?y4ZZ9a>6Fk_VFA!FK=eg$IFXGT(5DZ)R(i_sL~ z(@L*DJBaREbS&q7GFd6hC34|4Xw|>Jo{F^`+F_zTnzPd8%DU#np6gsPkfkZyZwtFdvzGAlk3w1YgvD_?+OmW2uTb>a|%x2 z@et>LP<%LQB`LN_@!U68PY*?|H2+K{VSGCO<*nZL&V-$M^exQu4hh3~w?j>QkzO zy0^aP_z^?`@!tQA`*~G7-Z>12=q#pmqQX9%p0{gCuslUD$27*pgyFee#`uwUzJ7D> zJ!6!;M9rX<Af+4_e>aeFCd{BE zRr3U}T=SnWUZSq^bC188NRcY|5sK>dewjlv%n58rmc)+`jeq3jZnjr5-1B6c?c~Sb za8S0`=NSFEJ-Y(G@5V&6Zhzg9is{bs4~pm@z`2ItC_3J77&oPGY_YfsaS3*~d`KCf z`1c12;9P4BoCMfiN);a87k|Xq@3(Zyff^xK0L3FYW^`n7VwMh!WE=5C=)QAM80}zQ z`n4K5Ewzc7)EKp@W2;<;xFm~ua%sXdhaA=u=VJ)63WRp@+0CCp0I?YX&nQJxgafT) zgc8YWBr5*9kKoVylSScaPSdSTdmi1@;VwMrok2FwP05@J)kemIi3djBY`)o#|X<4l>B-dtWTKL5|EXF)Vuga{AgaTEGZw)@dS)=ddM&Jbg^e~cBl1Xl0$~*_f?089ZcZafT^Yv{JXTH9~8*$)! zs#)b4NuziOUsD@I8+baME~KGdG2G_SJ+&MTJOaeR?=7G-X&-A*&T@-5se68tMMGE| z%)znT(n^YYIpx~J|?UXvyYFT9g??lk>uS+ixBlF!Qqo%7(UK7;`g4yTzP z*fI!KpiV7N(2vvcmJ<>!DS>AYpojusX>1m{QAZ?Pu_MDcaI@CoUKqbsA3!cA(uF^K zo~3==O_7*f2_)c_vFB>CTS=FZliMq$`);ra4#<_T!ndD3UUFa)0-P{%0d;mWLeB`( zGj2FkK{i2q{l$xc42_Iwf-+%dIz52w+Wi>@$)TdgWaSra63EMN+D`g~;T6V}Ki|k) zV3_o-VO$*6WV=)@5pmqG4>RjYwx=bjgKN6#&YUaQ08KJ_V9n#X?D zsTkoXP9hc~oFR#DU-%v^iNa2Hgw?NUlT?;a4Uh+VEZM7_?=p#eX|+?PwzOsJz~r8Tb?A%y0PaUPqxY>)yrt)W9F3Z}=);6z* zD|avbS3fMHc4B=jP%-mEK;H3FLfqd7hM0}x5Pk`KZ7I|gm>L~1|)`F(wxT|Xg2TS+(KV~_j@J&v%^Y(_r_c)3GlpKx>4 zhI{{-K(0ki&^Pf#%y4R!b^ltEzgiQ)N-l>51s>%yp_M;lF2)C2MY>F-B)fiJ5+M@) zS}x&v~$^*B=2FSGxqeN|#P~yKk0T-_zjm?ySMfLMUV;C-Il3O#!j{ z(OTbUu`|SjpTAyO{>FL5`>cStOFmrSg=~i66hu?)JNRu2v_eus{Q3rP!hu!GRXn)3cTtytGb9 zmT09g=sUuE#wBG8y8hA*^wEy*elq!-=dhzgD37?`bV%6yMW&hhG%6E=l~ERx{e76S zM>)Z1IJ9InV3UjI@+INp(OA@SGkcBxtHJ^-{i^G9>C-t%tEi&ZGyx`}ahZ>+{xpMyR>D3q-*;Ho$B_`NWt_Xvtb!Klj7Z9R{_FpMZ zlJ%vAO~fakW(#&G)-8)|u*qowLGszkm}e>RzPLF%W@Be~M?_p`>As;14!s;!3b0kT zv1C(8CB*WZCnYzA~ zi*X*AS?af^w>2q#2*g~T4RpR$%z&B&1t;l3JGz&9gvck3qhi$qx`@7qW!B#seVJKg zvNtW{GAhdNawNK429ycc^j>NW6P#Y%1XFr+;L5{al!=PLiYt2+{9Z?&JW)Q&jJ}!I z2O?d*dmXmX!ydy4V}QFo40{${_PVorXsB@brb?f(eE}nwwXcJP%}Ipb%1P_Qt`szX zE4HH1uTcCRqqYk$pFj1F!b8+JVLJYk8AUou->d}(IQA%AWkLkd^#8D6B&_c$WYX=b;*d3c zM^Wd|AX-zHq7PkBi_{Nct1rNn)Ne5Xlh-mZvSrDwQOQP}Dej}&?*H6h6zQ|YF}nJh z7AiG7VhJEU6~k#04!Aw_##$5-J3Z(#n?OgIMd$CO<`o4VC2ZG*0zQ(pNL2uRSZys7 zS`-41zJUd`kL%pZ(r86y1vSM7Kc?HWh=3v_&1T=9b<7#R?TE;k$Z80dD{6<7wF=s++!bNZS0OkD0&v|vk&qA`_*-YuPs|1j5|BB+G7JqHnN0mhR zaz+?7iVxDGVMK%QqbH6yFec7q*ae0-m>#78jV-Z33YI*OHs5N(1g)r|e8&Pl^NkB8 zZ&zq>1>Fg$ewR8@dOjx(Z~ONmh;@GZn8>|=!28p2g^fOaa@xDp-}8Jl9|Z!^>ujez zN_6b{QNMx|LtM~W22#dc^fKST6^BQAr^d4XdVkVCKCGyXc{c?w`r^N-1Ngs)xrJAl zOi+l_Vrxhp02YkCkO_@Ax1=!)nzo<`)3em<9>s4c!#5xoPNBkfOdU@BQ#RYm^|8m2 z#3L~9-z_)nA_suEXDwDW4Y)oymc1`j7|7*0YL2s>IeNoN4}LloHcuhW%Mcvs%MD#O z`jc(09K*4IJ*{ec)VAmWBVWT@+cB5AG^<0KL5iVal65^qze+JNxe29Pr6KSOcmM(q z5@WPIxxVkzI??*mLuB&!MpweN`^sB11%5T#2v&RlGMmtTC)>=R-GE%LvxxPY;P0~e z#lQ%USnP>&kz2eF?~2lByD8oy9pnP%$TKbiQ$YO`xN~naR$S-V!uFedeMXP3Eg4(9 zJT~<^HordTkIG@pP%Wk(yS^duH47Iv-}Ld-x==20o7;i;p>a7GydAz{#t`7jTeO_g zK#3F5W{)N7TX^-lSp@U!kEy&d>uejj4L7jA^2%Ffm?IG?>T@M<6_^iC3~c#tVe0Z8ok>^yfJ9T4N=*9&i)&!x&X(rjkF zLRyMunB-ix7QqfQ$D@}`A^9JmTm>q)m$E|KPOhFqUc33jY#7Q2iTjll_aftJ zrqQa*-9mS9U>wMUqT8cW&TP}dk!#IeKRsUHhSPEgfO!#cwm-E{TalV>dhLlu)O**DcpTqoLj9<2cKqUUKvjQ@(cG>gN2h3chOC)MHy!)wlK zLCpqlcYu8JKFj`Kcy+j!i41`l87knBI%5*_xX9XC+OjzA!)Abg!OjuBAPS}~!R=BsQB+fn|i(~?a_$?=czCv#_2si%bmtEe7(=HVfgGbg(J&c~K9{$Y2zuZDr<<#Zr6_JBn zFv?evg0BaYK97}o5~H~IFH67qj@7a+aM(V))sM%VMzTmSmX+MrxHV{gyP%MQDX^mP zUVo{!2Dn^{gP+%;0_62KS)L zuhg?tZl+>DYU2a4ce@>?>cwhP-tve53u2bFygYeIFgt_&zu7RScC=WPzY7*kbrTm` z($W5&VZsDPdQaJ}kg`%C#-gUdHTV>N$jP68`Y9Oylf>A%hW4dcv>exc^P$@Xt%Tq>0t$pG}WY z127=;z_mcN4wxWNZO1KDB6~z^hm6sxUqukstGLm}LxdQdaD9 zA|o_FHYY2}xT}s=fbB?$6GyR^;)K5(c;HvS@Ss~FkW6Czd=?EVYUGWis4Y7}k@%)| zE^eLl-lWJU%?r5eQ?f2m*w^4a&tE~%M3Gew4<4Kck>~wkiE#*M8h8`Eexi-*bo|5UkREP)#88%(ue zG?TYFo6$fmoG)9GWzJ4T@AlF*iypWuBo}oFt6gB>tj9{JY0yTjD;>FWl0zw)~_{f-On{6Qz~i8-8a$}2Qf;IKeMd!mgp2qcfZplAOh zJ1`C%4??65yRi>G=d8b9_dD|gLuOUi?O~!&SfiY=SYD3=J~dk0I{_cDS%!1eYXzWToo0T%}}6{T>JLh6#&0&%<+VOclH!&ds%7dC)?Wb$7yXE1d zfDh;bWu;CkMPHP(pRnt_<>$v%4YV)hj+Zw`up%1AvMFRP?%-g4Bdegz`@Z4B4u_CP zi7Afd$o#})aV^DN!x9UsE7uSZGJiDl{zfXvWh9my3BcZT56X4BuBjnyO(}H&El?EWq&0P?RLB0B z00ZRm>%e&-;UnC&*NcWUJ7hi|c31&N^>w@2qPh)mJMRd4rF}PJUTToT*d>(k>FL?4Pne?2_z z3yVg+oX#z&Y$O}Q&&WDFDyjhwg3hxCf&n?A*435y>b914w{IUh!eb}CjkF3}WV_n# z_!bqMEUdI4 zbnJ96$1)j%tuMf({p1TCt>E#Z)e3k8&}!g%JfCHMtqF`B-bp2jZ`B?YBvi z2e@F(f2=ioLUJ9%)6et#hb&bVtRtE+OR%NQoQE$eIPSvYUS};TCndK`E1UJ1Iwd&(+ZI!rgL@6fSOb9{Fj&(oGTcAaOIh(+oX2 zT3F`7#>EyLp;`U*aD3`FVc@Ny0W(^9&|dxUu;d!e=0Rg;e2#*GV&=5gj4O+XrM>lJ z{8-9BSaFY?h9p6%2cfh|d`oIvu$puJXL~BgQ^n^t(O;o0-?Od#VhXDHTK9Ov?>E2m-Q40* z1=TYAe8s7V4_|%uYgD?-3GZ^OW1X1_64`#wJx1x5_r* zi*kkP{)308#cIh2-U&w3Ldx(X@**wJqxKI5d8#E!*_geSOm*?5W#fR7TaU`$z1#Mm zC8&~?Xs1Den2N1+b(>G(Slw9Z++X)hM4hyfVZfD`1bCF42g_8Jh_(Rj;=zT>POZv8 z8y&IP7%sR*y*K6IOH=v_Y8g$#SH)u=xJki~DyU|0kOfr=;v5zTu%JrkLD@u#I$lcc z{UUh-@3#G?GdvMQz)cPZq{yI;vo`Th64&*@zs|H9umO|OZG zQMT|>^AEm&H+5T8oH=srxcH}M{>8eufBDE?hz;x3TBqYn0>BK|*%xUOjY3JG9m6)TT_1t7=SLci=7v+>JCS0txm0P3Mejam*EGp8V23 zP@(oZd=m$HO&3&Mag9nG%4+u4c8*e$_d2`_9_vi>s_Fx)stl^Ykkw~3rLUo27Fs=0 z%HD*qCKCb{R9R59<*tief+~s{ zTYK{CbKelpKK~6Jcg+jqm5-z|OIn(pM&S8po)qufw$%naCvo@e%|1uxp1Zd>Nxz-Q zwQJ(bPd#J3YR+ATx|b3~Afe;}D^vgV*{3K`x)L^zFSuC~IRioEiN_uh^X7Tg)>zfu z=bre2ReVH#;?W&qtH(cAh=C9Mq*Nl+|FZ#{^a&Q7kwdR9%ktqzKc8D(lQ%g({gp4}rspe8MTDrTpCFU6!4a-qy(%94 zYq#(3DB@j%p)k(c_g?s+`2G+7+c>_8*aX}vYZs&z@YzQmC-)N)tCqaHT7hRl43f4) z-Nj&$uS%o3oP$=Hal@NE{k7-CGhct+Fm^kdS1)f%&SXbq=8d=eqvWert!yK(W%Fin z^Qu)gAbJsZdTOej&vWO`iRr0nFY-*yWH-0_E6&8i7nB#Aalx0K-X5`h*>cg-+e@ud zFT=aQEjG+h(PLw0Ds7vn?FD>)%k7)JVcqTe1IoSj?5VBANT)iSk@ zD7(wD<;%r#dBtKb@Pn`R^z=rBTUmQoz@L`8}>E4wps3KkX{e2($pzbZa4~O~~KYP}?9Q^|W(fB>U z`oCZON*lAhRnnjO**`5{MIjZmn82f~Ucg#lh#z&GF zB`wLOLWD{4X8A^USm*-lyW;9Q?}(X>&`QOtDdDRWR8^T3S3>8BDsOu*W67085LR!x zDH?$VLNqY5F~-aWz3jMezI9rhI(?d)aEv}bF+NVlDH-9y#Y2GR10!CY{x2q)VIFz6T5gSXJ@ z19dSmF=1Rxh}Rb`T@-(?eYu2BYAm^U{%+umeASymO~mx_F6LbL(##)1|MkkH>z7=*2V+^5Q_}TmX1PNwCB^df3zJCQe&8RchMK}DCUiiC_&Zvc7I*xTPe?igKgfl zQK;{_aeWJ?1l0={E_flXHbGETbqsERBe2kUf--_5xNL0c(xn1d490lTYS{%|S$DJ* zRF!{!==VQBE0Cw5(I-q!P8#K2tp-MW7zJ zOfG9uER3lSe(|NBiZhz$L+c2tD^{!!5LII^g`JIv5EEYLGhHoEiOENG`p+_diwQ5& zKA;T4#P8mSMT9ct1tsZBl${I?@xOB)v0CR zVsf}?^GC>vNl_ilp3{LXQR8WnDXPD4myl~NK6>pNG zG4ZUd-z@W8%p(>pTo{F4f@g;gA1Q;x#qXms5xmJf1|x6#FY70&1`X{6EQY>GiKi9$ zL0MJ8N8l@swxsGiB6AAD!|=U!8yp-En>K7{=Og4*iAqpy3ul)v*N(f&fGcsNTLl6? zVBko|{6HDO5nFyN#9&Bv2pAZJY>XAHcEPJl1p<|BYC1xeZdxiFd>i6d=myT>ExQy; zKk$kLD_~UgQZHQ|KJ>8%DR6Vx$Pn1MYnK=sqrW^ZdG;G#NEL%ou_~G5Y%%Eu#hTVGvl=_b%Y2 zpX6X?V7h6;dZE7S$Mz?r5>zi;x>T#Usyiu7A*(tGaHdk;fD@r}16YF}w%kAmv3T)f zfzObQvC^+y7g!m#8Ks%rxPE=xw?jA7!Fl0RrIehyy16DzG3~Pmv@t}Dth4vFbjQb0wZ$u(u`O1QObB=0E+(#dds{sc8_>sIu8;}=r z7Ie=g7?CMAX7%de!0aLa8`iIjLR_7ini5woUm;hnO$5X3{>f$8iZl4E)WDC>xdA{S zz}RqO!NLWix3@R=$%`#cz#3S^@ZzeeOH*x5nV7DSWs-JGK7HXGy|a_^b`YTL+bFNl zEsv`d9)9eBkAw~rm(KtJOfGiq-eV9`2qhMxZ|z;&Ym|mhk#_BVMNY=XNm!27dp5tY_&m(!daVpPV!<*kUCEU~Vjnv65j`J*wbR|n)Cczl&U># z#!0UVOmQ-N2&0#&PN?;=okv?AvH^db6IW(MZFCGc0iW~d=C0^fFY7A955LEPlMWUf z<~jmrXt@wh^%1rShR89a$aKV-nnAG4U8X->vZE#Y)aqr#Rf^A=vQ%+A$lis9Tg`kSO zp?zP!HhfVvZ%aEc&}pM%z$uHP6hReOK5+jZR@M*kjr>3WlaAec_ZekYO#oxq(EyB0 zCTLk_<6(>F9AOrXD|0$g483ka4=c3S7rQF`f-sEJ^dKj6YeJ!SCkJpC1)Y zI^3}c(~@z66O1hIP*E}Oul*cV^`gj-+XeK?xXSt)2X(5G1pEOOv`@GYRFh!BSzwi~ zM$D?w{{Fu9zt;^92P=MDy>hi(#+jLEarNr#cUv2>&>g`=D<}a9Ugt-rqbUCH-vYPf==>vZO zYZj-dEBZpm2^hh2e9zKHFb9TF=E0V?^fEf>997#hlNCbfW9D}j40(x~MyAnzV%$;z z*EMn@;15A;<<}`J!nhVx)At>b|7gtWRSa_1$_b_tT6ID4C7ha6UXenYjkszkj3m!b zr9!57g3rn2^p2?IPvwnw{8u# zJy-sl#Z8?Rs;aKm4iB~ay-*EpToNOb{_Fk^>ujzF6+tdn8iBo1C>)=#6I2(;^VmW* z8;sI|Sa0BO5I-)3)Z-^kYSAH2ECin*3WRDdFjCko=)9RbRIE_0JiK(oX2cI2Bj7WO zlb+vMaHS>VvWTh=AbH!r$*SvE+JXvsURJ}SG61eO5mv5JCh957TyY6JshlI=0Qe9h7f?@%4?0}P6AeSg2N*#xEIUG@ zNHmO@L&SCiZ3THymDffO4@tqW)0T(Ujw|0yuQsQy!rXS(ox!$kY2P&wR72-bh;Qwc^$GL}5f_c~pMs z#u6XJk;?di`X62cVKqD+pt2Mft`+d0Opq8JS}oQ}j6yYts#iKkX(Y?2>l-oRql=Bi zGuc)+^Y7687CfApxh$05QUz7S-*)F6!M1BC-}LnC5hrEsS6;gny5EM{UU#Eh|NEmC zjg!Te+rfbWDS*{Eyf)6hR>Xa;|4a(blUiZ30;vh|ym|BFw(O9_)JXxXD3%lt7@ut9 zvINxy@+gFm&)_}&CPe2`rBuojn;D-{=yU_LWIz^>bkWKVN80wUBdCJkuu$6o_&-di z4X~@4(%9fXjC~*lRZ~WMBb|IzzA`aQoxT;bLfY_})k1wQrI$wmFdvSDu|XjrU;!I~h=q@b#}=E= zx#LA8)|^QQVXyuC7sk1`V+#b;@Q#sEfQRZ;>j_TRjqUzA`2aU6 z72ZSVz{KR+#zGBalTTU3=3Q(sKrQN|Nl&ix5Dyt|lzCi=KBj_d=-8Zm%Wk4RbnC4; zeGi8T6oHAiCk6UJQ)B&;6Yph2^3B3t`ibF*erqT{cd&zCq*( zkOft68hxu=ZyhXjHa_nF6mTBKBB~*nJ~Fd%5zP?WSfVwh<6&OJ!Zoe^LJ7y(9r5nu!u0Y;!!1e!9RtrdidZcaK!wW2)5b{iH^9oD#yf@-q(5YZa3 zdevc__?DE8KvHFyl(33>;yW87zz8%40p0dO`7!%&bLx(B!U!+|i~u9R2rvSSKqv^X zs8ml)I@yg@Pu9J?>!{2*E@hM3-W?kF;b#P))Mh7Jh z!w6J|0JYEI)vDFi4d00oU<4QeMt~7u1Q>zP5nv&;-WukyeKq(q&#S>X;KL|pS0`5f zX84Ngttq!@x}cgC+*xdI3UfFgF(P2vUhspF7}>&QW&{`kUlBlCk9M1l5nu!ufhHjU zfr-zFtiMLkKVX$An|lPnaTxR5J+`?iQdbLAm^G)jQUf~{)SSZFUY$DXHp#B1#$4z+ z#C!urAQJ(!6*eo{`H40YixFT1>P7(Vm>NgaotGxwUrpxJXA|?td13?@fw&Mrd&B}$ zv+A7BQFT`zs=sB9yTg=}QY?aM8WaqMdG#uZDw%6YH#5o)zzzDcT023EAd2yX~2f0i7;l9F7qvN5FNP!Gp1K%sIqd1TZL7 z15?I_5uhM2Dg$GZ&SK$Q)PRk8$d0iSW0cJ-;KXCgY;4djEB|7QC@m9*1p93RemKx@PP3p|TtTWszpvr-SV#6^SZ2=kUk9`NzbAZy+l;yO*o`&=KFU~U zV~zhZHgmDTz*Idn^}n^@5>$-<^Cuhebo 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(); From 382d33706f2b5e2f8543cbb41da854512b89c1dc Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Sat, 4 Jul 2026 09:35:21 -0700 Subject: [PATCH 2/3] feat(app): F10 interactive render profiler capture Toggle a per-frame render capture in-game with F10 (press to start, press again to stop); Shift+F10 keeps the mods/ hot-reload it replaces. On stop it logs a statistical summary (frame-time min/avg/p50/p95/p99/max, 1%-low fps, GPU time, CPU pass costs, per-subsystem CPU breakdown, draw counts) and writes the raw per-frame rows to perf_capture_.csv for offline analysis. A red "REC PERF" banner shows while capturing and GPU timing is forced on so samples carry a real gpu_us. FrameProfiler now keeps the just-closed frame's per-phase durations (last_frame_phases) so the capture can sample them each frame. --- crates/fpsmaster_app/src/frame_profiler.rs | 14 ++ crates/fpsmaster_app/src/main.rs | 71 +++++- crates/fpsmaster_app/src/perf_capture.rs | 260 +++++++++++++++++++++ 3 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 crates/fpsmaster_app/src/perf_capture.rs 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/main.rs b/crates/fpsmaster_app/src/main.rs index 1bb1b02..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; @@ -249,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`. @@ -305,6 +308,7 @@ impl WinitApp { smoke_profile, pass_bench, profiler: frame_profiler::FrameProfiler::new(now), + perf_capture: None, window_shown: false, occluded: false, } @@ -447,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() { @@ -491,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 @@ -962,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, @@ -975,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() { @@ -983,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 @@ -2328,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. @@ -2345,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(); @@ -2358,7 +2409,9 @@ fn render_frame( // 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 @@ -2780,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; + } +} From 9db42b082ac5dd96a7096614a96bfac68e5c3c19 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Thu, 15 Oct 2026 19:34:11 -0700 Subject: [PATCH 3/3] test(ext): drop removed scaffold_demo from shipped example mods --- crates/fpsmaster_ext/src/lib.rs | 1 - 1 file changed, 1 deletion(-) 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");