From d9976b93392a78d4ddd4c98a9e601143f4f4407b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 09:55:45 +0200 Subject: [PATCH 01/15] fix(profiler): honest readouts + crash-triage kit + test repairs (round-2 audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiler correctness (audit F9 — these skewed every round-1 measurement): - Rolling stats expire: a pass that stops running (feature toggled off) drops out of snapshot/summary/overlay after one window instead of showing a frozen average forever; entries evict after 4 windows. - Re-enabling the profiler starts a fresh session (clears rolling stats + frame histogram) instead of blending pre-disable numbers in. - One-shot warning when the 32-pair GPU timestamp budget is exhausted (was a silent truncation of later passes). - Restore the orphaned `post_fx` CPU bracket — begin() was lost at some point, leaving end() a no-op while a comment claimed the phase covered the composite tail's cost. - frame_end doc no longer claims the readback is non-blocking: it map_asyncs + poll(Wait)s every frame, serialising CPU<->GPU, so wall fps is pessimistic while profiling (measured ~3 fps at combat load). - Unit tests for the expiry + fresh-session semantics. Boot observability (audit F4 — the silent HW->SW GI fallback cost a debugger day): - windows: log adapter name/backend + ray_query/timestamp feature flags at device creation. - One-shot "bloom: ssgi trace backend = hw-ray-query|sdf-clipmap| hiz-screen" when the trace tier is chosen (re-logs on promotion). Crash-triage kit (audit F1, EN-020): - native/windows release profile carries line-tables-only debug info so `perry compile --debug-symbols` yields a usable PDB. - docs/crash-triage-windows.md: the standing WER LocalDumps + symbols + llvm-symbolizer workflow, and everything known about the layout- sensitive heap-read AV (3x reproduced pre-relink, gone after relink). - docs/tickets.md: EN-020 filed with repro history and ruled-out causes. Test repairs (pre-existing breakage from the round-1 merge): - material_system_tests: MaterialDrawCommand fixture gained the `model` field Tier 2 added — cargo test has not compiled since. - Regenerate the two golden references (lit_primitives_3d, many_point_lights_clustered_scene) that still encoded pre-round-1 shading; the other goldens pass unchanged and stay untouched. --- docs/crash-triage-windows.md | 59 +++++++++++ docs/tickets.md | 28 ++++++ native/shared/src/profiler.rs | 92 ++++++++++++++++-- .../src/renderer/material_system_tests.rs | 7 ++ native/shared/src/renderer/mod.rs | 11 +++ native/shared/src/renderer/ssgi_pass.rs | 8 ++ .../shared/tests/golden/lit_primitives_3d.png | Bin 24840 -> 45671 bytes .../many_point_lights_clustered_scene.png | Bin 4754 -> 43927 bytes native/windows/Cargo.toml | 4 + native/windows/src/lib.rs | 14 +++ 10 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 docs/crash-triage-windows.md diff --git a/docs/crash-triage-windows.md b/docs/crash-triage-windows.md new file mode 100644 index 0000000..e7b26f0 --- /dev/null +++ b/docs/crash-triage-windows.md @@ -0,0 +1,59 @@ +# Windows crash triage — native AVs in Perry-compiled games + +Written after the 2026-07-04 round-2 audit, where the Bloom Shooter hit the +same access violation in three scripted runs (`main.exe+0xe8e5`, read of an +address 8 bytes below a page boundary, exception c0000005) and the fault +could not be reproduced after a relink. Everything below is the kit that +makes the *next* occurrence a five-minute diagnosis instead of a hunt. + +## What we know about the 2026-07 AV (EN-020) + +- Same faulting instruction offset in all three crashes; faulting address + `0x…FFF8` = a read that walked off the end of a heap allocation onto an + unmapped page. Classic buffer-overrun-read signature: the overrun likely + happens often and only faults when the allocator places the buffer at + the end of a page — which makes it **layout-sensitive**: any relink + reshuffles the odds. Our fishing runs on a rebuilt binary (60 s with the + same workloads, plus a 19-transition feature-toggle gauntlet) did not + reproduce. +- Crash contexts (shooter audit tour): 20–56 s into runs that combined the + profiler (enabled), profiler-string FFI reads (`getProfilerOverlay` / + `getProfilerFrameHistory`), stage transitions, and in two cases runtime + feature disables. Toggles alone were exonerated by the gauntlet run; + string churn alone was exonerated by run 3 (crashed with only ~8 tiny + prints). No Rust panic output on stderr — this is raw UB, not a panic + (the engine builds with `panic = "abort"`, which would print first). +- Suspect space: Perry runtime heap/string handling at the FFI boundary, + or an engine-side out-of-bounds read into a heap buffer. The faulting + module offset (0xe8e5, very low in `.text`) is consistent with a small + shared helper (memcpy-class) rather than a leaf feature. + +## Standing infrastructure on the dev box + +- **WER LocalDumps** (HKCU, no admin needed): full dumps for `main.exe` + land in `shooter/tools/.testout/dumps/`, dialog suppressed + (`Windows Error Reporting\DontShowUI = 1`). Configured 2026-07-04; + survives reboots. Every crash from now on leaves a `.dmp`. +- **Symbols**: `native/windows/Cargo.toml` sets + `[profile.release] debug = "line-tables-only"`, so the staticlib carries + line tables at negligible cost. Link the game with + `perry compile src/main.ts -o main --debug-symbols` to get `main.pdb` + next to the exe (lld `/DEBUG`). Keep the exe+pdb pair that produced any + dump. +- **Symbolisation** (LLVM is installed at `C:\Program Files\LLVM`): + `llvm-symbolizer --obj=main.exe --use-native-pdb-reader 0x` maps + the WER event's module offset to a function/line. For a full stack, + open the `.dmp` in WinDbg (`winget install Microsoft.WinDbg`) with + `.sympath` pointing at the exe's folder, then `!analyze -v`. +- The WER Application-log event (Id 1000) alone already gives the module + + offset — check it first: + `Get-WinEvent -FilterHashtable @{LogName='Application'; Id=1000} -MaxEvents 3`. + +## Repro harness + +The shooter's audit tour (`AUDIT` block for `src/main.ts`, preserved with +the round-2 artifacts) drives the game unattended: scripted combat at the +enemy-pool max, camera pose hops, profiler-string churn every 2 s, and an +optional feature-toggle gauntlet. Historical crash probability was 3/3 +within 60 s on the af98dbe-era binary; treat every future tour run as a +fishing run — the dump infrastructure is armed. diff --git a/docs/tickets.md b/docs/tickets.md index 3d05c36..e227841 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -661,3 +661,31 @@ test. **Blocked on:** access to a Windows 11 box with a HiDPI display (or a VM forwarding DPI), and a Linux dev with X11. Web checks can be done from any modern browser on the macOS dev box. + +## EN-020 — Native AV: heap read overrun, layout-sensitive 🔴 + +**Symptom.** Bloom Shooter round-2 audit (2026-07-04): three scripted runs +crashed with c0000005 at the same instruction (`main.exe+0xe8e5` on the +af98dbe-era link), reading `0x…FFF8` — 8 bytes below a page boundary. +No Rust panic output (engine builds `panic = "abort"`, which would print), +so this is raw UB: something reads past the end of a heap allocation and +faults only when the allocation abuts an unmapped page. + +**What is ruled out.** Runtime feature toggles alone (a 19-transition +ssgi/shadows/profiler gauntlet under combat load survived); profiler +string churn alone (one crash happened with only ~8 small prints); +specific stages (three different ones). The fault did not reproduce after +a relink (two 60 s fishing runs) — consistent with layout sensitivity, +not with a fixed trigger. + +**Standing kit.** WER LocalDumps armed on the dev box (dumps → +`shooter/tools/.testout/dumps/`, dialog suppressed), line tables in the +staticlib (`debug = "line-tables-only"`), `perry compile --debug-symbols` +emits `main.pdb`, LLVM symbolizer available. See +[crash-triage-windows.md](crash-triage-windows.md). Next occurrence = +symbolized stack; fix then. + +**Suspect space.** Perry-runtime heap/string handling at the FFI boundary +(profiler overlay/history strings are the heaviest string traffic in the +crashing runs), or an engine-side heap read overrun in a small shared +helper. Audit report: `shooter/docs/audit-round2.md` finding F1. diff --git a/native/shared/src/profiler.rs b/native/shared/src/profiler.rs index cdc54d2..3e41629 100644 --- a/native/shared/src/profiler.rs +++ b/native/shared/src/profiler.rs @@ -57,6 +57,8 @@ pub struct Profiler { next_query: u32, // label -> (begin_index, end_index) pending_gpu: Vec<(&'static str, u32, u32)>, + /// One-shot warning guard for GPU timestamp-pair exhaustion. + budget_warned: bool, /// Phase 8 — last `ROLLING_FRAMES` frame totals (sum of all /// samples in `frame` at frame_end), in microseconds. Ring @@ -74,11 +76,15 @@ struct RollingStats { has_gpu: bool, idx: usize, filled: usize, + /// Frame index of the most recent sample. A pass that stops running + /// (feature toggled off) must drop out of the readouts instead of + /// showing its frozen average forever. + last_frame: u64, } impl RollingStats { fn new() -> Self { - Self { cpu: [0.0; ROLLING_FRAMES], gpu: [0.0; ROLLING_FRAMES], has_gpu: false, idx: 0, filled: 0 } + Self { cpu: [0.0; ROLLING_FRAMES], gpu: [0.0; ROLLING_FRAMES], has_gpu: false, idx: 0, filled: 0, last_frame: 0 } } fn push(&mut self, cpu: f64, gpu: Option) { self.cpu[self.idx] = cpu; @@ -113,6 +119,7 @@ impl Profiler { timestamp_period_ns: 1.0, next_query: 0, pending_gpu: Vec::new(), + budget_warned: false, frame_total_cpu_us: [0.0; ROLLING_FRAMES], frame_total_gpu_us: [0.0; ROLLING_FRAMES], histogram_idx: 0, @@ -151,7 +158,20 @@ impl Profiler { self.gpu_enabled = true; } - pub fn set_enabled(&mut self, on: bool) { self.enabled = on; } + pub fn set_enabled(&mut self, on: bool) { + if on && !self.enabled { + // Fresh measuring session: without this, stats captured before + // a disable (potentially under a different feature set) show + // until the rolling window refills and skew the first seconds + // of every new session. + self.rolling.clear(); + self.frame_total_cpu_us = [0.0; ROLLING_FRAMES]; + self.frame_total_gpu_us = [0.0; ROLLING_FRAMES]; + self.histogram_idx = 0; + self.histogram_filled = 0; + } + self.enabled = on; + } pub fn is_enabled(&self) -> bool { self.enabled } pub fn has_gpu(&self) -> bool { self.gpu_enabled } @@ -175,7 +195,16 @@ impl Profiler { pub fn reserve_gpu_pair(&mut self, label: &'static str) -> Option<(u32, u32)> { if !self.enabled || !self.gpu_enabled { return None; } self.query_set.as_ref()?; - if self.next_query + 2 > MAX_GPU_PAIRS * 2 { return None; } + if self.next_query + 2 > MAX_GPU_PAIRS * 2 { + if !self.budget_warned { + self.budget_warned = true; + eprintln!( + "bloom profiler: GPU timestamp budget ({} pairs) exhausted — later passes report CPU time only", + MAX_GPU_PAIRS + ); + } + return None; + } let begin = self.next_query; let end = self.next_query + 1; self.next_query += 2; @@ -223,9 +252,11 @@ impl Profiler { } } - /// End-of-frame bookkeeping. Reads back GPU timestamps from the - /// previous frame (non-blocking map), folds samples into rolling - /// stats, and clears per-frame state for the next frame. + /// End-of-frame bookkeeping. Resolves this frame's GPU timestamps via + /// a BLOCKING map (map_async + poll(Wait) — serialises CPU⇄GPU, so + /// wall-clock fps is pessimistic while the profiler is enabled; see + /// docs/crash-triage-windows.md), folds samples into rolling stats, + /// and clears per-frame state for the next frame. pub fn frame_end(&mut self, device: &wgpu::Device) { if !self.enabled { self.frame.clear(); @@ -287,10 +318,15 @@ impl Profiler { self.histogram_idx = (self.histogram_idx + 1) % ROLLING_FRAMES; self.histogram_filled = (self.histogram_filled + 1).min(ROLLING_FRAMES); + let fc = self.frame_count; for s in self.frame.drain(..) { let entry = self.rolling.entry(s.label).or_insert_with(RollingStats::new); entry.push(s.cpu_us, s.gpu_us); + entry.last_frame = fc; } + // Drop entries that have not reported for several windows so a + // disabled feature's passes leave the map instead of lingering. + self.rolling.retain(|_, s| fc.saturating_sub(s.last_frame) <= (4 * ROLLING_FRAMES) as u64); self.open_cpu.clear(); self.next_query = 0; self.pending_gpu.clear(); @@ -316,7 +352,9 @@ impl Profiler { if !self.enabled { return String::from("profiler: disabled\n"); } + let fc = self.frame_count; let mut entries: Vec<(&&str, &RollingStats)> = self.rolling.iter() + .filter(|(_, s)| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) .map(|(k, v)| (k, v)) .collect(); entries.sort_by(|a, b| b.1.avg_cpu().partial_cmp(&a.1.avg_cpu()).unwrap_or(std::cmp::Ordering::Equal)); @@ -338,12 +376,18 @@ impl Profiler { /// Average total CPU frame time across the rolling window (sum of /// all phases). Useful for a single headline number. pub fn avg_frame_cpu_us(&self) -> f64 { - self.rolling.values().map(|s| s.avg_cpu()).sum() + let fc = self.frame_count; + self.rolling.values() + .filter(|s| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) + .map(|s| s.avg_cpu()).sum() } /// Average total GPU frame time where available. pub fn avg_frame_gpu_us(&self) -> f64 { - self.rolling.values().filter_map(|s| s.avg_gpu()).sum() + let fc = self.frame_count; + self.rolling.values() + .filter(|s| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) + .filter_map(|s| s.avg_gpu()).sum() } /// Snapshot the rolling averages in a stable, CPU-time-descending @@ -351,7 +395,9 @@ impl Profiler { /// label/cpu/gpu out via the accessors below — HashMap iteration /// order would jitter the overlay otherwise. pub fn snapshot(&mut self) -> Vec<(&'static str, f64, Option)> { + let fc = self.frame_count; let mut v: Vec<(&'static str, f64, Option)> = self.rolling.iter() + .filter(|(_, s)| fc.saturating_sub(s.last_frame) <= ROLLING_FRAMES as u64) .map(|(k, s)| (*k, s.avg_cpu(), s.avg_gpu())) .collect(); v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); @@ -394,6 +440,36 @@ mod tests { assert_eq!(h[4].0, 500.0); } + #[test] + fn stale_labels_drop_out_of_snapshot_and_evict() { + let mut p = Profiler::new(); + p.set_enabled(true); + // One label reports once, another keeps reporting. + p.frame.push(FrameSample { label: "once", cpu_us: 5.0, gpu_us: None }); + p.frame_end_cpu(); + for _ in 0..(ROLLING_FRAMES + 1) { fake_frame(&mut p, 1.0); } + let snap = p.snapshot(); + assert!(snap.iter().any(|(l, _, _)| *l == "fake")); + assert!( + !snap.iter().any(|(l, _, _)| *l == "once"), + "a pass that stopped reporting must leave the snapshot" + ); + for _ in 0..(4 * ROLLING_FRAMES) { fake_frame(&mut p, 1.0); } + assert!(!p.rolling.contains_key("once"), "stale label must eventually evict"); + } + + #[test] + fn reenable_starts_fresh_session() { + let mut p = Profiler::new(); + p.set_enabled(true); + fake_frame(&mut p, 100.0); + assert!(!p.frame_history().is_empty()); + p.set_enabled(false); + p.set_enabled(true); + assert!(p.frame_history().is_empty(), "histogram must clear on re-enable"); + assert!(p.snapshot().is_empty(), "rolling stats must clear on re-enable"); + } + #[test] fn frame_history_wraps_oldest_first_at_capacity() { let mut p = Profiler::new(); diff --git a/native/shared/src/renderer/material_system_tests.rs b/native/shared/src/renderer/material_system_tests.rs index 6266cbf..54583d9 100644 --- a/native/shared/src/renderer/material_system_tests.rs +++ b/native/shared/src/renderer/material_system_tests.rs @@ -378,6 +378,13 @@ mod translucent_sort_tests { draw_slot: 0, view_depth, instance: None, + // Identity — the sort under test only reads view_depth. + model: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], } } diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 3bc9933..ad66ec3 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -979,6 +979,11 @@ pub struct Renderer { pub ssgi_radius: f32, /// SSGI master switch. pub ssgi_enabled: bool, + /// One-shot log guard: which SSGI trace backend was last reported to + /// stderr (hw-ray-query / sdf-clipmap / hiz-screen). The silent HW→SW + /// fallback made "why is bounce gray?" a debugger question during the + /// round-2 audit — keep it answerable from the log. + pub ssgi_backend_logged: Option<&'static str>, // --- Ticket 007a: Lumen-style screen-probe SSGI --- @@ -6022,6 +6027,7 @@ impl Renderer { ssgi_intensity: 1.0, ssgi_radius: 20.0, ssgi_enabled: true, + ssgi_backend_logged: None, probe_grid_w, probe_grid_h, probe_header_buffer, @@ -9426,6 +9432,11 @@ impl Renderer { } } + // CPU bracket over the composite + custom post-pass encode tail. + // The matching `end("post_fx")` below predates this begin — it was + // orphaned (a no-op) for months while comments claimed the phase + // covered scene_compose's cost. + profiler.begin("post_fx"); let composite_src_view = self.composite_source_view(); // composite_uniform_buffer carries per-frame composite state. diff --git a/native/shared/src/renderer/ssgi_pass.rs b/native/shared/src/renderer/ssgi_pass.rs index 203dcd9..2e18295 100644 --- a/native/shared/src/renderer/ssgi_pass.rs +++ b/native/shared/src/renderer/ssgi_pass.rs @@ -189,6 +189,14 @@ impl Renderer { let use_sdf = !use_hw && self.scene_sdf_clipmap_built && self.tlas_instance_data_buffer.is_some(); + // Log the backend once (and again if it changes, e.g. clipmap + // finishing its first bake promotes hiz → sdf). Nothing else in + // the engine reveals which tier actually runs. + let backend = if use_hw { "hw-ray-query" } else if use_sdf { "sdf-clipmap" } else { "hiz-screen" }; + if self.ssgi_backend_logged != Some(backend) { + self.ssgi_backend_logged = Some(backend); + eprintln!("bloom: ssgi trace backend = {}", backend); + } if use_hw { // Build the HW bind group lazily. V3 uses a per- diff --git a/native/shared/tests/golden/lit_primitives_3d.png b/native/shared/tests/golden/lit_primitives_3d.png index 271c1b433964cf8902a7efbabc90610ceb9ca519..c42d8b742ed6c696a01d70d6a054f73c387d9128 100644 GIT binary patch literal 45671 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 U%1&N&i2(>aUHx3vIVCg!0COUWC;$Ke diff --git a/native/shared/tests/golden/many_point_lights_clustered_scene.png b/native/shared/tests/golden/many_point_lights_clustered_scene.png index 23e65aa007b3791ac95291dc147e603b91e4a1cf..d81c9fb13e2f37abf282e87d9c06cfb40bf22189 100644 GIT binary patch literal 43927 zcmeFZe_T{$-adYxGs8F|V+=#$5JqQU#7vT5XeX;N#z_ehwQM(#2oDZFQpeP2TPTw= zG9(1n2r3Zn*pIazThbtD(ozhF6E3i`Wf26=npi`vmEvUn;>YK@54O+lK7W7z`@Wu@ z6$Y3&=f2>2UpxbWBN_g2_Z95QXOC3 zEnHm{KHQ>yL#Sya+$SpP4hcHFb! zi}(Ik@a46uzs5hgd2`pNPb&ODU-f6W4<9~pw(Zk~C)=+4klA=ONzqF; zeZrS<@Z7iC+J2ee)^)mFOYZNU<6E1p-!kA`%Xs{Kv762(Z~E)Fzj;H;KPQ!U?0;-S z3{=J9*~ z(wB9y?X&gI#b3I1c+C}D{tb6!F1X@*zbV7ra`;4K>%T5&&GifVa_WL6a6#=;7c{xY z7#>WH95EJ;-}%hLKi#?ivF_hZZg>T^QTWAV(K{DEB4b~@Hx|8P+72@N_S&(P?xqa# z>zlKB@wd;sI`!Kr`0b%{UvB$$)?KEB-#p&(P0691$G7($T>g)*cm8AdH7p6PI%Vpr zlf3_a)yzU%_NiPeUG)uE_1EwGb{m#E>td4ZWnbfO(#Jn|^x(Omudlzg^lQBLgJd-zbR7zG(;6viEnBw_o3H>`@WjqZ5;Pk(EIW4GpdBk!|xXHTW746yZ}BvL^rV z$FxqE6yS2ksy>K@{vY&G~EcA z(_+v}-sYbSAKlk8bNI?v8F8e4Xy}_6|G2!o<&+rWI~I$PS@OXbSF_2lpF>`Jaqn0| z#pI(sZ3A8}7OI!-!l~;E^@pioE~&wv{B&tU+gRo7sHi8OdjFf%oxht5?P<2z;{NMH z8m1zm()Qo0m347Vfw*r|r{(&-MPL7bPqE9S?U#SMl>DNlE}lviidRep%GCW|kHTj0Ze~tJ_pdvD^X)DFENR=hGl={eDTKzLxGR$@ z5B}RHIPn4LS(f!LB zKV1KB(I>9XWQh2?C7)kEG-9T@>+Z}azxZ8zm)E=f`tFK%ccJ7)8b^~m{?D4Ek8h|O z(M==`^wT5Ug8VQ#m9Qp4#1NVk{yT!QF1}`%G!9S37Ghmy5aXS97MI?+?O}r3yplv{ zj+;F2uiW}%QBl{YpH{z<{ae4iCva2W2J_G+nI088f4<1sPo zzk(gN|I9;m(+nZJD{s-e5zpIew^qz5c=;xB_>qZU&P}|y`qW8ZJ}DoGo!FF+UOo+} z_rGpsmUt^A_Wye;@8ysliN-Uu>7yvk^S_B7RmZpAu{`ahRM3(2w|~Cs%3#~%vsXVZ zFg7q;RLWQhg6%(Rz+@1O&?~pB|Mw#>`{vuPoa#2s9bNMY(Rhf=lhu^{-qdB+@-N4} zv2Hjh^uhH9-r3VOey#1zbI(eDZjKmkshC`egz;;>5X;fzt5ffu`|s~grry4F^KjBj zjNX?k;TtpF`%-z#v+U!}W%}hYCqKXZ!?k(;0kv;Z1##WBT*z_|ai(hxy>1wrk>A(PFt-rX4oA|v_ zU~EcW^U1D9pJ=K7_Q?~--A9^wD=q3twZi=Fmm7|JaHQzM!}}jN_4~0kIk>l{WvHtv z+eWb@}>y?uDqhHuYJ{B-X7Yp#KJr;*OK9t0g$XUpuC)6YjfKCn6V$iUE0r}0Sf zHr?oJ&dz5(N>(3!#P!S1Z#FkI*^9FM#T6sIv9skPzSi$Lo9jy=M_YRmBRoR?ugB=) z6AcV4>LrXAE;Um*Wc=GTU!VV)WdFy7G-sgKpLo|I7dZhkF zkCa2oW#4%Ck@W}1{^!*AqUTN}He4h^QF~Q!Y@|fv-SW-zk&SO2_Kw@E4M52cY zji^*#4GqsY^-b?lS`H^u#bXrm{;x&XURrt|is6eypI%;&=-YpEKOuqm1mCi;Wmtic zm07W_Ug9;?v{>B#esBp+?wV!lOYi);J zG`y#TkMfvy;Mi7+h4){#W1qSmHwCosO?(!=xu{6)`SjD!klbu}Ak9DBel*S0P#lW9 z?uy4|2FH@l=7>O=@zUU_q_5|!xp2Jx4euSxhkQo4%_<}u;60{f)fdr>(kzC`6n6e~xF zK+9yCJ>l%d=Z4OumwZ2R*Fq65ZW|ux3C#4K(wpBtyf#wOPvmZ?vD%ZYu3mWe-Rvv7w{MJQ2Q{TFX=-E2SY!l!|F#BZ z^tA+EswPe~d7Dfsw6k`+5XyT5tP6k$>hu1qqqhX{$e5JeoS`s#Z2p7Ap};rn`T1>^ z13-f8=3joei+D_PCBxffoiqJcR6LVxa+*!k7=IAH_Fqv?J}`VGjPS&aWmqD8XgaX7 z?FpL`nTJbl)gNiuuhXFr?sDjKquc97d}8vnM(W(5f@6l7FLhw?ReYpX?^|ousC=~~ zSYKN)Kh70jSjK;po!HP@6vZkzPnK4Njn`pgNHEFC{_XJeKpGoIJmn=4COln(<0vPR z%}+fgrAH#^;+drBp6gp7qo6XLy)C{|giL$Zj1}K-L&(u{KddHF0p#94&{>$s(QNkK zTqIn=g!}GmH6#8KD@L^0di-3vWF81l?#sQsGmxU_O)*Y~4uMzgWGlLDje3nLy{^RG zPK-wqxX5K$`7uoSNRG9ztX-o_>5^59a4uYR4pHLY;=LE$`5IaFFL~L|w~f8iwzlsQ z0TA#-d&uxoZKGZ|+JTRr$&RuLy-(NZE?TTCpWhSE;>SS=zEh_^4P_~w|NN!NTOWN? zWHAw9RSyywEHeecXm;3KBscE3|9+E`sPG1;Dz!-Z^~mv9$5|=J6!+rfmZ=fJbGIao z+K~5Vcowc0DmpVG?I0s7tjF1JX-Un$u)RN7P2Rb9Q)&u=JSFRq4d4DUc9$F;{quIF*uAi@KUTwBhtG_x$wAxw}bEVS-T1 z1=5}$FMW;{v+e6%u6v^*b3TQ1Kb@O!eSGfwf<^IFmuu@s*1DFPLXag(q67OngXa+G z_FsX_>BzRJTV~!eofHxC)n#p-_S4$_p+Ec**Izfe(7$GFqn^`quJ}N_DqfAm)G_v` zTu%<%_(q?{RH?gh`22aNSZ^9e!b9U~=j-GOVvAF;RWqvHI}_?=b!dphD-;_CO-X7& z&yh&nXk?^^a14<-^uFZ+jS|BD^ue!nVphT7%q?x}UM9GmPfY$#aa8}(cI}?4AHTW3 zw`dy{Iw*{fG7HS;*7`{f0T-C-!49-bWsdgY?=~wupPm+D_67SvkdyPv|DhB>2#8ek zHB#cNg(lNC=f5lO1{e#>MWs)nMPj&E4ixCBDcVJAS*)wJI2*fpDa@DOlQ=*$84GGn z-|e0D=~tZrr+eo{HisRJ(hdQ_2gfRq3B+wunXPhEbcprC;OTnFz3Y_PsXO zx|j9oas>E|cAdjnw6~o5<3grtqdA3Bq~6LhhaY*O?aU2V8_(E#66*^L1n}_U6ke)2 zld@sPpvkF=daLgNK)3O~w7q`r+5F~+_}j3NiS@H^&qd)Vb-3;%d(i}aX$4K9`L2_o z!1TCYj@@*Fu2| zb!J4%Sbf2vg!HO2T|4g_GH=*9vdm(BgL(LYcH^byoxL+z2KQaFWC~M!e0DLw=Lu3* zaHwhdSAfwozFgzGV|l;dpkQ9$rsB9iHaaEJW-ClW=&P%pOAhWC+|*Xuc&_LM)D^)# zKK{QktQc(Ns*9cwfzFSoCT<>ox1i5QaDl5k3&jfz;eRS{)g0XqutNX1ctR?eP4R&= zZ|i2(SrmnXIc_u>$y@j1#Li_Nj)--p^_yj_3*Y%-IvEr*UFY}|?hn-6f0D9`|2cK?(xUNq zr_#xJSqpGB*Usd)%~?6XF(D}XvRnzfrHtv3L@YF+Sh02U zr2?feB(P7cf3tdk4~&vw1Bzh2D@6PvX*+{*g-|&$b~d7CylUf5bM3cFH8BbtOuA_a z0s{Fxp|cFDq(bd@jGLo6DE?zUmW=zDANWB*+# z<$zp~#7Z}Uo`?QqHd1Hr&67zwa1%m;#)C7cIr+tl1w&6qo>_Roz2n~9>(;;DR}>zp zv&DE&@(C8`u8Yt4YW9!ie%Sct*?WK~Eo(PzdTHIuK)IkPSA6>AVWI7ixkA~_%CxK; zdNm&)H)LyYR|>tjnnKa1Sl_b026HHsL?TD5qiEuGeX$ciQf|-&bYt97R;Kr*5-wZk zFRq{ez`XbS%obBTv)3b&peXGGoSm(6FJ>cEM-rx>sa2w(==Zzz_aEFt@8IOZQTL8T zyK^*0_ixnYG?b*yE6bd}a_eU`{X_jyj}iHwNnIkvQSHw!EDRe&feguQ7>TVYsSu&g z_aEM%Dlb{Y$d#-IF*Kc$-7uWuHC&_n=zhHn31Nf>)+wK+LL8GNfMiTzeeu0gxoVP? z$a)IHfJU4}6G}k&pXQod^)e;#ulYn^@UuLbQbUhUDWf$1c(?27`)@w>6Mj1-gAa9M-lQRpq7iY@~%P5N>sBHuKQIqGz&~Abr1+Ip)1NyP z*!||cd){31P1e>qpbrPD%YV1HG3(D^P!P{j~^vZG;}TQlkpNeoYz_+RnV+A_`f1@uzjOyyYbo zNN3xsiaxCy`9uzUGtd(-#5))<;#`=V9D+xB{Jp&fMRW_Wo76Dcgy*?>1* z+S(aYboJl|pZ#GG-;=1w#liC+r+>1x>FXndPgrNqjrzsC%;oQg`gBgl!AN;MZ*AyC#;*WASAwOljT{`?kh=D8c8ntR^UR{pYFwRO ze=M#}f*{W@O&i^ctZVZF^U68{RvL`gTKk#fC|{Wv-qLP_!D5w`IT&{C6L^8qc=;_{(6s0KvR;**kg1D6`h=(&r&F_5i!YB9o z6P|kP6ACAqnm;IE?h2dxJKqik|u6IgxIK*-K&sdI_EB``1ssFf4^B>*B$3% z>_vnrF9_?%5X$r(Tvg7Gub3?Ypr>n<;JJSempgP^WdY8n$8>=Dio!nSOpXBxhgj8);4`G^nmLd>fyJ z#{XmzBgaLUv+~U{Da4NZh()HCR~_ANi_-$hR}_TRD$EvnCzeWRM|nsYT1n&;-BQk7 zyufQD&{8%=b;%|DyjkYBQVT@a4O~Mj3ER^gp+beJ@o<2}U~!lgum z#;z24#@}ka6>!t1BdYkjQH%@}LRJ0^fW7#1XCMb6uE(97sEmofQXT)dg)0d15QM>g zq??+~aU7q_gT(-y_fc*)*k;|Zp;E+q`U`*Gaj#>`?0JV*J#e~W=Xm~)HA-6I}s+z!zqd|eCbAt>}-KJHS`c11zyyE zj-$MzIZ{`kmiJV8aFio(+mQgji1={1RAUwp8$&+c6}hJX9K{6H9`7RIrL2C~E=Bf5 zk!>gl3$IlKY%B?6AwIyca!`=7(443Rg-596e-s5eaZB&Q6;!fj_y8N>@o&Q(q*(+? zn9S7~2Onwex8Abs;GR#CCPop<=f8Q=REB$ZH=)WGg=B2qDH2~l&- zqHQ{ct7v>gDo1Guycd(L3A_d=T#u6@qnIZ+$0Ms^Y-@Ct~knad{TRJ&&gQqD$7I}0o2$g$Ps z@}IuUyicAwv{E=eGu#)wAPfm22E24lEXbeA%K}@-=ylJ4#DD7n;;e_d^l1dMSLK!A zkJQZrRdesXa)NeNqZ5$}(`kukY}>9%JmvO?@A@~5*<2+3V=>2bGJhY zkTWS%?!$c(?QOmt42pi;$ecz!z)=V|DeZWO%yvvp(886l;ABM$ScqbckJu!t&|mSi zp=MgOV`Ef`8V9`^?dlYZme`#5O@}^Qa551wdPt(JRIuVHoNBHwNaS2g1;8yt^M+>x^CrO5CYywJQhu`KD{3@pl>M`ER~y~7(=`-Oe!rlZmCzM5NrjYd>rNG zCc#}41>D?-{F;D52C266cwPrTIwVroR!Dq?(S6{f5!+)Ip+*i7=Kxq$ghN*47OE=vM7^M z6^^#?Xgk*o6mPabP?8U12rV66Dh1(&Nw68BgY1T* zlyY3k=Z9k5Mx&Ddaw(+f?b>Hjsp^-- zvUa_ur}=OnZ3usT-s$An!Rx%}-d)&w$N|TR!H{7A(1IlkO&mRpfYcY>3m#UwpM6FVVt zW&*rP{Pi{h1uEVFTmgWF2C!i??y(<>{t3UsydY?>=z%fL?T}J*c9hh2)8`|XuOd8D zv$YalV#ME9F&?zx2MjS%o{k=u`)?-wacA%HYz}f5eH+?~rPoNmag`#!2}~&y zX{&1KfNYZnk=(1XR?X13nShzaGLZ6VdS369Df`itf`X;mGje}c>_jOvV3zTpS_=9# z+M&>=fBsdDMJ6hq4*gya zhrpJyW`dUA@o`WvRGf4 zJ4XQbsL%LIZ&K?dwhOl{?8?eXcN%pAj!&O&whd>f_VSb52j zrsYdEKUE}JNJ4S}J^LXZ0>G_}6re(X6LU-Z!WV~DqA}|rQd%ilhEs89*E6gN5Chs| z`K(=pu?YAfFh5iu7(}j3yVm;ovX6A{Tzt9jCV&v9nXMQ(*6$SnxrdKOUCW3X|L*?g zwl<$$DF^09fh2a&L|h}fmwQmm6jBb=7hM3fE{Z{#SS>0g++e(fhfYJk*J&M6pikNj z)BEf%NM$*$8lV(}yP49e!g|W(I{+H8!Rtqd;97Fn=|UoigwRB1Gq zxQdbdC=ZkpovXK>K*CRf6N=(OD^5%Y_*2o|oJIPEi>+L^PnYXD0oEMQBCT1$-f@N3 zu3bY@;`X)Q~}I&u?!p0Gt&>vK5|)?Ogd}_b_VF-RKDXl z5fs!g95}Rb)F$f*sPX7Pc>T1-gME_l>uO0S2^O2t(_<^K_yhet%p<6A2Awsbh zvOU~QN`P@SiYg5;@cI6JzG@?A6ys1)^nw5ywHb|?(iuiyG&uAW@InCCYU;9RHb8Lv8*1^#>t@^EgmW$3|CaZvb*s zy4+f-qeV`i41L{dqn3d*|9~=lDwtcPXYSatc{2=o-z*b_%%7k0*^Fn3MQMpz-AY?5 z@B^SBkILIlo~Ql8e;&x*{>A#w^CMR;Gi>V3d?#EBWMao;1I23csh?mGsJ^7^;kRc7QOtx|>xrPmM`@DO;q&Y36g zc<6!CP=}#NFz5!U$&tmV$5^qa+2J*Yci~IyqRZgUI!K%ll}GzOF}<95&%`gACSUyV z$Bk&r)K?V~01E@{(%({g`s?#T?Y6gTa}Q?+#_)FWyL*GzLhHpFSChv@nQUS>URF0Z z>D#v<8a69Bp`ph??MG!gkrk^|QWl+S#Zl}h;P0yHcAN*`Iw_Kr6`&1(uB!t*$e=^S zs<16c6cWr&mKDW5+(b1-sAjZ7G7}O>m6C%;BMB-3_7$B0m`meaH9NtWQIb7frW&wX z4PI#n{jpZfJD}eg0|%5;1N_P2tROi``o+?3i-9jhDKwS#=r-n-c1kKx2JIK!2=WVa z(7gNh$qK0KB=Ne7R$GC2_%}LuTJj&o8K$*FNNZ#gw_DqCohGjl!Fz^ z_h@=%q9iy;<;WZy@=_2!1LQ<5xDu?|l`1gwrTNNm^4mZw#XTDQ4T*?|W0G;Fv^9g* zfR%yKh2xNWDR4p`n1S{$Q$b@WWgJ12QW>G^$X5a7t>;@WaFkvj*c`^gRyrm3nb%`jrta~}5@ex#^fVrCBE0aIrrB`W|gkJp7 zN4Bk?aS(9`oI zkpXZZx7M`aj-k9(GJs4nPwV4U@rbm!XP_5XIs-h}7#?Z*_U)Cg?(aoPHTL%-zrvMP zIsN*)m)iu$x9-s~~m?3kH;cMH;IIyBmtyTNJwm_lB!hQb4I9EJKBz zx9SSQ;_pUH>1+nM3e3%xONJ43;HsU$Fg(DeN3(Q-%wvl)7X9^MSI4nie$n?G1M^hz z3!2*vu!s$kegRpQ=yGYtyCZD>W!;C3)}|(p&@*%U#b;0s#3Z?89~hM>Br-tM!_xxm^N)1hGp#N_7rWCf_G4dDUyUt z)xl9FzI1m-GitjT$T_}Fs)>L}A%;rHBb<{_5G#TKI$E;aBSXh3l%^F;3=GE+an5!& zhxXtQ^j&;?!D~+@jn1Z8T$x@DqEru+ebhEQJ=i?%6AFWa5tXQ+4vZSyI>2Q5#aBiet=7t&Z z0ou67;ZTS2a57Mz6HqIZ7C|)hseF%64f`4Ob6?tvj1nAH#BV?E$%-Wkg-(m8R~j`q z*+!so(Gc~&$U`qfY_U|;u|N)>@*pEQ(k=tZhJa*IPuB*)Lli{2hQo%y*78|9TY6x; zKtraN+FvLPu?+_$`i?^%2G;4WYUknFtK1p1a@$`YF77;b3(ULk_qmy>8Sw4UtS+>_ z_SBw6Q9eaCC9-lx6$23|b@uGp=Rb=dU-f=p#zC6@23y~<1>(_%*)qGhqPA#@I6E<5 z^KLat&|N7n2#se;6O6umm>|#(g**k9H1+5{0V^De6lL*T5M?{0UsiGL_m54J=RncM z73h77vtnsv3}m2-kq&(YIFwf~oD|`Ev;)Zz{%8WOy>pJk%t6e8V2J-DJUX^}kwG9n zu7)8XU`|G1fsdnrpqr(tRrEB&NYT;*;tu7Ndb>f+BpNv4W+`%;nwq|*=3XfFm$4$x zeB_lOd+wzDS7lEDawpV~YcK9RE|XX73SyaKv~0%X(DWkE)oPf3TsvTXhU?e#3(5(K z039Ux;~#G(lvZ?PLCr+Rn;6r9sq{xkOoe>QQ%^bUKOVeYbiE*L9)XV)KB5;$B*cw| z5meZS2r2=i9n>s$JC0g}Ik{%99I`O6My1HDiY-Z6WE3kJPxNH_2Xwj6BhUkia3-(~ z^Q&g?3m|!=rXbBwG}mE1ZkiTIGZ2GMhlB)ngI=;8$}lnWSZdllFx?p=p{mDp+QvgZ zggLCzBDenZTob4qf9jvgyM>M05Gb+WN9e_2|}MnszLC= zz!jllp=_d#yBF#vFvX6G8N<}Eim%yIdvLDc2 z0`!dx=js&+g1)XG!Pg5sgWrKr3cip4cnG;pLS@e7oXVn>>$Kn%EW-X#wA8Q6Tlwm_ zXWi{yS>$Us!ly{VO7T}GU0w)?m%=O%d)2#F^$!jnLv>kdMLa*@HTsJIfzYY}55P&0 z+{lW(2LmmPMtEitjwJAKiTo}~&jQl$9*yI0+H_ua4U?x=hK%D{UCq^iZDMytLYb{A?pLP!JeS)rE9 zm~z`S?d%QYVJTo25>-0LzM-KhYd^{k3xyorzhoY*UvT7O?ID`0sEyu>+7D&KY(Zw` zv^1V!y{ZCmVp32Wr0U@o4P*z}Q9!#{i&7rKdt~5R9$CPQeM1D91vKDM?WM>wuH}Lm z21@7`Z#mcSG z4Gob$KlhSIBX6{*wRAO^1^los_jyh7(a#>e#a>uySY>OL>GzhsYQ&CE`l@1xzZ4 zK8}yFt&t4{1_IbA!7oIjGq1DHfR+JzYNTe(mi=T9snd++4p6)r2RB_V<*QIHKvvui zB0Z#+i8jg(J_p5^Ny&-@?1G0Jnb|={Q8YSOio#2S4mA-8%Vhi(F01tNSv^@fux_$( zuBE2A4PY|mBP+@9DT~19z~@$KB%E_s8I$We-pX(P_^g?8=v188g;CHRA3JJ_-v0XQ zF*Zo8T;eVeZX4R6H~?r-xNec8$f>YDY(RGr7;!BQ1aLl%qVnV&IncnfVm)fo57k@O zs5kb@mN*dh&`Ws8z6uRv6&rzy2&SjvJv^g{5ML8; zUcfoRgx7Kmm`_avQ(;-1$o1i>n+bKhH!9Zw9}wf+hs?G#;4OAG;9?s3n~< zEe6h`finrj+UQH=RK!BSIz56?2zwUlBB z2`WR{x7*;K&!aa@mrK|MD?VnQfYoS&N6P0yz6al-jsAr6F46(XGz2ArNQi$mh&l=> zS}cWSDmgHT0ve%0TCMWEv)i&^29qK*X``pwIDnoqvrydL6R5^AEKx%fo1sS1QKDsl zmICUsXg9ttkWzyLdK&PK4`i^B01lXA8w4d3Gb8%pOXi_zep+GMxbgHIdqXXTy($Wz zJ!*r{giC={0f;DE&R|hA(qy30NP42oTEUf*NRPbXioWn@=4+GjHW{w}lj+FFlb)SvXdvlTNqg#>S)N3uH)9p+c8K z2P1e14OV~xRB$UQLd}YOv48B3eL!pb>W&-&^^k+2r?+TQcdy!IR?~!q{?Q=f!`+g` zq7(e(?-%aO)2CR%s~1D|>kQ5Zn3nLlc5qQN?TbDJS7=A+h=8t~$_0jcsG=ye8xEOe z3Ia_z9&(Jkopcy9f9?&y=_3Zi2o4f#h(rU?mjWrsDFl3tg!>l5kqFE3wFoAIzD8v{ z>QKPciO!Lin}gm69EIDF{HT`{YWeqgvECZy!@F*Lcb_86PuK@@b*5op5%X2j2M z!z?gtqU*e4F5bOjqrP@^vicBoO(}~}0Y-+uGIa9ED zU>jOOcKkho8)$Z!}Z76oT_Q;S89VVRCF zR~Md#;RP;E4&S@Wp~7ykH2a6wp2*6F0l&T*(q(O`sS>;jOWozg6c{?VHdj3I0Gt?3 z%GIjoUqd{M6FoKU97_b0XX6FV%EDdDtRA9p=#ow4o)RiadV; zE<&-%?$t!XW!zI}0G<2bA6J#nZ~5kQNQnlcGX)7;sbfxVj6%ZZmO7XL2+N3V^6&<9 z1j1aGmmD$P3B5omL&ZP3hP9(PtVV*)*;22Ev!_erY*!ybE4~swCGKYsM>y0Y98ObrBp52EHB-W@7$g}Tm?@R% zd;Ep2%Z|C9iH=AOf}A!fVf;Zah2g5^NClOQ38!;UYt6=F5DGR2Okbv;FawF6EzNVo z9SuPoMcBZl8vfEh!}rq0Id1+@fbZU1yA zf_!1w46wpdsnH9;k?Mbt6nLl@(+se7fww>L(Zz_gGhN4S8QuK+MOb6{Gb{pO4Di8q zrMU)e#u3HJqUc8(8aRSUA5>7Pwq2_)Xi}m7Cy*EhVvRtDVS;$vwrrUkxmwRj49T=> z8?ibg83u%zdw~m~xT6t__PB0An4Ezp&FXA_5CfsGbW`JG!F(I>tT%bpqPp? zqYOle5Dc23Rkj8j-wOMc0l?c-c{B~}HcTvP0RM3rcpHxqjm>Jvm>5k?nK$#E;_yg- zA9Tz>sadDHrGsz8@j23ju}Dz<-~v^L0oINW|B9iI8>2?g@Ax#+x_$2Zn>W-n6tO|&+T|;{EyhH#DM7oga7_S`s8nV8u zFbVWhDz^jk<^#m&(cpma*XVSh^XlQ295>A^tcSk=*>5()Z4XX3#(Yp_VcN$M6eOn0 zOM@_ab-7j(7*ayjfa#+d8FoLWfRkVi7~8X|E>lva1P|rxFkaR}OGA$a_GMH|P(gyz z+@EX!TZ4NOXaRnumL6EdITcQv5BcGk*Sr1E{i8~_u|q>j7Gm()0Fy!wiJgf+(C_yZ zkA=BBeg1Ib?E*BF0u`V-;~ea2H{Ajyl&UL129awZziYsEAUDFz(hn~V7&1mLXpE?2 z6)=OqSAf()BZHG>Scn{-uHqM;j{~)=0(FU0f?{f@BZwjilxUg6%Y!aK8Fo=w95jqp zVJIDt@91yQp&6*gw8b7^>qemJmbMEC@zW*#D`!7^QEVs5r&`E302;l_b|#H7Mbrct z(j@(Y;4H|*SB`AC>CLJCQekFV@q<(XOh>7#BGti`Jtzlc1dqgo93HIT>ag`;V(niL zETQ@1kGhzG$hsT25Jf1)VNbJ(E-wukcULfkhhhj`}xfi1T(> zZo#%-87@eqGXhHBjA&OWrd{_*>Fi<{DmRiApM9ffG6C9uoKmG~YEnWvOv~V#5{sICmH76pXN}qZmO=iWy9K9If<8PW9 zHBr6;Pl|{mrR^G_IO&X-Iv#yAE8ne@Y2)xml+nRgt#cjcl@OY%7zCqIjU$h^TTB%@ z7-(~xMd5a3G6(4r23;vigqCuTE&wW`gvquao;(Igg>~v5WB?f^f((N4`C zzM|qsx6?XHFo94vavybzg;1Xt+HVY8bRn?J!AuF1>ILwRF_&is^KYz!CzMq}rALe(P^Wi6=ye(-m*V*{$)*N`w^m7KCn8nhVk2h& zps@yBAzv(YiL=OW5*AFHjborP1RcWo`=F_^T?53%F~HBl3r9OF&r3#YT^kLF__p1I*jr^t7=bZwAoTBh7#N% zox=kf=^8tW1Awh!04+G8@-4<-3WOUl)VM=#WjGw^i!q)C_nz1zib9X*otd;D7Y!>d ztJ9*A~e<>$?u=L zAM=rCB0k9J0j9+7K~zA0Xh9fty+FS=3$SF;(6<=Fd9>xxhhZaDbB!3YKob@@6dpu~ zw~kyh;Z*Qr^rLrS5Y-3xKndFG*0@r*wJs&Xy3FZHUyW{b11bm@Utnk$WG@7Q@U>Ih z#cK&Log&rn+33AfS9BcU2D-K=IMmt-VWzZYe}XUHEZCu5uPnasFrDM_K9d3KK@6$D z(*%&9WRcJz9-uBqpk(??vLgw}a?JB9Fw7~QYE*_e;1I|Vg4vv`s?Q~|)f%Yk-A_F? zO~4*=kc*&lEC^%ms^K-!5YQ?UP~F<8d5jCjfnJTmgeQ^=U|JxYcYvg#zH?AGBS=gs zh^U3KR#aT7vtSl38MiIGg*KPIwgTfpbRHaHb}w{Iaat2D3P(#1$`*79V87|qNokq| zno*cQGbA)#C?Rt(eM2=GA8I9_O~7`&Q#;4@2T9HVDQw;Jj$HU$VHeK=a zbD#LVvBV3(Btq+OS5f&+rN(hYv^2sWLm+@kRg3?W;58i5BRR2Cswma80AVe#zlp0c6d;{w|id+NDfk9hF$(I(z!c{@Ac%{WF zjIe^(^K4Je_dwGqY{{L>7kpdRIpqQtt`KSWZ*ha zSpvq{8&#T^U@HzaT5mdfmowo=0Qn`$q&z3lcXiN(Xi@xH0US=Ez-)%7-kB{%9EQal z(kQJQXnbNcO{?Odx2=Xq5v+$ss|Tbm2&G0NJShRsU-4$;ZqF&JE>2g3|M&m3{QKi+maWqdBfKJ0VC1>PjN7Y6OV}X^!y<|%CL{g*+ zqy_u!@oJO+nN*ojAy9<`D3=LHCh(O&8wjQ(yj?ShhIW5zhq z_ZWoL96&3;LMl6mFuO0DHVMY#Vnn}wwG&Q8o8HsPlxC*ANnyo?(>y<+rcEDc9xgaCm8BR zVx7n_5phVI?J~U`h#Q072pM9L(}zrf{v!dS!H(w&)KV=#1N2>6KTVBw_N|4@jymly z4~5@_9D*;qVdF)ez(zsqbxPG1LK8mJ?G%>B8oX)xb{-x$BYIC_Av-m!njV;-Pf{j7ghwbiuV|bojtX=oEqits5j-#=I1J zOOMs1Fqvn#-T{%T9*+Tw#Z@%XiXVs$W2=q_FrEOc0co9yfagFs+9MQE34=O?HDenisBYL*|q&)7ntY7`5mxjQrDxKKF4lC0hII%QfjDg8Cq7DiU8+?cQV#W z97z3f6dRbSB&Tge)j@fuPwUx-XF6(8(G(24g?3CtwQB$?IEk9bX)48YlAtBQ{w-nA z`CUw>`~w9@7_k2so&-U0oFg@J)dgYND03?SnhG5*1wMB4wJ+y)x=5 z_^u#v{k*h?@AKI1g~qTI8pESMKNo^`M4HD=hpJySLm`K4!7L|KN#k8h=}^d2SjlN? z4kf4?;1N^}PczUXO@bu`Y*Ud(Luo-XeTsn4+bkE~Z93wK03pG2Z3LP%Ba>8PKIs)a zGl&dNw?gKEZ(n@SjQAnYj4_WwrgaFa579o)s4r4SGGtp1! zEIfoahvPI9&$a^(<1h~c^#)0lKJO7~FGuxc*x)dy#6lpjKmeyhbV|(o1o63)Pnl(b zI1owGnZ5#jl_=Q0b>RHvPm=Ipp4)NvRQs|c8aQ}3v;ea06e6p!VZhTl#7Y$dJRJfk zT!tOu14Gh`VFMLZJ+#21hXIy>b{x(KIP<=$c8C~pj>Tv+(@IM^ph!D+G9ZceDxwl! zW9f5Q`e}6tNBL$Q(7Tle%|l?7voXICmD1V;3#SlD=)>0_b~#Wi@O%Y5p53u@Eoua& z%<#Au%rOIz7h?KJ29qKtWl_)Jk-Cq_X)5h7(AiL#mc+$WHlD*X>IPj~BE7ban{bvH zKv%mC4L{uP02(uB$Af%^Bj4iQdqt(fE5JlD4W+zm*k08R_oYF?N~J<)a|+r!M?2sd zz#Vi6^ki7LQcP;n{*E5eniB_{l4}M)&eh|(G?BSxnUo4z)DHlW%1V&?FzA|V=Cn#V zqt38AKOMOOPc8sukdK+bwrFjk_vCu)dt}emB=%*GTP67xdXa4SchByc`&=peyT*v{mVU(5Q zu8PC>0*@@BpnU=lr}Y>$7oa?^nZ9ajs~WVV;oAk)0`|9?IPWHM~w~Ih5`73(MnT@g=}As z2MQfWmLnQMu_cZ}4Js+)3=*Jc2}KW~(HV^AqaYnIUOa2c2-yc>Y)~S7n1+By4}hR_ z5Ba(}@R^W{VGx&a4hGdX2LuSexDA>&LFMx;S6d(-)8mKx%YfRd_d!gMGn%*<2u`5! zQUl(CNRY)O=ssiE$v~er#Jf?7O*ME1p@N~(lOV;^c3~1y8)!xx@HI$ZE_N7C&Os%V zgYT+o^(?HX?$a*tEf2ez2#|62!dRI=hgjgp(@BN5h^D8l2d5wZ=fg97G3;DK;DV;l zTPVU!O%osdRjKlhZBXPO6zG`}8%34Z@Ms*2;Q}Mm^~8Q7wgI}ZO!O%hNfyp2MVMA$ zMPbH4qSQk7CMf_M02cteS}7i(A-5xOpm^wLBRUbJht?PlERl|Jd7T6-EsS z;{{|D5+D`BkT=Da*TQ;dg#;zRO;5e%f@9@S0)bOko zs3~{B4ulRKwDok2)}x67p@{MzqdbTsjfdf&Ut?f6_I$>tNG_J=R;kdXff!0J zh&Ysc85CY3rle>qz(F2f5SxdX*HcxaVF27MDeuOSt%8;V9ZLvhkp(8`T{Cz(0I(Xa z4}p`6IImTp5sHDWJU5OyDg1EC)x;|Uas#DA8}hn=r750>;kFAkd6g&cvJ{&bxa-WV7|Twwx);v74!94+!JYy=fqVVGbgkkb$aRyRV9HN)a9K9+>e#N!rW zAJPOn@P0`B@QX?07}CzBPaSc?>6w*-q7+NjZjA&a-i_F#`bAj~R1_F05=e(ei!{z* zjt>5|o`97SP(Kw0%$gr^Sk#>^s=&$`BYYt<4Dda0Q8Q zHbLx0UsM*%>0T!T^D!y~$QOy!#$ksnAXPz!+$0joiN_w&6Cxch?&6igMm(W1 z4VX#Qg<(FXpWZz*AJA7BBt$1e?CnqkIr>B$ZcF_fup1c2s3i@}RW0Z~js&m;uslwX zFIKcLAT;qVLIO`8@dGG_dbvVo_3+eZN0X+i3$_d!7@$6#ju!tpf%1r>X}D4P){F=A zmxZYoDIC&=bwMIsZbF}Wh9~S$d_#Mxcq|uqAaxg-X?6t15EctTkp3}2ODh73CsYXw zZS%MwO=xjgNe*oPG|52)tU+zS5snPjV~xAfif}p!Hh@m?Rt2GJP|DDPieso`R|96z zj{_|QoYm5}rIwrHd8`WMinrUypEouFmaD{@UrTKZHH)z`r2HD5UeI2L!3pY_4q)lF zqswJBVvH5|Qv=&N)MyX-=14N=<)P3(`vI=Pr-Cx#lhOPJt3#@^?*viDxsO0pVdF)8 z7*rp_cgqBZSV>?jhB>iUAR0Cd2*&7e6yZk1<6R}N?C)eGtQwUKu%;seGl0ZulK_Zp zpN2uH4$R2+HlN_^a4F=mw2O?cinm`T(Zo5M0MwDdb8~S0umixll{+=x4eEoWj)G6N zzc54IO)(b&0m%ZFU#N%BiCq_+3KD<>%p>r$8uGbQ(L}91N;NM<2L%lnJiBaaShEUR z0v?M%j9|X$}cEQV0Jlg{YW;0YS8ABr*pyVSs_rtw3D))L>H(ikX-k z%uXis!!af35eRfqfQ}YmDodVA7~(HG1djoZX9Nl)k_YX|b7u4AGFA9wG)$b}tx!u< z6rNzf80ZjrPLA&Zi1DDuAtiZ!J%LAsz_A*Pp+@X&PXM9?sxCIYvfYF#O6&|JWPs9W z2l*KaCOY8V6gy2YB=2pfx;mX_)PpkRO`U7Ghnk)84i!aF9T^9WXOdB;yX7$3s{UV1 z_ZJ)GnWYK*t8%$g#_pkT8d9D_8Q(hc3abCJnK#sMt~651)#!Q-@pe6eZ?hQ3)Y;4T`*7p*AW z+5Mf*+3AZ3##DWO-uIm6Jm)#jTVc|~qd{=y8EJk6*4~1MGVHXq7^ozeb!!wqwf;t> z3nMn8+*zyXErtsJr%^L{K%H1N^SQC0&&mKRU$TMuTmyr3U(frDCehIs@=I85FKIiv z`^6hZgh+jmsC4&#rv!w(L!5P7JY!aL9}-xbeCAp9q1$$~8bzR#(5+io-4iX4q;PL0 z=2P}anVXf@cU|q>*(?a`!@PG&2xReqSf$19*+z{HKa$`De3b}9#rK2EouHFP!M31@ z`Le2}o?<}(0Od1nI6gN9e1aa<%1DDx!IhLmw}XkSyZfQJp)siuh)oK6553BQjZwC! zv)4_gBm~c%`|EeDjsW}GK{x*C9l3W!+=DH~0_*T(pdk3HIc^$OML28J?Nny*NTMM= z9jY!w)Y8W=1H8~#?jO#Y3M$*hPvnooq08!3D~Bzpfm&U-Pp(^CucQ-Em<>j}HJT~G z`iqOX+mVnYy#V0?J?az6%Si005EWm_gfNL(Cn_h{0Mt(dT&z@JgOpxG(1@a|wA$)$ zPiZZ_Q;v)>xzLHu!TeAX^zYz8cU+`j{k-c%OpYuEyJ#__!gWV4AQ&BkXMuDVjv=*r z13sJkdiUheX`nPw>q}4}81ux1=+Gpj;#LYpT?*HMrWLyo+{XGe1yZL)DJf!aJG(`T zd!^DblAb5Bo4b*f8*r{z(**l8BuaDYmr`ubVsG!FC6Cf9W25T#Hu=%qlZ(h)yD=nV z>;~9E4P{-9mFo&vbQsUoo6X!vcmec>wS*k*F_JA6Ljg6QG;1`HVxR2d|E;PVSt7WX zhS&F!PS*9Pu3s@Hly3xy85873w%k3wEauMV#z`Z3dn3yPbGW6M%zGYV40nvK?tXlv z2pQ^7c(>G+Zw$aC34*E~yliRQ369+O%PzJX}mV@&_G*bD_P0|l3Lc{JtK zox&W{4+>c^cI)!JP49oz!zyEzFYx00Ym;@qrSdzvB?>jW~GC% zUtVe1{gjIc+Gn0k_KNgsc54*1&m7(hmM@}_ke8Du+S7x%ZidH(@}DMe$?80{k)AbFpo8(R+P5%XQch1 z)>1a2p}LXWtE#{0$oa5Kx}*JM2MshpaJ;CUJ2!OBGLrZ>`2BWLAnuT>YIcR;JaAt4wZ3MLONc+&3ay=2hvAteSi-AtIYi1 zjeVuA(b8&DX;UFmP_ilz0!N(f6caQ2E6r?HQyKY0=dON{PJ|2h-f*oARwI;OFZ*H` zLQ4j&v0uiT>(k~cHW_=4PO#FfJ+v|PDBHh~7Fy*M)oyMi42YIm$NUORn#^vwlp7!D zCudOZSFtLCN)zSRb<$gXcXiXeaSRW;olG`bxpCFUBYzcx*D<4Fs)w#-vPGb*ff$lZ z!kQtzoBqvr8a1Tlq49}d{@WYSWF7${-Eo#f?Ei5-bL7x11fo8wQ@Con)uVgT+5HH` zY+u%H3F|1Gu@~YI1=Ce~=8R_4Z7a(g@462D3FXo9CVs!FXI>0vw~!n&ja^gsTM#N4;`sYDo?gaFqfg|exO zKJPu;D6R%iYiEf>tXTN3Ecahh!$yS6wxU%Navb!+U9{HTC`%$R4R+FnlguNdu-03Y z26`tJH0on$zeCVEpt?WwD%I?zMJ8|uCm#%s+&eBlOaiCMu^(&;UP2b!LqvJ?BCqvW zWy4_H)%^$zg(ysY_rDeV4RrpAyO(G4$$y zQWUlLY1fNaFNW#Jc(|_Ld zCGFr@wGrRhOfH%S*}(u0*9O6U14+On2p6dwD7 z9?PP+a3aJ;|M4h^YDG4!5Xgzp~4p5OsuZLxUrbmn7?+nCTDx_g<>1IXW z-tTMt>iI7x{g-I#Y`B*T3%hq2I!773WI^p24VHxUQ+IzeQuFm|F9xZXWDcE76)5o3 z^hLEq4s_btMa~`qrJh8t=tY|KmF{@MY6%;%P=c7&Rx1O2sXvuvhzFxqz~27lQVuT? ziO{-*t~XMD7M)@i%?leS!%~q-r9>E@X(N4tV&QynNJ`1l-}rp2bm9^&sHr7A%s4|=>exsRlAwi)y?5JZ#uhb?n2FPA`Es+b z1*{hTN1F}qe%iEAI`v`1G6v)Bj1L+yiWjh@P+^V8K=-+U`T-HQH!e=$oe>`cHpi7c zDGsJuQ$hAdw^CT>vkKz<1r)fLo9hlq&yy7@eVMIiz2(tt{Al;WkVh*;NZX5aH8RuX%HW(7H-W&31x2#K z(9b_3vWzOU+Sas3?EvfQxmzkSn>ks)B93Dp`G)IrCzTy&#R4T z&xC9s&lN5wm1y}XZ-PQ;`tDQVWA|*ct~Bh*=#JA#V3bmQbF&i_ z`S(}y1K%%|ZEhe}jT@b$4GLbsmfL%=XB{Vxz^C1M`z}6Mj=n@MAE^6&=~w09F`euh zK}p@1e!Zf5HR|f3LOSturnLVVkNT{f{zwluoFI%+&`lDk;c1MLzwhqjFG0f3}3ZqDXkQxIw{H&k>QMIAqvnCwJ7SQ zP)zPQUQgOlwo`#^C`Y10VZ{bryZfmLtLBauK2TL;O=c3qB(~s0lDAEp7sTM7( zcDOnO)gitWJHaeqDjt@N^u^;^7=hB{6PtDwLr3CPG|rWs+!yMK`R7WF74m*kdvj&Z z%TPeruvU0Rm3C>QzI|j!AQ@V|h0k~*_in@&*L8WoGP*Z=x~6D0lRAs?#p`XJT$CWv z%#t#jr@+_PQTM0lrjT(I5{z7V4FlVeIljB0D8Vl5m6)l_Hcg1C{IkcilyU>WE(q6_LNC$K^Uf0m6Wr;zNeJ(V~@Wv53de$koo5MKA{l*4XevLQPc zR4IBox1IVF)}TycHqFhTIb+IAW(m#=%GRpvKn6dHN{47b4b)rxAvn=RGA@5 zQpF6h8ARQn8_Jar#JfyO2Rs_Uwg>0-WF_CIh5+bOb(5eJO-q#iAwZzE66ZpO6}d1- zOuA%RAD#&6yU3tqocbl# z5-)bzt6Fa6mMfvPI{R+!?XUI;Df-W`L$i#)hsI`U$m20a*L4dT92!lHg$Hn@tq~a2 zMGiLE(CJN~c*2Br@5n}VolSVC)VAG1vP?`yuN$i+Z1*Ve80ko_q<&ahexd~x$!x?~ zn1P2^B1+Ag$3(g#sw-HrNZN48%r+FHw9ormM=_>2#c+k7ZT&jPwlGA}a#)+>NnRl- zcDtQ;kh;)9d~Q4h(^Ap$-C;>b%tvhHWM*>)DgjD589`HYk{INsC$nAP0VI6Tf!LkA ziU<;{`GE6oa4=D_Mqz@Q@`e)~D#*#F9SGLMV1sU(K#(qeoR#{rNBr6sM>yCJC8_ho zh<^Nh!4%Ozh~?24VD{@WS7G>8)ARAJ5Pq;W;a5!!0B3aNB}&rhU2$XqRp0$dS4p^b zE6OcI8u0#kZ!>!A{mn0)$|mUgO5cs+CGdS=RTi~^#cBK?I6^|WvAYLBk7feU8xP0uV*!CNcT3NThG3r zyZC2R3~#t_;)#Rc%zMB2W3*`Jq@pfioI>2;12MLzVC8C7SEU(c#`1;CG~Ch9m!EE3 zmfcWI29eApMX{1`+Q5XhlJBdK$wcqP^WFUjDd#pVbu%lbkaV}UT11iKNhd)gx$;Yx zd^&KB%qX<9?e}mF>oorLF}6s9HfGRyD1P2&c&9JTZ$Ks((sPimN60BZ&k*b?aYq^` z2G=w;TFhD!0IJYDr5?oyMIt{s@Z(SyClTg{89yhH(rS*i1FPhxT6|qDm5riIXMV5Q zoS$fGA*`Z&5~mxq@hFRsUI@$0p*qKguIK#yN%F(KVjER{XeSZUm%|Y}KttKjM4C7Q zXGa1kO@6_>U+Z}Pa_RFa&#)bl-Q^{wHx;PN=yZlpG-@z>W{j=}z;&}Ukzr|v0MKCq zPiu!c7nqUfq)2jryV`mM#jN1KW4!`IlfXp!?rZ$GlqfcoQf{`8r(=nVi_V|9fZLVW z85SbS@zz<1yQZ4~fC1QHO!$i!z z@4eEl)l|&xUkN?_R_~W+GT1Y zy^{N@*C<3*p*e53_WHqO;H^(92TjQs=Q{=Xf{TGW+i}=Hu0d^v$<)oQqwDM@; z9lE&rEt;bt1C60c_TQgd&m1p-Ysn*Gb=B$tXdm`c`WmB=I5WJ!M4(r80 z+G$xwR;Jv0BmkM3UP5nC41B&mZZ6l8)*q65gWSJLMW#aqlXOKl1jV?QW6p({+ z@8GB8$BDTO=>sY$N1h@<+%0`xwSmqzibNuhf%gLy*@s9$P& zHxEt>%>M(?B*GzT8I5+Y`4jkX`-%YUh)$ejjeD92KQ?XH(`c-jpo~N5lx31=)$JLK zt!`iW|8@9%tMGPh@+0{^w;a!AjPP8KR#u_dfR5!WzP)Yhf}1QF zP{o||!1Lv_ao3{17~Mx?Lb09#mwo*pV3(SRpDnO*CYS4x2#X|ep5-fL#}H-WhOVsF z*QLFaD;8Q)ZOSHRb?I{d4a(Nq696Q_uK(&F*h@DtD3!*@}+pN$ZvA z2QhEVspkgvt+Wtxml{oEV^|C=jR3d(qbuob_o!NK?XUp`c_t4VWe+hYS5Hi@=l#1V zueBm|(~EP`|K3vXq`?&;J8mFZb*(i5>A~ZxO*r|7S{`i@6i4}l(wx}Cq zN6y`N{p82TM&=Y-$yS!C8ph@lF5FO#hK!Lj-eQ=Q#c+#4s0j||=3cAcviH!Ph@14( zR}{~=zAN|@jrLYDP=Zmnu@rnr@I%>*X3DDnBUcz=9aOWb-?NFH{oGtfp*CS;+k|oA z7yvm`o#?6N$0KY1+S9}BOod?Dj~H?+D6%W{@e&rgW$9O&y&3p=tKS2MRbie zoumzEeIO1`)sc4ZeH2K3dhzbPvmmmjvz4&>8SwvD&_K_vkkDh!NWj+918(L zjw0^xE9o5+-`U!pe~sbYkc;Y5#Q?$h7e~O+RalmdHJ&~nHl~RK3L|eFa8#j=|GwRi zRDGJN=&cZCY-k5|6hH;sAW8s^BavuI!Ar~6(lF`?iRIPL{c!Ct9MgP&894gAnA(YP zaGm%2lM^Cbb+D`)KOR5Gl>SjEAw5Dfv|v6qXv`pj3&_pxAsAryF7HENUFc?5!oy@0 z*6wHnq$+&><+0~{8$Fc1w#oL%S;g}c`%WZ+BCuMVGw*TIz_65k;Z!cDs(T0f##--k zc0io;%V56o!9VT@L14c16OHP!4O}%yZ!L5+r?)p!NqB&+)c6f5(D4(raAj0~dq@ZE zuzHVAPdVzwrf$?ZW~i2B;LE%`IlOW5-`pqrMad(ltw+u4cn#+1ltOe2h2doMS{gpF zl(15lh%%(1;jL`0UD!9e`#;2pS{K2(qaVcjD5*Pp%uIVQ&g6gqb$BGU^4hmUMSJ1q za%*3jwQ?jcbGQdT|0fY-#ECcJP*N0PG=`#qPX0(eIMd^hj^iI}XjM@|deGQXag)rx zBK!mium469HxkKSVNL-kDv>JRSRcnxa;hWQ7|VpHp01{iOcc<0R!J5BOoqe6`Vnrz z{lg#>&P_JS1U?s|7GVn`_!!ywL2#44@>kIgM`IUS^sJTffji&^1)+v%=z9D8O*`h@ zz{#0y);CpweXa|A_TzU32csm0Njz{eApvUFdK~DOe%)OxEbIBq5=JK3$W|Lr`_9Iq z`Q@MLteh>!)NNknv5BB!zP>jQIGUJ8HyXT+wlm*_t0G*$cVw+#A+zJFa$)dC`53Qz zwCR)`_Uby@6-gjXWHdh}|A~?eal8%B5!spG*(*fJ)G_lMos)R8;g%9(WXbY3TEB^I ztp6#*gSjjAf&nOQoCF!P=3Ef0^Fve>N|RD35&f{5!Goa#-kQ2ciQV(y#O-2d#KvCg z=Yge6mfj+T0e%{GTar9aS~Mv9Aj}qlvM6~iiXDR!bDn@-w8tmWC^%$b8u>${O)HL_ zjCC-*h*@H$zxb`n4xk8g6#p12F|j!FD;=ojcCkZDv(|9qF;bHDUXFK(gg@B z%UgS!(K{fT5Rge*0VkWz6{C90)3mm{>=qFVH;S*H{PK~3Y}=TD2=gRp)T!Xl4wS`# zOwIY<^I z!jjk9B@_&0y{M|Gkny(iIrrz^=GF=q3T0-yJx=1BS1KG~>P}^flGULcKM&2@jbJVX zyS2}P-wTLcUVDNg7E#KXrTldCTN$t!m$7DZksFXGL-{j$0AUvtOMO{T zxBscu1ZbxWqK$%=^=GRfQ^d_r(_qk0#*ww^gFXlDDk6@J5GPmwRDY%mqO+hC|2g1xV*FN(t6BoV*b#$c4jCg4QAu)&O>*GtSv3iMZ!(&!&+2t)E94?Ul ze2%z=#Eu4Vn=P;XZ0PrwsowH~K_7xVoxD0lpN$~S2@SL5%_5dkO3ycVI)de}iNrcl ziD$ATcWfcszI;P0wotMaTEbs@$ggE(a|Vh6;20d&H0LyMy4(^{j2#@m*Zr^ou(9Tj za0{Iu5)!1nfOV0Kf?0_%`9syYJy;zILh(fH1#Ru+&^0x>`)_1|d5|tVzDs(lHMd@% zHwjCxhRto9!S~ zLr+9qPeDtTs_n$vCKi)i=(-Pnpe;C;Gc8MC_sp~B_T?L`Cyo4X_KYE;I29yzDu0Qr zk@Kj&lD0HOXbSGfXq2w;YaeYiXU??oB>4X&E^^@$DM=ZAswa8IK#aOu8+thRoM!rs zI07oLRjHK8dSf0edoVF`@ol6gDWz-dX{6jhdOtnoIzzS8tPardZ8JudjBr=jVS|u2 zYRA+SgM{9pnobZIjx5p-^!CHLd;LkS2G&DGGq-_a2Z(IflRx3YN=mGbj6=!KD`;FA z!?rkbS7ojf-PZT1O0csr6CWMlMg*F`^G*Fx_ItfBuJZ#a?`?>HWGV<0qCQFh1SZ!(HB{_r(p ztZ_l?4e-#zR0x$uIb3!ZC!duV3b-!6h$Q!DN!<;NEr+-hbe0v|7k(Oz;i&Az;PVbL+7m}v zr9hb2{csbSg4iQEV-To}$O4c*AiJ=KThv;hlUZzN`-jRkbc^?&T;QlBifH@-~Z>98eai^?lUIu zkNm<<-DIh!2D`+a>4X+@&AylJh*CK<|0M^>jryRdL=uavJrsFn?bn1_snhJ5@wW_a8zznQ)0tH`379zLVh*r^bE|DllNPQ=l{B?<=Gzc0&o zuf`-mVnmYAAh1RHjmd1#)^f$6@#-)C1v{@Gt!hhaW6m6(p3`lzJ9A#Cc`3XYL={7s zkyxXfB1J?`MOE7E?lYO&tzw3X57s(C?T2Jgs?BWfn71@SBA82i2nabDO~`w~Z-@w0 zihFNif9QhYtw8d#M4!=c7|)hiMHUWiteb2(dRb|9m^x z6Fl;%A*l*B-e5=K85}@pJ(|^FmXj1lR#fBpwt?YJ$>8lkwP%;?t#bp@C=X})EH<+D zpkA@Yij*R4#BOj^ zg!fm5g2P0LMj0M0+tW|uTN5HUyhE#7TyEj5gWhHag*pu{C?jPw{Y$mT=FE|j1?)n# z9aSvkk`>gST-YOxM_onWz*0k z>zNp51LiPOY~(ACcA%=Yn$cJ(@xqO?%~WBQ=TqE<{b+OliP$dwWe2ny=$g9s<+H7S zFs37=__Q$AZ1&WXOL7l7$%7&x(!M&NstP_LUcVNTw`OyA0gl54j>Cl`--tjV(di}_ zdvo$aqKzJ!$hC9XJV+GzfFh23L(;hdTtDM$vLCjYJhglYclBm3E_9UJlTO+H5^!SJtsOM^U{;8hX_tdt9(gAV zth99)c(;bTiJ{EQetUx*TTb43Q^1NkGDh|~@4b0GQnT9Q!DBq6r=qd7?!P|Rv|)a{#m4w zwnBJT5`?4ACV?J5OOI~&$>TqNAN4#n*CVYyFF zIevriVPWX56O$j$`FwFE&IO5sK0W$abC3CBtDA3O(i>HvZks1T8$&3EJhOm&>Fy7< zELpx^JOx|j5CEI_E2##|IAb#cT8jt~h{6+hPqBat=xbRe8f%Ola}OSizs(eDN28x1 zXJN|rlq(a)1!hJxWdV`d8lr>vKql*Rka$GL$rBy4Dq?&qA@6}0>PQnqU#89;d@~hp zDg({u+>+-JJwv0;0qH%A@nrSz`uV{a4uJDpVUR+O|BU^_{shcgC0@(oy__Gy?83b- z7jv;u488AtrxY$V07jICbf$ED2ClnYlm!;_{E_}^Ov!G{$4X8=dHa#yIM4KLWj0uHW2?MDb+wlt2~)Lw$;uy2 zsXd>-e1F;t@*n%5(|B7$Nq#+4fpSl$7s%gbvT3ul7zbvxXf|H$5-WRhbtska?0Z_cP<0f)siubS1|{o z)U`_HW~zis51S&r_rlKEs}~n?eR-UI$d^V~nKlub1QZh=W%VRFs2N#mI0+(!ZX@tb z1~fvNcR{!Ney!s`y$|UJ1fkXDc%SDCr)5QXlxA7s-KRgt)Gi7`15Xq?p3bSpUU$AF z(gyG>EbdFKo*r;iNUB@$&o?t$qBB$hztP^V8ZdO%h@RHWU>_vu!b6Z0+^ zAURO9wFLg1kYAqxLE2HLkj{`zy$_=WIwEE|fJ_tewZ+3nEjxfNR#unxjcgWXqq*>Y zuPnd>=Q0Do?54GLgzVzP*H03`t<7!9+z|{Mc=%Fja6a3ja*rOSSCB;TbZtgKjoS;F zcQTj&)oR_Zmc-V3cJ|QP9yt(~pn&j`gYz?KRr@CnlJ=LD!1nyXu~z$78n}8`mwZQ| zNFrr?g?~`X_^t+xL3vf~Z?4IY7U&UI5J`JCZk5gN4Ntm^@) zRTf+=2ohLKE=!0k)Ob#H4b~$-NS0#BoB24HN0L_O_rysJOZnL^&&3Eh*Gs2!|F6P( zZ_X~^tQjgVJATyqo6BadVw{ZxPfyr;KeST=4q0nc@0tx#%Q^~l5fO`AGEjKKV1KV9 zd7dFj7P-I@N>0^>`ZH!aiY<_k^g}QVT6a|Mu+1 zH!-~!TC{k`BBOP(SK=wJO-2GHS)xB(uB^Xt{^?0JE3}7_x$@|JQO6{6jQXjwl{`Z_ zfjV>L;;A>cx&pfXm{1IzTU_gB&hfPeLrmjnkH`v^!<6&3owmy_$ICtvjSfKvNtmb} z&)@$Ms?&ykMytH^;IR*M*^E#zD$hG4p69}r_Z|r=o=>MvD@GHpo3GTvP->zO9)dx| z#f%3XTVu3{jER*JTp(5_eh_Ud$;=m;lcIgS^fAh035XTXj5Ly3j}rO9L_ljnWjgR5Gs!e_BDvjCwjPYNv*1=S zs`q}XYQB5*tw%aW(YfSEz;WRDR$Ob$6hN^Qe*uI5gj)#`*jIh6#=o%dTHOlv#=dqT zG_Y?FmbkOj!H;}!_LmCD));h`jPV-%ti!^ynXmVdxuBBO4z$p_#MsCG4Uz zBc>9yaxl%?S6cS)(>2d;c8eN$F_!gO_OmvoB$;Ll!odO}vG;_GD4eo#rq5AbSZIxB zTRYm<=N#N6lN1F3hdAB9vGGnz0@JL$kTkoJShH4|_H82-b1!^`#?m$2XS>z7^ z3gh$tlsGT`QO0VV5esQ9rlUbac(KdWLcodse43b>x^?S%W-O|&Fq%X(yi0}{PcmE4 zSfez{|4AIV;s7#D#n8j`FkDo{>xYgwadMs@Kk=`L{N)u^!daQ=oa=0Tr$9~M>8mT;3s34+2zD2bW+~QWM4UBf}!Wjgm3bkRMBwkXU6Jd~Jnt$1xTuyhX%QP@0 zBU6IbrVy0SWwZ9)~YMIGUrx!l?ywrE) z<1>YN?d{i0B0ud5px@I5=qmSCc}i`rxrz3_`>;hyOv6Z-au3 z6RcK(I96nfk-}dnM-#2-P$#*B=W@&5pFC*l+U literal 4754 zcmeHLe^gWF8NLAlCq;JLCQPYpT5Y>ZL#+jDhpAMhj$>I{)5y=DSW$sSp*RHLqRx6+ zS~;gXg;hJ_*5hnRlc4+(1ViUR?9j&JS%4TvI3Qw!7&L}Je%x>G_vOajQ6@Z|lT_j#W8{Vsg?+c%QK<}8>)QB>H5*Vn#9Q37}?pk@cdOJ>nm+bL>Z+J?1j zHkAq{x&lu=l~#M{KKk9gPcC0MU6himDm1jPKRZ7@QTrS9i4`v{zBjYCbywM6kGztv zQ-7b=e@vljH=!j5$;)$(U;djC{7T}|G^ZOwyY}ymfwyZ%>yhN0@$xpK(B;$yOfHi# zwKrv}&(|s#^s-(#{4!Dy>z8KD_>=o<~CobbGWW+(ejXx5bExP*&^M^>GD$t zv{$4<3wEqN-4eqKCQl2g;Xw#Y6(AXK)#a923Tiu!?aL83LTB7W;6=|gQ8TshY zP*ta^f_d7YAKadwUmd^C(t4|s@D|N;^R79=a&48$_H#iEB*Hb4YfO z*eN{AlSPb5xSh5c@o;rszP5j_vdtugAcl6~@$#fx;<9nMvlgv$3$x!hEGoM^Kd<~a zJ8x_fEr$sg?OETjK&ETBjoyowXZ4D;ZtCCk*#ah6^NF8gjA2{|%au27Y&;n7GYG-y9G6xo}2iAAqrV`fA-zLNvDkP)Qz z-fc`cSwHmGT-Wg|h$XH(-;g+k#22)fOPg}@wyV!_Rs>l-ptTYD=vDSMuMDOwZ2_>nLZ{zI~u_Te^QF)5g z4)I4tEIh`W9Qqgdf?xvqlOxHpnj1NWlAVtPBwF!a<=wJ~(JG!r>j|0Xp!lQffvj){ zV!fZMkPgCRvS<0R2v*7s&N?J3B|?Cgrx1^u_aG}^rNHgxwNf4g5E-%kkLHaPz1>6~ z;RzFn;r?aO7<{LhnjPy|fMmO7f$tkR)=+nPDyKuj+*6fhMHg{_5;E=ZU|0t?CiOpj zDr8dcv>yeHB#Y0P=w{XUz~<%C)tc#)xcQ-AjP$@zJ!*#c3}Z$IdJ>uYU#zb9NMj$@ zY{@-ya{Oh0ta}yixsrC(mx|BG=jG+qjzXF2{<$8}32WKVyV>?r3F*(n)J7bHS}(P$ za&ylNnGq9RdUGUNJ2f?5t2CFlw6;zwG|WOcsa-WvugIq0(M{cOX7i+kcEw-Wo@g*+ zI8y*czEqYtxkBiAD=MZACb0OIK)lIK9G3vl9mk0XvC&olEb!M0md}4bs|N;z`b@zj zo7#`6-eF8?^nw1HJRnOeCMIGQ*`|9Mv%@>Cq-cIRvhszaOWjwvi zMM*~6!wT)lB2?NpexPpO4|T&k+HTdO{;%LcX?=f*(`7_~jcK?q^mF@#)8=w=6b5en zV%eoV0Ii#?veP@^WGpAaFRTv2Pp7+eC~_p>!LX3o`s=9|YPQg*3Kdis8#_C@r7o)x z)VNmiBmk2D_{>N`QB&pvEW=%M^rUnPzup>vy`J2=RA{*_>}D9j#n?$XUut9lI??0M zM7Px0U3xcn(M7vV0ws+~IX{>=S`VZ@++r^lnFv+nIxpXNHBBa-%Jg zQ?~GF^)(x@QYS@W&~;Y-9vymi7FZ3EdsFq(ksNpQ@_$mY43}K#RT7Ck9YLGY>sUzj zdZ@R{`&fBlWH1;jsXQgw7L^Ro%AfpQjK-ggsq28Lh|E$tF>@ToyaA(#ggf%|hhL6< zs$lw;oHL={?}=KK{`|hr>nfjxb|XJHfS$P++n}MldOIGb>}kqefs-Z26Z7`b+LUZy z@-Z7m2qBXQ-zml4y|mMvs<*)#QC?Is#@;?9{mA7CWRxBdd`R+Cj6v8yJZiuyCo(vD zlh>*6ET6UsKohLXcXo}c8M|x%@_bnD(?%+8qr#l_@xZ{cEk2bl5+e+!5->FOMZa|GyGU bC+91FEXt3&@e}x86SZO88*5cbIS2m*4BS_t diff --git a/native/windows/Cargo.toml b/native/windows/Cargo.toml index a76ecf8..0b472b1 100644 --- a/native/windows/Cargo.toml +++ b/native/windows/Cargo.toml @@ -33,6 +33,10 @@ image-extras = ["bloom-shared/image-extras"] # in native/macos/Cargo.toml. [profile.release] panic = "abort" +# Line tables in the staticlib objects so `perry compile --debug-symbols` +# can emit a usable PDB for crash triage (negligible runtime cost; the +# symbols stay out of the exe unless /DEBUG is requested at link). +debug = "line-tables-only" [dependencies] bloom-shared = { path = "../shared", default-features = false, features = ["mp3"] } diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index c3cf180..eee9783 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -446,6 +446,20 @@ unsafe fn init_engine_for_hwnd( ..Default::default() })).expect("No adapter found"); + { + // One line of boot truth: which GPU we got and whether the + // features that silently reshape the frame (HW ray query for + // GI, timestamps for the profiler) are available on it. + let info = adapter.get_info(); + eprintln!( + "bloom: adapter '{}' ({:?}), ray_query={}, timestamps={}", + info.name, + info.backend, + adapter.features().contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY), + adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY), + ); + } + // Ticket 007b: HW ray-query via DXR 1.1 / VK_KHR_ray_query. let supported = adapter.features(); let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI") From 61c8f9222c7d20456056448e3853e27f3de9d908 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 10:01:29 +0200 Subject: [PATCH 02/15] =?UTF-8?q?fix(render):=20round-2=20visual=20correct?= =?UTF-8?q?ness=20=E2=80=94=20cutout=20transmission,=202D=20gamma,=20sharp?= =?UTF-8?q?en=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fs_main_scene's foliage backlit-transmission block was pasted TWICE (verbatim, 1.7x intended strength) and ran unshadowed — a canopy in another tree's shadow still glowed at full strength. Dormant in the shooter today (no MASK assets drawn) but a landmine for any cutout foliage. De-duplicated + multiplied by the sun shadow factor. (audit F3 side-finding) - 2D colors (shapes, textures, text) are now sRGB-decoded to linear before hitting the sRGB swapchain view — they were double-encoded: washed-bright HUD midtones, gamma-skewed glyph AA. Deliberately scoped to the 2D pass via a separate color_to_f32_srgb helper: the 3D immediate batch and model tints keep their historical raw /255 interpretation, because flipping those would silently re-tint every existing drawCube/drawModel call in shipped games. shapes_2d golden regenerated (midtones darken to their authored values); all 3D goldens unchanged. (audit F5) - New FFI `setSharpenStrength` / bloom_set_sharpen_strength: the composite unsharp mask was hardcoded 0.8 with no runtime control while visibly haloing silhouettes at 4K output. Default unchanged. (audit F8; manifest entry included — Perry silently no-ops undeclared natives) - docs/tickets.md: EN-021 (SSR+IBL exclusive ownership — deferred because a correct fix needs the env-miss fallback in the SSR pass, not just the ibl_spec complement) and EN-022 (motion vectors for material-system draws — the in-motion quality project). --- docs/tickets.md | 51 +++++++++++++++++++++ native/shared/src/ffi_core/visual.rs | 8 ++++ native/shared/src/renderer/draw2d.rs | 6 +-- native/shared/src/renderer/mod.rs | 38 +++++++++++++-- native/shared/src/renderer/shaders/core.rs | 15 ++---- native/shared/src/text_renderer.rs | 18 +++++--- native/shared/tests/golden/shapes_2d.png | Bin 3250 -> 3346 bytes package.json | 7 +++ src/core/index.ts | 10 ++++ 9 files changed, 129 insertions(+), 24 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index e227841..cefae79 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -689,3 +689,54 @@ symbolized stack; fix then. (profiler overlay/history strings are the heaviest string traffic in the crashing runs), or an engine-side heap read overrun in a small shared helper. Audit report: `shooter/docs/audit-round2.md` finding F1. + +## EN-021 — SSR + IBL specular exclusive ownership 🟡 + +**Why.** Round-2 audit (B): compose is `hdr + ssr` and `fs_main_scene` +already adds IBL specular into hdr for metals — pixels with an SSR hit +double-count specular for roughness ≈0.05–0.85, worst for metals at +r≈0.55–0.75 (in the shooter: alien carapaces / weapon per their glTF +factors). Dielectrics are largely starved of IBL spec by design and are +fine; the shooter's custom world materials never call ibl() and are +unaffected. + +**Why not a one-liner.** Scaling `ibl_spec` by the complement of the SSR +fade must be PAIRED with an env fallback on SSR miss (today miss = black, +ssgi.rs:1495) or off-screen-reflection pixels lose their specular +entirely — a worse artifact than the double-count. The fallback needs the +env cubemap bound into the SSR pass (bind-group layout change). + +**Sketch.** (1) Bind env_tex into the SSR march; on miss return +`sample_env(r, r*max_mip) * fresnel * roughness_fade * strength` instead +of black. (2) In `fs_main_scene`, scale `ibl_spec` by +`1 − (1 − smoothstep(0.5, 0.85, r)) * ssr_strength` (the exact +complement of the SSR shader's own fade). (3) Regenerate goldens; verify +with the audit's metal-ROI protocol (on-hit vs panned-off luma converge +<5%). + +**Interim calibration option:** `set_ssr_strength(0.25–0.35)` halves the +overlap engine-wide with zero shader work. + +## EN-022 — Motion vectors for material-system draws 🔴 + +**Why.** Round-2 audit (F8): every material-path draw writes velocity = 0 +(terrain/tree/grass/building — the whole static world plus wind sway), +so TAA's motion adaptation (`post.rs` motion_alpha ramp) never engages +for the world: camera translation is covered only by depth reprojection +and object sway not at all. This is the primary mechanism behind TSR 0.5 +shimmer on thin grass + sway ghosting, and it blocks any future +motion-blur quality on the world. + +**Scope.** Per-draw previous-frame model matrix (the per-draw UBO slot +already carries the current one; the shadow path already keeps a CPU +copy in `MaterialDrawCommand.model`), previous view-proj in PerView (the +core path has it), and a velocity write in the material ABI's OpaqueOut +path — plus the wind displacement evaluated at previous-frame time in +foliage vertex shaders so sway produces real motion vectors. Roughly: +ABI struct + material_system plumbing + 4 world materials + goldens. + +**Acceptance.** Strafe at the audit's S7 pose: thin-grass shimmer index +(frame-diff metric) drops materially; wind sway no longer ghosts; +`post.rs` motion clamp engages on world pixels (debug: motion_alpha +visualization). + diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index a2218b8..5018d8c 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -218,6 +218,14 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_sharpen_strength [round-2 audit F8] + #[no_mangle] + pub extern "C" fn bloom_set_sharpen_strength(strength: f64) { + $crate::ffi::guard("bloom_set_sharpen_strength", move || { + engine().renderer.set_sharpen_strength(strength as f32); + }) + } + // bloom_set_sun_shafts [source: macos] #[no_mangle] pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) { diff --git a/native/shared/src/renderer/draw2d.rs b/native/shared/src/renderer/draw2d.rs index c803936..1ce8850 100644 --- a/native/shared/src/renderer/draw2d.rs +++ b/native/shared/src/renderer/draw2d.rs @@ -10,7 +10,7 @@ impl Renderer { pub fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; let (x, y, w, h) = (x as f32, y as f32, w as f32, h as f32); @@ -32,7 +32,7 @@ impl Renderer { pub fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let dx = (x2 - x1) as f32; let dy = (y2 - y1) as f32; let len = (dx * dx + dy * dy).sqrt(); @@ -53,7 +53,7 @@ impl Renderer { pub fn draw_circle(&mut self, cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let segments = 36u32; let base = self.vertices_2d.len() as u32; let (cx, cy, radius) = (cx as f32, cy as f32, radius as f32); diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..bf63ccb 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -656,6 +656,16 @@ struct CachedModelDraw { // Renderer // ============================================================ +/// 0-255 sRGB channel → linear f32. The 2D pass renders into an sRGB +/// swapchain view whose hardware encode expects LINEAR shader output; +/// passing the sRGB byte value straight through double-encoded every 2D +/// color (washed-bright HUD, gamma-skewed AA edges — round-2 audit F5). +/// Alpha stays linear by definition and is NOT decoded. +pub(crate) fn srgb_u8_to_linear(c: f64) -> f32 { + let c = (c / 255.0).clamp(0.0, 1.0); + (if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }) as f32 +} + pub struct Renderer { pub device: wgpu::Device, pub queue: wgpu::Queue, @@ -7851,6 +7861,14 @@ impl Renderer { self.grain_strength = strength.max(0.0); } + /// Composite-pass unsharp mask. Default 0.8; 0 disables the 4 extra + /// HDR taps + extra tonemap entirely. Round-2 audit: this was + /// hardcoded with no runtime control while visibly haloing + /// silhouettes at 4K output (F3/F8). + pub fn set_sharpen_strength(&mut self, strength: f32) { + self.sharpen_strength = strength.max(0.0); + } + /// Sun shaft (screen-space god ray) strength. 0 (default) = off. /// 0.4 = subtle haze, 1.0+ = obvious cinematic shafts. The /// shafts are sampled from the depth buffer along a screen-space @@ -9841,14 +9859,24 @@ impl Renderer { } } + /// Raw 0-255 → 0-1, no gamma. Used by the 3D immediate batch and + /// model tints, whose values feed the linear HDR pipeline directly — + /// changing their interpretation would silently re-tint every + /// existing drawCube/drawModel call in shipped games. fn color_to_f32(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32] } + /// 2D variant: sRGB-decodes rgb so the sRGB swapchain view's hardware + /// encode does not double-encode (see `srgb_u8_to_linear`). + fn color_to_f32_srgb(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { + [srgb_u8_to_linear(r), srgb_u8_to_linear(g), srgb_u8_to_linear(b), (a / 255.0) as f32] + } + pub fn draw_triangle(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; self.vertices_2d.push(Vertex2D { position: [x1 as f32, y1 as f32], uv: [0.0, 0.0], color }); @@ -9860,7 +9888,7 @@ impl Renderer { pub fn draw_poly(&mut self, cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let n = sides as u32; if n < 3 { return; } let base = self.vertices_2d.len() as u32; @@ -9905,7 +9933,7 @@ impl Renderer { pub fn draw_texture(&mut self, bind_group_idx: u32, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); self.draw_textured_quad(x as f32, y as f32, tw as f32, th as f32, 0.0, 0.0, 1.0, 1.0, color, bind_group_idx); } @@ -9917,7 +9945,7 @@ impl Renderer { ) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; let u1 = (src_x + src_w) as f32 / tw as f32; @@ -9934,7 +9962,7 @@ impl Renderer { ) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; let u1 = (src_x + src_w) as f32 / tw as f32; diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 2c84307..ecb3407 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -849,18 +849,13 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { // (the bright rim glow when the sun is behind a tree). Gated on the // alpha-cutoff so only cut-out foliage materials get it; opaque surfaces // (cutoff == 0) are unaffected. Matches shade_foliage's transmission term. + // Round-2 audit: this block was pasted TWICE (1.7x strength) and ran + // unshadowed — a canopy in another tree's shadow still glowed at full + // transmission. De-duplicated and multiplied by the sun shadow factor. if (alpha_cutoff > 0.0) { let trans = pow(max(dot(v, -legacy_dir), 0.0), 3.0) * 0.85; - lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans; - } - - // Foliage backlit transmission — sun bleeding THROUGH alpha-cut leaf cards - // (the bright rim glow when the sun is behind a tree). Gated on the - // alpha-cutoff so only cut-out foliage materials get it; opaque surfaces - // (cutoff == 0) are unaffected. Matches shade_foliage's transmission term. - if (alpha_cutoff > 0.0) { - let trans = pow(max(dot(v, -legacy_dir), 0.0), 3.0) * 0.85; - lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans; + lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans + * direct_shadow; } let dir_count = u32(lighting.dir_light_count.x); diff --git a/native/shared/src/text_renderer.rs b/native/shared/src/text_renderer.rs index 49b9f34..3affa0a 100644 --- a/native/shared/src/text_renderer.rs +++ b/native/shared/src/text_renderer.rs @@ -270,10 +270,13 @@ impl TextRenderer { None => return, }; + // sRGB-decode to linear like every other 2D vertex color — the + // swapchain view's hardware encode expects linear shader output + // (see renderer::srgb_u8_to_linear). let color = [ - (r / 255.0) as f32, - (g / 255.0) as f32, - (b / 255.0) as f32, + crate::renderer::srgb_u8_to_linear(r), + crate::renderer::srgb_u8_to_linear(g), + crate::renderer::srgb_u8_to_linear(b), (a / 255.0) as f32, ]; @@ -419,10 +422,13 @@ impl TextRenderer { None => return, }; + // sRGB-decode to linear like every other 2D vertex color — the + // swapchain view's hardware encode expects linear shader output + // (see renderer::srgb_u8_to_linear). let color = [ - (r / 255.0) as f32, - (g / 255.0) as f32, - (b / 255.0) as f32, + crate::renderer::srgb_u8_to_linear(r), + crate::renderer::srgb_u8_to_linear(g), + crate::renderer::srgb_u8_to_linear(b), (a / 255.0) as f32, ]; diff --git a/native/shared/tests/golden/shapes_2d.png b/native/shared/tests/golden/shapes_2d.png index 1bb3aeed3836d7eb58f7cd754399d03bf74de6e7..4ae32158da478ec5642f5837e1b97b649e4c1ee9 100644 GIT binary patch literal 3346 zcmeHKaZr~Ggu;#gs2wIe^Mfm;!mkWJ_e0;^?`he|si-hEo@(e>}MI<1mUwI;^{6R$V582le?KbhNI+_5Q2 zJ0DbTG#zn4-2digNuYqvRqOo?FZ8H2!{)in@M-@QbHQK~W?x>v(&W?WZ&V7w(toC0 zY_P6jbp>CmoYkcUPhUM`9U@Esbmmus!kyM#dHsl~us|`bfz2anT zU=leUE}A?|lhq>JW)VCYQL6~mh^PX$q7Z{jFQ8}AJaqdJ-FSxCB)YYTZqev=Ke^pY zZpxM9pkvQg3Z?Xa;WD~G z7b?r+4sh#mG~xUqixZm#oFKP>SIBK8xtU7$iF32CgZPpP#CP#|U3o=zSSEu^VsQ9s z7W>n`?bSY&ul0A%O;dHDe(3uz9FqYd>F)`m0aU ziUvb>n=36V-=9up0l`Hfc=oU;ZwxX#D1&MY##9=Q=>f5QciJkALY3_{%n+3v8=C>l za$&!>W-;e?o)2F7 zh=Elz+8nu%jH;M?8k7Yi;6rg`X}q|tG#2|<D)$6w$6aU>P->J3{M3P;tbzeG+mEN3VH=_6ldG70Z1hU{pC&AQB8vnHcF9G{cy{SS@jwhaQCCe-=X; zeJ!Lvo0j^mgK5XxFpZ*Q@HP25Fhc*jxWpBOCZDpmHzVJ#iS|1B^>4ZPABfN>=)?TQ zZUp2I(9NkZtx9_McPy_boVNyb!acYXD4|W|cpNgRliy2yrhwl5BqqrON>RWhX~o$6 zW{i#arE(#O;xS`9UdQ{Caw<%@5bRXtgs_4Tssactgs>|#S5OXN;cX+8P#)}XnCBT( zX>P|Y=-7mgyb(_gCW<++iTq)ed1g_;na=PBB2`gJWA^t3Aj{{|U87+cXv z>JADD#Vm$0g(1$cYJxcrE+&?)kyRoy-taR{ zt~}-$Sr$$>#1c(A)%(b}v;GBde`XCD)s44h+~JvZwGo=5p_ZX-Q!79wpMjzlV=`ck zT9@wx`F5{zv!n#`?9n27US1bW5*OyPg#+TkhUeJANOn^RyZY+CL{FH`xKvxX|P&Liny8QyU7i;2eiWcJ{F7~E*mhpS-J z)=cr;H1@8Hz2n~$8@NJ&(wK8;t8F@#0bD9G{1lRMb~w{jZ)n~y7F$<^#rZI1kPf%! zfgKEpdzyjs2gEDQj3?&fPBJkZjm<;j)cM-cDAaJnvy8(9k0|hSW?5ixKMuC#ivz>O z)C&d(V+vzXQBA9R_*h1dN1FTuCbp;~mePcbc9+9WB-AXn9MF2AJ|Y=gT;27H+fQm+osCpR!S;@Xfg6nQMbq&fP<0e|=ilN#c) zglcwBsI>uZ&k}Y8g2%IuRVbqZ!RL6mQMC}W9m~}yEZF~=#i%O e?|pF#VZm#hFH1Jw^ELjjBT=P2KHrp@TmBc%3!1?I literal 3250 zcmeHKZA_C_6mGR{6~!^oxoH-_k5#L)%WyUflUJai)yRjoVK@+@jzp(n)-Vv0T~gze zG3RGNg;@ic);1J}n-jC;#mH1pLb9yJ&1@w|F{x7!#-#ZAo}F`BoKAo2#}cz-iOKc8 z_nz}S&-uEgEF*1Uc-W*cg+dW-Tx3|LP$==gN`?A4TyiXqe1&4{7Na52RII$+H1g=Q z?1~F_q3F)8OIMCtmaK7aynZ_LZ{4RyDi+rtc{6Uxoxq3RtS>pXBjJ5d{fKU-%RK}; zJNWX+vt3%*$!)vk4elZPnp-ZR3Nr57ub5%fY4IS8P-W#7dLZlZ#pFKg_pZ5L-sl&a zWKF-f^x5+NAN>3@@T}hn6V!aE|F0~LoI;OyQPynsz>3G~EVNUPo4>>)C;OUD8GJBHbr3Fp zw62Yv&>!055tpXT-{FudFO}Vluz@aOm8=^=Z*{+}yPhByZBh$4otBa#WB3c`waeh!+U0mUi z>JLP%X8Vx@ zLlT4H49%SKR#+FmDcdCLlS%{L&dRHj{rA`6WL?YXnm$;ZhMG1ajGo9HCz#tDMm)e_ z##kYoP_|&G*eNcyAjQ{EJ?OP|u(JpQ7j$(2ZxCi{X5(y)tdAMA|Ar`H|C0A*eG3M` z;)&xn_Rp|klAR`{r}hdS^uTVRMS3M+Iw!_xD}BviQbE*%9}dGk!Z(ou z_s~QGVcU0hu)}#PHhbde`~>doE&TFG zYX&ZjCd&o_%vH+?s0E1=pR5PZ$DZ*!Z|);TFeNiqBPS3eGvtxRkzK0HM!)!u=~5Z2 zS3xeBKq@8(gFEt(d!g%bSQdEQNY@uX(KMri&a&xL`^4#!F970E;X^7l(^K&XRw^pl z|4P;aDqDQ*UP$`DgQ8f<;|qpsX+DOFuI3IzKYS9ImA7379u|7o8-L}BcU%+omJ?LN zTw(vW2y)~yR(pK}Cf5o1@rfqcH5>>jz5lg1cgZpGX@)YjtU)~Jp-Nwmk#!FhP?*KY zb6}!Qstev5HJm6}vz4wBhI>et)GPdgK zevkKrTX-)$lCzpZtP&!mf-gsq@MP+1prS4!@pVwxn@hn1g9Vbk$W>e+)~ zV`1p59i53ZZ9vNfj3cGzuXP9ZHkT`uSd}Xc_{BY>$Aa%GSY5@!jo+#xR-vrCSZ)wT z1}UclyJN~GDs32Nx`|DVV{*qvdFrB0uaexz*i$q)lCE_N)@d@5qApG3^Yz|_{^-O= z*v{RC#?fT%5%TF389=%?TVVWkY2lt`$OwK5&bU|Un}NFt=k~$^zS+xtwx{#W>%o%@ znwRr}RL!B;i9&>T_@%l0fVudboE%I1*ggv2oFJ7&BvhYV+K2^gpoz9}2Is?$af|Cg zi-zjrmnLvIC^);OkMWqKudV|1zQLa0%dmvrPNoQ|8PCUiJ%-E-VI1kcj~=AxSU&v= zx!4@8M(3<99+w%=?Tu=W)j}SIZ~!WJjD;0{djef@`>8gjGD<}_8Zt5)!!QCv!ufgd z; Date: Sat, 4 Jul 2026 10:19:46 +0200 Subject: [PATCH 03/15] =?UTF-8?q?fix(render):=20release=20the=20depth-snap?= =?UTF-8?q?shot=20transient=20=E2=80=94=208=20MB/frame=20texture=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The translucent pass acquires TWO transients when a refractive material is on screen (scene-colour + depth snapshots) but released only the colour one. The pool has no auto-reclaim by design, so every frame: - the still-in-use depth slot can never be reused, - acquire() allocates a brand-new render-res Depth32Float (~8.3 MB at 1920x1080 — ~370 MB/s of driver allocation churn at 45 fps), - the slot vector grows by one forever (linear scans slow down too). This was the round-2 audit's "translucent_pass CPU mystery" (F7): the bracket climbed 0.4 ms -> 6.7 ms over ~50 s in every session, tracking RUN TIME, not scene load. Measured with the audit's instrumented combat build, same workload, before vs after: before (leaky): 750 us at t=8, dipping then climbing to 1144 us at t=58 and still growing after (fixed): 30-46 us, flat for the entire run (~16-26x) On the 4 GB shared-memory dev iGPU the unbounded VRAM growth is also a plausible contributor to long-session instability (EN-020 heap pressure). Also: - transient pool: leak watchdog — warn at every power-of-two slot count past 64, naming the acquire-without-release failure mode. This exact bug cost a day of profiler archaeology; the next one costs a log line. - docs/tickets.md: EN-023 (GI software path — colored bounce is unreachable on non-RT adapters; measured achromatic <=2.5%). --- docs/tickets.md | 24 ++++++++++++++++++++++++ native/shared/src/renderer/scene_pass.rs | 9 +++++++++ native/shared/src/renderer/transient.rs | 15 +++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tickets.md b/docs/tickets.md index e227841..6f7ef1d 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -689,3 +689,27 @@ symbolized stack; fix then. (profiler overlay/history strings are the heaviest string traffic in the crashing runs), or an engine-side heap read overrun in a small shared helper. Audit report: `shooter/docs/audit-round2.md` finding F1. + +## EN-023 — GI software path: colored bounce is unreachable 🟡 + +**Why.** Round-2 audit (F4): on adapters without EXPERIMENTAL_RAY_QUERY +(the dev box's Radeon 760M reports none — see the new boot log), the +probe trace runs the SDF path, where the mesh-card lookup compares +world-space hits against OBJECT-space AABBs (ssgi.rs broad phase) — every +transformed instance falls through to the flat gray 0.55 analytic — and +the software WSRC bake is analytic sun+sky with no geometry at all. +Measured in-game: SSGI on/off is ≤2.5% achromatic luma, hue ratio +unchanged to 4 decimals. The ~267 gi_only proxies feed a pipeline whose +colored output cannot reach the screen on SW adapters, at ~1.6 ms GPU in +combat. + +**Sketch.** (1) Transform SDF hits into instance object space (or +instance AABBs into world space) in the card broad-phase so textured/ +tinted cards resolve. (2) Make the SW WSRC bake sample cards (or at +least sun-shadowed ground/albedo bounce) instead of pure analytic. +(3) Re-run the audit's GI A/B: acceptance = G/R hue shift at the +building base and ≥8-10% luma in shaded receivers. + +**Interim option** (decision D2-B in the shooter audit): boot-time +`setSsgiEnabled(false)` on SW adapters banks the ~1.6 ms until this +lands; needs a backend query FFI or the game reading the new boot log. diff --git a/native/shared/src/renderer/scene_pass.rs b/native/shared/src/renderer/scene_pass.rs index 29fabf7..b24c59e 100644 --- a/native/shared/src/renderer/scene_pass.rs +++ b/native/shared/src/renderer/scene_pass.rs @@ -464,6 +464,15 @@ impl Renderer { if let Some(tid) = scene_color_tid { self.transient_pool.release(tid); } + // Round-2 audit: the depth snapshot was acquired every frame but + // NEVER released — the pool (which has no auto-reclaim by design) + // allocated a brand-new render-res Depth32Float every frame with + // refractive water on screen. ~8 MB/frame of texture creation plus + // an ever-growing slot scan; measured as translucent_pass CPU + // climbing 0.4 ms → 6.7 ms over ~50 s of play, in every session. + if let Some(tid) = scene_depth_tid { + self.transient_pool.release(tid); + } profiler.end("translucent_pass"); } } diff --git a/native/shared/src/renderer/transient.rs b/native/shared/src/renderer/transient.rs index c5e6a73..dd7b8fb 100644 --- a/native/shared/src/renderer/transient.rs +++ b/native/shared/src/renderer/transient.rs @@ -187,6 +187,21 @@ impl TransientPool { } // Nothing matched — allocate a new slot. + // + // Leak watchdog: a steady-state frame needs a handful of slots. + // A count that keeps growing means some caller acquires without + // releasing (the pool has no auto-reclaim by design) — exactly + // the depth-snapshot leak the round-2 audit spent a day tracing + // from the profiler side. Warn at every power of two past 64 so + // the log stays quiet but the failure mode is named. + let n = self.slots.len(); + if n >= 64 && n.is_power_of_two() { + eprintln!( + "bloom transient pool: {} slots allocated and growing — \ + an acquire without a matching release is leaking textures", + n + ); + } let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("transient"), size: wgpu::Extent3d { From 05c59166f9d62a4ef3d1493d12858889704e500d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 16:42:03 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(render):=20EN-021=20=E2=80=94=20SSR?= =?UTF-8?q?=20+=20IBL=20specular=20exclusive=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F10: compose is `hdr + ssr` and fs_main_scene already adds IBL specular into hdr, so SSR-hit pixels double-counted specular for roughness ~0.05-0.85 (worst on metals at r~0.55-0.75). The fix is ownership, not subtraction: - fs_main_scene scales ibl_spec by the complement of SSR's OWN roughness fade x its strength. The share rides the free dir_light_count.z lane (written per frame; 0 when SSR is disabled, so the full IBL term returns automatically). - The SSR march no longer returns black on a miss: camera-facing rays and off-screen/no-hit marches fall back to the env panorama sampled by the world-space reflection direction at the material path's roughness x 6 mip ramp, x env intensity (camera_pos.w), x the same fresnel/fade/strength weighting as a hit. Without this, reducing ibl_spec would darken every off-screen reflection - the reason the ticket deferred the one-line version. - SsrParams gains the view->world rotation (transpose of the view 3x3) + env LOD/intensity; SSR bind group gains the env panorama at bindings 9/10 and its cache invalidates wherever the env source swaps (HDR upload, panorama/procedural lighting-bg swaps). Suite green (99 lib + 7 golden; goldens unchanged within tolerance). Boot-smoked on the shooter at 4K - no WGSL errors, clean frames. Closes EN-021. --- native/shared/src/renderer/mod.rs | 37 ++++++++++++- native/shared/src/renderer/shaders/core.rs | 12 ++++- native/shared/src/renderer/shaders/ssgi.rs | 60 +++++++++++++++++----- native/shared/src/renderer/ssr_pass.rs | 29 +++++++++++ 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..c0801be 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -550,8 +550,13 @@ struct SsrTemporalParams { struct SsrParams { inv_proj: [[f32; 4]; 4], proj: [[f32; 4]; 4], - /// x=strength, y=max_dist, z=n_steps, w=padding + /// x=strength, y=max_dist, z=n_steps, w=frame index params: [f32; 4], + /// EN-021 — view→world rotation (transpose of the view 3×3) for the + /// env-miss fallback's direction lookup. + inv_view_rot: [[f32; 4]; 4], + /// EN-021 — x = env max LOD, y = env intensity, zw unused. + params2: [f32; 4], } #[repr(C)] @@ -4118,6 +4123,19 @@ impl Renderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + // EN-021 — env panorama for the miss fallback. + wgpu::BindGroupLayoutEntry { + binding: 9, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, + }, count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 10, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); let ssr_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -8204,6 +8222,9 @@ impl Renderer { self.sky_bind_group = Some(bg); self.env_diffuse_texture = Some(diffuse_texture); self.lighting_bind_group = new_lighting_bg; + // EN-021 — the SSR bind group holds an env view; rebuild it when + // a new HDR panorama is uploaded. + self.ssr_bg_cache = None; } /// Whether a sky env map has been uploaded — controls whether @@ -8514,6 +8535,9 @@ impl Renderer { let new_bg = self.make_lighting_bind_group("lighting_bg_panorama", &env_view, &diffuse_view); self.lighting_bind_group = new_bg; self.lighting_bg_is_procedural = false; + // EN-021 — the SSR bind group holds an env view; rebuild it when + // the env source swaps. + self.ssr_bg_cache = None; } /// EN-005 Phase 3 — rebuild `lighting_bind_group` so PBR materials @@ -8530,6 +8554,11 @@ impl Renderer { ); self.lighting_bind_group = new_bg; self.lighting_bg_is_procedural = true; + // EN-021 — the SSR env-fallback binding should track the active + // env source. (SSR keeps sampling sky_texture today; nulling the + // cache here at least rebuilds against the current state on the + // next frame.) + self.ssr_bg_cache = None; } /// Sample the transmittance LUT for the current sun direction @@ -10449,7 +10478,11 @@ impl Renderer { // (shadow_cascade_splits.w is NOT usable for this — it carries the // TSR mip-LOD bias, written by the shadow pass each frame.) let shadows_flag = if self.shadow_map.enabled { 1.0 } else { 0.0 }; - self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, 0.0, 0.0]; + // dir_light_count.z carries SSR's ownership share for EN-021's + // IBL-specular complement in fs_main_scene: strength while SSR + // runs, 0 when disabled (full IBL specular returns). + let ssr_share = if self.ssr_enabled { self.ssr_strength } else { 0.0 }; + self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, ssr_share, 0.0]; self.lighting_uniforms.point_light_count = [0.0; 4]; } diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 2c84307..4bc53c9 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -1026,8 +1026,18 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { let cap2_luma = dot(ibl_spec_raw, vec3(0.2126, 0.7152, 0.0722)); let cap2 = 1.0 / (1.0 + cap2_luma / 0.3); let roughness_amp = smoothstep(0.05, 0.75, roughness); + // EN-021 exclusive ownership: where SSR is active it owns specular — + // hit (traced colour) or miss (env fallback inside the SSR shader). + // Scale IBL specular by the complement of SSR's own roughness fade + // × its strength (dir_light_count.z, written per frame; 0 when SSR + // is disabled so the full IBL term returns). Kills the metal + // double-count on hits (round-2 audit F10) without darkening + // off-screen reflections. + let ssr_own = clamp( + lighting.dir_light_count.z * (1.0 - smoothstep(0.5, 0.85, roughness)), + 0.0, 1.0); let ibl_spec = ibl_spec_raw - * dielectric_scale * spec_occ * roughness_amp * cap2; + * dielectric_scale * spec_occ * roughness_amp * cap2 * (1.0 - ssr_own); // Indirect-shadow attenuation. 0.15 — deep enough that windows // Shadow darkening floor. Prior 0.15 matched Cycles path- diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 14475ef..7630b0d 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -1330,6 +1330,13 @@ struct SsrParams { /// z = number of march steps /// w = frame index (Hammersley rotation + march jitter) params: vec4, + /// EN-021 — view→world ROTATION (inverse of the view matrix's 3×3) + /// so the env-miss fallback can turn the view-space reflection ray + /// into a world direction for the equirect lookup. + inv_view_rot: mat4x4, + /// EN-021 — x = env max LOD (matches the material path's + /// roughness×6 mip ramp), y = env intensity, zw unused. + params2: vec4, }; @group(0) @binding(0) var u: SsrParams; @@ -1341,6 +1348,8 @@ struct SsrParams { @group(0) @binding(6) var mat_samp: sampler; @group(0) @binding(7) var albedo_tex: texture_2d; @group(0) @binding(8) var albedo_samp: sampler; +@group(0) @binding(9) var env_tex: texture_2d; +@group(0) @binding(10) var env_samp: sampler; const PI: f32 = 3.14159265; @@ -1365,6 +1374,22 @@ fn view_pos_from_depth(uv: vec2, depth: f32) -> vec3 { return view_h.xyz / view_h.w; } +/// EN-021 — env-miss fallback. The scene shader scales its IBL specular +/// down by SSR's ownership share (lighting.dir_light_count.z × the same +/// roughness fade this shader uses), so a miss MUST return the env +/// sample instead of black or off-screen reflections go dark. Same +/// equirect mapping as common/pbr.wgsl's sample_env; explicit-LOD +/// sampling needs no seam handling. +fn env_fallback(r_view: vec3, roughness: f32) -> vec3 { + let d = normalize((u.inv_view_rot * vec4(r_view, 0.0)).xyz); + let theta = acos(clamp(d.y, -1.0, 1.0)); + let phi = atan2(d.z, d.x); + let uu = phi / (2.0 * PI); + let uv = vec2(uu - floor(uu), theta / PI); + return textureSampleLevel(env_tex, env_samp, uv, roughness * u.params2.x).rgb + * u.params2.y; +} + /// Interleaved gradient noise — per-pixel pseudo-random in [0, 1). /// Varies with frame so the temporal accumulator averages over /// different march offsets each frame. @@ -1405,15 +1430,16 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { if (depth >= 0.9999) { return vec4(0.0); } // sky // SSR for every smooth-enough surface — the metals-only gate is gone. - // Rationale: the scene shader deliberately starves polished DIELECTRICS - // of IBL specular (dielectric_spec_amp rolls to zero on smooth surfaces - // because the visibility-less prefiltered env produced bright stripes), - // so screen-space hits are the only grounded reflections a wet floor or - // polished stone can receive — no double-counting by construction. The - // F0 = 0.04 Fresnel below keeps dielectric contribution physically - // small except at grazing angles. Very rough surfaces still fade out - // to IBL where one-ray-per-pixel SSR noise would dominate even after - // temporal accumulation. + // EN-021 EXCLUSIVE OWNERSHIP: within this shader's roughness_fade + // range, SSR owns specular reflections outright — the scene shader + // scales its IBL specular by the complement of the same fade + // (× strength, piped through lighting.dir_light_count.z), and a + // march MISS falls back to the env sample here instead of black. + // Metals previously double-counted on hit (IBL spec in hdr + full + // SSR on top, round-2 audit F10); dielectrics were starved of IBL + // spec by design and now get the coherent hit-or-env behaviour too. + // Very rough surfaces still fade to pure IBL where one-ray-per-pixel + // SSR noise would dominate even after temporal accumulation. let mat = textureSample(mat_tex, mat_samp, in.uv).rg; let metallic = mat.r; let roughness = mat.g; @@ -1444,12 +1470,17 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let h = importance_sample_ggx(xi, n, roughness); let r = reflect(-v, h); - if (r.z > 0.0) { return vec4(0.0); } - let n_dot_v = max(dot(n, v), 0.0); let f0 = mix(vec3(0.04), albedo, metallic); let fresnel = f0 + (vec3(1.0) - f0) * pow(1.0 - n_dot_v, 5.0); + // Camera-facing rays can't be marched — env fallback (EN-021). + if (r.z > 0.0) { + let fb = env_fallback(r, roughness) * fresnel * roughness_fade * u.params.x; + let fb_safe = select(vec3(0.0), fb, fb == fb); + return vec4(fb_safe, 0.0); + } + let max_dist = u.params.y; let n_steps_f = u.params.z; let n_steps = u32(n_steps_f); @@ -1492,7 +1523,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { prev_t = t; t = t + step_size; } - if (!hit_found) { return vec4(0.0); } + if (!hit_found) { + // March left the screen or found nothing — env fallback (EN-021). + let fb = env_fallback(r, roughness) * fresnel * roughness_fade * u.params.x; + let fb_safe = select(vec3(0.0), fb, fb == fb); + return vec4(fb_safe, 0.0); + } let edge_fade = min( min(hit_uv.x, 1.0 - hit_uv.x), diff --git a/native/shared/src/renderer/ssr_pass.rs b/native/shared/src/renderer/ssr_pass.rs index ba07214..568cdd1 100644 --- a/native/shared/src/renderer/ssr_pass.rs +++ b/native/shared/src/renderer/ssr_pass.rs @@ -17,6 +17,17 @@ impl Renderer { // ============================================================ if self.ssr_enabled { let inv_proj = self.current_inv_proj_matrix; + // EN-021 — view→world rotation for the env-miss fallback: the + // transpose of the view matrix's 3×3 (rigid view ⇒ inverse + // rotation = transpose). Column j of the inverse is row j of + // the view rotation. + let v = self.current_view_matrix; + let inv_view_rot = [ + [v[0][0], v[1][0], v[2][0], 0.0], + [v[0][1], v[1][1], v[2][1], 0.0], + [v[0][2], v[1][2], v[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; let sp = SsrParams { inv_proj, proj: self.current_proj_matrix, @@ -28,10 +39,26 @@ impl Renderer { // step_size so the relative-error reject heuristic // still works with the larger strides. params: [self.ssr_strength, 8.0, 8.0, self.taa_frame_index as f32], + inv_view_rot, + // Env max LOD 6.0 matches the material path's roughness×6 + // mip ramp; intensity rides lighting camera_pos.w exactly + // like sample_env does. + params2: [6.0, self.lighting_uniforms.camera_pos[3], 0.0, 0.0], }; self.queue.write_buffer(&self.ssr_uniform_buffer, 0, bytemuck::bytes_of(&sp)); if self.ssr_bg_cache.is_none() { + // EN-021 — env panorama (or the 1×1 default) for the miss + // fallback. The cache is invalidated wherever the lighting + // bind group swaps env sources. + let env_view = self + .sky_texture + .as_ref() + .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default())) + .unwrap_or_else(|| { + self._scene_env_default_texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }); self.ssr_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("ssr_bg"), layout: &self.ssr_layout, @@ -45,6 +72,8 @@ impl Renderer { wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, + wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&env_view) }, + wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, ], })); } From 944445cdbf179b5ea0b01841c3763b951f4b5b58 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 16:58:21 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(gi):=20EN-023=20groundwork=20?= =?UTF-8?q?=E2=80=94=20SDF=20card=20lookup=20fixed,=20WSRC=20ground=20boun?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F4: on non-RT adapters (the dev 760M) SSGI's SDF path degraded every transformed instance to flat gray and the SW radiance cache was light-source-only — colored bounce structurally impossible. Fixed in this PR: - instance data now carries WORLD-space AABBs alongside the object- space ones (all four shader layout mirrors updated). The SDF broad- phase compares its world-space clipmap hits against the world boxes — the old object-space comparison only ever matched assets whose vertices were already in world space (Sponza). HW paths keep using the object-space boxes with hit.world_to_object, unchanged. - broad-phase picks the SMALLEST containing box, not the first: the shooter's +/-140 m terrain proxy otherwise swallowed every hit (walls and trees included) and its mostly-empty side cards actively darkened the bounce. - the SW WSRC bake gains a ground-bounce term for below-horizon octels (scene-average instance albedo x sun-shadowed irradiance + half sky) — it previously returned ~black for every downward miss ray, dropping the strongest real bounce source entirely. Measured honesty (GI pose A/B, sky-normalized, settled): - ship intensity 1.0: deltas within noise (<=1%), hue unchanged - intensity 4.0: +1.4-2.1% luma in shaded receivers, hue still unchanged (G/R 0.9456 vs 0.9460) The data path is now correct, but visible colored bounce on the SW tier remains capped downstream (probe resolve magnitude, composite AO multiply on exactly the shaded receivers, radius/hit-rate at the receivers). EN-023 stays open pointing at the resolve/integration stage; decision D2-B (bank the ~1.6 ms on iGPU) remains sensible until that lands. Suite green (99 lib + 7 golden). Boot-smoked at 4K; A/B runs above were on the live game. --- native/shared/src/renderer/mod.rs | 52 +++++++++++++++++++++- native/shared/src/renderer/shaders/gi.rs | 20 ++++++++- native/shared/src/renderer/shaders/ssgi.rs | 44 +++++++++++++----- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..0b7ee02 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -467,6 +467,10 @@ struct WsrcBakeParams { shadow_splits: [f32; 4], /// x = shadow bias, y = shadows_enabled (0/1), zw unused. flags: [f32; 4], + /// EN-023 — xyz = scene-average albedo (mean of card-instance flat + /// albedos) for the SW bake's ground-bounce term; w unused. The HW + /// bake ignores it (it traces real geometry). + ground_albedo: [f32; 4], } /// Uniform struct for the card-lighting compute pass. Matches @@ -532,10 +536,20 @@ struct InstanceGiDataCpu { /// `card_slot.w` = flag (1.0 = card captured, 0.0 = no card → fall /// back to `albedo` flat value). card_slot: [f32; 4], - /// Object-space AABB min (xyz) + unused pad (w). + /// Object-space AABB min (xyz) + unused pad (w). The HW paths + /// transform hits into object space (hit.world_to_object) and + /// compare against THESE — do not world-ify them. card_aabb_min: [f32; 4], /// Object-space AABB max (xyz) + unused pad (w). card_aabb_max: [f32; 4], + /// EN-023 — WORLD-space AABB min/max. The SDF trace has no + /// world_to_object (it marches a world-space clipmap), so its + /// broad-phase compares the world hit against these. With the old + /// object-space-only bounds, every transformed instance fell + /// through to the flat-gray analytic fallback — zero colored + /// bounce on non-RT adapters (round-2 audit F4). + world_aabb_min: [f32; 4], + world_aabb_max: [f32; 4], } #[repr(C)] @@ -979,6 +993,10 @@ pub struct Renderer { pub ssgi_radius: f32, /// SSGI master switch. pub ssgi_enabled: bool, + /// EN-023 — mean flat albedo over the card instances; feeds the SW + /// WSRC bake's ground-bounce term. Neutral mid-gray until the first + /// instance-data upload computes the real scene average. + pub gi_scene_avg_albedo: [f32; 3], /// One-shot log guard: which SSGI trace backend was last reported to /// stderr (hw-ray-query / sdf-clipmap / hiz-screen). The silent HW→SW /// fallback made "why is bounce gray?" a debugger question during the @@ -6028,6 +6046,7 @@ impl Renderer { ssgi_radius: 20.0, ssgi_enabled: true, ssgi_backend_logged: None, + gi_scene_avg_albedo: [0.35, 0.35, 0.35], probe_grid_w, probe_grid_h, probe_header_buffer, @@ -7190,6 +7209,12 @@ impl Renderer { c as f32, 0.0, ], + ground_albedo: [ + self.gi_scene_avg_albedo[0], + self.gi_scene_avg_albedo[1], + self.gi_scene_avg_albedo[2], + 0.0, + ], }; self.queue.write_buffer( &self.wsrc_bake_uniform, @@ -7527,6 +7552,9 @@ impl Renderer { let mut instance_data: Vec = Vec::with_capacity(instance_count as usize); + // EN-023 — running mean of instance albedos feeds the SW WSRC + // bake's ground-bounce term. + let mut albedo_sum = [0.0f32; 3]; for &h in instance_handles { let n = scene.nodes.get(h).unwrap(); let e = n.material.emissive; @@ -7534,6 +7562,18 @@ impl Renderer { Some(s) => (s as f32, 1.0_f32), None => (0.0, 0.0), }; + // EN-023 — world AABB for the SDF broad-phase. The scene's + // bounds pass keeps world_bounds fresh; the sentinel + // (min.x > max.x = not yet computed) falls back to the local + // box, which matches the old behaviour for identity nodes. + let (wmin, wmax) = if n.world_bounds_min[0] <= n.world_bounds_max[0] { + (n.world_bounds_min, n.world_bounds_max) + } else { + (n.bounds_min, n.bounds_max) + }; + albedo_sum[0] += n.flat_albedo[0]; + albedo_sum[1] += n.flat_albedo[1]; + albedo_sum[2] += n.flat_albedo[2]; instance_data.push(InstanceGiDataCpu { albedo: n.flat_albedo, emissive_luma: (e[0] + e[1] + e[2]) * (1.0 / 3.0), @@ -7542,8 +7582,18 @@ impl Renderer { card_slot: [first_slot, 0.0, 0.0, has_card], card_aabb_min: [n.bounds_min[0], n.bounds_min[1], n.bounds_min[2], 0.0], card_aabb_max: [n.bounds_max[0], n.bounds_max[1], n.bounds_max[2], 0.0], + world_aabb_min: [wmin[0], wmin[1], wmin[2], 0.0], + world_aabb_max: [wmax[0], wmax[1], wmax[2], 0.0], }); } + if !instance_data.is_empty() { + let inv = 1.0 / instance_data.len() as f32; + self.gi_scene_avg_albedo = [ + albedo_sum[0] * inv, + albedo_sum[1] * inv, + albedo_sum[2] * inv, + ]; + } if !instance_data.is_empty() { self.queue.write_buffer( self.tlas_instance_data_buffer.as_ref().unwrap(), diff --git a/native/shared/src/renderer/shaders/gi.rs b/native/shared/src/renderer/shaders/gi.rs index 4630d64..0a2aa3d 100644 --- a/native/shared/src/renderer/shaders/gi.rs +++ b/native/shared/src/renderer/shaders/gi.rs @@ -340,6 +340,8 @@ struct WsrcBakeParams { // output z-slice so this pipeline can be dispatched once per // cascade with the same layout. flags: vec4, + // EN-023 — xyz = scene-average albedo for the ground-bounce term. + ground_albedo: vec4, }; @group(0) @binding(0) var u: WsrcBakeParams; @@ -441,7 +443,17 @@ fn cs_main( let up = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); let sky = u.sky_color.xyz * up * up; - let radiance = sun + sky; + // EN-023 — ground bounce for below-horizon octels. This envelope + // was light-source-only: any miss ray pointing downward returned + // ~black, so shaded receivers lost the strongest real bounce + // source (sunlit ground). Approximate it as the scene-average + // albedo lit by the sun (shadowed at the probe) + half the sky. + let down = clamp(-dir.y, 0.0, 1.0); + let ground_irr = u.sun_color.xyz * max(u.sun_dir.y, 0.0) * shadow + + u.sky_color.xyz * 0.5; + let ground = u.ground_albedo.xyz * ground_irr * down * down; + + let radiance = sun + sky + ground; // V13 — cascade index in flags.z offsets the output z-slice. let cascade_idx = u32(u.flags.z); @@ -485,6 +497,9 @@ struct WsrcBakeParams { shadow_vps: array, 3>, shadow_splits: vec4, flags: vec4, + // EN-023 — layout mirror; the HW bake traces real geometry and + // ignores the scene-average ground albedo. + ground_albedo: vec4, }; struct HwBakeInstanceGiData { @@ -495,6 +510,9 @@ struct HwBakeInstanceGiData { card_slot: vec4, card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB (SDF path only; layout mirror). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const HW_BAKE_CARD_SLOTS_PER_ROW: f32 = 64.0; diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 14475ef..714fe22 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -402,6 +402,9 @@ struct InstanceGiData { // Object-space AABB min (xyz) / max (xyz). card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB (SDF path only; layout mirror). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const CARD_SLOTS_PER_ROW: f32 = 64.0; @@ -715,6 +718,12 @@ struct SdfInstanceGiData { card_slot: vec4, card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB. This trace marches a WORLD-space + // clipmap and has no world_to_object; comparing world hits against + // the object-space box above only ever worked for identity- + // transform assets (Sponza). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const SDF_CARD_SLOTS_PER_ROW: f32 = 64.0; @@ -913,16 +922,29 @@ fn cs_main( // geometry, etc.). let count = arrayLength(&instance_data); var picked: i32 = -1; + var picked_vol: f32 = 1e30; for (var i: u32 = 0u; i < count; i = i + 1u) { let ad = instance_data[i]; if (ad.card_slot.w < 0.5) { continue; } - let bmin = ad.card_aabb_min.xyz - vec3(0.05); - let bmax = ad.card_aabb_max.xyz + vec3(0.05); + // EN-023 — compare the WORLD hit against the WORLD AABB. + // The old object-space comparison only matched assets whose + // vertices were already in world space; every transformed + // instance fell through to the gray analytic fallback. + // Pick the SMALLEST containing box, not the first: a scene- + // spanning instance (the shooter's ±140 m terrain proxy) + // otherwise swallows every hit — walls and trees included — + // and its mostly-empty side cards darken the bounce. + let bmin = ad.world_aabb_min.xyz - vec3(0.05); + let bmax = ad.world_aabb_max.xyz + vec3(0.05); if (hit_pos.x >= bmin.x && hit_pos.x <= bmax.x && hit_pos.y >= bmin.y && hit_pos.y <= bmax.y && hit_pos.z >= bmin.z && hit_pos.z <= bmax.z) { - picked = i32(i); - break; + let ext = bmax - bmin; + let vol = ext.x * ext.y * ext.z; + if (vol < picked_vol) { + picked = i32(i); + picked_vol = vol; + } } } @@ -947,13 +969,13 @@ fn cs_main( let slot_x = slot % 64u; let slot_y = slot / 64u; - let bmin = ad.card_aabb_min.xyz; - let bmax = ad.card_aabb_max.xyz; - // Hit is in world space and Sponza meshes are in world - // space too (no per-instance transform beyond identity on - // the Sponza asset). For now use world-space hit directly; - // instances with a non-identity transform would need the - // transform stored on `instance_data` to round-trip. + // EN-023 — project against the WORLD AABB, consistent with + // the world-space hit. Exact for the translate+scale (yaw-0) + // instances the GI proxies use; a yaw-rotated instance would + // sample its card with rotated UVs — hue still right, which + // is what the probe integral actually consumes at 64² cards. + let bmin = ad.world_aabb_min.xyz; + let bmax = ad.world_aabb_max.xyz; var u_os: f32; var v_os: f32; var u_lo: f32; var u_hi: f32; From 4ec1698d30a665d7e0f4bef23e95147f16475d20 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 17:04:05 +0200 Subject: [PATCH 06/15] =?UTF-8?q?feat(render):=20setPresentMode=20FFI=20?= =?UTF-8?q?=E2=80=94=20Fifo/Mailbox/Immediate=20at=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F6: the present mode was hardcoded Fifo at both surface config sites, which also made setTargetFPS inert — its sleep-based cap only engages when vsync is off, and vsync could never be off. - Renderer::set_present_mode(0=Fifo, 1=Mailbox, 2=Immediate): reconfigures the live surface on change; no-op when unchanged; logs the switch. All three modes are DXGI-native on Windows. - FFI bloom_set_present_mode + TS setPresentMode + manifest entry (Perry silently no-ops undeclared natives). Validated on the live game at 4K: "bloom: present mode = Immediate" followed by getFPS pinning at a steady 29 under setTargetFPS(30) — the first time the cap has ever engaged. This is the knob that makes audit decision D1 (operating point) testable: cap-45 / cap-60 / uncapped can now be compared like-for-like. --- native/shared/src/ffi_core/visual.rs | 8 ++++++++ native/shared/src/renderer/mod.rs | 22 ++++++++++++++++++++++ package.json | 7 +++++++ src/core/index.ts | 11 +++++++++++ 4 files changed, 48 insertions(+) diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index a2218b8..589de12 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -218,6 +218,14 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_present_mode [round-2 audit F6] + #[no_mangle] + pub extern "C" fn bloom_set_present_mode(mode: f64) { + $crate::ffi::guard("bloom_set_present_mode", move || { + engine().renderer.set_present_mode(mode as u32); + }) + } + // bloom_set_sun_shafts [source: macos] #[no_mangle] pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) { diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..099d3e8 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -7851,6 +7851,28 @@ impl Renderer { self.grain_strength = strength.max(0.0); } + /// Present mode: 0 = Fifo (vsync), 1 = Mailbox (uncapped, no tearing), + /// 2 = Immediate (uncapped, tearing allowed). Round-2 audit F6: the + /// mode was hardcoded Fifo, which also made `set_target_fps` inert + /// (its sleep-based cap only engages when vsync is off). All three + /// modes are supported by DXGI on Windows; on other backends an + /// unsupported request falls back to Fifo at configure time. + pub fn set_present_mode(&mut self, mode: u32) { + let requested = match mode { + 1 => wgpu::PresentMode::Mailbox, + 2 => wgpu::PresentMode::Immediate, + _ => wgpu::PresentMode::Fifo, + }; + if self.surface_config.present_mode == requested { + return; + } + self.surface_config.present_mode = requested; + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.surface_config); + } + eprintln!("bloom: present mode = {:?}", requested); + } + /// Sun shaft (screen-space god ray) strength. 0 (default) = off. /// 0.4 = subtle haze, 1.0+ = obvious cinematic shafts. The /// shafts are sampled from the depth buffer along a screen-space diff --git a/package.json b/package.json index dfa63c0..7094c3a 100644 --- a/package.json +++ b/package.json @@ -1229,6 +1229,13 @@ ], "returns": "void" }, + { + "name": "bloom_set_present_mode", + "params": [ + "f64" + ], + "returns": "void" + }, { "name": "bloom_set_sun_shafts", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index f1be5fe..0c218e6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -20,6 +20,7 @@ declare function bloom_set_fog(r: number, g: number, b: number, density: number, declare function bloom_set_chromatic_aberration(strength: number): void; declare function bloom_set_vignette(strength: number, softness: number): void; declare function bloom_set_film_grain(strength: number): void; +declare function bloom_set_present_mode(mode: number): void; declare function bloom_set_sun_shafts(strength: number, decay: number, r: number, g: number, b: number): void; declare function bloom_set_auto_exposure(on: number): void; declare function bloom_set_taa_enabled(on: number): void; @@ -265,6 +266,16 @@ export function setFilmGrain(strength: number): void { bloom_set_film_grain(strength); } +/** + * Swapchain present mode: 0 = Fifo (vsync, default), 1 = Mailbox + * (uncapped, no tearing), 2 = Immediate (uncapped, tearing allowed). + * With a non-vsync mode active, `setTargetFPS`'s sleep-based cap + * becomes effective — under Fifo it is inert by design. + */ +export function setPresentMode(mode: number): void { + bloom_set_present_mode(mode); +} + /** Screen-space sun shafts (god rays). strength 0 = off. */ export function setSunShafts(strength: number, decay: number, r: number, g: number, b: number): void { bloom_set_sun_shafts(strength, decay, r, g, b); From 60ba704cab23ff17ac02dced4a0914ef74474c17 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 17:09:42 +0200 Subject: [PATCH 07/15] feat(text): rasterize glyphs at physical resolution (crisp 4K HUD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F5: glyphs were rasterized at LOGICAL pixel size and the 2D projection stretched them x1.5 at 150% display scaling — every HUD string was a bilinear-magnified blur at 4K. - draw_text_ex rasterizes at size x dpi (dpi = physical surface width / logical width) and divides the layout metrics back to logical, so the projection's stretch lands the bitmap 1:1 on physical pixels. - measureText stays in logical units and stays exact: fontdue advances are strictly linear in pixel size, so advance(phys)/dpi == advance(logical) up to f32 rounding. - The glyph atlas warns once when full instead of silently wrapping UVs into garbage glyphs (grow/evict is the durable follow-up; the physical-size cache entries raise pressure, so the warning matters now). Validated on the live game at 4K: title + HUD render with correct layout and native-res rasters; no atlas warnings on the shooter's glyph set. --- native/shared/src/text_renderer.rs | 54 ++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/native/shared/src/text_renderer.rs b/native/shared/src/text_renderer.rs index 3affa0a..3e943e4 100644 --- a/native/shared/src/text_renderer.rs +++ b/native/shared/src/text_renderer.rs @@ -74,6 +74,8 @@ pub struct TextRenderer { atlas_row_height: u32, atlas_bind_group_idx: Option, atlas_dirty: bool, + /// One-shot guard for the atlas-full warning. + atlas_overflow_warned: bool, // SDF atlas (separate from bitmap atlas) sdf_glyph_cache: HashMap<(usize, char), GlyphInfo>, sdf_atlas_data: Vec, @@ -99,6 +101,7 @@ impl TextRenderer { atlas_row_height: 0, atlas_bind_group_idx: None, atlas_dirty: false, + atlas_overflow_warned: false, sdf_glyph_cache: HashMap::new(), sdf_atlas_data: Vec::new(), sdf_atlas_cursor_x: 0, @@ -127,6 +130,7 @@ impl TextRenderer { atlas_row_height: 0, atlas_bind_group_idx: None, atlas_dirty: true, + atlas_overflow_warned: false, sdf_glyph_cache: HashMap::new(), sdf_atlas_data: vec![0u8; (atlas_width * atlas_height * 4) as usize], sdf_atlas_cursor_x: 0, @@ -162,6 +166,19 @@ impl TextRenderer { self.atlas_row_height = 0; } + // Round-2 audit F5: the atlas used to overflow SILENTLY — writes + // were clipped by the bounds check below and the quads then + // sampled wrapped UVs (garbage glyphs). Warn once so the failure + // mode is named; grow-or-evict is the durable follow-up. + if self.atlas_cursor_y + gh > self.atlas_height && !self.atlas_overflow_warned { + self.atlas_overflow_warned = true; + eprintln!( + "bloom text: glyph atlas ({}x{}) is full — new glyphs will render corrupted \ + until eviction lands (too many distinct size/char combinations)", + self.atlas_width, self.atlas_height + ); + } + let ax = self.atlas_cursor_x; let ay = self.atlas_cursor_y; @@ -257,9 +274,26 @@ impl TextRenderer { ) { let idx = if font_idx < self.fonts.len() { font_idx } else { 0 }; + // Round-2 audit F5: glyphs were rasterized at LOGICAL pixel size + // and stretched ×dpi by the 2D projection — a soft, bilinear- + // magnified HUD at 150% display scaling. Rasterize at PHYSICAL + // size and divide the layout metrics back to logical. fontdue + // advances are exactly linear in pixel size, so measureText + // (which stays in logical units) remains consistent with the + // drawn advances. + let dpi = if renderer.logical_width > 0 { + (renderer.surface_config.width as f32 / renderer.logical_width as f32).max(0.25) + } else { + 1.0 + }; + let phys_size = (((size as f32) * dpi).round().max(1.0)) as u32; + // Exact logical/physical ratio actually in use (phys_size is + // rounded, so derive from it rather than from dpi). + let inv_dpi = size as f32 / phys_size as f32; + // Ensure all glyphs are rasterized first for ch in text.chars() { - self.rasterize_glyph(idx, ch, size); + self.rasterize_glyph(idx, ch, phys_size); } // Upload atlas if dirty @@ -290,12 +324,18 @@ impl TextRenderer { cursor_x += spacing; } first = false; - let key = (idx, ch, size); + let key = (idx, ch, phys_size); if let Some(glyph) = self.glyph_cache.get(&key) { - let gx = cursor_x + glyph.x_offset; - let gy = y as f32 - glyph.y_offset - glyph.height as f32 + size as f32; - let gw = glyph.width as f32; - let gh = glyph.height as f32; + // Physical-res glyph metrics, scaled back to the logical + // coordinate space the 2D pass works in. The projection's + // ×dpi stretch then lands the bitmap 1:1 on physical + // pixels instead of magnifying a logical-res raster. + let gx = cursor_x + glyph.x_offset * inv_dpi; + let gy = y as f32 + - (glyph.y_offset + glyph.height as f32) * inv_dpi + + size as f32; + let gw = glyph.width as f32 * inv_dpi; + let gh = glyph.height as f32 * inv_dpi; if gw > 0.0 && gh > 0.0 { let u0 = glyph.atlas_x as f32 / aw; @@ -306,7 +346,7 @@ impl TextRenderer { renderer.draw_textured_quad(gx, gy, gw, gh, u0, v0, u1, v1, color, atlas_bg); } - cursor_x += glyph.advance; + cursor_x += glyph.advance * inv_dpi; } } } From 73ef59173e3e8f55a2af46b5adf547396d61a6df Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 17:17:25 +0200 Subject: [PATCH 08/15] =?UTF-8?q?feat(render):=20EN-022=20engine=20half=20?= =?UTF-8?q?=E2=80=94=20real=20prev-frame=20transforms=20for=20material=20d?= =?UTF-8?q?raws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The material ABI carried PerDraw.prev_mvp "for motion vectors" since day one, but every submit path filled it with the CURRENT mvp — world motion vectors were identically zero by construction, so TAA's motion-adaptive clamp never engaged for material-drawn geometry (round-2 audit F8, the primary TSR-shimmer mechanism). - MaterialSystem keeps a per-slot model history (draw slot = submission order, stable frame-to-frame — the same identity convention the cached-model path relies on). submit_draw / submit_draw_instanced now compose prev_vp x prev_model[slot] into PerDraw.prev_mvp; a fresh slot falls back to the current model (zero object motion on its first frame). - reset_draw_slot rotates the history and pins the previous frame's view-projection (from Renderer::prev_vp_matrix, which already fed PerView.prev_view_proj correctly). - The legacy prev_mvp parameter on the submit APIs is ignored and renamed _legacy_prev_mvp (callers passed current-mvp garbage). - material_abi.wgsl gains `abi_motion_vector(curr, prev)` — the core path's exact NDC-delta x 0.5 convention — plus doc guidance for static vs wind-displaced vertex shaders (PerFrame.delta_time already enables prev-time wind evaluation with no ABI change). Behavioral no-op until materials read prev_mvp / write velocity (the shooter's material updates ride a stacked shooter PR). Suite green: 99 lib + 7 golden, all unchanged. --- native/shared/shaders/material_abi.wgsl | 16 ++++++ native/shared/src/renderer/material_system.rs | 56 ++++++++++++++++++- .../src/renderer/material_system_tests.rs | 2 +- native/shared/src/renderer/mod.rs | 6 +- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/native/shared/shaders/material_abi.wgsl b/native/shared/shaders/material_abi.wgsl index 1c54612..7921db2 100644 --- a/native/shared/shaders/material_abi.wgsl +++ b/native/shared/shaders/material_abi.wgsl @@ -215,6 +215,22 @@ struct JointMatrices { @group(3) @binding(0) var draw: PerDraw; @group(3) @binding(1) var joints: JointMatrices; +// EN-022 — standard motion vector from current/previous clip positions. +// Matches the core path's convention exactly (NDC delta × 0.5). Vertex +// shaders pass their clip position plus a previous-frame clip position: +// static geometry uses `draw.prev_mvp * local` (or +// `view.prev_view_proj * world` — for static models the two agree); +// procedurally displaced geometry (wind sway) re-evaluates the +// displacement at `frame.time - frame.delta_time` and projects with +// `view.prev_view_proj`. Write the result to OpaqueOut.velocity — a +// zero write disables TAA's motion-adaptive clamp for that surface +// (the round-2 audit's primary TSR-shimmer mechanism, F8). +fn abi_motion_vector(curr_clip: vec4, prev_clip: vec4) -> vec2 { + let c = curr_clip.xy / curr_clip.w; + let p = prev_clip.xy / prev_clip.w; + return (c - p) * 0.5; +} + // ===================================================================== // Group 4 — SceneInputs (optional; `reads_scene = true` materials only) // ===================================================================== diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index f3489f6..bd57291 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -294,6 +294,19 @@ pub struct MaterialSystem { pub translucent_commands: Vec, // Transparent + Refractive + Additive next_draw_slot: usize, + /// EN-022 — per-slot model matrices from the PREVIOUS frame, keyed + /// by draw slot (= submission order, which is stable frame-to-frame + /// for immediate-mode games; the cached-model path relies on the + /// same convention). `submit_draw` composes prev_vp × prev_model + /// into PerDraw.prev_mvp so materials can output real motion + /// vectors — the ABI field existed since day one but was filled + /// with the CURRENT mvp, making world velocity identically zero + /// (round-2 audit F8). + prev_models: Vec<[[f32; 4]; 4]>, + cur_models: Vec<[[f32; 4]; 4]>, + /// Previous frame's view-projection (set in reset_draw_slot). + prev_vp: [[f32; 4]; 4], + /// EN-001 — instance buffers, indexed by InstanceBufferHandle /// (1-based; 0 = invalid). Each entry owns a wgpu Buffer + element /// count. Created via `create_instance_buffer`, consumed by @@ -622,6 +635,9 @@ impl MaterialSystem { commands: Vec::new(), translucent_commands: Vec::new(), next_draw_slot: 0, + prev_models: Vec::new(), + cur_models: Vec::new(), + prev_vp: super::IDENTITY_MAT4, instance_buffers: Vec::new(), texture_arrays: Vec::new(), material_texture_arrays: Vec::new(), @@ -694,8 +710,15 @@ impl MaterialSystem { /// Reset the per-draw slot cursor. Commands lists are cleared by /// the Renderer from its own `begin_frame` so the order of reset /// vs. submit is deterministic. - pub fn reset_draw_slot(&mut self) { + /// + /// EN-022 — also rotates the per-slot model history: this frame's + /// models become the previous frame's, and `prev_vp` is set to the + /// VP that was current when those models were submitted. + pub fn reset_draw_slot(&mut self, prev_vp: [[f32; 4]; 4]) { self.next_draw_slot = 0; + std::mem::swap(&mut self.prev_models, &mut self.cur_models); + self.cur_models.clear(); + self.prev_vp = prev_vp; } /// Phase 5 — set/replace `user_params` for a specific material. The @@ -1393,7 +1416,10 @@ impl MaterialSystem { mesh_idx: usize, mvp: [[f32; 4]; 4], model: [[f32; 4]; 4], - prev_mvp: [[f32; 4]; 4], + // EN-022: ignored — callers historically passed the CURRENT mvp + // here, zeroing every motion vector. The real previous-frame + // transform is reconstructed from the per-slot model history. + _legacy_prev_mvp: [[f32; 4]; 4], tint: [f32; 4], skin_info: [u32; 4], ) { @@ -1408,6 +1434,17 @@ impl MaterialSystem { self.next_draw_slot += 1; self.ensure_draw_slot(device, joint_buffer, slot); + // EN-022 — draw identity is the submission slot (stable order + // frame-to-frame, same convention the cached-model path uses). + // A fresh slot (first frame, or the frame after a draw-count + // change) falls back to the current model = zero object motion. + let prev_model = self.prev_models.get(slot).copied().unwrap_or(model); + let prev_mvp = super::mat4_multiply(self.prev_vp, prev_model); + if self.cur_models.len() <= slot { + self.cur_models.resize(slot + 1, model); + } + self.cur_models[slot] = model; + let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info }; queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw)); @@ -1447,7 +1484,8 @@ impl MaterialSystem { instance_count: u32, mvp: [[f32; 4]; 4], model: [[f32; 4]; 4], - prev_mvp: [[f32; 4]; 4], + // EN-022: ignored — see submit_draw. + _legacy_prev_mvp: [[f32; 4]; 4], tint: [f32; 4], skin_info: [u32; 4], ) { @@ -1462,6 +1500,18 @@ impl MaterialSystem { self.next_draw_slot += 1; self.ensure_draw_slot(device, joint_buffer, slot); + // EN-022 — same slot-history reconstruction as submit_draw. + // Instance transforms live in the (static) instance buffer, so + // prev vs current only differ by the fallback model + camera; + // per-instance wind sway derives its own previous position in + // the vertex shader from frame.time - frame.delta_time. + let prev_model = self.prev_models.get(slot).copied().unwrap_or(model); + let prev_mvp = super::mat4_multiply(self.prev_vp, prev_model); + if self.cur_models.len() <= slot { + self.cur_models.resize(slot + 1, model); + } + self.cur_models[slot] = model; + let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info }; queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw)); diff --git a/native/shared/src/renderer/material_system_tests.rs b/native/shared/src/renderer/material_system_tests.rs index 54583d9..5bee241 100644 --- a/native/shared/src/renderer/material_system_tests.rs +++ b/native/shared/src/renderer/material_system_tests.rs @@ -171,7 +171,7 @@ fn fs_main(_in: VsOut) -> TranslucentOut { }; let pv = bytemuck::Zeroable::zeroed(); sys.update_frame_uniforms(&queue, &pf, &pv); - sys.reset_draw_slot(); + sys.reset_draw_slot(crate::renderer::IDENTITY_MAT4); // MVP = identity so the fullscreen tri stays in NDC. let identity = [ diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..62e6ccc 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -8885,10 +8885,12 @@ impl Renderer { self.uniform_slot_count = 0; self.render_mode = RenderMode::ScreenSpace; // Phase 1c — clear last frame's material draws so the new - // frame's submissions start from an empty list. + // frame's submissions start from an empty list. EN-022: the + // reset also rotates the per-slot model history and pins the + // previous frame's VP for motion-vector reconstruction. self.material_system.commands.clear(); self.material_system.translucent_commands.clear(); - self.material_system.reset_draw_slot(); + self.material_system.reset_draw_slot(self.prev_vp_matrix); // Write identity uniforms to slot 0 (2D uses logical points, // not physical pixels — see Renderer::new). From 0f2fe8bafed2e123619b66570c66dfc7e2349566 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 19:11:10 +0200 Subject: [PATCH 09/15] fix(EN-020): tail-pad Perry FFI strings; add in-engine crash reporting Root cause of the round-1/round-2 'unreproducible' AVs and the title- screen freeze family: alloc_perry_string sized allocations exactly to header+payload, while Perry's compiled string scanners (split/indexOf) read word-at-a-time and touch up to a word past byte_len. When an allocation lands flush against an unmapped page the overread is an AV (reproduced 3/3 in ~7-29s with the shooter profiler overlay on: faults in perry_fn_...getProfilerOverlay at +0xe925 and ...getProfilerFrameHistory at +0xf0a3; historical EN-020 signature was main.exe+0xe8e5 reading 0x...FFF8). Fix: 16 zeroed pad bytes after every FFI string payload; capacity still reports byte_len so Perry never writes into the pad. Regression test included. Diagnostics added while hunting this (kept - they make the next silent death loud): SetUnhandledExceptionFilter that prints code + main.exe-relative address and writes a minidump via runtime-loaded dbghelp (import lib is not on perry's link line), eprintln on WM_CLOSE/WM_DESTROY (clean-exit path), one-shot log when surface acquire fails (headless-spin path). Report upstream to Perry: scanner overread on exact-sized string allocations. --- native/shared/src/renderer/draw2d.rs | 13 +++ native/shared/src/string_header.rs | 29 ++++++- native/windows/Cargo.toml | 4 + native/windows/src/lib.rs | 115 +++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/native/shared/src/renderer/draw2d.rs b/native/shared/src/renderer/draw2d.rs index 1ce8850..186d909 100644 --- a/native/shared/src/renderer/draw2d.rs +++ b/native/shared/src/renderer/draw2d.rs @@ -103,6 +103,19 @@ impl Renderer { wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => Some(FrameTarget::Surface(t)), _ => { + // Diagnostic (title-freeze investigation): this path used + // to fail silently; an eternal acquire failure looks like + // a frozen game (headless spin, last frame stays on + // screen). One-shot + periodic so a wedged swapchain is + // visible in stderr. + static ACQ_FAILS: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = ACQ_FAILS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if n == 1 || n % 300 == 0 { + crate::ffi::log_error(&format!( + "bloom: surface acquire failed (count={n}) — reconfiguring and skipping frame" + )); + } surface.configure(&self.device, &self.surface_config); None } diff --git a/native/shared/src/string_header.rs b/native/shared/src/string_header.rs index 3fe027c..84908ba 100644 --- a/native/shared/src/string_header.rs +++ b/native/shared/src/string_header.rs @@ -121,6 +121,17 @@ pub fn str_from_header(ptr: *const u8) -> &'static str { /// Perry's 0.5.x runtime read 8 bytes into the payload. Always go through /// this helper — the layout comes from the `StringHeader` type, which the /// compile-time assertions above pin to the documented ABI. +/// EN-020 — Perry's string scanners (`split`, `indexOf`, …) step +/// word-at-a-time and may read up to a word past `byte_len`. With an +/// exactly-sized allocation that lands flush against an unmapped page, +/// that overread is an access violation (observed 3/3 in the shooter as +/// `perry_fn_…getProfilerOverlay` / `…getProfilerFrameHistory`, faulting +/// reads at page ends; historical EN-020 signature `main.exe+0xe8e5`, +/// read at `0x…FFF8`). Every string this function returns is scanned by +/// Perry, so keep a zeroed tail pad behind the payload — `capacity` +/// still reports `byte_len`, so Perry never writes into the pad. +const TAIL_PAD: usize = 16; + pub fn alloc_perry_string(s: &str) -> *const u8 { let bytes = s.as_bytes(); let byte_len = bytes.len(); @@ -130,7 +141,7 @@ pub fn alloc_perry_string(s: &str) -> *const u8 { } else { s.encode_utf16().count() }; - let total = std::mem::size_of::() + byte_len; + let total = std::mem::size_of::() + byte_len + TAIL_PAD; let layout = std::alloc::Layout::from_size_align(total, 4).unwrap(); unsafe { let ptr = std::alloc::alloc(layout); @@ -149,6 +160,11 @@ pub fn alloc_perry_string(s: &str) -> *const u8 { ptr.add(std::mem::size_of::()), byte_len, ); + std::ptr::write_bytes( + ptr.add(std::mem::size_of::() + byte_len), + 0, + TAIL_PAD, + ); ptr } } @@ -196,6 +212,17 @@ mod tests { assert_eq!(str_from_header(buf.as_ptr()), ""); } + #[test] + fn tail_pad_present_and_zeroed() { + // EN-020 regression guard: a word-stepping scanner may read past + // byte_len; the pad must exist and read as NULs. + let p = alloc_perry_string("abc"); + let payload_end = std::mem::size_of::() + 3; + for i in 0..TAIL_PAD { + assert_eq!(unsafe { *p.add(payload_end + i) }, 0, "pad byte {i} not zero"); + } + } + #[test] fn rejects_invalid_utf8() { let p = alloc_perry_string("abcd") as *mut u8; diff --git a/native/windows/Cargo.toml b/native/windows/Cargo.toml index 0b472b1..fe83f95 100644 --- a/native/windows/Cargo.toml +++ b/native/windows/Cargo.toml @@ -53,6 +53,10 @@ windows = { version = "0.58", features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", + "Win32_System_Diagnostics_Debug", + "Win32_System_Kernel", + "Win32_Storage_FileSystem", + "Win32_System_Memory", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Threading", diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index eee9783..2d627a5 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -61,6 +61,114 @@ fn map_keycode(vk: u32) -> usize { } } +// --------------------------------------------------------------------------- +// Crash reporting (title-freeze / EN-020 investigation): the game was dying +// with empty stderr, no WER event, and no dump — i.e. invisibly. Catch +// unhandled SEH exceptions, print code + module-relative address +// (symbolizable against main.pdb via llvm-symbolizer), and write our own +// minidump. Fast-fail (0xC0000409) bypasses this filter by design, so a +// death with no output at all narrows to fail-fast / TerminateProcess. +#[cfg(windows)] +mod crash_report { + use std::os::windows::io::AsRawHandle; + use std::sync::atomic::{AtomicBool, Ordering}; + use windows::core::{s, w}; + use windows::Win32::Foundation::BOOL; + use windows::Win32::System::Diagnostics::Debug::{ + SetUnhandledExceptionFilter, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, + }; + use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}; + use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId, + }; + + // dbghelp.dll is loaded at crash time — its import lib is not on + // perry's link line, and adding manifest libs would force a + // .perry-cache clear for every consumer of the engine. + type MiniDumpWriteDumpFn = unsafe extern "system" fn( + hprocess: *mut core::ffi::c_void, + processid: u32, + hfile: *mut core::ffi::c_void, + dumptype: i32, + exceptionparam: *const MINIDUMP_EXCEPTION_INFORMATION, + userstreamparam: *const core::ffi::c_void, + callbackparam: *const core::ffi::c_void, + ) -> i32; + + // MiniDumpNormal | MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo + const DUMP_FLAGS: i32 = 0x0000_0040 | 0x0000_1000; + + static INSTALLED: AtomicBool = AtomicBool::new(false); + + pub fn install() { + if INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + unsafe { + SetUnhandledExceptionFilter(Some(filter)); + } + eprintln!("bloom: crash filter installed (dumps -> tools/.testout/dumps)"); + } + + unsafe extern "system" fn filter(info: *const EXCEPTION_POINTERS) -> i32 { + let (code, addr) = { + let mut c = 0u32; + let mut a = 0usize; + if !info.is_null() { + let rec = (*info).ExceptionRecord; + if !rec.is_null() { + c = (*rec).ExceptionCode.0 as u32; + a = (*rec).ExceptionAddress as usize; + } + } + (c, a) + }; + let base = GetModuleHandleW(None).map(|m| m.0 as usize).unwrap_or(0); + let rel = addr.wrapping_sub(base); + eprintln!("bloom: FATAL unhandled exception {code:#010x} at {addr:#x} (main.exe+{rel:#x})"); + + let _ = std::fs::create_dir_all("tools/.testout/dumps"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let path = format!( + "tools/.testout/dumps/crash_main_{}_{stamp:x}.dmp", + GetCurrentProcessId() + ); + let write_dump = LoadLibraryW(w!("dbghelp.dll")) + .ok() + .and_then(|h| GetProcAddress(h, s!("MiniDumpWriteDump"))) + .map(|p| std::mem::transmute::<_, MiniDumpWriteDumpFn>(p)); + match (std::fs::File::create(&path), write_dump) { + (Ok(f), Some(dump_fn)) => { + let exc = MINIDUMP_EXCEPTION_INFORMATION { + ThreadId: GetCurrentThreadId(), + ExceptionPointers: info as *mut EXCEPTION_POINTERS, + ClientPointers: BOOL(0), + }; + let ok = dump_fn( + GetCurrentProcess().0, + GetCurrentProcessId(), + f.as_raw_handle() as *mut core::ffi::c_void, + DUMP_FLAGS, + &exc, + core::ptr::null(), + core::ptr::null(), + ); + if ok != 0 { + eprintln!("bloom: minidump written: {path}"); + } else { + eprintln!("bloom: minidump FAILED (MiniDumpWriteDump returned FALSE)"); + } + } + (Err(e), _) => eprintln!("bloom: minidump file create failed: {e}"), + (_, None) => eprintln!("bloom: minidump unavailable (dbghelp load failed)"), + } + 1 // EXCEPTION_EXECUTE_HANDLER — let the process die + } +} + // Win32 windowing implementation #[cfg(windows)] mod win32 { @@ -124,7 +232,13 @@ mod win32 { unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { match msg { + WM_CLOSE => { + // Diagnostic (title-freeze investigation): who closes us? + eprintln!("bloom: WM_CLOSE received"); + DefWindowProcW(hwnd, msg, wparam, lparam) + } WM_DESTROY => { + eprintln!("bloom: WM_DESTROY — should_close set, main loop will exit cleanly"); PostQuitMessage(0); if let Some(eng) = ENGINE.get_mut() { eng.should_close = true; @@ -396,6 +510,7 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u #[cfg(windows)] { + crash_report::install(); let (hwnd, phys_w, phys_h) = win32::create_window(width, height, title); unsafe { init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h); } if fullscreen != 0.0 { From 54385c25ab266166a901da04134cbfcd609d423b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 19:28:22 +0200 Subject: [PATCH 10/15] fix(EN-020): numeric profiler ABI - Perry split/parseFloat overread their own slices Tail-padding engine-allocated strings (previous commit) did not stop the crashes: 3/3 runs still faulted at the same perry_fn offsets, proving the overread happens on Perry-INTERNAL allocations (split() slices fed to parseFloat), which the engine cannot pad. Route the per-frame profiler data across the FFI as f64s instead: bloom_profiler_row_count/_label/ _cpu_us/_gpu_us + _hist_* natives, getProfilerOverlay/getProfilerFrame- History rewritten on top (no split, no parseFloat; the label crosses whole and is only drawn). Packed-text FFIs stay for back-compat. Validated on the shooter: 3/3 runs x 90s gameplay with the overlay ON continuously, zero faults, correct overlay data - previously 6/6 dead within 7-29s of overlay time across two link layouts. EN-020 ticket updated with root cause + upstream repro; remaining: file against Perry, migrate the OBJ text loader off split/parseFloat when touched. --- docs/tickets.md | 56 ++++++++++++------- native/shared/src/ffi_core/game_loop.rs | 71 +++++++++++++++++++++++++ package.json | 45 ++++++++++++++++ src/core/index.ts | 46 +++++++++------- 4 files changed, 179 insertions(+), 39 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index 591311f..fd4e700 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -662,7 +662,7 @@ test. (or a VM forwarding DPI), and a Linux dev with X11. Web checks can be done from any modern browser on the macOS dev box. -## EN-020 — Native AV: heap read overrun, layout-sensitive 🔴 +## EN-020 — Native AV: heap read overrun, layout-sensitive ✅ root-caused 2026-07-04 **Symptom.** Bloom Shooter round-2 audit (2026-07-04): three scripted runs crashed with c0000005 at the same instruction (`main.exe+0xe8e5` on the @@ -671,24 +671,42 @@ No Rust panic output (engine builds `panic = "abort"`, which would print), so this is raw UB: something reads past the end of a heap allocation and faults only when the allocation abuts an unmapped page. -**What is ruled out.** Runtime feature toggles alone (a 19-transition -ssgi/shadows/profiler gauntlet under combat load survived); profiler -string churn alone (one crash happened with only ~8 small prints); -specific stages (three different ones). The fault did not reproduce after -a relink (two 60 s fishing runs) — consistent with layout sensitivity, -not with a fixed trigger. - -**Standing kit.** WER LocalDumps armed on the dev box (dumps → -`shooter/tools/.testout/dumps/`, dialog suppressed), line tables in the -staticlib (`debug = "line-tables-only"`), `perry compile --debug-symbols` -emits `main.pdb`, LLVM symbolizer available. See -[crash-triage-windows.md](crash-triage-windows.md). Next occurrence = -symbolized stack; fix then. - -**Suspect space.** Perry-runtime heap/string handling at the FFI boundary -(profiler overlay/history strings are the heaviest string traffic in the -crashing runs), or an engine-side heap read overrun in a small shared -helper. Audit report: `shooter/docs/audit-round2.md` finding F1. +**Root cause (found via the title-freeze report on the round-2 +integration build).** Perry 0.5.x runtime string scanning: `split()` +slices and `parseFloat()` read past the end of Perry's own exact-sized +string allocations. Any TS that runs a packed FFI text blob through +`split`+`parseFloat` every frame — exactly what `getProfilerOverlay` / +`getProfilerFrameHistory` did — crashes within seconds once a slice lands +flush against an unmapped page. Reproduced 6/6 across two different link +layouts (faults in `perry_fn_…getProfilerOverlay` at `+0xe925` and +`…getProfilerFrameHistory` at `+0xf0a3`, 7–29 s of overlay time each); +engine-side tail-padding of `alloc_perry_string` alone did NOT fix it, +proving the overread is on Perry-internal allocations. Minidumps +archived in `shooter/tools/.testout/dumps/crash_main_*.dmp`. + +**Fix shipped (2026-07-04, `fix(EN-020)` commits).** +1. Numeric profiler ABI: `bloom_profiler_row_count/_label/_cpu_us/_gpu_us` + + `_hist_*` — f64s cross the FFI; the label crosses whole and is only + drawn, never parsed. `getProfilerOverlay`/`getProfilerFrameHistory` + rewritten on top; the packed-text FFIs remain for back-compat but + per-frame consumers must not parse them. +2. Defense-in-depth: `alloc_perry_string` now zero-pads 16 bytes after + every payload (word-stepping scanners on ENGINE-allocated strings stay + in bounds), regression test included. +3. Observability: unhandled-exception filter in `native/windows` prints + code + `main.exe+RVA` and writes a minidump via runtime-loaded dbghelp; + WM_CLOSE/WM_DESTROY and surface-acquire failures now log to stderr. + Silent deaths are structurally impossible for AV-class faults now. + +Validated: 3/3 runs × 90 s gameplay with the overlay ON continuously — +zero faults, overlay data correct (previously 6/6 dead in ≤29 s). + +**Remaining.** File the scanner overread upstream against Perry 0.5.x +(repro: return a `"1.23|4.56\n"…` blob from an FFI, split+parseFloat it +per frame; dumps + offsets above). The OBJ text loader +(`engine/src/models/index.ts`) uses the same split/parseFloat pattern at +LOAD time — one-shot exposure, worth migrating when touched. Audit +report: `shooter/docs/audit-round2.md` finding F1. ## EN-021 — SSR + IBL specular exclusive ownership 🟡 diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index 89ab12c..0c67d86 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -395,5 +395,76 @@ macro_rules! __bloom_ffi_game_loop { }) } + // EN-020 — numeric profiler ABI. The packed-text FFIs above stay + // for back-compat, but Perry 0.5.x's split()/parseFloat() overread + // their own slice allocations, so per-frame consumers must use + // these instead: f64s cross the FFI clean and the label is only + // ever drawn, never parsed. Row/hist indices address the same + // snapshot the text FFIs serialize; rows are few (≤ MAX_GPU_PAIRS) + // so the per-call snapshot() is negligible overlay-only cost. + + // bloom_profiler_row_count [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_row_count() -> f64 { + $crate::ffi::guard("bloom_profiler_row_count", move || { + engine().profiler.snapshot().len() as f64 + }) + } + + // bloom_profiler_row_label [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_row_label(i: f64) -> *const u8 { + $crate::ffi::guard("bloom_profiler_row_label", move || { + let snap = engine().profiler.snapshot(); + match snap.get(i as usize) { + Some((label, _, _)) => $crate::string_header::alloc_perry_string(label), + None => $crate::string_header::alloc_perry_string(""), + } + }) + } + + // bloom_profiler_row_cpu_us [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_row_cpu_us(i: f64) -> f64 { + $crate::ffi::guard("bloom_profiler_row_cpu_us", move || { + engine().profiler.snapshot().get(i as usize).map(|r| r.1).unwrap_or(0.0) + }) + } + + // bloom_profiler_row_gpu_us [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_row_gpu_us(i: f64) -> f64 { + $crate::ffi::guard("bloom_profiler_row_gpu_us", move || { + match engine().profiler.snapshot().get(i as usize) { + Some((_, _, Some(g))) => *g, + _ => -1.0, + } + }) + } + + // bloom_profiler_hist_count [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_hist_count() -> f64 { + $crate::ffi::guard("bloom_profiler_hist_count", move || { + engine().profiler.frame_history().len() as f64 + }) + } + + // bloom_profiler_hist_cpu_us [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_hist_cpu_us(i: f64) -> f64 { + $crate::ffi::guard("bloom_profiler_hist_cpu_us", move || { + engine().profiler.frame_history().get(i as usize).map(|h| h.0).unwrap_or(0.0) + }) + } + + // bloom_profiler_hist_gpu_us [source: windows] + #[no_mangle] + pub extern "C" fn bloom_profiler_hist_gpu_us(i: f64) -> f64 { + $crate::ffi::guard("bloom_profiler_hist_gpu_us", move || { + engine().profiler.frame_history().get(i as usize).map(|h| h.1).unwrap_or(0.0) + }) + } + }; } diff --git a/package.json b/package.json index 4211a4a..a61dfbc 100644 --- a/package.json +++ b/package.json @@ -947,6 +947,51 @@ "params": [], "returns": "string" }, + { + "name": "bloom_profiler_row_count", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_profiler_row_label", + "params": [ + "f64" + ], + "returns": "string" + }, + { + "name": "bloom_profiler_row_cpu_us", + "params": [ + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_profiler_row_gpu_us", + "params": [ + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_profiler_hist_count", + "params": [], + "returns": "f64" + }, + { + "name": "bloom_profiler_hist_cpu_us", + "params": [ + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_profiler_hist_gpu_us", + "params": [ + "f64" + ], + "returns": "f64" + }, { "name": "bloom_create_instance_buffer", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index 33baac4..673c8c6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -64,6 +64,18 @@ declare function bloom_get_profiler_frame_gpu_us(): number; declare function bloom_print_profiler_summary(): void; declare function bloom_profiler_overlay_text(): string; declare function bloom_profiler_frame_history(): string; +// EN-020 — numeric profiler ABI. Perry 0.5.x's split()/parseFloat() +// overread their own exact-sized slice allocations; parsing a packed +// text blob every overlay frame crashes within seconds once a slice +// lands at the end of a heap page. Numbers cross as f64, labels cross +// whole and are only ever drawn, never parsed. +declare function bloom_profiler_row_count(): number; +declare function bloom_profiler_row_label(i: number): string; +declare function bloom_profiler_row_cpu_us(i: number): number; +declare function bloom_profiler_row_gpu_us(i: number): number; +declare function bloom_profiler_hist_count(): number; +declare function bloom_profiler_hist_cpu_us(i: number): number; +declare function bloom_profiler_hist_gpu_us(i: number): number; declare function bloom_splat_impulse(x: number, z: number, radius: number, strength: number): void; declare function bloom_set_material_params(handle: number, paramsPtr: any, paramCount: number): void; declare function bloom_set_material_params_scratch(handle: number, paramCount: number): void; @@ -628,19 +640,15 @@ export function setMaterialParams(handle: number, params: number[]): void { * render one `drawText` per entry. */ export function getProfilerOverlay(): { label: string, cpuUs: number, gpuUs: number }[] { - const raw = bloom_profiler_overlay_text(); - if (!raw || raw.length === 0) return []; + // EN-020: per-row numeric FFI — do NOT reintroduce a packed-text + + // split()/parseFloat() path here (Perry runtime overread, crashes). const out: { label: string, cpuUs: number, gpuUs: number }[] = []; - const lines = raw.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.length === 0) continue; - const parts = line.split('|'); - if (parts.length < 3) continue; + const n = bloom_profiler_row_count(); + for (let i = 0; i < n; i++) { out.push({ - label: parts[0], - cpuUs: parseFloat(parts[1]), - gpuUs: parseFloat(parts[2]), + label: bloom_profiler_row_label(i), + cpuUs: bloom_profiler_row_cpu_us(i), + gpuUs: bloom_profiler_row_gpu_us(i), }); } return out; @@ -652,16 +660,14 @@ export function getProfilerOverlay(): { label: string, cpuUs: number, gpuUs: num * is 0 when the device lacks TIMESTAMP_QUERY. */ export function getProfilerFrameHistory(): { cpuUs: number, gpuUs: number }[] { - const raw = bloom_profiler_frame_history(); - if (!raw || raw.length === 0) return []; + // EN-020: numeric FFI — see getProfilerOverlay. const out: { cpuUs: number, gpuUs: number }[] = []; - const lines = raw.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.length === 0) continue; - const parts = line.split('|'); - if (parts.length < 2) continue; - out.push({ cpuUs: parseFloat(parts[0]), gpuUs: parseFloat(parts[1]) }); + const n = bloom_profiler_hist_count(); + for (let i = 0; i < n; i++) { + out.push({ + cpuUs: bloom_profiler_hist_cpu_us(i), + gpuUs: bloom_profiler_hist_gpu_us(i), + }); } return out; } From 93abb23989dc4756db96ee06eb20b95bd5b8e49e Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 05:08:03 +0200 Subject: [PATCH 11/15] docs: consolidate specs, sync all docs to post-round-2 + EN-020 reality - Remove Surreal_Engine_Spec_v01.md (pre-rename draft; philosophy lives in README + the API itself) and bloom-renderer-spec.md (superseded; its Strategic Framing already survives as v2 spec section 6). bloom-renderer-spec-v2.md is now the only renderer spec and carries an as-built status block (wgpu/WGSL deferred MRT vs planned forward+/Slang; what landed from each track; where live status lives). - tickets.md: EN-019 Windows half field-validated on the 4K@150% dev box; EN-021/EN-022 implemented (PRs #78, #82 + shooter #4); EN-023 partially landed (PR #79), stays open pointed at resolve/AO. - crash-triage-windows.md: rewritten around the in-engine crash reporting (stderr + self-written minidumps), symbolizer flag drift, WinDbg/lldb tool notes, working repro patterns (PostMessage keys, pixel-diff freeze detection), EN-020 case study. - CLAUDE.md: Windows + test/golden build commands, consumer relink/perry-cache rules, real renderer/ tree, hard-won FFI rules (manifest discipline, EN-020 numeric-ABI rule, string_header, i64 scratch pattern), runtime/debug behavior, docs index. --- CLAUDE.md | 94 +++++- Surreal_Engine_Spec_v01.md | 562 ----------------------------------- bloom-renderer-spec-v2.md | 30 +- bloom-renderer-spec.md | 205 ------------- docs/crash-triage-windows.md | 153 ++++++---- docs/tickets.md | 47 ++- 6 files changed, 255 insertions(+), 836 deletions(-) delete mode 100644 Surreal_Engine_Spec_v01.md delete mode 100644 bloom-renderer-spec.md diff --git a/CLAUDE.md b/CLAUDE.md index 6e58c21..33b69f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,17 @@ Bloom is a native TypeScript game engine compiled by [Perry](../../perry/perry) ## Build Commands ```bash -# Native (macOS example) +# Native (macOS) cd native/macos && cargo build --release +# Native (Windows — set INTERPROCEDURAL_OPTIMIZATION=OFF so the Jolt +# cmake build doesn't trip over LTO with the MSVC toolchain) +INTERPROCEDURAL_OPTIMIZATION=OFF cargo build --release --manifest-path native/windows/Cargo.toml + +# Tests: unit + golden-image suite (run before any renderer PR) +cd native/shared && cargo test --release +BLOOM_UPDATE_GOLDEN=1 cargo test --release # regenerate goldens — commit ONLY the ones your change intentionally moved (mean tol 2, outliers 1%) + # Web/WASM cd native/web && cargo check --target wasm32-unknown-unknown ./native/web/build.sh [game.ts] # Full web build pipeline @@ -21,6 +29,13 @@ cd native/shared && cargo check # nativ cd native/shared && cargo check --target wasm32-unknown-unknown --no-default-features --features web # WASM ``` +After an engine-only rebuild, a consuming game does NOT relink by +itself: `perry compile` skips the link if the game's `main.ts` is +untouched — touch it first. After ANY change to `package.json`'s +native-function manifest, also delete the game's `.perry-cache/`. +Games should link with `perry compile … --debug-symbols` so `main.pdb` +lands next to the exe (see `docs/crash-triage-windows.md`). + ## Architecture ``` @@ -36,10 +51,26 @@ src/ TypeScript API (compiled by Perry) physics/ Jolt-backed rigid + soft bodies, character, vehicles native/ Rust implementations (one crate per platform) - shared/ Cross-platform core (~7000 lines) - - renderer.rs: wgpu + WGSL shaders (2D/3D, shadows, post-FX) - - audio.rs: platform-agnostic mixer - - text_renderer.rs: fontdue-based text + shared/ Cross-platform core + - renderer/: wgpu 29 renderer — deferred MRT + (hdr/material/velocity/albedo), TSR upscaling, + cascaded shadows, SSR, Lumen-class GI (screen + probes; HW ray-query / SDF-clipmap / Hi-Z tiers, + mesh cards, WSRC), material system with a + 5-bind-group ABI (shaders/material_abi.wgsl), + transient texture pool, 2D draw layer. + WGSL lives as strings in renderer/shaders/*.rs + - profiler.rs: CPU+GPU pass profiler (enabling it + inserts a blocking per-frame GPU sync — never + benchmark with it on) + - audio/: control/render split over a lock-free + SPSC ring (mod/render/stream/decode) + - text_renderer.rs: fontdue text, rasterized at + physical resolution + - string_header.rs: Perry string ABI (read its + header comment before touching FFI strings) + - ffi_core/: define_core_ffi! macro — the shared + FFI surface each platform crate instantiates - physics_jolt.rs: JoltPhysics wrapper (native only) - jolt_sys.rs: C ABI bindings to bloom_jolt shim - textures.rs, models.rs, scene.rs, etc. @@ -63,6 +94,41 @@ Each platform implements ~230 `bloom_*` FFI functions declared in `package.json` String parameters are `i64` on native (Perry StringHeader pointers) and NaN-boxed string IDs on web (converted by JS glue layer). +### Hard-won FFI rules (violating these produced real shipped bugs) + +- **Every new native MUST be declared in `package.json`'s manifest.** + Perry silently no-ops undeclared functions — no error, the call just + does nothing. Consumers must clear `.perry-cache/` after manifest + changes. +- **Never return packed text for per-frame parsing (EN-020).** Perry + 0.5.x `split()`/`parseFloat()` read past their own slice allocations; + parsing a delimited FFI string every frame is a crash lottery the + engine cannot pad away. Cross numbers as `f64` FFIs; strings cross + whole and get drawn, not parsed (see the `bloom_profiler_row_*` + numeric ABI and `docs/tickets.md` § EN-020). +- Engine→Perry strings go through `string_header::alloc_perry_string` + (tail-padded); Perry→engine through `str_from_header` (validated, + never UB). Don't hand-roll headers. +- **Perry 0.5.1171 i64-array regression:** passing a TS `number[]` to + an `i64` param is broken — use the all-f64 scratch pattern + (`bloom_mesh_scratch_*`) like createMesh does. +- Engine TS in `src/` is compiled by Perry too, so Perry codegen quirks + apply here as well (no reachable `throw`, explicit object keys in + returns — the shooter's `docs/perry-quirks.md` is the reference list). + +### Runtime/debug behavior worth knowing + +- `panic = "abort"` on all native targets: a Rust panic prints to + stderr then fast-fails (0xC0000409). An AV instead triggers the + Windows crate's crash filter, which prints `main.exe+RVA` and writes + a minidump to the game's `tools/.testout/dumps/` — + `docs/crash-triage-windows.md` is the triage runbook. +- **WGSL compiles at runtime** (`create_shader_module`), so + `cargo build` does NOT validate shaders — boot a game (or the golden + suite) after any WGSL edit. +- `begin_frame` resets per-frame lighting state; renderer FFI calls + before `initWindow` panic with "Engine not initialized". + Physics FFI (~110 of the ~230) is generated by the `define_physics_ffi!` macro in `native/shared/src/physics_jolt.rs`; each platform crate invokes it once to re-export the full surface. On web the same surface is wasm_bindgen wrappers forwarding to `native/web/jolt_bridge.js`, which drives JoltPhysics.js. ## Web/WASM Target @@ -86,7 +152,11 @@ The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants ( | `package.json` | FFI function manifest + per-platform build config | | `src/core/index.ts` | Core API: window, input, drawing, `runGame()` | | `src/physics/index.ts` | Physics API (see `docs/physics.md` for architecture) | -| `native/shared/src/renderer.rs` | wgpu renderer (2D/3D, ~2600 lines) | +| `native/shared/src/renderer/mod.rs` | Renderer root: frame orchestration, lighting, GI plumbing | +| `native/shared/src/renderer/material_system.rs` | Material draws, per-slot UBOs, prev-frame model history (EN-022) | +| `native/shared/src/renderer/shaders/` | All WGSL (core scene, ssgi/SSR, gi, post) as Rust strings | +| `native/shared/shaders/material_abi.wgsl` | The 5-bind-group material ABI games compile against | +| `native/shared/src/string_header.rs` | Perry string ABI both directions (padded alloc + validated read) | | `native/shared/src/engine.rs` | EngineState with timing, frame callbacks | | `native/shared/src/physics_jolt.rs` | Jolt handle registries + `define_physics_ffi!` macro | | `native/third_party/bloom_jolt/` | C++ shim wrapping JoltPhysics behind a C ABI | @@ -96,3 +166,15 @@ The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants ( | `native/web/index.html` | Engine-only standalone page (no game); creates `__bloomReady` + loads bloom_glue.js | | `native/web/splice_game.py` | Splices the Bloom bootstrap into Perry's self-contained WASM HTML, gating `bootPerryWasm()` on `__bloomReady` | | `native/web/build.sh` | Build script: wasm-pack + wasm-opt + Perry compile + splice/assembly | + +## Where the truth lives + +- `bloom-renderer-spec-v2.md` — the (only) renderer plan; its header + carries the as-built status vs. plan. +- `docs/tickets.md` — EN-xxx work items with current status. +- `docs/perf/` — per-feature design docs + `lumen-roadmap.md` for GI. +- `docs/crash-triage-windows.md` — native-fault runbook (the engine + self-reports crashes since 2026-07). +- The Bloom Shooter (`../shooter`) is the flagship consumer; its + `CLAUDE.md` + `docs/perry-quirks.md` document the Perry-side rules + games (and engine `src/` TS) must follow. diff --git a/Surreal_Engine_Spec_v01.md b/Surreal_Engine_Spec_v01.md deleted file mode 100644 index 4b62605..0000000 --- a/Surreal_Engine_Spec_v01.md +++ /dev/null @@ -1,562 +0,0 @@ -# SURREAL - -**A Native TypeScript Game Engine — Powered by Perry** - -Technical Specification v0.1 · Skelpo GmbH · March 2026 · DRAFT — INTERNAL - ---- - -## 1. Vision & Philosophy - -**Surreal** is a native TypeScript game engine compiled by Perry. It follows the raylib philosophy: a simple, opinionated library of functions — not a visual editor, not a framework with inheritance hierarchies, not an IDE. Just functions you call from your game loop. - -The design principles, in order of priority: - -- **Simplicity first.** If a feature can't be explained in one sentence, it's too complex. The entire API should fit on a single cheatsheet. -- **Native by default.** Perry compiles TypeScript to native machine code via Cranelift. No browser, no V8, no Electron, no WebGL. Actual GPU calls, actual native windows. -- **2D and 3D unified.** One coordinate system, one camera system, one draw pipeline. 2D is just 3D with an orthographic camera. -- **Zero magic.** No hidden update loops, no component lifecycle hooks, no dependency injection. You write a while-loop. You call draw functions. That's it. -- **Ship everywhere.** macOS, Windows, Linux, iOS, Android. One codebase. Perry Publish handles signing/notarization/distribution. - -**The pitch:** *"Write TypeScript. Ship native games to Steam, the App Store, and the Play Store. No browser. No C++. No engine license fees."* - -### 1.1 Reference Model: Why Raylib - -Raylib is the gold standard for simple-but-powerful game libraries. It has six modules (core, shapes, textures, text, models, audio), fits its entire API on a cheatsheet, and has been ported to 70+ languages. Surreal follows the same modular architecture but is designed from the ground up for TypeScript idioms: strong typing, async/await for asset loading, and object literals for configuration. - -### 1.2 Target Games (What Surreal Is For) - -- **2D:** platformers, roguelikes, puzzle games, visual novels, tower defense, top-down RPGs -- **Lightweight 3D:** voxel games (Minecraft-style), low-poly adventures, isometric RPGs, racing games -- **Simulation:** farming sims, city builders, idle games, card games -- **Educational & creative:** interactive art, generative visuals, music visualizers, game jam entries - -### 1.3 What Surreal Is NOT - -- Not an Unreal/Unity competitor — no AAA photorealism, no ray tracing, no massive open worlds -- Not a visual editor — no drag-and-drop scene builder (but Hone could become one later) -- Not a framework — no mandatory game object hierarchy, no ECS baked in, no forced architecture - ---- - -## 2. Architecture - -Surreal is organized into seven modules, mirroring raylib's proven structure but adapted for TypeScript. Each module is independently importable. - -| Module | Responsibility | Raylib Equivalent | -|---|---|---| -| `surreal/core` | Window creation, game loop, input (keyboard, mouse, gamepad, touch), timing, file I/O | rcore | -| `surreal/shapes` | 2D shape drawing (line, rect, circle, polygon, bezier), 2D collision detection | rshapes | -| `surreal/textures` | Image loading/manipulation (CPU), texture loading/management (GPU), sprite batching | rtextures | -| `surreal/text` | Font loading (TTF/OTF/bitmap), text rendering, text measurement, SDF fonts | rtext | -| `surreal/models` | 3D model loading (glTF, OBJ), mesh generation, skeletal animation, materials, PBR | rmodels | -| `surreal/audio` | Audio device management, sound loading (WAV/OGG/MP3), music streaming, spatial audio | raudio | -| `surreal/math` | Vec2, Vec3, Vec4, Mat4, Quaternion, Ray, BoundingBox, easing functions, RNG | raymath | - -### 2.1 The Native Layer (Perry Bindings) - -Underneath the TypeScript API, Surreal calls native platform APIs through Perry's FFI system. These are not WebGL calls — they are actual native GPU and OS calls compiled into the binary. - -| Platform | Graphics | Windowing | Audio | Input | -|---|---|---|---|---| -| macOS | Metal | AppKit / NSWindow | Core Audio | AppKit Events | -| iOS | Metal | UIKit / UIWindow | Core Audio | UIKit Touch | -| Windows | DirectX 12 / Vulkan | Win32 / HWND | WASAPI / XAudio2 | Win32 Messages / XInput | -| Linux | Vulkan / OpenGL | X11 / Wayland | PulseAudio / ALSA | X11 Events / libinput | -| Android | Vulkan / OpenGL ES | NativeActivity | AAudio / OpenSL ES | MotionEvent | - -Perry's compiler emits the correct native calls per target at compile time. The developer writes one TypeScript API; the compiler resolves the platform-specific implementation. This is the key architectural advantage over browser-based engines. - -### 2.2 Graphics Abstraction: surreal/gpu - -Internally (not exposed to most users), Surreal has a thin GPU abstraction layer that normalizes Metal/DirectX/Vulkan/OpenGL into a common command buffer API, similar to raylib's rlgl module. Power users can access this layer directly for custom shaders and render pipelines. - -### 2.3 No Hidden Runtime - -There is no garbage collector, no JIT compiler, no event loop. Perry compiles TypeScript to native code with deterministic memory management. Surreal's game loop is a plain while-loop that the developer controls. Frame timing, input polling, and buffer swapping are explicit function calls. - ---- - -## 3. Core API Design - -The API is designed to be learnable from a single cheatsheet plus examples. Function names are verbs. Configuration uses object literals. No class hierarchies. - -### 3.1 Hello World — Minimal Window - -```typescript -import { initWindow, closeWindow, windowShouldClose, - beginDrawing, endDrawing, clearBackground, - drawText, Color } from "surreal"; - -initWindow(800, 450, "My First Surreal Game"); - -while (!windowShouldClose()) { - beginDrawing(); - clearBackground(Color.RayWhite); - drawText("Hello, Surreal!", 190, 200, 20, Color.DarkGray); - endDrawing(); -} - -closeWindow(); -``` - -That's a complete, compilable program. `perry build` produces a native binary. `perry publish` packages it for the App Store or Steam. - -### 3.2 Hello 3D — Spinning Cube - -```typescript -import { initWindow, closeWindow, windowShouldClose, - beginDrawing, endDrawing, clearBackground, - beginMode3D, endMode3D, drawCube, drawGrid, - getDeltaTime, getFPS, drawText, - Camera3D, Vec3, Color } from "surreal"; - -initWindow(800, 450, "3D Cube"); - -const camera: Camera3D = { - position: Vec3(10, 10, 10), - target: Vec3(0, 0, 0), - up: Vec3(0, 1, 0), - fovy: 45, - projection: "perspective" -}; - -let rotation = 0; - -while (!windowShouldClose()) { - rotation += getDeltaTime() * 45; - beginDrawing(); - clearBackground(Color.White); - beginMode3D(camera); - drawCube(Vec3(0, 1, 0), 2, 2, 2, Color.Red, { rotationY: rotation }); - drawGrid(10, 1); - endMode3D(); - drawText(`FPS: ${getFPS()}`, 10, 10, 20, Color.Gray); - endDrawing(); -} - -closeWindow(); -``` - ---- - -### 3.3 Module API Reference (Summary) - -Each module's key functions. The full cheatsheet will be a standalone document, like raylib's. - -#### surreal/core — Window, Loop, Input - -| Function | Description | -|---|---| -| `initWindow(w, h, title)` | Create native window and GPU context | -| `closeWindow()` | Close window and release resources | -| `windowShouldClose(): boolean` | Check if close requested (X button, ESC, etc.) | -| `setTargetFPS(fps)` | Set target frame rate (0 = unlimited) | -| `getDeltaTime(): number` | Get time since last frame in seconds | -| `getFPS(): number` | Get current frames per second | -| `getScreenWidth/Height(): number` | Get current window dimensions | -| `isKeyPressed/Down/Released(key)` | Check keyboard input state | -| `isMouseButtonPressed/Down/Released(btn)` | Check mouse button state | -| `getMousePosition(): Vec2` | Get mouse position in screen coordinates | -| `isGamepadAvailable(id): boolean` | Check if gamepad is connected | -| `getGamepadAxisValue(id, axis): number` | Get gamepad axis value (-1 to 1) | -| `getTouchPosition(index): Vec2` | Get touch point position (mobile) | -| `getTouchPointCount(): number` | Get number of active touch points | -| `beginDrawing() / endDrawing()` | Begin/end frame drawing (manages buffer swap) | -| `beginMode2D(camera) / endMode2D()` | Begin/end 2D camera mode | -| `beginMode3D(camera) / endMode3D()` | Begin/end 3D camera mode | -| `clearBackground(color)` | Clear screen with specified color | -| `setWindowTitle(title)` | Change window title at runtime | -| `toggleFullscreen()` | Toggle between fullscreen and windowed | -| `setWindowIcon(image)` | Set window icon from Image | - -#### surreal/shapes — 2D Drawing & Collision - -| Function | Description | -|---|---| -| `drawLine(start, end, color)` | Draw line between two Vec2 points | -| `drawRect(x, y, w, h, color)` | Draw filled rectangle | -| `drawRectLines(x, y, w, h, color)` | Draw rectangle outline | -| `drawCircle(center, radius, color)` | Draw filled circle | -| `drawTriangle(v1, v2, v3, color)` | Draw filled triangle | -| `drawPoly(center, sides, radius, rotation, color)` | Draw regular polygon | -| `drawBezier(start, cp1, cp2, end, color)` | Draw cubic bezier curve | -| `checkCollisionRecs(r1, r2): boolean` | Check collision between two rectangles | -| `checkCollisionCircles(c1, r1, c2, r2): boolean` | Check collision between two circles | -| `checkCollisionPointRec(point, rect): boolean` | Check if point is inside rectangle | -| `getCollisionRec(r1, r2): Rect` | Get overlap rectangle of two colliding rects | - -#### surreal/textures — Images & Sprites - -| Function | Description | -|---|---| -| `loadImage(path): Image` | Load image from file (CPU memory) | -| `loadTexture(path): Texture` | Load texture from file (GPU memory) | -| `loadTextureFromImage(image): Texture` | Upload image to GPU as texture | -| `unloadTexture(texture)` | Free GPU memory for texture | -| `drawTexture(tex, x, y, tint)` | Draw texture at position | -| `drawTextureRec(tex, source, dest, tint)` | Draw portion of texture (sprite sheets) | -| `drawTexturePro(tex, source, dest, origin, rotation, tint)` | Draw with full transform | -| `imageResize(image, w, h)` | Resize image in CPU memory | -| `imageCrop(image, rect)` | Crop image in CPU memory | -| `imageFlipH/V(image)` | Flip image horizontally/vertically | -| `genTextureMipmaps(texture)` | Generate mipmaps for texture | - -#### surreal/text — Fonts & Text - -| Function | Description | -|---|---| -| `loadFont(path): Font` | Load TTF/OTF font | -| `loadFontEx(path, size, codepoints): Font` | Load font with specific size and character set | -| `unloadFont(font)` | Free font resources | -| `drawText(text, x, y, size, color)` | Draw text with default font | -| `drawTextEx(font, text, pos, size, spacing, color)` | Draw text with custom font | -| `measureText(text, size): number` | Get text width in pixels | -| `measureTextEx(font, text, size, spacing): Vec2` | Get text dimensions with custom font | - -#### surreal/models — 3D Geometry & Models - -| Function | Description | -|---|---| -| `drawCube(pos, w, h, d, color, opts?)` | Draw 3D cube | -| `drawSphere(pos, radius, color)` | Draw 3D sphere | -| `drawCylinder(pos, rTop, rBot, h, slices, color)` | Draw 3D cylinder | -| `drawPlane(pos, size, color)` | Draw flat plane | -| `drawGrid(slices, spacing)` | Draw reference grid | -| `drawRay(ray, color)` | Draw 3D ray for debugging | -| `loadModel(path): Model` | Load 3D model (glTF, OBJ) | -| `unloadModel(model)` | Free model resources | -| `drawModel(model, pos, scale, tint)` | Draw 3D model | -| `loadModelAnimation(path): Animation[]` | Load model animations | -| `updateModelAnimation(model, anim, frame)` | Apply animation frame to model | -| `checkCollisionBoxes(b1, b2): boolean` | Check collision between bounding boxes | -| `checkCollisionSpheres(c1, r1, c2, r2): boolean` | Check collision between spheres | -| `getRayCollisionMesh(ray, model): RayHit` | Raycast against model mesh | -| `genMeshCube(w, h, d): Mesh` | Generate cube mesh procedurally | -| `genMeshHeightmap(image, size): Mesh` | Generate terrain mesh from heightmap | - -#### surreal/audio — Sound & Music - -| Function | Description | -|---|---| -| `initAudioDevice()` | Initialize audio device | -| `closeAudioDevice()` | Close audio device | -| `loadSound(path): Sound` | Load sound effect (fully loads into memory) | -| `playSound(sound)` | Play sound effect | -| `stopSound(sound)` | Stop playing sound | -| `setSoundVolume(sound, vol)` | Set sound volume (0.0 to 1.0) | -| `loadMusic(path): Music` | Load music stream (streams from disk) | -| `playMusic(music)` | Start music stream playback | -| `updateMusic(music)` | Update music stream buffer (call each frame) | -| `setMusicVolume(music, vol)` | Set music volume (0.0 to 1.0) | -| `setMasterVolume(vol)` | Set global audio volume (0.0 to 1.0) | - -#### surreal/math — Vectors, Matrices, Utilities - -| Function | Description | -|---|---| -| `Vec2(x, y) / Vec3(x, y, z) / Vec4(...)` | Create vector (value types, not classes) | -| `vec2Add, vec2Sub, vec2Scale, vec2Length, ...` | Vector operations (non-mutating) | -| `mat4Identity, mat4Multiply, mat4Rotate, ...` | 4×4 matrix operations | -| `quatFromEuler, quatToMat4, quatSlerp, ...` | Quaternion operations | -| `lerp(a, b, t): number` | Linear interpolation | -| `clamp(val, min, max): number` | Clamp value to range | -| `remap(val, inMin, inMax, outMin, outMax)` | Remap value between ranges | -| `randomInt(min, max): number` | Random integer in range (inclusive) | -| `randomFloat(min, max): number` | Random float in range | -| `easeInOut / easeElastic / easeBounce ...` | Easing functions for animation | - ---- - -## 4. Core Types - -Surreal uses TypeScript's type system heavily. All core types are plain interfaces / object literals — no classes with methods, no prototypes. - -```typescript -// All vectors are simple objects, not class instances -interface Vec2 { x: number; y: number } -interface Vec3 { x: number; y: number; z: number } -interface Vec4 { x: number; y: number; z: number; w: number } - -interface Color { r: number; g: number; b: number; a: number } - -interface Rect { x: number; y: number; width: number; height: number } - -interface Camera2D { - offset: Vec2; // Camera displacement in screen space - target: Vec2; // Camera target (what it looks at) - rotation: number; // Camera rotation in degrees - zoom: number; // Camera zoom (scaling factor) -} - -interface Camera3D { - position: Vec3; // Camera position - target: Vec3; // Camera look-at point - up: Vec3; // Camera up vector - fovy: number; // Field of view (degrees) - projection: "perspective" | "orthographic"; -} - -interface Texture { - readonly id: number; // GPU texture handle - readonly width: number; // Texture width - readonly height: number; // Texture height -} - -interface Model { - readonly meshCount: number; - readonly materialCount: number; - transform: Mat4; -} -``` - ---- - -## 5. Build & Distribution Pipeline - -Surreal games are built and shipped entirely through Perry's toolchain. No webpack, no bundler, no npm scripts. - -### 5.1 Project Structure - -``` -my-game/ - perry.toml # Perry project config - src/ - main.ts # Entry point - player.ts # Game logic - enemies.ts - assets/ - sprites/ # PNG, JPG textures - models/ # glTF, OBJ models - sounds/ # WAV, OGG, MP3 - fonts/ # TTF, OTF - levels/ # JSON level data - build/ # Output (gitignored) -``` - -### 5.2 perry.toml Configuration - -```toml -[package] -name = "my-game" -version = "1.0.0" -entry = "src/main.ts" - -[dependencies] -surreal = "0.1" - -[assets] -bundle = "assets/" # Embedded in binary - -[build.macos] -bundle_id = "com.mycompany.mygame" -icon = "assets/icon.icns" - -[build.windows] -icon = "assets/icon.ico" - -[build.ios] -bundle_id = "com.mycompany.mygame" -orientation = "landscape" -``` - -### 5.3 Build Commands - -| Command | Output | -|---|---| -| `perry build` | Native binary for current platform | -| `perry build --target macos-arm64` | macOS Apple Silicon binary | -| `perry build --target windows-x64` | Windows .exe | -| `perry build --target ios` | iOS .app bundle | -| `perry build --release` | Optimized release build | -| `perry run` | Build + run immediately | -| `perry publish` | Package for distribution (App Store, Steam, etc.) | - -### 5.4 Asset Bundling - -Assets in the configured bundle directory are compiled into the binary at build time. At runtime, `loadTexture("sprites/player.png")` resolves from the embedded bundle. In debug mode, assets are loaded from disk for fast iteration. In release mode, they're embedded for single-binary distribution. - ---- - -## 6. Showcase Starter Games - -Each game ships as a complete, well-commented project in the `surreal/examples/` directory. They serve as both learning material and proof that the engine works. Ordered by complexity. - -### 6.1 🎾 Pong — "Surreal Pong" - -*The "Hello World" of game engines. If you can't make Pong, the engine isn't ready.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐ Beginner — ~150 lines | -| **Modules used** | core, shapes, text, audio | -| **Teaches** | Game loop, input, collision, score, sound effects | -| **Gameplay** | Classic 2-player pong. Keyboard (W/S + Up/Down) or gamepad. | -| **Key concepts** | `getDeltaTime()` for frame-independent movement, `checkCollisionRecs()` for paddle/ball collision, `drawRect()` for rendering, `playSound()` for hit/score events | - -### 6.2 👾 Space Blaster — "Surreal Invaders" - -*Classic top-down shooter. Introduces sprites, textures, and entity management.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐⭐ Intermediate — ~400 lines | -| **Modules used** | core, textures, text, audio, math | -| **Teaches** | Texture loading, sprite drawing, arrays as entity pools, particle effects, scrolling backgrounds | -| **Gameplay** | Ship moves left/right, shoots upward. Waves of enemies descend. Power-ups drop. | -| **Key concepts** | `loadTexture()` + `drawTexturePro()` for sprites, array-based entity management (no ECS needed), basic particle system using `randomFloat()` and easing functions | - -### 6.3 🎲 Dungeon Crawl — "Surreal Depths" - -*Tile-based roguelike. Introduces tilemaps, camera control, and procedural generation.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐⭐ Intermediate — ~600 lines | -| **Modules used** | core, textures, text, audio, math | -| **Teaches** | Tilemap rendering from sprite sheets, Camera2D scrolling/zoom, procedural level generation, turn-based logic, JSON level loading | -| **Gameplay** | Top-down dungeon crawler. Move with WASD or tap. Explore rooms, fight monsters, find loot. | -| **Key concepts** | `Camera2D` with target tracking and smooth follow, `drawTextureRec()` for tilemap sprites, `randomInt()` seeded dungeon generation, JSON asset loading for level templates | - -### 6.4 🏎️ Kart Racer — "Surreal Karts" - -*3D racing game. The first 3D showcase — proves the engine handles real-time 3D.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐⭐⭐ Advanced — ~800 lines | -| **Modules used** | core, models, textures, text, audio, math | -| **Teaches** | 3D camera, model loading, basic physics (velocity/acceleration), heightmap terrain, 3D collision | -| **Gameplay** | Low-poly kart racing. Single track, 3 AI opponents, lap timer. Gamepad recommended. | -| **Key concepts** | `Camera3D` following player with smooth lerp, `loadModel()` for kart + track glTF assets, `genMeshHeightmap()` for terrain, `getGamepadAxisValue()` for analog steering, simple AI using waypoints | - -### 6.5 ⛏️ Voxel Sandbox — "Surreal Craft" - -*Minecraft-style voxel world. The ultimate stress test — chunk generation, meshing, infinite terrain.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐⭐⭐⭐ Expert — ~1500 lines | -| **Modules used** | All seven modules | -| **Teaches** | Chunk-based world, greedy meshing, frustum culling, first-person camera, block placement/destruction, procedural terrain with noise | -| **Gameplay** | First-person voxel sandbox. Place/destroy blocks. Infinite procedurally generated terrain. Day/night cycle. | -| **Key concepts** | Chunk class with greedy mesh generation, `Camera3D` in FPS mode with mouse look, `getRayCollisionMesh()` for block picking, custom shader for block face lighting, music streaming for ambient soundtrack | - -### 6.6 🏰 Isometric RPG — "Surreal Quest" - -*Diablo-lite isometric RPG. Showcases the full 2D/3D hybrid pipeline.* - -| Aspect | Details | -|---|---| -| **Complexity** | ⭐⭐⭐⭐ Expert — ~2000 lines | -| **Modules used** | All seven modules | -| **Teaches** | Isometric projection with Camera3D (orthographic), animated sprites, dialogue system, inventory UI, save/load with JSON, NPC AI state machines | -| **Gameplay** | Click-to-move isometric RPG. One village, one dungeon, three quests, boss fight. | -| **Key concepts** | `Camera3D` with orthographic projection for isometric view, `loadModelAnimation()` for character animations, `drawTextEx()` for dialogue boxes, file I/O for save games, full game state management across multiple screens (title/game/inventory/dialogue) | - ---- - -## 7. Naming & Branding - -### 7.1 Name: Surreal - -The name "Surreal" works on multiple levels: - -- **Wordplay on "Unreal"** — instantly positions it in the game engine space while being clearly distinct -- **Art movement connotation** — surrealism = creative, dreamlike, unexpected. Fits the indie/creative game space perfectly -- **"Sur" prefix** means "above/beyond" in French — beyond the real, beyond what's expected from TypeScript -- **Short, memorable, easy to type** — works as a CLI command: `surreal init`, `surreal build` - -**Note:** "Surreal Software" was a defunct game studio (closed 2010, absorbed by WB/Monolith). "SurrealEngine" is a small open-source Unreal 1 reimplementation project. Neither conflict is blocking — both are inactive/niche — but we should verify trademark availability before committing. - -### 7.2 Alternative Names (For Consideration) - -| Name | Pros | Cons | -|---|---|---| -| **Surreal** | Perfect Unreal wordplay, art movement, memorable | Minor historical overlap with defunct studio | -| **Ethereal** | Evocative, no conflicts, "beyond physical" | Harder to spell, less punchy | -| **Prism** | Short, visual, light/color connotation | Very generic, many products use it | -| **Forge** | Implies creation, strength | Overused in dev tools (Electron Forge, etc.) | -| **Tempest** | Dynamic, powerful, unique | No game engine connotation | -| **Mirage** | Visual illusion, game-appropriate | Connotes "fake/unreliable" | - -### 7.3 Import Ergonomics - -The package name should be short and work well in import statements: - -```typescript -import { initWindow, drawCube } from "surreal"; // ✓ clean -import { initWindow } from "surreal/core"; // ✓ modular -import { Vec3, Mat4 } from "surreal/math"; // ✓ specific -``` - ---- - -## 8. Implementation Roadmap - -The engine is built in phases, each producing a usable milestone. Each phase unlocks one or more showcase games. - -| Phase | Milestone | Unlocks | Est. Effort | -|---|---|---|---| -| **Phase 0: Perry FFI** | Perry can call native C functions (Metal/DX/Vulkan wrappers) from TypeScript | Nothing yet — but this is the prerequisite for everything | Already in progress | -| **Phase 1: Window + 2D** | initWindow, game loop, keyboard/mouse input, 2D shape drawing, basic text, sound effects | Pong, simple puzzle games, game jam entries | 4–6 weeks | -| **Phase 2: Textures + Sprites** | Image loading, texture management, sprite batching, Camera2D, drawTexturePro() | Space Blaster, Dungeon Crawl, any 2D sprite game | 3–4 weeks | -| **Phase 3: 3D Basics** | Camera3D, primitive 3D shapes, grid, basic lighting, model loading (glTF) | Kart Racer, simple 3D games | 6–8 weeks | -| **Phase 4: 3D Advanced** | Skeletal animation, PBR materials, heightmaps, custom shaders, frustum culling | Voxel Sandbox, Isometric RPG | 8–12 weeks | -| **Phase 5: Mobile + Polish** | Touch input, iOS/Android support, gamepad, spatial audio, SDF text, performance profiling | Full cross-platform shipping | 6–8 weeks | -| **Phase 6: Community** | Documentation site, example gallery, starter templates, npm-style package registry for Surreal plugins | Community ecosystem growth | Ongoing | - -**Total estimated time to Phase 3 (playable 3D games):** ~4–5 months from Perry FFI readiness. - -**Total to Phase 5 (shippable cross-platform):** ~8–12 months. - -### 8.1 What Perry Needs First - -Surreal is only possible once Perry's compiler supports these features: - -- FFI to native C functions (for GPU and OS API calls) -- Static compilation with asset embedding -- Basic struct/interface memory layout (for Vec2/Vec3/Color to be zero-cost) -- Numeric types mapping to native floats/ints (f32/f64/i32) -- while-loop compilation to native code (the game loop) -- Array/buffer handling for vertex data and audio buffers - ---- - -## 9. Competitive Positioning - -| Engine | Language | Native? | 3D? | Simplicity | Target | -|---|---|---|---|---|---| -| **Surreal** | TypeScript | ✓ Yes (Perry) | ✓ Yes | ⭐⭐⭐⭐⭐ | Indie / Mid-tier | -| Raylib | C (70+ bindings) | ✓ Yes | ✓ Yes | ⭐⭐⭐⭐⭐ | Education / Indie | -| Unity | C# | ✓ Yes (IL2CPP) | ✓ Yes | ⭐⭐ | Indie → AAA | -| Godot | GDScript / C# | ✓ Yes | ✓ Yes | ⭐⭐⭐ | Indie / Mid-tier | -| Unreal | C++ / Blueprints | ✓ Yes | ✓ Yes | ⭐ | AAA | -| Babylon.js | TypeScript | ✗ Browser only | ✓ Yes | ⭐⭐⭐ | Web apps / games | -| Phaser | JS/TS | ✗ Browser only | ✗ 2D only | ⭐⭐⭐⭐ | Web / casual | -| Excalibur | TypeScript | ✗ Browser only | ✗ 2D only | ⭐⭐⭐⭐ | Web / hobby | -| LÖVE | Lua | ✓ Yes | ✗ 2D only | ⭐⭐⭐⭐⭐ | Indie / jams | -| libGDX | Java/Kotlin | ✓ Yes (JVM) | ✓ Yes | ⭐⭐⭐ | Indie / mobile | - -**Surreal's unique position:** The only native game engine for TypeScript. Combines the simplicity of raylib/LÖVE with the developer familiarity of TypeScript and the native performance of compiled code. No other engine occupies this exact space. - -### 9.1 Strategic Value for Perry - -- The most demanding proof that Perry's compiler produces real, performant native code -- Taps into the largest developer community in the world (TypeScript/JavaScript developers) -- Creates a self-reinforcing ecosystem: developers use Surreal → learn Perry → build other Perry apps -- Every Surreal game shipped to Steam/App Store is a Perry success story -- Game jams become viral marketing events for Perry ("Built with Surreal/Perry" splash screens) - ---- - -## 10. Open Questions - -- **Shader language:** Custom TypeScript-like shading language? Or support GLSL/HLSL/MSL pass-through? Or a simplified subset? -- **Physics:** Built-in simple physics (like raylib) or optional integration with Rapier/Box2D via Perry FFI? -- **Networking:** Out of scope for v1? Or include basic TCP/UDP for multiplayer prototyping? -- **ECS:** Provide an optional ECS module (surreal/ecs) or leave it to community packages? -- **UI system:** Immediate-mode UI for game menus (like raygui) or leave to community? -- **Editor integration:** Should Hone get a Surreal scene preview panel? Live-reload during development? -- **License:** MIT? Apache 2.0? zlib (like raylib)? No revenue share, ever. -- **Name finalization:** Surreal vs alternatives. Trademark search needed. diff --git a/bloom-renderer-spec-v2.md b/bloom-renderer-spec-v2.md index 970753a..4242f09 100644 --- a/bloom-renderer-spec-v2.md +++ b/bloom-renderer-spec-v2.md @@ -1,6 +1,34 @@ # Bloom Engine Renderer — v2 Spec (UE5-Tier Target) -**Supersedes (in ambition):** `bloom-renderer-spec.md` — the 12-month "2019 AAA" plan. That document is still correct about the wedge and the taste bottleneck; read §4 of it before this one. +**This is the only renderer spec.** It absorbed and replaced +`bloom-renderer-spec.md` (the 12-month "2019 AAA" plan — its still-true +§4 "Strategic Framing" survives as §6 below) and +`Surreal_Engine_Spec_v01.md` (the pre-rename founding draft; its API +philosophy lives on in `README.md` and the raylib-modeled `src/` API). +Both were removed 2026-07-06; git history has them. + +## Status vs. plan (as-built, 2026-07-06) + +This document is the *plan*; the code has made choices where the plan +offered options, and diverged where reality was cheaper: + +- **API/backends:** wgpu 29 (DX12/Metal/Vulkan/WebGPU through one + crate), not hand-built per-API backends. Shaders are **WGSL strings + in `native/shared/src/renderer/shaders/`**, not Slang. +- **Architecture:** deferred MRT (hdr/material/velocity/albedo), not + clustered forward+ and not (yet) a visibility buffer. No bindless, + no mesh shaders yet. +- **Landed from the tracks:** Lumen-class GI (screen probes; HW + ray-query / SDF-clipmap / Hi-Z tiers, mesh cards, WSRC — see + `docs/perf/007a/007b/013/014/016/017` + `docs/perf/lumen-roadmap.md`), + TSR with real motion vectors incl. material-system draws (EN-022), + SSR with env fallback + IBL ownership (EN-021), cascaded shadow maps + with material-path casters/receivers, material system with a stable + 5-bind-group ABI (`material_abi.wgsl`), CPU+GPU pass profiler with + overlay, planar-reflection water, physical-resolution 2D text. +- **Live status lives in** `docs/perf/lumen-roadmap.md` (GI), + `docs/tickets.md` (EN-xxx work items), and per-ticket docs in + `docs/perf/`. When this spec and those disagree, those win. **Target:** As close to UE5 (Lumen + Nanite + VSM + Substrate + TSR) as a small orchestrated-agent team can pragmatically reach. The goal is *not* to beat UE5. The goal is that a technical art director looking at Bloom screenshots cannot name a specific rendering subsystem where Bloom is obviously behind modern AAA. diff --git a/bloom-renderer-spec.md b/bloom-renderer-spec.md deleted file mode 100644 index 087a510..0000000 --- a/bloom-renderer-spec.md +++ /dev/null @@ -1,205 +0,0 @@ -# Bloom Engine Renderer — 12-Month Spec - -**Target:** 2019–2020 AAA visual quality (Gears 5, Days Gone, Rise of the Tomb Raider tier). Not UE5 Lumen/Nanite. Unambiguously professional, beautiful, competitive with ~90% of shipped AAA titles. - -**Constraint:** Built primarily by Claude Code agents under human orchestration. Spec quality, review discipline, and integration architecture are the real bottlenecks — not raw engineering hours. - -**Non-goals (v1):** Nanite-equivalent virtualized geometry, Lumen-equivalent fully dynamic GI, strand-based hair, MetaHuman-tier characters, hardware ray tracing as a hard requirement. - ---- - -## 0. Architectural Ground Rules - -These constrain every downstream decision. Lock them before any agent writes a line of renderer code. - -- **Graphics API:** Vulkan + Metal via a thin internal abstraction layer. D3D12 deferred to year 2. -- **Rendering architecture:** Clustered forward+ (not deferred). Justification: better MSAA support, cleaner transparency, mobile-friendly future, modern GPU perf gap is closed. -- **Color pipeline:** Linear-space lighting throughout, sRGB only at final output, ACES or AgX tone mapping, physical exposure in EV stops. **Locked from day one — retrofitting this is a multi-week pain.** -- **BRDF:** GGX specular, Burley diffuse, energy-conserving, multi-scatter compensation (Fdez-Agüera approximation). Metallic-roughness workflow. -- **Asset format:** glTF 2.0 as the primary import format. Native binary format for shipped builds, deferred to month 10+. -- **Render graph:** All passes registered through a render graph abstraction from day one. Non-negotiable — this is what lets you reorder, add, and remove passes without rewriting the world later. -- **Shader language:** WGSL or HLSL with cross-compilation. Pick one and lock it. - ---- - -## 1. Phase Plan - -Each phase has: scope, agent task breakdown, integration risks, and acceptance criteria. Phases overlap where dependencies allow — agent parallelism is the whole point. - -### Phase 1 — Foundation (Months 1–2) - -**Scope:** Clustered forward+ base, color pipeline, PBR BRDF, glTF importer, render graph skeleton, basic shadow mapping. - -**Agent task breakdown:** -- Graphics API abstraction layer (Vulkan + Metal backends, command buffer recording, resource management, sync primitives) -- Render graph implementation (pass registration, resource lifetime tracking, automatic barriers) -- Clustered light culling compute shader + CPU-side cluster build -- Forward+ main pass with PBR BRDF -- glTF 2.0 importer (meshes, materials, textures, scene hierarchy, animations stubbed) -- Cascaded shadow maps for directional light (4 cascades, stable fitting, slope-scaled bias) -- Basic shadow atlas for point/spot lights -- Tone mapping pass (ACES + AgX, switchable) -- Test scene loader and screenshot harness - -**Integration risks:** -- Vulkan/Metal abstraction is the single highest-risk piece. Easy to over-engineer, easy to under-engineer. Spec it tightly, review the API surface manually before agents implement. -- Render graph design choices propagate everywhere. Reference: Frostbite's "FrameGraph" GDC talk, Granite's render graph implementation. - -**Acceptance criteria:** Test scene with 200+ PBR objects, 50+ dynamic lights, directional sun with stable cascade shadows, 60fps at 1440p on RTX 3060-tier hardware. Color pipeline verified against reference renders. - ---- - -### Phase 2 — Shadows and AO (Months 3–4) - -**Scope:** Production-quality shadow filtering, GTAO, shadow atlas allocation strategy. - -**Agent task breakdown:** -- PCF shadow filtering with Vogel disk sampling, configurable kernel size -- Stable cascade fitting (texel snapping) -- Shadow atlas allocator with importance-based region sizing -- GTAO implementation following XeGTAO reference -- Contact shadows (screen-space ray-marched short shadows) -- Shadow caching for static geometry (only re-render moving casters) - -**Integration risks:** -- GTAO requires depth + normal buffers in specific formats. Lock the G-buffer-lite layout before starting. -- Shadow caching needs static/dynamic geometry classification — tie this into the scene system early. - -**Acceptance criteria:** Outdoor scene with non-shimmering sun shadows during camera movement, indoor scene with 30+ shadow-casting lights at 60fps, AO that grounds objects without obvious haloing. - ---- - -### Phase 3 — Post Stack and TAA (Months 5–6) - -**Scope:** The full post-processing chain and temporal anti-aliasing. **This is the biggest visual leap of the year.** - -**Agent task breakdown:** -- Physically based bloom (CoD:AW downsample/upsample chain, 13-tap filter) -- Depth of field with hexagonal/circular bokeh -- Per-object motion vector buffer -- Motion blur (camera + per-object) -- Auto-exposure with histogram metering and adaptation curves -- Vignette, chromatic aberration, film grain, lens dirt -- **TAA implementation:** jittered projection matrix, history reprojection, neighborhood variance clipping, disocclusion handling - -**Integration risks:** -- TAA is the hardest piece in the entire 12 months. Budget 4–6 weeks even with agent parallelism. Plan for multiple iterations. Reference: Karis "High Quality Temporal Supersampling," Pedersen "Temporal Reprojection AA in INSIDE." -- TAA depends on motion vectors being correct for *every* dynamic object including skinned meshes — coordinate with whoever builds the animation system. -- Motion vector buffer format and precision affects everything downstream. Lock early. - -**Acceptance criteria:** Screenshots that look "cinematic" without further work. TAA stable on thin geometry (foliage, hair-like objects), no ghosting on fast camera motion, no shimmering on specular highlights. - ---- - -### Phase 4 — Reflections and GI (Months 7–8) - -**Scope:** SSR, reflection probes, offline lightmap baker, light probe volumes for dynamic objects. - -**Agent task breakdown:** -- Hierarchical Z-buffer construction -- Screen-space reflections with importance sampling for rough surfaces -- Temporal SSR accumulation (now possible because TAA infrastructure exists) -- Reflection probe capture pipeline (cubemap rendering, prefiltering for roughness mips) -- Parallax-corrected cubemap blending -- **Offline lightmap baker:** CPU path tracer (Embree-based), lightmap UV generation/packing, denoiser integration (Intel Open Image Denoise) -- Light probe volume placement and SH9 storage -- Runtime SH probe sampling for dynamic objects - -**Integration risks:** -- The lightmap baker is essentially a second renderer. It's a major project on its own — easily 6+ weeks of agent work even with good parallelism. -- Lightmap UV generation is a known-hard problem. Consider integrating xatlas rather than rolling your own. -- Decision point at start of phase: confirm we're going baked GI for v1, not DDGI. **Recommendation: stay baked.** DDGI is a year-2 layer. - -**Acceptance criteria:** Indoor scene with bounce lighting that matches a path-traced reference within reasonable tolerance. Wet outdoor scenes with believable reflections. Dynamic characters lit consistently with their static surroundings. - ---- - -### Phase 5 — Volumetrics and Sky (Months 9–10) - -**Scope:** Volumetric fog, physical sky, volumetric clouds. The "atmosphere" tier. - -**Agent task breakdown:** -- Froxel-based volumetric fog (3D texture aligned to view frustum, single raymarch, sampled by main pass) -- Light injection into froxel volume (every shadow-casting light contributes) -- Hillaire sky model (Rayleigh + Mie scattering, precomputed transmittance/multiscatter LUTs) -- Time-of-day system tied to sky model -- Volumetric clouds (Schneider/Vos approach: noise-based density field, raymarched, lit by sun + ambient) -- Aerial perspective LUT for distance fog on opaque geometry - -**Integration risks:** -- Volumetric fog needs every light to inject — coordinate with the light culling system. May require a second light list pass. -- Cloud rendering is expensive. Plan the LOD/quality settings from the start. -- Sky needs to feed back into reflection probes for accurate ambient — this couples Phases 4 and 5. - -**Acceptance criteria:** Forest scene with god rays through canopy. Mountain vista with believable atmospheric perspective. Day/night cycle that looks right at every hour. Drifting volumetric clouds that cast shadows on the ground. - ---- - -### Phase 6 — Upscaling, Materials, Polish (Months 11–12) - -**Scope:** FSR2/3 integration, advanced material features, particles, decals, water, skin shading. The long tail. - -**Agent task breakdown:** -- FSR2 or FSR3 integration (use AMD's reference implementation, do not roll your own) -- Material features: clearcoat, sheen, anisotropy, subsurface approximation -- Particle system rendering: soft particles, lit particles, GPU simulation backend -- Deferred decals (screen-space projection) -- Skin shading: Jimenez separable SSS -- Eye shading model -- Water v1: tessellated plane, Gerstner waves, SSR + cubemap fallback, foam approximation -- Foliage rendering: two-sided lighting, wind animation, alpha-to-coverage with TAA -- Final tuning pass against reference screenshots - -**Integration risks:** -- Material features bloat shader permutations fast. Use uber-shader with feature flags + compile-time pruning, not separate shaders per combination. -- Water touches everything (reflection, refraction, fog, sky). It's a horizontal feature, not vertical. Budget accordingly. -- The "tuning month" is non-negotiable. Real engines have technical artists tuning defaults for years; we get one month. - -**Acceptance criteria:** Screenshots that pass a blind test against 2019–2020 AAA references. No single rendering subsystem is the obvious "this looks worse than Unreal" weak point. - ---- - -## 2. The Agent Orchestration Layer - -The thing that makes this plan actually work — and the thing that's unique to your approach. - -**Spec discipline.** Every phase needs a written spec at the same level of detail as Phase 1's task breakdown above, *before* any agent starts. Vague specs produce agent code that looks plausible and is subtly wrong, which is worse than no code. - -**Reference implementations as ground truth.** For every major subsystem, identify 1–2 open-source reference implementations the agents can study. XeGTAO for AO, AMD FSR for upscaling, Hillaire's sky shader code, Granite's render graph, Bevy's renderer for general patterns. Agents working from references produce vastly better code than agents working from descriptions. - -**Test scenes as acceptance gates.** Build a library of test scenes early — Sponza, Bistro, San Miguel, plus custom Bloom-specific scenes. Every phase ends with screenshot diffs against the previous phase and against reference renders. This is how you catch regressions when 6 agents are touching the renderer in parallel. - -**Integration windows.** Agents work in parallel on isolated subsystems, but integration is sequential and human-driven. Plan for one integration day per week minimum. This is where you (the human) earn your keep. - -**The taste bottleneck.** Agents will produce technically correct renderers that look subtly wrong. Tone mapping curves, bloom intensity, exposure response, AO darkness — these all require human eyes against reference material. Don't try to delegate this. - ---- - -## 3. Honest Risk Assessment - -**What can actually break this plan:** - -- **TAA.** If TAA isn't solid by end of month 6, everything downstream (SSR, stochastic effects, upscaling) is compromised. This is the single highest-risk subsystem. If it slips, slip the whole plan rather than shipping bad TAA. -- **The lightmap baker.** Building an offline path tracer is a real project. If it's not ready by end of month 8, fall back to a simpler ambient solution (SH probes everywhere, no lightmaps) and ship baked GI in v1.1. -- **Render graph design debt.** If the render graph abstraction is wrong, every phase pays interest. Spend disproportionate time on this in month 1. -- **The taste wall at month 7–8.** Everything will look technically correct but not as good as references. Budget a week of pure tuning at this point or the wall becomes month 11's problem. - -**What this plan does NOT get you:** - -- Lumen-equivalent dynamic GI -- Nanite-equivalent geometry -- Strand-based hair -- MetaHuman-tier characters -- Hardware ray tracing as a primary path - -These are all year-2+ items. The 12-month target is "visuals are not the reason a developer rejects Bloom," not "Bloom out-renders Unreal." - ---- - -## 4. Strategic Framing - -The renderer is not Bloom's wedge. Perry, native compilation, sub-second iteration, and the developer experience are the wedge. This spec exists so visuals don't become a *reason to reject* Bloom — not so they become the headline. - -A developer who picks Bloom because their build times dropped from 5 minutes to 5 seconds will happily accept "looks like 2019 Unreal." This plan delivers exactly that, in the only way one person realistically can: by orchestrating agents against tight specs rather than typing every line. - -The fact that "everyone could do this" but almost no one actually is — that's the moat. Specs like this one are the moat. diff --git a/docs/crash-triage-windows.md b/docs/crash-triage-windows.md index e7b26f0..c594164 100644 --- a/docs/crash-triage-windows.md +++ b/docs/crash-triage-windows.md @@ -1,59 +1,100 @@ -# Windows crash triage — native AVs in Perry-compiled games - -Written after the 2026-07-04 round-2 audit, where the Bloom Shooter hit the -same access violation in three scripted runs (`main.exe+0xe8e5`, read of an -address 8 bytes below a page boundary, exception c0000005) and the fault -could not be reproduced after a relink. Everything below is the kit that -makes the *next* occurrence a five-minute diagnosis instead of a hunt. - -## What we know about the 2026-07 AV (EN-020) - -- Same faulting instruction offset in all three crashes; faulting address - `0x…FFF8` = a read that walked off the end of a heap allocation onto an - unmapped page. Classic buffer-overrun-read signature: the overrun likely - happens often and only faults when the allocator places the buffer at - the end of a page — which makes it **layout-sensitive**: any relink - reshuffles the odds. Our fishing runs on a rebuilt binary (60 s with the - same workloads, plus a 19-transition feature-toggle gauntlet) did not - reproduce. -- Crash contexts (shooter audit tour): 20–56 s into runs that combined the - profiler (enabled), profiler-string FFI reads (`getProfilerOverlay` / - `getProfilerFrameHistory`), stage transitions, and in two cases runtime - feature disables. Toggles alone were exonerated by the gauntlet run; - string churn alone was exonerated by run 3 (crashed with only ~8 tiny - prints). No Rust panic output on stderr — this is raw UB, not a panic - (the engine builds with `panic = "abort"`, which would print first). -- Suspect space: Perry runtime heap/string handling at the FFI boundary, - or an engine-side out-of-bounds read into a heap buffer. The faulting - module offset (0xe8e5, very low in `.text`) is consistent with a small - shared helper (memcpy-class) rather than a leaf feature. - -## Standing infrastructure on the dev box - -- **WER LocalDumps** (HKCU, no admin needed): full dumps for `main.exe` - land in `shooter/tools/.testout/dumps/`, dialog suppressed - (`Windows Error Reporting\DontShowUI = 1`). Configured 2026-07-04; - survives reboots. Every crash from now on leaves a `.dmp`. -- **Symbols**: `native/windows/Cargo.toml` sets - `[profile.release] debug = "line-tables-only"`, so the staticlib carries - line tables at negligible cost. Link the game with - `perry compile src/main.ts -o main --debug-symbols` to get `main.pdb` - next to the exe (lld `/DEBUG`). Keep the exe+pdb pair that produced any - dump. -- **Symbolisation** (LLVM is installed at `C:\Program Files\LLVM`): - `llvm-symbolizer --obj=main.exe --use-native-pdb-reader 0x` maps - the WER event's module offset to a function/line. For a full stack, - open the `.dmp` in WinDbg (`winget install Microsoft.WinDbg`) with - `.sympath` pointing at the exe's folder, then `!analyze -v`. -- The WER Application-log event (Id 1000) alone already gives the module - + offset — check it first: +# Windows crash triage — native faults in Perry-compiled games + +Written during the 2026-07-04 round-2 audit, when the Bloom Shooter hit a +layout-sensitive access violation nobody could reproduce (EN-020). That +bug is now **root-caused and fixed** — see `tickets.md` § EN-020 — and the +hunt left permanent infrastructure behind. This doc describes how to +triage the *next* native fault. + +## The engine self-reports now (first stop: stderr) + +Since 2026-07-04 (`crash_report` module in `native/windows/src/lib.rs`), +every game linked against the engine: + +- catches unhandled SEH exceptions and prints + `bloom: FATAL unhandled exception 0x at 0x (main.exe+0x)` + to stderr, then writes a minidump to `tools/.testout/dumps/` + (dbghelp is loaded at runtime — its import lib is deliberately NOT on + perry's link line); +- logs `WM_CLOSE` / `WM_DESTROY` (so a "clean" exit via window death is + visible — a frozen-looking game whose window died says so); +- logs surface-acquire failures once + every 300th (a game that lost its + swapchain spins headless: last presented frame stays on screen, input + looks dead — that's what a user reports as "frozen"). + +So: **capture stderr** (launch via +`Start-Process -RedirectStandardError`), and the failure class is +usually labeled before you open a debugger. + +What still dies silently: fast-fail (`0xC0000409` — Rust abort after a +panic message, or a CRT heap-corruption check) bypasses the filter by +design, and `TerminateProcess`. An empty stderr + no WER event now +narrows to exactly those. + +## Reading what it gives you + +- **`main.exe+0x` → function:** + `llvm-symbolizer --obj=main.exe 0x14000` (LLVM is at + `C:\Program Files\LLVM`; add the default PE image base `0x140000000` + to the RVA; the `--use-native-pdb-reader` flag no longer exists in + the installed version). Perry-compiled TS functions symbolize to + `perry_fn___` with no line info; engine Rust frames get + file:line from `main.pdb`. +- **Symbols:** `native/windows/Cargo.toml` sets + `debug = "line-tables-only"`; link games with + `perry compile src/main.ts -o main --debug-symbols` to get `main.pdb`. + Keep the exe+pdb pair that produced any dump. +- **Dumps:** open in WinDbg (installed via + `winget install Microsoft.WinDbg`, launches as `WinDbgX`). Note the + store WinDbg is GUI-first; for scripted use, pass `-z ` and mind + that `-c` command strings containing `;` after `.sympath` get eaten — + set the sympath interactively or via workspace instead. +- **Do NOT reach for lldb**: the LLVM-bundled `lldb.exe` on the dev box + delay-load crashes on a missing `python311.dll`. +- **WER LocalDumps** (HKCU, no admin) remains armed as a second net: + dumps for `main.exe` → `shooter/tools/.testout/dumps/`, error dialog + suppressed. The WER Application-log event (Id 1000) carries + module+offset even without a dump: `Get-WinEvent -FilterHashtable @{LogName='Application'; Id=1000} -MaxEvents 3`. +- **PageHeap** (the definitive overrun-catcher) needs an elevated shell + for the HKLM IFEO write — the dev shell is not elevated; ask before + reaching for it. + +## Repro harness patterns that actually work (2026-07 hunt) + +- Launch the game batch-style with stdout/stderr redirected; the game + steals the whole screen, so runs are unattended. +- **Keystrokes:** `keybd_event`/SendKeys never reach a + background-launched game (no keyboard focus). Use + `PostMessageW(hwnd, WM_KEYDOWN/WM_KEYUP, vk, lparam)` against the + `FindWindowW(null, 'Bloom Shooter')` handle — the engine's wndproc + consumes it like a real key. +- **Freeze detection:** pixel-diff consecutive full-frame captures over + a probe rect. A live frame at TSR 0.5 has ≥ ~2.0 mean-abs-diff of + grain; exactly 0.0 across 2.5 s means presents stopped. Gate on mean + luma 15–240 to skip boot black / init-white phases, and make no + judgment before ~15 s (boot takes 8–12 s and `Process.Responding` is + false throughout it — it is NOT a hang signal during boot). +- **Exit codes:** set `$proc.EnableRaisingEvents = $true` right after + `Start-Process` or `.ExitCode` reads back null. +- Reference implementation: the shooter session scratchpad's + `repro5.ps1`/`repro7.ps1` (title-detect → PostMessage key → + gameplay soak → per-run stderr/exit-code/dump collection). -## Repro harness +## Case study: EN-020 (resolved 2026-07-04) -The shooter's audit tour (`AUDIT` block for `src/main.ts`, preserved with -the round-2 artifacts) drives the game unattended: scripted combat at the -enemy-pool max, camera pose hops, profiler-string churn every 2 s, and an -optional feature-toggle gauntlet. Historical crash probability was 3/3 -within 60 s on the af98dbe-era binary; treat every future tour run as a -fishing run — the dump infrastructure is armed. +Three audit-tour crashes at `main.exe+0xe8e5` reading `0x…FFF8` looked +unreproducible — every relink reshuffled the heap and hid it. The +profiler overlay turned out to be a near-deterministic trigger (two +fresh packed-text strings parsed per frame → 6/6 AVs in 7–29 s across +two link layouts, faulting inside `perry_fn_…getProfilerOverlay` / +`…getProfilerFrameHistory`). Root cause: Perry 0.5.x `split()` + +`parseFloat()` read past their own exact-sized slice allocations. +Engine-side tail-padding of `alloc_perry_string` did NOT fix it (the +overread is on Perry-internal allocations) — the fix was the numeric +profiler ABI (`bloom_profiler_row_*` / `_hist_*`), validated 3/3 × 90 s +clean. Moral: when a "random" AV correlates with per-frame string +parsing on Perry, believe the correlation; and a layout-lottery bug +needs a rate amplifier (per-frame allocations), not more fishing runs. +Minidumps from the hunt are archived in +`shooter/tools/.testout/dumps/crash_main_*.dmp`. diff --git a/docs/tickets.md b/docs/tickets.md index fd4e700..44ee957 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -658,9 +658,15 @@ test. the initial window create. If this becomes a real-world issue add `GetDeviceCaps(hdc, LOGPIXELSX)` as a third fallback. -**Blocked on:** access to a Windows 11 box with a HiDPI display -(or a VM forwarding DPI), and a Linux dev with X11. Web checks -can be done from any modern browser on the macOS dev box. +**Blocked on:** ~~access to a Windows 11 box with a HiDPI display~~ — +**the Windows half is field-validated as of 2026-07**: the shooter dev +box is exactly the canonical setup (Windows 11, 4K @ 150%), and months +of round-1/round-2 work ran on it — physical 3840×2160 vs logical +2560×1440 split correct end-to-end (borderless fullscreen at native +res, PMv2 capture tooling, and the 2D text physical-res work all depend +on it). Still untested: WM_DPICHANGED monitor-drag (single-monitor +box), Linux X11, Web Retina. Web checks can be done from any modern +browser on the macOS dev box. ## EN-020 — Native AV: heap read overrun, layout-sensitive ✅ root-caused 2026-07-04 @@ -708,7 +714,15 @@ per frame; dumps + offsets above). The OBJ text loader LOAD time — one-shot exposure, worth migrating when touched. Audit report: `shooter/docs/audit-round2.md` finding F1. -## EN-021 — SSR + IBL specular exclusive ownership 🟡 +## EN-021 — SSR + IBL specular exclusive ownership ✅ implemented (PR #78) + +**Status 2026-07-04.** Landed on `feat/en021-ssr-ibl-ownership` (PR #78, +pending merge): env-cubemap fallback bound into the SSR pass (miss +returns filtered env instead of black, `env_fallback()` in ssgi.rs; +fresnel applied before the facing check so both miss paths agree), and +`fs_main_scene` scales `ibl_spec` by the exact complement of the SSR +fade (`ssr_own` via `dir_light_count.z`, plumbed through +`clear_additional_lights`). Design notes below kept for review context. **Why.** Round-2 audit (B): compose is `hdr + ssr` and `fs_main_scene` already adds IBL specular into hdr for metals — pixels with an SSR hit @@ -735,7 +749,18 @@ with the audit's metal-ROI protocol (on-hit vs panned-off luma converge **Interim calibration option:** `set_ssr_strength(0.25–0.35)` halves the overlap engine-wide with zero shader work. -## EN-022 — Motion vectors for material-system draws 🔴 +## EN-022 — Motion vectors for material-system draws ✅ implemented (PR #82 + shooter PR #4) + +**Status 2026-07-04.** Landed on `feat/en022-material-velocity` (PR #82, +pending merge) + shooter PR #4: per-slot model history in +MaterialSystem (`prev_models`/`cur_models` rotated in +`reset_draw_slot(prev_vp)`, slot = submission order), `prev_mvp` +reconstructed engine-side (the legacy caller-supplied param is +ignored), `abi_motion_vector()` in material_abi.wgsl, and all four +shooter world materials (terrain/building/tree/grass — incl. the inline +grass copy in main.ts) write real velocity with wind sway evaluated at +`frame.time - frame.delta_time`. Smoke-validated: no TSR tearing, +sway ghosting gone. Design notes below kept for review context. **Why.** Round-2 audit (F8): every material-path draw writes velocity = 0 (terrain/tree/grass/building — the whole static world plus wind sway), @@ -758,7 +783,17 @@ ABI struct + material_system plumbing + 4 world materials + goldens. `post.rs` motion clamp engages on world pixels (debug: motion_alpha visualization). -## EN-023 — GI software path: colored bounce is unreachable 🟡 +## EN-023 — GI software path: colored bounce is unreachable 🟡 partially landed (PR #79), still open + +**Status 2026-07-04.** `feat/en023-gi-sw-cards` (PR #79, pending merge) +fixed the data path: world-space AABBs carried per instance +(`world_aabb_min/max` in InstanceGiData, both HW and SDF struct +mirrors), smallest-containing-box broad-phase pick (a scene-spanning +terrain proxy no longer swallows every hit), and the SW WSRC bake got a +`ground_albedo` bounce term. Measured payoff on the 760M is still ≤2% +achromatic even at 4× intensity — the bottleneck moved downstream to +probe resolve/AO integration, so the ticket STAYS OPEN pointed there. +Interim option D2-B (disable SSGI on SW adapters) remains reasonable. **Why.** Round-2 audit (F4): on adapters without EXPERIMENTAL_RAY_QUERY (the dev box's Radeon 760M reports none — see the new boot log), the From 713e2a14346effba30b6afc6145f27c9af0f064f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 12:05:59 +0200 Subject: [PATCH 12/15] fix(render): fullscreen lag + flicker root causes (2026-07-06 investigation) One commit because the fixes interleave through renderer/mod.rs and intermediate splits would not build. Each fix is documented at its site. LAG (multi-second GPU stalls every ~5s of camera motion on iGPUs): - Scene-SDF-clipmap rebake was a single brute-force dispatch (64^3 voxels x ALL scene triangles, timestamp-less, refired on every 10m of eye travel -> 1-2.4s stall inside submit). Now: CPU triangle binning into 16^3 cells (one-cell expansion, conservative 2.5m narrow-band clamp, sphere-trace-safe) + 16 Z-layers/frame baked into a staging volume, copied over atomically on completion. Startup GI stall (~2.5s) gone too. - WSRC bakes one cascade per frame. Also fixes params aliasing: all same-frame cascade dispatches read the LAST cascade's uniform because queue.write_buffer applies before any encoded command executes. FLICKER (building face banding/popping, reported on round2/integration): - Material path sampled shadow cascades with the PREVIOUS frame's VPs (PerView uploaded before the shadow fit; the deferred path reads the fresh lighting buffer, which is why only material-path receivers banded). refresh_shadow_uniforms patches PerView after the fit; the splits .w mip-bias slot stays 0 as the material path always had it. - Cascade pancake extents now grow-fast/shrink-hysteresis at 2m steps; animated caster bounds crossing the old 1/16m ceil() steps toggled the fitted VP between two matrices every idle-anim cycle. - Auto-exposure: in-bin percentile interpolation (target was quantized to whole 0.22-log2 bins = 16% exposure steps), anchored 2% deadband (anchor persisted in the exposure texture .g, now Rg16Float), and gap-proportional adaptation rate. Measurement sits downstream of TAA, so its wiggle no longer round-trips into visible brightness hunting. - EN-022 motion vectors: prev_mvp / prev_view_proj were composed from the raw jittered previous VP, giving every static pixel a one-texel jitter-delta velocity that wobbled TAA history reprojection. All velocity references now use prev UNJITTERED proj+view with the CURRENT frame's jitter re-applied, cancelling exactly in (curr_ndc - prev_ndc). - GTAO temporal: the ao_delta>0.35 hard history refresh fired simultaneously across whole surfaces (per-frame 2-of-8 direction phase is globally shared), replacing converged AO with the noisiest possible single-frame estimate. Now: raw sample clamped to history +/-0.15 and the phase is dithered across each 2x2 quad so all 4 phases land every frame. TAA disocclusion reject deliberately NOT motion-gated -- it is what flushes chroma-poisoned history (luma-only variance clamp leaves chroma free); weakening it green-tints the frame within seconds. WINDOWS: - Windowed mode crashed on the first frame with a scene-reading translucent material: default render_scale is 0.5, so setRenderScale(0.5) no-ops and windowed (which never gets the WM_SIZE the borderless transition forces) kept construction-time render targets -> partial Depth32Float snapshot copy -> fatal validation error. Windows init now routes through resize() once. - Swapchain gets COPY_SRC so bloom_take_screenshot's readback is legal, and its failure paths now eprintln instead of being swallowed. (The FFI is still never invoked by Perry-compiled code -- upstream bug.) Measured on the 4K/Radeon 760M box: rotating-camera gameplay 40fps with 1-2.4s freezes -> locked 59-60fps, GI enabled throughout; startup worst frame 2.5s -> ~90ms; shadow banding eliminated (wall-region captures 15-16% alternation -> 0.0% with TAA off, stable with TAA on). --- native/shared/src/engine.rs | 6 +- native/shared/src/ffi_core/assets.rs | 1 + native/shared/src/renderer/formats.rs | 48 ++- native/shared/src/renderer/material_system.rs | 41 ++ native/shared/src/renderer/mod.rs | 406 +++++++++++++++--- native/shared/src/renderer/shaders/ao.rs | 37 +- native/shared/src/renderer/shaders/gi.rs | 108 +++++ native/shared/src/renderer/shaders/mod.rs | 2 +- native/shared/src/renderer/shaders/post.rs | 51 ++- native/shared/src/renderer/shadow_pass.rs | 18 + native/shared/src/shadows.rs | 43 +- native/windows/src/lib.rs | 16 +- 12 files changed, 693 insertions(+), 84 deletions(-) diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index aab9b76..c341331 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -166,7 +166,11 @@ impl EngineState { &self.renderer.device, &self.renderer.queue, &self.renderer.vp_matrix(), - &self.renderer.prev_vp_matrix, + // EN-022 fix: the velocity reference (prev unjittered VP + // + current jitter) — NOT the raw jittered prev VP — so + // static scene nodes get true zero velocity instead of + // jitter-delta noise that flickers TAA history. + &self.renderer.velocity_ref_vp, self.renderer.uniform_3d_layout(), Some(&self.renderer.occlusion), ); diff --git a/native/shared/src/ffi_core/assets.rs b/native/shared/src/ffi_core/assets.rs index 2260e36..05a3b55 100644 --- a/native/shared/src/ffi_core/assets.rs +++ b/native/shared/src/ffi_core/assets.rs @@ -15,6 +15,7 @@ macro_rules! __bloom_ffi_assets { pub extern "C" fn bloom_take_screenshot(path_ptr: *const u8) { $crate::ffi::guard("bloom_take_screenshot", move || { let path = $crate::string_header::str_from_header(path_ptr).to_string(); + eprintln!("bloom: screenshot requested -> '{}'", path); let eng = engine(); eng.renderer.screenshot_requested = true; eng.renderer.pending_screenshot_path = Some(path); diff --git a/native/shared/src/renderer/formats.rs b/native/shared/src/renderer/formats.rs index 5c0cc8d..076050b 100644 --- a/native/shared/src/renderer/formats.rs +++ b/native/shared/src/renderer/formats.rs @@ -111,7 +111,9 @@ pub(super) fn create_exposure_textures(device: &wgpu::Device) -> ([wgpu::Texture mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::R16Float, + // Rg16Float (was R16Float): .r = smoothed exposure, .g = the + // anchored AE target (flicker fix — see EXPOSURE_SHADER_WGSL). + format: wgpu::TextureFormat::Rg16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], @@ -333,12 +335,51 @@ pub(super) const SCENE_SDF_CLIPMAP_EXTENT: f32 = 40.0; /// of the full extent. 0.25 = rebake when the camera has moved more /// than 10 m from the clipmap centre on a 40 m clipmap. pub(super) const SCENE_SDF_CLIPMAP_REBAKE_THRESHOLD: f32 = 0.25; +/// Amortized-rebake fix: triangles are binned on the CPU into this many +/// cells per axis (must divide SCENE_SDF_CLIPMAP_RES) so each voxel only +/// tests triangles from its own pre-expanded cell instead of the whole +/// scene. One cell width (extent / cells = 2.5 m) is also the narrow-band +/// clamp the bake shader writes for empty cells. +pub(super) const SCENE_SDF_CLIPMAP_BIN_CELLS: u32 = 16; +/// Amortized-rebake fix: voxel Z-layers baked per frame into the staging +/// texture (must be a multiple of the 4-deep workgroup). 16 layers → +/// a full 64³ rebake spreads over 4 frames while traces keep sampling +/// the live clipmap. +pub(super) const SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME: u32 = 16; pub(super) fn create_scene_sdf_clipmap( device: &wgpu::Device, +) -> (wgpu::Texture, wgpu::TextureView) { + // COPY_DST: the amortized rebake bakes into a staging clone and + // copies over the live texture when the last slice lands. + create_scene_sdf_clipmap_tex( + device, + "scene_sdf_clipmap", + wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, + ) +} + +/// Amortized-rebake fix: staging clone the sliced bake writes into while +/// the live clipmap keeps serving traces. +pub(super) fn create_scene_sdf_clipmap_staging( + device: &wgpu::Device, +) -> (wgpu::Texture, wgpu::TextureView) { + create_scene_sdf_clipmap_tex( + device, + "scene_sdf_clipmap_staging", + wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + ) +} + +fn create_scene_sdf_clipmap_tex( + device: &wgpu::Device, + label: &str, + usage: wgpu::TextureUsages, ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("scene_sdf_clipmap"), + label: Some(label), size: wgpu::Extent3d { width: SCENE_SDF_CLIPMAP_RES, height: SCENE_SDF_CLIPMAP_RES, @@ -348,8 +389,7 @@ pub(super) fn create_scene_sdf_clipmap( sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::R32Float, - usage: wgpu::TextureUsages::STORAGE_BINDING - | wgpu::TextureUsages::TEXTURE_BINDING, + usage, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); diff --git a/native/shared/src/renderer/material_system.rs b/native/shared/src/renderer/material_system.rs index bd57291..256a3c4 100644 --- a/native/shared/src/renderer/material_system.rs +++ b/native/shared/src/renderer/material_system.rs @@ -707,6 +707,38 @@ impl MaterialSystem { queue.write_buffer(&self.per_view_buffer, 0, bytemuck::bytes_of(per_view)); } + /// Shadow-flicker fix — re-upload just PerView's trailing shadow + /// fields. `update_frame_uniforms` runs BEFORE the shadow pass fits + /// this frame's cascade VPs, so PerView otherwise carries the + /// PREVIOUS frame's matrices while the depth maps hold this frame's + /// content — a one-frame mismatch that flashes acne bands across + /// material-path receivers whenever the fit moves (the deferred path + /// reads the freshly-written lighting buffer and never had this). + /// Called from `record_shadow_pass` after the fit; queued buffer + /// writes all apply before any of this frame's draws execute, so + /// this patch wins over the earlier stale upload. + pub fn refresh_shadow_uniforms( + &self, + queue: &wgpu::Queue, + shadow_splits: [f32; 4], + shadow_view: [[f32; 4]; 4], + shadow_cascades: [[[f32; 4]; 4]; 3], + ) { + #[repr(C)] + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] + struct ShadowTail { + shadow_splits: [f32; 4], + shadow_view: [[f32; 4]; 4], + shadow_cascades: [[[f32; 4]; 4]; 3], + } + let tail = ShadowTail { shadow_splits, shadow_view, shadow_cascades }; + // The shadow fields are the last three in PerViewUniforms; Pod + // guarantees no padding, so the tail offset is exact. + let offset = (std::mem::size_of::() + - std::mem::size_of::()) as u64; + queue.write_buffer(&self.per_view_buffer, offset, bytemuck::bytes_of(&tail)); + } + /// Reset the per-draw slot cursor. Commands lists are cleared by /// the Renderer from its own `begin_frame` so the order of reset /// vs. submit is deterministic. @@ -721,6 +753,15 @@ impl MaterialSystem { self.prev_vp = prev_vp; } + /// EN-022 fix — `begin_mode_3d` overrides the velocity reference + /// with the previous frame's UNJITTERED VP re-jittered with the + /// CURRENT frame's offsets, so prev_mvp cancels the TAA jitter + /// exactly. (reset_draw_slot runs at begin_frame, before the + /// current frame's jitter is known.) + pub fn set_velocity_reference_vp(&mut self, vp: [[f32; 4]; 4]) { + self.prev_vp = vp; + } + /// Phase 5 — set/replace `user_params` for a specific material. The /// next dispatch of this handle binds a per-material BindGroup with /// the given bytes uploaded to `@group(2) @binding(11)`. Materials diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index cb8e31c..34d363a 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -59,8 +59,10 @@ use formats::{ create_mesh_card_radiance_atlas, CARD_ATLAS_SIZE, CARD_SLOT_SIZE, CARD_SLOTS_PER_ROW, CARD_MAX_SLOTS, CARD_AXES_PER_MESH, MESH_SDF_RES, - create_scene_sdf_clipmap, SCENE_SDF_CLIPMAP_RES, + create_scene_sdf_clipmap, create_scene_sdf_clipmap_staging, + SCENE_SDF_CLIPMAP_RES, SCENE_SDF_CLIPMAP_EXTENT, SCENE_SDF_CLIPMAP_REBAKE_THRESHOLD, + SCENE_SDF_CLIPMAP_BIN_CELLS, SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME, create_wsrc_atlas, WSRC_GRID_RES, WSRC_CASCADE_COUNT, WSRC_CASCADE_EXTENTS, WSRC_REBAKE_THRESHOLD, create_taa_textures, @@ -445,6 +447,21 @@ struct SdfBakeParams { counts: [u32; 4], } +/// Fullscreen-lag fix — an in-flight amortized scene-SDF-clipmap bake. +/// A job bakes `SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME` voxel Z-layers per +/// frame into the staging texture; when the last slice lands the staging +/// contents are copied over the live clipmap and the origin flips +/// atomically, so traces never observe a half-baked field. The bind +/// group keeps the job's transient buffers alive. +struct SdfClipmapBakeJob { + origin: [f32; 3], + aabb_min: [f32; 4], + aabb_max: [f32; 4], + uniform: wgpu::Buffer, + bind_group: wgpu::BindGroup, + next_z: u32, +} + /// Ticket 014 V6 — uniform for `WSRC_BAKE_WGSL`. Analytic sun × shadow /// + analytic sky computed per probe-octel. Shadow VPs + splits + /// flags mirror CARD_LIGHT_WGSL so the shader can re-use the same @@ -937,6 +954,21 @@ pub struct Renderer { /// removing ghosting under camera motion. Updated at the end /// of each frame from current_vp_matrix. pub prev_vp_matrix: [[f32; 4]; 4], + /// EN-022 fix — previous frame's UNJITTERED projection + view, + /// kept separately so `begin_mode_3d` can compose the velocity + /// reference VP (prev unjittered proj + CURRENT jitter × prev + /// view). Building prev_mvp from the raw jittered prev VP gave + /// every static pixel a one-texel velocity wobble (the jitter + /// delta), which cycled TAA history between converged and + /// rejected — periodic sharp/soft flicker on detailed surfaces. + prev_proj_matrix_unjittered: [[f32; 4]; 4], + prev_view_matrix: [[f32; 4]; 4], + /// Current frame's TAA jitter as NDC offsets (0,0 when TAA off). + current_jitter_ndc: [f32; 2], + /// The composed velocity-reference VP for this frame — what all + /// prev_mvp compositions must use so jitter cancels in the + /// shader's (curr_ndc - prev_ndc). + pub(crate) velocity_ref_vp: [[f32; 4]; 4], /// Fog color (rgb) — blended into scene where fog factor > 0. pub fog_color: [f32; 3], /// EN-005 Phase 4 — `true` once the user has called @@ -1139,6 +1171,17 @@ pub struct Renderer { /// snapped camera position at last bake). Read every frame by the /// trace uniform; updated at bake time. pub scene_sdf_clipmap_origin: [f32; 3], + /// Fullscreen-lag fix — dedicated binned+sliced clipmap bake + /// pipeline (the per-mesh `sdf_bake_pipeline` stays brute-force; + /// its 32³ per-mesh volumes are already rate-limited). + sdf_clipmap_bake_pipeline: wgpu::ComputePipeline, + sdf_clipmap_bake_layout: wgpu::BindGroupLayout, + scene_sdf_clipmap_staging_tex: wgpu::Texture, + scene_sdf_clipmap_staging_view: wgpu::TextureView, + /// Camera drifted past the rebake threshold — start a new bake job + /// as soon as the previous one (if any) completes. + scene_sdf_clipmap_rebake_needed: bool, + sdf_clipmap_job: Option, // --- Ticket 014 V6/V10: World-Space Radiance Cache --- pub wsrc_atlas_tex: wgpu::Texture, @@ -1594,7 +1637,11 @@ impl Renderer { height: u32, ) -> Self { let surface_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + // COPY_SRC: bloom_take_screenshot reads the swapchain back; + // without it the readback copy is a swallowed validation + // error and screenshots silently produce nothing. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, format: wgpu::TextureFormat::Bgra8UnormSrgb, width, height, @@ -5143,9 +5190,66 @@ impl Renderer { mapped_at_creation: false, }); + // Fullscreen-lag fix — binned + sliced clipmap bake pipeline. + // Same first four bindings as sdf_bake_layout, plus the two + // triangle-bin buffers (cell offsets + per-cell tri indices). + let sdf_clipmap_bake_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("sdf_clipmap_bake_shader"), + source: wgpu::ShaderSource::Wgsl(SDF_CLIPMAP_BAKE_WGSL.into()), + }); + let storage_ro = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let sdf_clipmap_bake_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sdf_clipmap_bake_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, min_binding_size: None, + }, count: None, + }, + storage_ro(1), + storage_ro(2), + wgpu::BindGroupLayoutEntry { + binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::R32Float, + view_dimension: wgpu::TextureViewDimension::D3, + }, count: None, + }, + storage_ro(4), + storage_ro(5), + ], + }); + let sdf_clipmap_bake_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sdf_clipmap_bake_pl_layout"), + bind_group_layouts: &[Some(&sdf_clipmap_bake_layout)], + immediate_size: 0, + }); + let sdf_clipmap_bake_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("sdf_clipmap_bake_pipeline"), + layout: Some(&sdf_clipmap_bake_pl_layout), + module: &sdf_clipmap_bake_shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + // --- Ticket 014 V2: scene clipmap --- let (scene_sdf_clipmap_tex, scene_sdf_clipmap_view) = create_scene_sdf_clipmap(&device); + let (scene_sdf_clipmap_staging_tex, scene_sdf_clipmap_staging_view) = + create_scene_sdf_clipmap_staging(&device); // --- Ticket 014 V6: WSRC --- let (wsrc_atlas_tex, wsrc_atlas_view) = create_wsrc_atlas(&device); @@ -5846,9 +5950,11 @@ impl Renderer { fragment: Some(wgpu::FragmentState { module: &exposure_shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::R16Float, + // Rg16Float: .g carries the anchored AE target + // (flicker fix — see EXPOSURE_SHADER_WGSL). + format: wgpu::TextureFormat::Rg16Float, blend: None, - write_mask: wgpu::ColorWrites::RED, + write_mask: wgpu::ColorWrites::RED | wgpu::ColorWrites::GREEN, })], compilation_options: Default::default(), }), @@ -6040,6 +6146,10 @@ impl Renderer { render_scale: 0.5, render_scale_explicit: false, prev_vp_matrix: IDENTITY_MAT4, + prev_proj_matrix_unjittered: IDENTITY_MAT4, + prev_view_matrix: IDENTITY_MAT4, + current_jitter_ndc: [0.0, 0.0], + velocity_ref_vp: IDENTITY_MAT4, fog_color: [0.7, 0.75, 0.82], fog_color_user_override: false, fog_density: 0.0, @@ -6128,6 +6238,12 @@ impl Renderer { scene_sdf_clipmap_view, scene_sdf_clipmap_built: false, scene_sdf_clipmap_origin: [0.0, 0.0, 0.0], + sdf_clipmap_bake_pipeline, + sdf_clipmap_bake_layout, + scene_sdf_clipmap_staging_tex, + scene_sdf_clipmap_staging_view, + scene_sdf_clipmap_rebake_needed: true, + sdf_clipmap_job: None, wsrc_atlas_tex, wsrc_atlas_view, wsrc_atlas_sampler, @@ -6936,13 +7052,17 @@ impl Renderer { self.current_camera_pos } - /// Ticket 014 V5 — invalidate the SDF clipmap if the camera has - /// moved past the rebake threshold from the current clipmap - /// centre. Called at the top of every frame before the bake - /// check, so a moving camera triggers exactly one re-bake per - /// `threshold × extent` chunk of travel. + /// Ticket 014 V5 — flag the SDF clipmap for a re-bake if the camera + /// has moved past the rebake threshold from the current clipmap + /// centre. Fullscreen-lag fix: instead of clearing `built` (which + /// used to fire a full-volume single-dispatch rebake that stalled + /// weak GPUs for seconds), this only raises `rebake_needed`; the + /// live clipmap keeps serving traces while the amortized job bakes + /// the re-centred volume a few Z-slices per frame. fn maybe_invalidate_sdf_clipmap(&mut self) { - if !self.scene_sdf_clipmap_built { + // A job in flight already re-centres on its own origin — let it + // land before measuring drift again. + if !self.scene_sdf_clipmap_built || self.sdf_clipmap_job.is_some() { return; } let cam = self.current_camera_world_pos(); @@ -6952,7 +7072,7 @@ impl Renderer { let dist_sq = dx * dx + dy * dy + dz * dz; let threshold = SCENE_SDF_CLIPMAP_EXTENT * SCENE_SDF_CLIPMAP_REBAKE_THRESHOLD; if dist_sq > threshold * threshold { - self.scene_sdf_clipmap_built = false; + self.scene_sdf_clipmap_rebake_needed = true; } } @@ -6961,7 +7081,12 @@ impl Renderer { scene: &crate::scene::SceneGraph, encoder: &mut wgpu::CommandEncoder, ) { - if self.scene_sdf_clipmap_built { + // Continue an in-flight job first: one slice batch per frame. + if let Some(job) = self.sdf_clipmap_job.take() { + self.encode_clipmap_bake_slices(job, encoder); + return; + } + if !self.scene_sdf_clipmap_rebake_needed { return; } // Wait for all per-mesh queues to drain — builds the clipmap @@ -6980,24 +7105,10 @@ impl Renderer { return; } - // Upload the unified vertex + index buffers. STORAGE usage so - // the bake shader can bind them directly. These are transient - // — dropped at the end of this function once the dispatch is - // encoded. (The encoder keeps them alive until the submit.) - let vbuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_vbuf"), - contents: bytemuck::cast_slice(&vertices), - usage: wgpu::BufferUsages::STORAGE, - }); - let ibuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("scene_sdf_bake_ibuf"), - contents: bytemuck::cast_slice(&indices), - usage: wgpu::BufferUsages::STORAGE, - }); - // V5 — centre the clipmap on the current camera position, // voxel-snapped for sampling stability (sub-voxel shifts // would change which voxel each sphere-trace step reads). + // The live origin flips only when the job completes. let half = SCENE_SDF_CLIPMAP_EXTENT * 0.5; let voxel = SCENE_SDF_CLIPMAP_EXTENT / SCENE_SDF_CLIPMAP_RES as f32; let cam = self.current_camera_world_pos(); @@ -7006,45 +7117,179 @@ impl Renderer { (cam[1] / voxel).round() * voxel, (cam[2] / voxel).round() * voxel, ]; - self.scene_sdf_clipmap_origin = origin; let aabb_min = [origin[0] - half, origin[1] - half, origin[2] - half, 0.0]; let aabb_max = [origin[0] + half, origin[1] + half, origin[2] + half, 0.0]; - let params = SdfBakeParams { - aabb_min, - aabb_max, - counts: [tri_count, SCENE_SDF_CLIPMAP_RES, 0, 0], + + // Bin triangles into BIN_CELLS³ cells, each list expanded by one + // cell (the shader's narrow band) so the per-cell clamp stays a + // conservative lower bound for sphere tracing. Two-pass counting + // sort: count, prefix-sum, fill. + let cells = SCENE_SDF_CLIPMAP_BIN_CELLS as usize; + let cell_size = SCENE_SDF_CLIPMAP_EXTENT / cells as f32; + let grid_min = [aabb_min[0], aabb_min[1], aabb_min[2]]; + let cell_range = |tri: usize| -> Option<([usize; 3], [usize; 3])> { + let i0 = indices[tri * 3] as usize * 12; + let i1 = indices[tri * 3 + 1] as usize * 12; + let i2 = indices[tri * 3 + 2] as usize * 12; + let mut lo = [f32::MAX; 3]; + let mut hi = [f32::MIN; 3]; + for base in [i0, i1, i2] { + for a in 0..3 { + let v = vertices[base + a]; + lo[a] = lo[a].min(v); + hi[a] = hi[a].max(v); + } + } + let mut c_lo = [0usize; 3]; + let mut c_hi = [0usize; 3]; + for a in 0..3 { + // Expand by one cell width (= the shader's band). + let lo_c = ((lo[a] - cell_size - grid_min[a]) / cell_size).floor(); + let hi_c = ((hi[a] + cell_size - grid_min[a]) / cell_size).floor(); + if hi_c < 0.0 || lo_c >= cells as f32 { + return None; // entirely outside the clipmap volume + } + c_lo[a] = lo_c.max(0.0) as usize; + c_hi[a] = hi_c.min(cells as f32 - 1.0) as usize; + } + Some((c_lo, c_hi)) }; - self.queue.write_buffer( - &self.sdf_bake_uniform, - 0, - bytemuck::bytes_of(¶ms), - ); + let cell_count = cells * cells * cells; + let mut counts = vec![0u32; cell_count]; + for t in 0..tri_count as usize { + if let Some((lo, hi)) = cell_range(t) { + for z in lo[2]..=hi[2] { + for y in lo[1]..=hi[1] { + for x in lo[0]..=hi[0] { + counts[(z * cells + y) * cells + x] += 1; + } + } + } + } + } + let mut offsets = vec![0u32; cell_count + 1]; + for i in 0..cell_count { + offsets[i + 1] = offsets[i] + counts[i]; + } + let total_refs = offsets[cell_count] as usize; + let mut cursor: Vec = offsets[..cell_count].to_vec(); + // wgpu rejects zero-sized buffers — keep one dummy entry when no + // triangle touches the volume (all cells then read empty ranges). + let mut tri_refs = vec![0u32; total_refs.max(1)]; + for t in 0..tri_count as usize { + if let Some((lo, hi)) = cell_range(t) { + for z in lo[2]..=hi[2] { + for y in lo[1]..=hi[1] { + for x in lo[0]..=hi[0] { + let ci = (z * cells + y) * cells + x; + tri_refs[cursor[ci] as usize] = t as u32; + cursor[ci] += 1; + } + } + } + } + } - let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + let vbuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_vbuf"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::STORAGE, + }); + let ibuf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_ibuf"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::STORAGE, + }); + let cell_offsets_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_cell_offsets"), + contents: bytemuck::cast_slice(&offsets), + usage: wgpu::BufferUsages::STORAGE, + }); + let cell_tris_buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("scene_sdf_bake_cell_tris"), + contents: bytemuck::cast_slice(&tri_refs), + usage: wgpu::BufferUsages::STORAGE, + }); + // Per-job uniform: sharing sdf_bake_uniform would alias with the + // per-mesh bakes — queue.write_buffer applies before any of this + // frame's commands, so the last write would win for every pass. + let uniform = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene_sdf_clipmap_bake_uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("scene_sdf_clipmap_bake_bg"), - layout: &self.sdf_bake_layout, + layout: &self.sdf_clipmap_bake_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.sdf_bake_uniform.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: vbuf.as_entire_binding() }, wgpu::BindGroupEntry { binding: 2, resource: ibuf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.scene_sdf_clipmap_view) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.scene_sdf_clipmap_staging_view) }, + wgpu::BindGroupEntry { binding: 4, resource: cell_offsets_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 5, resource: cell_tris_buf.as_entire_binding() }, ], }); + + self.scene_sdf_clipmap_rebake_needed = false; + let job = SdfClipmapBakeJob { + origin, + aabb_min, + aabb_max, + uniform, + bind_group, + next_z: 0, + }; + // Encode the first slice batch right away so a full rebake takes + // exactly RES / LAYERS_PER_FRAME frames end to end. + self.encode_clipmap_bake_slices(job, encoder); + } + + /// Fullscreen-lag fix — encode this frame's slice batch of the + /// in-flight clipmap bake. On the final batch, copy the staging + /// volume over the live clipmap and flip the origin — the copy is + /// encoded before this frame's probe traces, so the swap is atomic + /// from the tracer's point of view. + fn encode_clipmap_bake_slices( + &mut self, + mut job: SdfClipmapBakeJob, + encoder: &mut wgpu::CommandEncoder, + ) { + let res = SCENE_SDF_CLIPMAP_RES; + let layers = SCENE_SDF_CLIPMAP_LAYERS_PER_FRAME.min(res - job.next_z); + let params = SdfBakeParams { + aabb_min: job.aabb_min, + aabb_max: job.aabb_max, + counts: [SCENE_SDF_CLIPMAP_BIN_CELLS, res, job.next_z, 0], + }; + self.queue.write_buffer(&job.uniform, 0, bytemuck::bytes_of(¶ms)); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("scene_sdf_clipmap_bake"), + label: Some("scene_sdf_clipmap_bake_slice"), timestamp_writes: None, }); - pass.set_pipeline(&self.sdf_bake_pipeline); - pass.set_bind_group(0, &bg, &[]); - // 64³ voxels → 16³ workgroups at 4×4×4 threads each. - pass.dispatch_workgroups( - SCENE_SDF_CLIPMAP_RES / 4, - SCENE_SDF_CLIPMAP_RES / 4, - SCENE_SDF_CLIPMAP_RES / 4, - ); + pass.set_pipeline(&self.sdf_clipmap_bake_pipeline); + pass.set_bind_group(0, &job.bind_group, &[]); + pass.dispatch_workgroups(res / 4, res / 4, layers / 4); drop(pass); - self.scene_sdf_clipmap_built = true; + job.next_z += layers; + if job.next_z >= res { + encoder.copy_texture_to_texture( + self.scene_sdf_clipmap_staging_tex.as_image_copy(), + self.scene_sdf_clipmap_tex.as_image_copy(), + wgpu::Extent3d { + width: res, + height: res, + depth_or_array_layers: res, + }, + ); + self.scene_sdf_clipmap_origin = job.origin; + self.scene_sdf_clipmap_built = true; + } else { + self.sdf_clipmap_job = Some(job); + } } /// Ticket 014 V6/V7/V12/V13 — invalidate WSRC cascades on @@ -7211,8 +7456,15 @@ impl Renderer { let cam = self.current_camera_world_pos(); + // At most ONE cascade per frame. Besides amortizing the cost, + // this fixes a params-aliasing bug: all cascades share + // `wsrc_bake_uniform`, and `queue.write_buffer` applies every + // write before any of this frame's dispatches execute — baking + // two cascades in one frame made both dispatches read the last + // cascade's params (wrong extent + wrong atlas slice flag). + let mut baked_one = false; for c in 0..WSRC_CASCADE_COUNT as usize { - if self.wsrc_built[c] { + if self.wsrc_built[c] || baked_one { continue; } let extent = WSRC_CASCADE_EXTENTS[c]; @@ -7270,6 +7522,7 @@ impl Renderer { self.wsrc_last_sun_dir[c] = ld; self.wsrc_last_sun_color[c] = [sun_color[0], sun_color[1], sun_color[2]]; self.wsrc_last_sky_color[c] = [sky_color[0], sky_color[1], sky_color[2]]; + baked_one = true; } } @@ -9803,6 +10056,7 @@ impl Renderer { // Synchronous GPU readback is not available on WASM (device.poll(Wait) blocks). #[cfg(not(target_arch = "wasm32"))] if self.screenshot_requested { + eprintln!("bloom: screenshot readback branch running"); // Use actual texture dimensions (accounts for Retina/DPI scaling) let tex_size = self.frame_texture(&output).size(); let width = tex_size.width; @@ -9868,8 +10122,18 @@ impl Renderer { rgb.push(chunk[1]); rgb.push(chunk[0]); } - if let Some(png) = encode_png_simple(width, height, &rgb) { - let _ = std::fs::write(&path, &png); + // Failures were silently swallowed here for months — + // takeScreenshot "worked" while writing nothing. + match encode_png_simple(width, height, &rgb) { + Some(png) => { + if let Err(e) = std::fs::write(&path, &png) { + eprintln!("bloom: screenshot write '{}' failed: {}", path, e); + } + } + None => eprintln!( + "bloom: screenshot PNG encode failed ({}x{})", + width, height + ), } } self.screenshot_data = Some((width, height, rgba)); @@ -9917,6 +10181,10 @@ impl Renderer { self.taa_frame_index = self.taa_frame_index.wrapping_add(1); self.prev_vp_matrix = self.current_vp_matrix; } + // EN-022 fix — velocity reference inputs roll over every frame + // regardless of TAA state (velocity also feeds motion blur). + self.prev_proj_matrix_unjittered = self.current_proj_matrix_unjittered; + self.prev_view_matrix = self.current_view_matrix; // Swap probe-history ping-pong so next frame reads what we // just blended as the "previous" history and writes to the // other buffer. Ticket 007a. @@ -10177,6 +10445,9 @@ impl Renderer { // offset there shifts the whole frustum by jitter px. proj[2][0] += (jx * 2.0) / render_w; proj[2][1] += (jy * 2.0) / render_h; + self.current_jitter_ndc = [(jx * 2.0) / render_w, (jy * 2.0) / render_h]; + } else { + self.current_jitter_ndc = [0.0, 0.0]; } let view = mat4_look_at( @@ -10192,6 +10463,21 @@ impl Renderer { self.current_inv_vp_matrix = mat4_invert(vp); self.current_camera_pos = [pos_x, pos_y, pos_z]; + // EN-022 fix — compose the velocity reference VP: the previous + // frame's UNJITTERED projection with the CURRENT frame's jitter + // re-applied, times the previous view. Every prev_mvp built + // from this cancels the jitter term exactly in the shader's + // (curr_ndc - prev_ndc), so static geometry gets a true zero + // velocity instead of one-texel jitter-delta noise (which + // wobbled TAA history reprojection and cycled fine detail + // between sharp and soft — the periodic material-surface + // flicker). + let mut prev_proj_j = self.prev_proj_matrix_unjittered; + prev_proj_j[2][0] += self.current_jitter_ndc[0]; + prev_proj_j[2][1] += self.current_jitter_ndc[1]; + self.velocity_ref_vp = mat4_multiply(prev_proj_j, self.prev_view_matrix); + self.material_system.set_velocity_reference_vp(self.velocity_ref_vp); + // Mirror camera pos into lighting uniforms so the scene shader // can compute V for GGX specular. Preserve the .w slot — it // holds the env_intensity multiplier (set via load_env_from_hdr). @@ -10222,7 +10508,7 @@ impl Renderer { self.queue.write_buffer( &self.uniform_buffer_3d, 0, - bytemuck::bytes_of(&Uniforms3D { mvp: vp, model: IDENTITY_MAT4, prev_mvp: self.prev_vp_matrix, model_tint: [1.0, 1.0, 1.0, 1.0] }), + bytemuck::bytes_of(&Uniforms3D { mvp: vp, model: IDENTITY_MAT4, prev_mvp: self.velocity_ref_vp, model_tint: [1.0, 1.0, 1.0, 1.0] }), ); self.render_mode = RenderMode::Mode3D; } @@ -11945,7 +12231,11 @@ impl Renderer { view: main_view, proj, view_proj: self.current_vp_matrix, - prev_view_proj: self.prev_vp_matrix, + // EN-022 fix: velocity reference (prev unjittered VP + + // current jitter), so material shaders computing + // `prev_view_proj * world` get true zero velocity on + // static geometry instead of TAA jitter-delta noise. + prev_view_proj: self.velocity_ref_vp, inv_proj: self.current_inv_proj_matrix, camera_pos: [ cam_pos[0], cam_pos[1], cam_pos[2], @@ -12231,7 +12521,9 @@ impl Renderer { view: self.current_view_matrix, proj: self.current_proj_matrix, view_proj: self.current_vp_matrix, - prev_view_proj: self.prev_vp_matrix, + // EN-022 fix: velocity reference — see the main PerView + // build above. + prev_view_proj: self.velocity_ref_vp, inv_proj: self.current_inv_proj_matrix, camera_pos: [ self.current_camera_pos[0], diff --git a/native/shared/src/renderer/shaders/ao.rs b/native/shared/src/renderer/shaders/ao.rs index 099df9b..9b43e4a 100644 --- a/native/shared/src/renderer/shaders/ao.rs +++ b/native/shared/src/renderer/shaders/ao.rs @@ -240,7 +240,16 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // in the 0..π range so every frame covers roughly the full // horizon and history doesn't bias toward one hemisphere between // refreshes. Over 4 frames the full 8-direction set is sampled. - let phase = u.size.z; + // Flicker fix: dither the phase spatially in a 2×2 pixel quad so + // every quad covers all 4 phases EVERY frame. With a globally + // shared phase, the per-frame direction bias was identical across + // whole surfaces — when a phase pair landed badly on + // high-frequency geometry, the entire surface's AO pulsed in + // lockstep once per 4-frame cycle (periodic wall-wide flicker). + // Dithered, the same bias becomes 2×2 spatial noise that the + // bilateral blur and the EMA absorb completely. + let quad_offset = (px.x & 1u) + ((px.y & 1u) << 1u); + let phase = (u.size.z + quad_offset) % N_PHASES; for (var k = 0u; k < N_DIRS_PER_FRAME; k = k + 1u) { let d = phase + k * N_PHASES; let angle = (f32(d) / f32(N_DIRS_TOTAL)) * PI + jitter_angle; @@ -357,15 +366,25 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { history_ao = textureSampleLevel(history_in, filt_samp, prev_uv, 0.0).r; } - // Disocclusion protection via AO delta: if the reprojected - // history deviates from the current raw sample by more than - // 0.35 we treat it as a hard break and refresh to `ao_raw`. - // Cheaper than a spatial neighborhood clamp and sufficient - // given the 4-frame EMA absorbs short-term drift. - let ao_delta = abs(ao_raw - history_ao); - let force_refresh = reproj_oob || u.size.w != 0u || ao_delta > 0.35; + // Temporal stability (flicker fix). The old `ao_delta > 0.35 → + // alpha 1` HARD refresh fired on a perfectly static camera: each + // frame samples only 2 of 8 directions, and when that pair lands + // badly on high-frequency geometry the delta spikes across the + // whole surface at once (the direction phase is shared by every + // pixel) — converged history got replaced with the noisiest + // possible single-frame estimate, a region-wide AO pulse that read + // as periodic flicker on detailed walls. On a static scene the + // HISTORY is the trustworthy estimate, so instead: clamp how far a + // single frame's raw sample can pull (±0.15 around history) and + // keep the plain EMA. A sustained true occlusion change still + // tracks at ~0.04 AO/frame (0.35 in ~10 frames — imperceptible + // lag), while correlated sampling spikes are capped at an + // invisible single-frame step. Hard refresh only on real + // reprojection breaks (off-screen history / first frames). + let force_refresh = reproj_oob || u.size.w != 0u; + let ao_in = clamp(ao_raw, history_ao - 0.15, history_ao + 0.15); let alpha = select(u.temporal.x, 1.0, force_refresh); - let ao = mix(history_ao, ao_raw, alpha); + let ao = mix(history_ao, select(ao_in, ao_raw, force_refresh), alpha); // Contrast + floor (exact curve preserved). let ao_contrasted = pow(ao, 2.0); diff --git a/native/shared/src/renderer/shaders/gi.rs b/native/shared/src/renderer/shaders/gi.rs index 0a2aa3d..9b228c4 100644 --- a/native/shared/src/renderer/shaders/gi.rs +++ b/native/shared/src/renderer/shaders/gi.rs @@ -171,6 +171,114 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { } "; +/// Fullscreen-lag fix — scene-clipmap variant of the SDF bake. +/// +/// The original clipmap bake reused `SDF_BAKE_WGSL`: every one of the +/// 64³ voxels looped over EVERY scene triangle, and the whole volume +/// baked in a single dispatch. On integrated GPUs that one dispatch +/// stalled the end-of-frame submit for seconds whenever the camera +/// drifted 10 m. This variant fixes both axes of that cost: +/// +/// - Triangles are binned on the CPU into `counts.x` cells per axis, +/// each cell's list pre-expanded by one cell in every direction. A +/// voxel only tests its own cell's list. Distances are clamped at +/// one cell width (`band`); because of the one-cell expansion the +/// clamp never OVERestimates the distance to the nearest surface, +/// which keeps sphere-trace steps conservative. Empty-air cells skip +/// the triangle loop entirely. +/// - `counts.z` carries a voxel Z offset so the caller can bake a few +/// Z-layers per frame into a staging texture instead of the whole +/// volume at once. +pub(in crate::renderer) const SDF_CLIPMAP_BAKE_WGSL: &str = " +struct SdfClipmapBakeParams { + aabb_min: vec4, + aabb_max: vec4, + // x = bin cells per axis, y = sdf resolution, + // z = voxel Z offset of this slice batch, w unused + counts: vec4, +}; + +@group(0) @binding(0) var u: SdfClipmapBakeParams; +@group(0) @binding(1) var vertex_buf: array; +@group(0) @binding(2) var index_buf: array; +@group(0) @binding(3) var sdf_out: texture_storage_3d; +@group(0) @binding(4) var cell_offsets: array; +@group(0) @binding(5) var cell_tris: array; + +const VERTEX_STRIDE_F32: u32 = 12u; // Vertex3D: pos(3) + normal(3) + color(4) + uv(2) + +fn vtx_pos(idx: u32) -> vec3 { + let base = idx * VERTEX_STRIDE_F32; + return vec3(vertex_buf[base], vertex_buf[base + 1u], vertex_buf[base + 2u]); +} + +// Point-triangle distance, clamped-edge form (Ericson). +fn point_triangle_distance(p: vec3, a: vec3, b: vec3, c: vec3) -> f32 { + let ab = b - a; + let ac = c - a; + let ap = p - a; + let d1 = dot(ab, ap); + let d2 = dot(ac, ap); + if (d1 <= 0.0 && d2 <= 0.0) { return length(ap); } + let bp = p - b; + let d3 = dot(ab, bp); + let d4 = dot(ac, bp); + if (d3 >= 0.0 && d4 <= d3) { return length(bp); } + let vc = d1 * d4 - d3 * d2; + if (vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0) { + let v = d1 / (d1 - d3); + return length(p - (a + v * ab)); + } + let cp = p - c; + let d5 = dot(ab, cp); + let d6 = dot(ac, cp); + if (d6 >= 0.0 && d5 <= d6) { return length(cp); } + let vb = d5 * d2 - d1 * d6; + if (vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0) { + let w = d2 / (d2 - d6); + return length(p - (a + w * ac)); + } + let va = d3 * d6 - d5 * d4; + if (va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0) { + let w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return length(p - (b + w * (c - b))); + } + let denom = 1.0 / (va + vb + vc); + let v = vb * denom; + let w = vc * denom; + return length(p - (a + ab * v + ac * w)); +} + +@compute @workgroup_size(4, 4, 4) +fn cs_main(@builtin(global_invocation_id) gid: vec3) { + let res = u.counts.y; + let vox = vec3(gid.x, gid.y, gid.z + u.counts.z); + if (vox.x >= res || vox.y >= res || vox.z >= res) { return; } + + let uvw = (vec3(vox) + vec3(0.5)) / f32(res); + let voxel_ws = mix(u.aabb_min.xyz, u.aabb_max.xyz, uvw); + + let cells = u.counts.x; + let vpc = res / cells; + let cell = vox / vpc; + let ci = (cell.z * cells + cell.y) * cells + cell.x; + let start = cell_offsets[ci]; + let end = cell_offsets[ci + 1u]; + + let band = (u.aabb_max.x - u.aabb_min.x) / f32(cells); + var min_dist = band; + for (var k: u32 = start; k < end; k = k + 1u) { + let t = cell_tris[k]; + let a = vtx_pos(index_buf[t * 3u + 0u]); + let b = vtx_pos(index_buf[t * 3u + 1u]); + let c = vtx_pos(index_buf[t * 3u + 2u]); + min_dist = min(min_dist, point_triangle_distance(voxel_ws, a, b, c)); + } + + textureStore(sdf_out, vec3(vox), vec4(min_dist, 0.0, 0.0, 0.0)); +} +"; + /// Ticket 013 V3 — per-frame card-lighting compute pass with /// shadow-cascade sampling + emissive contribution. /// diff --git a/native/shared/src/renderer/shaders/mod.rs b/native/shared/src/renderer/shaders/mod.rs index 23b3694..6092ed3 100644 --- a/native/shared/src/renderer/shaders/mod.rs +++ b/native/shared/src/renderer/shaders/mod.rs @@ -10,7 +10,7 @@ pub(super) use env::{PREFILTER_SHADER_WGSL, SKY_SHADER_WGSL, AERIAL_PERSPECTIVE_ mod ao; pub(super) use ao::{HIZ_LINEARIZE_SHADER_WGSL, HIZ_DOWNSAMPLE_SHADER_WGSL, SSAO_SHADER_WGSL, SSAO_BLUR_SHADER_WGSL}; mod gi; -pub(super) use gi::{CARD_CAPTURE_WGSL, SDF_BAKE_WGSL, CARD_LIGHT_WGSL, WSRC_BAKE_WGSL, WSRC_BAKE_HW_WGSL}; +pub(super) use gi::{CARD_CAPTURE_WGSL, SDF_BAKE_WGSL, SDF_CLIPMAP_BAKE_WGSL, CARD_LIGHT_WGSL, WSRC_BAKE_WGSL, WSRC_BAKE_HW_WGSL}; mod ssgi; pub(super) use ssgi::{PROBE_HELPERS_WGSL, SSGI_PROBE_PLACE_WGSL, SSGI_PROBE_TRACE_SW_WGSL, SSGI_PROBE_TRACE_HW_WGSL, SSGI_PROBE_TRACE_SDF_WGSL, SSGI_PROBE_TEMPORAL_WGSL, SSGI_PROBE_RESOLVE_WGSL, SSR_TEMPORAL_SHADER_WGSL, SSR_SHADER_WGSL}; mod post; diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index fc4f5da..1f32289 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -882,6 +882,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // different world point and should be dropped. Absolute // threshold keyed to stddev so tight-gradient regions // reject aggressively; flat regions stay accumulating. + // NOTE: do not gate this by motion — the luma-only variance clamp + // deliberately leaves chroma unbounded, and this reject is what + // flushes chroma-poisoned history on static pixels (weakening it + // green-tints the whole frame within seconds). let history_dist = abs(history_y_clamped - mean.x); let disocclusion = smoothstep(stddev.x * 0.25, stddev.x * 1.0, history_dist); @@ -968,7 +972,21 @@ fn fs_main() -> @location(0) vec4 { break; } } - let median_log = log_min + (f32(median_bin) + 0.5) / 64.0 * log_range; + // Interpolate the percentile position WITHIN the bin (flicker fix). + // Bin centres quantized the target in whole-bin (~0.22 log2 ≈ 16%) + // steps, so histogram noise flipping the median across a bin + // boundary stepped the exposure hard enough for TAA to reject its + // history — a visible sharp/soft convergence cycle on detailed + // surfaces. The cumulative counts turn the bin index into a + // continuous position instead. + let below = accum - bins[median_bin]; + var frac = 0.5; + if (bins[median_bin] > 0u) { + frac = clamp( + (f32(target_count) - f32(below)) / f32(bins[median_bin]), + 0.0, 1.0); + } + let median_log = log_min + (f32(median_bin) + frac) / 64.0 * log_range; let median_luma = exp2(median_log); let key = u.params.x; @@ -976,14 +994,33 @@ fn fs_main() -> @location(0) vec4 { let min_e = u.params.z; let max_e = u.params.w; - let target_exp = clamp(key / max(median_luma, 0.01), min_e, max_e); - let prev = textureSample(prev_exposure_tex, prev_exposure_samp, vec2(0.5, 0.5)).r; + let raw_target = clamp(key / max(median_luma, 0.01), min_e, max_e); + let prev = textureSample(prev_exposure_tex, prev_exposure_samp, vec2(0.5, 0.5)); + // Anchored deadband (flicker fix): once converged, the exposure must + // not chase sub-2% measurement noise (TAA jitter, foliage sway). + // .g holds the anchored target; it only re-anchors when the raw + // measurement strays more than 2% from it, so a static scene gets a + // byte-stable exposure while real lighting changes re-anchor at + // once and adapt at the normal rate. + var anchor = prev.g; + if (anchor < min_e * 0.5 || abs(raw_target - anchor) > anchor * 0.02) { + anchor = raw_target; + } + // Proportional adaptation speed (flicker fix): the measurement is + // downstream of TAA, so its residual wiggle produces small target + // corrections; ramping them at full rate reads as a visible + // brightness sweep that the tonemap + sharpen chain amplifies on + // fine texture. Small gaps close glacially (imperceptible per + // frame); a real scene change (>25% luminance) adapts at the full + // authored rate. + let gap = abs(anchor - prev.r) / max(prev.r, 1e-3); + let rate_scale = smoothstep(0.0, 0.25, gap); // First frame: prev is 0; snap to target instead of crawling up. - var smoothed = mix(prev, target_exp, rate); - if (prev < min_e * 0.5) { - smoothed = target_exp; + var smoothed = mix(prev.r, anchor, rate * rate_scale); + if (prev.r < min_e * 0.5) { + smoothed = anchor; } - return vec4(smoothed, 0.0, 0.0, 1.0); + return vec4(smoothed, anchor, 0.0, 1.0); } "; diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index 4b693a7..9b66249 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -68,6 +68,24 @@ impl Renderer { 0, bytemuck::bytes_of(&self.lighting_uniforms), ); + // Shadow-flicker fix: the material system's PerView buffer was + // uploaded before this fit ran and still carries LAST frame's + // cascade VPs. Patch its shadow fields so material-path + // receivers sample the depth maps with the same matrices the + // maps are rendered with this frame. Keep .w at 0.0 — that slot + // is the TSR mip-LOD bias, which the material path historically + // never received (begin_mode_3d resets it before the material + // PerView upload); delivering -1.0 here makes hardware mip + // selection flip per-panel under TAA jitter (visible texture + // detail popping). + let mut splits = self.lighting_uniforms.shadow_cascade_splits; + splits[3] = 0.0; + self.material_system.refresh_shadow_uniforms( + &self.queue, + splits, + self.lighting_uniforms.shadow_view_matrix, + self.shadow_map.light_vps, + ); // Cache gate. Skip if nothing that affects shadow-map // content has changed since last render. Texel-snap + diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 1b1a3bf..a4aa4e3 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -209,6 +209,15 @@ pub struct ShadowMap { /// node's transform / cast_shadow / visibility / geometry changes; /// a mismatch here forces a re-render. pub rendered_scene_version: u64, + /// Shadow-flicker fix — per-cascade accepted pancake extents + /// (back, far). Animated caster bounds drift a few centimetres per + /// frame, and the raw 1/16 m ceil() quantization made the fitted VP + /// toggle between two matrices whenever that drift straddled a + /// step. The accepted extent grows immediately (casters must stay + /// inside the volume) but shrinks only when the raw need has + /// dropped well below it — so idle animations stop re-fitting the + /// cascades every few frames. + pancake_hysteresis: [[f32; 2]; NUM_CASCADES], } impl ShadowMap { @@ -540,6 +549,7 @@ impl ShadowMap { rendered_light_vps: None, rendered_light_dir: None, rendered_scene_version: 0, + pancake_hysteresis: [[0.0; 2]; NUM_CASCADES], } } @@ -725,9 +735,36 @@ impl ShadowMap { if -along_d > pancake_far { pancake_far = -along_d; } } } - // Quantize Z range so tiny scene-bounds drift doesn't shift depths. - let pancake_back = (pancake_back * 16.0).ceil() / 16.0; - let pancake_far = (pancake_far * 16.0).ceil() / 16.0; + // Quantize Z range so scene-bounds drift doesn't shift depths. + // Flicker fix: animated casters (idle anims, wind-swayed + // proxies) drift the raw pancake need by centimetres-to- + // decimetres every cycle, and every resulting VP change + // re-rolls the acne pattern on grazing receivers — visible + // as periodic banding bursts. Quantize UP to whole 2 m steps + // and only shrink after a full 2-step (4 m) drop, so the + // fitted VP is byte-stable against anything short of a + // structural scene change. The cost is a few metres of extra + // ortho depth range on a Depth32Float target — irrelevant — + // and coverage stays correct: the accepted extent is never + // below the raw need. + const PANCAKE_STEP: f32 = 2.0; + let quantize = |v: f32| (v / PANCAKE_STEP).ceil() * PANCAKE_STEP; + let prev = self.pancake_hysteresis[c]; + let pancake_back = if pancake_back > prev[0] + || pancake_back < prev[0] - 2.0 * PANCAKE_STEP + { + quantize(pancake_back) + } else { + prev[0] + }; + let pancake_far = if pancake_far > prev[1] + || pancake_far < prev[1] - 2.0 * PANCAKE_STEP + { + quantize(pancake_far) + } else { + prev[1] + }; + self.pancake_hysteresis[c] = [pancake_back, pancake_far]; // Place light eye at the far-back edge of the Z range so // ortho near=0 exactly touches the top of the pancake volume. diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index 2d627a5..52a5b0a 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -640,7 +640,11 @@ unsafe fn init_engine_for_hwnd( // caller's logical size separately so screenWidth() etc. keep // returning DPI-independent numbers. let surface_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + // COPY_SRC: bloom_take_screenshot reads the swapchain back; + // without it the readback copy is a swallowed validation + // error and screenshots silently produce nothing on Windows. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, format, width: phys_w, height: phys_h, @@ -651,7 +655,15 @@ unsafe fn init_engine_for_hwnd( }; surface.configure(&device, &surface_config); - let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); + let mut renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); + // Route initial target creation through the same resize path a + // WM_SIZE takes. Without this, windowed mode (which never gets a + // resize, unlike the borderless-fullscreen transition) keeps + // construction-time render targets that ignore render_scale — + // the depth-snapshot copy then spans a partial depth texture, + // which wgpu rejects (fatal validation error on the first frame + // with a scene-reading translucent material in view). + renderer.resize(phys_w, phys_h, logical_w, logical_h); let _ = ENGINE.set(EngineState::new(renderer)); } From 85de2834d65fa2ad4bb42838c10c2bd0bd56d677 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 12:23:21 +0200 Subject: [PATCH 13/15] fix(taa): bound history chroma at 3x the luma variance band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YCoCg clamp was luma-only (per-channel RGB clamping had caused chromatic sparkle on grazing stone), but fully unclamped chroma let stale history colour bleed through on high-contrast edges: green terrain fringing crawled along cloud and canopy silhouettes during camera motion — user-visible as flickering. Clamping Co/Cg at 3x the luma gamma bounds gross cross-object bleed while staying far looser than the sparkle-prone hard clamp. Verified in windowed gameplay captures: cloud-edge fringing gone, no reintroduced sparkle. --- native/shared/src/renderer/shaders/post.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index 1f32289..78b62c5 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -874,7 +874,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let history_ycocg = rgb_to_ycocg(history); let history_y_clamped = clamp(history_ycocg.x, y_min, y_max); - let clamped_history = ycocg_to_rgb(vec3(history_y_clamped, history_ycocg.yz)); + // Chroma is clamped too, but at 3x the luma band (flicker fix). + // Fully unclamped chroma let stale history colour bleed through on + // high-contrast edges — green terrain fringing crawling along cloud + // and canopy silhouettes during camera motion. The loose band keeps + // the anti-sparkle intent of the luma-only design (a hard + // per-channel clamp caused chromatic sparkle on grazing stone) + // while bounding gross cross-object colour bleed. + let c_gamma = gamma * 3.0; + let co_clamped = clamp(history_ycocg.y, + mean.y - c_gamma * stddev.y, mean.y + c_gamma * stddev.y); + let cg_clamped = clamp(history_ycocg.z, + mean.z - c_gamma * stddev.z, mean.z + c_gamma * stddev.z); + let clamped_history = ycocg_to_rgb(vec3(history_y_clamped, co_clamped, cg_clamped)); // Per-pixel disocclusion reject. If the history (already // variance-clamped) still sits far from the current From 43d944ca9d238c0f73d07f526c5324a6db4e8cee Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 12:32:48 +0200 Subject: [PATCH 14/15] feat(windows): native F12 screenshot hotkey Perry drops the game-side takeScreenshot FFI call entirely (PerryTS/perry#6087), so trigger the capture in the window procedure where no compiler sits in the path: F12 (initial press only) requests the swapchain readback and writes screenshot_.png into the working directory. Depends on the earlier COPY_SRC surface-usage fix; verified end to end via PostMessage-injected F12 -> valid 4K PNG. --- native/windows/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index 52a5b0a..9c647ad 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -252,6 +252,26 @@ mod win32 { eng.input.set_key_down(bloom_key); } } + // F12 — native screenshot hotkey. Perry currently drops + // the game-side takeScreenshot FFI call entirely + // (PerryTS/perry#6087), so the capture is triggered here + // where no compiler sits in the path. Initial press only + // (lparam bit 30 = previous key state) so holding the key + // doesn't machine-gun PNGs. The file lands in the working + // directory with a timestamped name; the readback runs at + // this frame's end_frame. + if wparam.0 as u32 == 0x7B && (lparam.0 as u32 >> 30) & 1 == 0 { + if let Some(eng) = ENGINE.get_mut() { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let path = format!("screenshot_{}.png", ms); + eprintln!("bloom: F12 screenshot -> {}", path); + eng.renderer.screenshot_requested = true; + eng.renderer.pending_screenshot_path = Some(path); + } + } DefWindowProcW(hwnd, msg, wparam, lparam) } WM_KEYUP => { From 7c5f20dc45f2fd52d5412cdb1cc4bd68d849c338 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 6 Jul 2026 16:42:21 +0200 Subject: [PATCH 15/15] fix(render): tame unsharp-mask halo/shimmer on silhouettes The composite unsharp mask ran at strength 0.8 (the engine already flagged it as visibly haloing silhouettes at 4K). Under half-res TSR the building silhouette edge wobbles a sub-pixel per frame, and a strong unsharp turns that wobble into a crawling bright/dark rim. Lower the default to 0.5 and clamp the detail term to +/-0.12: fine texture (small local contrast) sharpens as before, but extreme high-contrast edges no longer overshoot into a halo or amplify the sub-pixel shimmer. --- native/shared/src/renderer/mod.rs | 6 +++++- native/shared/src/renderer/shaders/post.rs | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 34d363a..a867f08 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -6077,7 +6077,11 @@ impl Renderer { vignette_strength: 0.0, vignette_softness: 0.25, grain_strength: 0.0, - sharpen_strength: 0.8, + // Was 0.8 — that haloed and, under half-res TSR, made the + // silhouette edge shimmer read as crawling gray lines. 0.5 + // keeps the crispness that covers TSR softness; the detail + // clamp in COMPOSITE_SHADER_WGSL bounds the edge overshoot. + sharpen_strength: 0.5, exposure_textures, exposure_views, exposure_current_idx: 0, diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index 78b62c5..8497677 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -1341,7 +1341,15 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let h_u = textureSample(hdr_tex, hdr_samp, sample_uv - oy).rgb; let h_avg = (h_r + h_l + h_d + h_u) * 0.25 * ao_weighted * exposure; let avg = tonemap_select(h_avg); - let detail = ldr - avg; + // Flicker fix: cap the unsharp detail term. On a high-contrast + // silhouette (building roofline vs sky) detail is huge, and at + // half-res TSR the reconstructed edge wobbles a sub-pixel every + // frame — a strong unsharp turns that wobble into a crawling + // bright/dark line (the reported gray lines on the building). + // Fine texture detail has small local contrast and passes + // through untouched; only the extreme edge overshoot is bounded, + // which also removes the silhouette halo the 0.8 default caused. + let detail = clamp(ldr - avg, vec3(-0.12), vec3(0.12)); ldr = clamp(ldr + detail * sharpen_strength, vec3(0.0), vec3(1.0)); }