From 2f8176e76dc7e0fd044ca25eec01ec2755db0fd3 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 17:26:45 +0200 Subject: [PATCH 1/5] quality: enable Radeon 760M Windows Vulkan measurements --- .gitattributes | 3 + native/shared/src/renderer/capture_pixels.rs | 77 +++++++++++ native/shared/src/renderer/quality_capture.rs | 30 ++--- native/windows/src/lib.rs | 15 ++- tools/quality/README.md | 21 +++ tools/quality/run.py | 123 ++++++++++++++++-- tools/quality/scenes.toml | 13 ++ tools/quality/test_run.py | 64 +++++++++ 8 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 .gitattributes create mode 100644 native/shared/src/renderer/capture_pixels.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..462cafe3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Qualification fixtures are hashed byte-for-byte on every host. +examples/quality-transparency/assets/transparent-quad.gltf text eol=lf +examples/quality-masked/assets/masked-card.gltf text eol=lf diff --git a/native/shared/src/renderer/capture_pixels.rs b/native/shared/src/renderer/capture_pixels.rs new file mode 100644 index 00000000..282b730f --- /dev/null +++ b/native/shared/src/renderer/capture_pixels.rs @@ -0,0 +1,77 @@ +//! Convert captured texture bytes to the RGB PNG contract without changing +//! transfer functions or the raw surface bytes exposed to screenshot callers. + +pub(super) fn rgba8_rgb( + data: &[u8], + width: u32, + height: u32, + padded_bytes_per_row: u32, +) -> Vec { + let mut rgb = Vec::with_capacity((width * height * 3) as usize); + for row in 0..height { + let row_start = (row * padded_bytes_per_row) as usize; + for column in 0..width { + let offset = row_start + (column * 4) as usize; + rgb.extend_from_slice(&data[offset..offset + 3]); + } + } + rgb +} + +pub(super) fn frame_rgb( + data: &[u8], + width: u32, + height: u32, + padded_bytes_per_row: u32, + format: wgpu::TextureFormat, +) -> Vec { + let mut rgb = rgba8_rgb(data, width, height, padded_bytes_per_row); + if matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ) { + for pixel in rgb.chunks_exact_mut(3) { + pixel.swap(0, 2); + } + } + rgb +} + +#[cfg(test)] +mod tests { + use super::frame_rgb; + use crate::renderer::util::encode_png_simple; + use wgpu::TextureFormat; + + #[test] + fn png_preserves_colors_for_rgba_and_bgra_with_padded_rows() { + let expected = vec![255, 16, 32, 8, 64, 192]; + for format in [ + TextureFormat::Rgba8Unorm, + TextureFormat::Rgba8UnormSrgb, + TextureFormat::Bgra8Unorm, + TextureFormat::Bgra8UnormSrgb, + ] { + let mut bytes = vec![99; 512]; + let bgra = matches!( + format, + TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb + ); + bytes[0..4].copy_from_slice(if bgra { + &[32, 16, 255, 255] + } else { + &[255, 16, 32, 255] + }); + bytes[256..260].copy_from_slice(if bgra { + &[192, 64, 8, 127] + } else { + &[8, 64, 192, 127] + }); + let rgb = frame_rgb(&bytes, 1, 2, 256, format); + let png = encode_png_simple(1, 2, &rgb).unwrap(); + let decoded = image::load_from_memory(&png).unwrap().to_rgb8(); + assert_eq!(decoded.dimensions(), (1, 2)); + assert_eq!(decoded.as_raw(), &expected, "{format:?}"); + } + } +} diff --git a/native/shared/src/renderer/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs index 93816e13..69498b2a 100644 --- a/native/shared/src/renderer/quality_capture.rs +++ b/native/shared/src/renderer/quality_capture.rs @@ -6,6 +6,10 @@ use std::sync::mpsc; +#[path = "capture_pixels.rs"] +mod capture_pixels; +use capture_pixels::{frame_rgb, rgba8_rgb}; + use super::util::encode_png_simple; use super::weighted_transparency::WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD; use super::Renderer; @@ -40,6 +44,7 @@ pub(super) struct MrtReadback { pub(super) struct FrameReadback { staging: wgpu::Buffer, + format: wgpu::TextureFormat, width: u32, height: u32, padded_bytes_per_row: u32, @@ -271,18 +276,6 @@ fn depth_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> rgb } -fn rgba8_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> Vec { - let mut rgb = Vec::with_capacity((width * height * 3) as usize); - for row in 0..height { - let row_start = (row * padded_bytes_per_row) as usize; - for column in 0..width { - let offset = row_start + (column * 4) as usize; - rgb.extend_from_slice(&data[offset..offset + 3]); - } - } - rgb -} - impl Renderer { fn record_quality_texture( &self, @@ -652,6 +645,7 @@ impl Renderer { .map(|_| self.record_mrt_readback(encoder)); FrameReadback { staging, + format: output.format(), width, height, padded_bytes_per_row, @@ -772,11 +766,13 @@ impl Renderer { } drop(data); if let Some(path) = self.pending_screenshot_path.take() { - let mut rgb = Vec::with_capacity((readback.width * readback.height * 3) as usize); - for chunk in rgba.chunks_exact(4) { - // Native surface captures are BGRA; the PNG contract is RGB. - rgb.extend_from_slice(&[chunk[2], chunk[1], chunk[0]]); - } + let rgb = frame_rgb( + &rgba, + readback.width, + readback.height, + readback.width * 4, + readback.format, + ); match encode_png_simple(readback.width, readback.height, &rgb) { Some(png) => { if let Err(error) = std::fs::write(&path, png) { diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index eddfdaf1..a5cc7bdd 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -711,6 +711,13 @@ unsafe fn init_engine_for_hwnd( let mut handle = raw_window_handle::Win32WindowHandle::new( std::num::NonZeroIsize::new(hwnd.0 as isize).unwrap(), ); + // Vulkan needs the instance that owns this window, including embedded HWNDs. + handle.hinstance = std::num::NonZeroIsize::new( + windows::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW( + hwnd, + windows::Win32::UI::WindowsAndMessaging::GWLP_HINSTANCE, + ), + ); let raw = raw_window_handle::RawWindowHandle::Win32(handle); instance .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { @@ -877,7 +884,13 @@ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f return 0.0; }; let target = { - let h = raw_window_handle::Win32WindowHandle::new(hwnd_nz); + let mut h = raw_window_handle::Win32WindowHandle::new(hwnd_nz); + h.hinstance = std::num::NonZeroIsize::new(unsafe { + windows::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW( + windows::Win32::Foundation::HWND(handle as *mut _), + windows::Win32::UI::WindowsAndMessaging::GWLP_HINSTANCE, + ) + }); wgpu::SurfaceTargetUnsafe::RawHandle { raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Windows( raw_window_handle::WindowsDisplayHandle::new(), diff --git a/tools/quality/README.md b/tools/quality/README.md index 2ad0545b..5ae83da5 100644 --- a/tools/quality/README.md +++ b/tools/quality/README.md @@ -30,6 +30,12 @@ python tools/quality/run.py run full ` --host-idle-timeout 600 ` --out tools/quality/out/windows-rtx4080-vulkan +# Measure on the available Radeon 760M Windows host. +python tools/quality/run.py run full ` + --machine-class amd-radeon760m-windows-vulkan ` + --host-idle-timeout 600 ` + --out tools/quality/out/windows-radeon760m-vulkan + # Explore on an unqualified machine without changing the process exit code. python3 tools/quality/run.py run full \ --report-only \ @@ -42,6 +48,21 @@ hard performance budget. `--report-only` records those same failures in `result.json`; it only makes the process exit zero for local investigation. It never turns a failure into a recorded pass. +The Radeon 760M profile selects Vulkan, opts into hardware GI, verifies the +reported adapter, and records host preflight/postflight CPU load. Visual, +intermediate-image, and telemetry contracts remain strict. Performance is +measured without hard budgets: RTX 4080 and Apple timings are not qualified +limits for this integrated GPU, and shared GPU memory is not treated as +dedicated VRAM. Use repeated runs and `repro-check` to establish useful local +comparisons before proposing Radeon performance budgets. This profile does +not satisfy the RTX 4080 hardware qualification requested in #153. + +On Windows, host CPU load comes from native PerfProc counters through +PowerShell/CIM. A fully occupied logical CPU is 100%, matching the Unix +process limits; aggregate CPU load is normalized by the logical CPU count. +Missing or unreadable counters fail the idle check. These snapshots identify +CPU pressure before and after the run, not continuous background GPU load. + Before the Windows run, use `python tools/quality/run.py check` from a clean checkout and confirm that Python, Rust/Cargo, Node/npm, Perry, Vulkan, and the pinned Bistro assets are available. The runner records `windows` as the host diff --git a/tools/quality/run.py b/tools/quality/run.py index 3046c18d..164acdb9 100644 --- a/tools/quality/run.py +++ b/tools/quality/run.py @@ -177,6 +177,8 @@ def sha256_file(path: Path) -> str: def host_load_snapshot() -> dict[str, Any]: """Capture scheduler pressure without adding a non-stdlib dependency.""" logical_cpus = max(int(os.cpu_count() or 1), 1) + if platform.system() == "Windows": + return windows_host_load_snapshot(logical_cpus) try: load_average = [round(float(value), 3) for value in os.getloadavg()] except (AttributeError, OSError): @@ -232,6 +234,60 @@ def host_load_snapshot() -> dict[str, Any]: } +def windows_host_load_snapshot(logical_cpus: int) -> dict[str, Any]: + # These counters use the same scale as ps: one saturated logical CPU is + # 100%, and a multithreaded process can exceed 100%. Exclude Idle/_Total + # (PID 0) and the monitoring processes, not unrelated workloads. + command = ( + "$ErrorActionPreference = 'Stop'; " + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); " + "@(Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | " + f"Where-Object {{ $_.IDProcess -ne 0 -and $_.IDProcess -ne {os.getpid()} " + "-and $_.IDProcess -ne $PID } | " + "Select-Object IDProcess,Name,PercentProcessorTime) | ConvertTo-Json -Compress" + ) + snapshot: dict[str, Any] = { + "logical_cpus": logical_cpus, + "load_average": [], + "available": False, + "source": "windows-perfproc", + } + try: + result = subprocess.run( + ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], + check=False, capture_output=True, text=True, encoding="utf-8", timeout=30.0, + ) + if result.returncode != 0: + raise ValueError(result.stderr.strip() or f"PowerShell exited {result.returncode}") + rows = json.loads(result.stdout) + if isinstance(rows, dict): + rows = [rows] + if not isinstance(rows, list) or not rows: + raise ValueError("Windows process counters are empty") + processes = [] + for row in rows: + cpu = float(row["PercentProcessorTime"]) + if not 0.0 <= cpu <= logical_cpus * 100.0: + raise ValueError("invalid Windows process CPU counter") + processes.append({ + "pid": int(row["IDProcess"]), + "command": str(row["Name"]), + "cpu_percent": cpu, + }) + except (OSError, subprocess.SubprocessError, ValueError, TypeError, KeyError) as exc: + snapshot["error"] = str(exc) + return snapshot + processes.sort(key=lambda item: item["cpu_percent"], reverse=True) + total = sum(item["cpu_percent"] for item in processes) + snapshot.update( + available=True, + total_cpu_percent=round(total, 3), + cpu_fraction=round(total / (logical_cpus * 100.0), 6), + top_processes=processes[:8], + ) + return snapshot + + def classify_host_snapshot( snapshot: Mapping[str, Any], max_cpu_fraction: float, @@ -633,7 +689,10 @@ def validate_manifest(manifest: Mapping[str, Any]) -> None: for key in ("description", "os", "backend", "gpu"): if not isinstance(item.get(key), str) or not item[key]: raise QualityError(f"machine class {item['id']}.{key} is required") - list_of_strings(item.get("hard_metrics"), f"{item['id']}.hard_metrics") + list_of_strings( + item.get("hard_metrics"), f"{item['id']}.hard_metrics", + allow_empty=not item.get("hard_gate", False), + ) max_host_fraction = item.get("max_host_cpu_fraction") if ( not isinstance(max_host_fraction, (int, float)) @@ -656,6 +715,8 @@ def validate_manifest(manifest: Mapping[str, Any]) -> None: raise QualityError( f"machine class {item['id']}.hardware_gi must be a boolean" ) + if "check_host_load" in item and not isinstance(item["check_host_load"], bool): + raise QualityError(f"machine class {item['id']}.check_host_load must be a boolean") for case in cases: machine_id = case["budgets"]["machine_class"] if machine_id not in machine_ids: @@ -840,12 +901,19 @@ def run_command( ) -> CommandResult: started = time.perf_counter() timed_out = False + argv = list(argv) + # CreateProcess resolves a relative executable against the parent's cwd, + # even when subprocess receives a different cwd for the child on Windows. + if not Path(argv[0]).is_absolute() and ("/" in argv[0] or "\\" in argv[0]): + argv[0] = str((cwd / argv[0]).resolve()) try: proc = subprocess.run( - list(argv), + argv, cwd=cwd, env=dict(os.environ, **(dict(env or {}))), text=True, + encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds, @@ -860,6 +928,10 @@ def run_command( stdout = exc.stdout if isinstance(exc.stdout, str) else "" stderr = exc.stderr if isinstance(exc.stderr, str) else "" stderr += f"\nquality runner: timed out after {timeout_seconds:.1f}s\n" + except OSError as exc: + returncode = 127 + stdout = "" + stderr = f"quality runner: cannot start command: {exc}\n" return CommandResult( argv=list(argv), cwd=str(cwd), @@ -1243,6 +1315,25 @@ def performance_failures( return failures +def machine_identity_failures( + machine: Mapping[str, Any] | None, telemetry: Mapping[str, Any] | None, +) -> list[str]: + """A named measurement profile must prove its adapter even without budgets.""" + if machine is None: + return [] + adapter = (telemetry or {}).get("adapter") + if not isinstance(adapter, dict) or adapter.get("availability") != "reported": + return ["machine profile did not report native adapter metadata"] + failures = [] + for key, adapter_key in (("backend", "backend"), ("gpu", "name")): + expected = str(machine.get(key, "")).lower() + actual = str(adapter.get(adapter_key, "")).lower() + matches = expected in actual if key == "gpu" else expected == actual + if expected and not matches: + failures.append(f"adapter {adapter_key} {actual!r} != machine profile {expected!r}") + return failures + + def diff_command( diff_bin: Path, case: Mapping[str, Any], @@ -1421,14 +1512,6 @@ def run_case( env.update(machine_capture_environment(machine)) capture_result = run_command(capture_argv, cwd, timeout_seconds, env) record["commands"].append({"kind": "capture", **command_record(capture_result)}) - if capture_result.returncode != 0: - record["status"] = "error" - record["failures"].append(f"capture failed with exit {capture_result.returncode}") - return record - if not candidate.exists(): - record["status"] = "error" - record["failures"].append(f"capture did not produce {candidate}") - return record host_postflight_passed = True if validate_host_load and machine is not None: max_cpu_fraction = float(machine.get("max_host_cpu_fraction", 0.20)) @@ -1451,6 +1534,14 @@ def run_case( record["failures"].append( f"performance measurement invalid because host became busy: {reason}" ) + if capture_result.returncode != 0: + record["status"] = "error" + record["failures"].append(f"capture failed with exit {capture_result.returncode}") + return record + if not candidate.exists(): + record["status"] = "error" + record["failures"].append(f"capture did not produce {candidate}") + return record candidate_dimensions = png_dimensions(candidate) if candidate_dimensions != tuple(case["resolution"]): record["failures"].append( @@ -1487,6 +1578,7 @@ def run_case( f"invalid external VRAM measurement {external_vram!r}" ) record["telemetry"] = telemetry + record["failures"].extend(machine_identity_failures(machine, telemetry)) record["failures"].extend(telemetry_contract_failures(case, telemetry)) record["artifacts"]["candidate"] = str(candidate.relative_to(out_dir)) intermediate_files = sorted( @@ -1596,7 +1688,8 @@ def write_summaries(result: Mapping[str, Any], out_dir: Path) -> None: f"Overall: **{str(result['status']).upper()}** \n" f"Commit: `{result['environment']['git_commit']}` \n" f"Manifest: `{result['manifest_sha256']}` \n" - f"Machine class: `{result.get('machine_class') or 'report-only'}`\n\n" + f"Machine class: `{result.get('machine_class') or 'unqualified'}` \n" + f"Performance budgets: `{result.get('performance_budget_mode', 'unspecified')}`\n\n" + markdown_table(headers, rows) + "\n" ) @@ -1618,6 +1711,7 @@ def write_summaries(result: Mapping[str, Any], out_dir: Path) -> None:

Bloom quality qualification: {html.escape(str(result["suite"]))}

Overall: {html.escape(str(result["status"]).upper())}

Commit {html.escape(str(result["environment"]["git_commit"]))}

+

Performance budgets: {html.escape(str(result.get("performance_budget_mode", "unspecified")))}

{"".join(f"" for h in headers)}{html_rows}
{html.escape(h)}
""" @@ -1968,7 +2062,9 @@ def execute_suite(args: argparse.Namespace) -> int: shutil.rmtree(out_dir) out_dir.mkdir(parents=True, exist_ok=True) host_preflight: dict[str, Any] | None = None - validate_host_load = bool(machine and machine.get("hard_gate", False)) + validate_host_load = bool(machine and ( + machine.get("hard_gate", False) or machine.get("check_host_load", False) + )) if validate_host_load: host_preflight = wait_for_idle_host( timeout_seconds=float(args.host_idle_timeout), @@ -2043,6 +2139,9 @@ def execute_suite(args: argparse.Namespace) -> int: "suite": args.suite, "machine_class": machine_class, "report_only": bool(args.report_only), + "performance_budget_mode": ( + "governed" if machine and machine.get("hard_gate", False) else "measurement-only" + ), "environment": environment, "features": sorted(observed_features or features), "artifacts": { diff --git a/tools/quality/scenes.toml b/tools/quality/scenes.toml index 6dd53e4f..6c9a341e 100644 --- a/tools/quality/scenes.toml +++ b/tools/quality/scenes.toml @@ -84,6 +84,19 @@ gpu = "Apple M1" max_host_cpu_fraction = 0.20 max_host_process_cpu_percent = 75.0 +[[machine_class]] +id = "amd-radeon760m-windows-vulkan" +description = "Radeon 760M integrated GPU measurement profile; performance budgets not yet qualified" +hard_gate = false +hard_metrics = [] +check_host_load = true +os = "windows" +backend = "vulkan" +gpu = "AMD Radeon 760M Graphics" +hardware_gi = true +max_host_cpu_fraction = 0.20 +max_host_process_cpu_percent = 75.0 + [[case]] id = "pbr-spheres-high" description = "BRDF energy, Fresnel, metallic/roughness lobes, and IBL orientation" diff --git a/tools/quality/test_run.py b/tools/quality/test_run.py index 901f2b27..54d8413b 100644 --- a/tools/quality/test_run.py +++ b/tools/quality/test_run.py @@ -168,7 +168,50 @@ def test_candidate_artifact_escape_is_rejected(self) -> None: quality.baseline_review(args) +class CommandExecutionTests(unittest.TestCase): + def test_relative_executable_resolves_against_child_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + cwd = Path(directory).resolve() + result = mock.Mock(returncode=0, stdout="ok", stderr="") + with mock.patch.object(quality.subprocess, "run", return_value=result) as run: + record = quality.run_command(["./main.exe", "--quality-run"], cwd, 10.0) + self.assertEqual(run.call_args.args[0][0], str(cwd / "main.exe")) + self.assertEqual(record.argv[0], str(cwd / "main.exe")) + + def test_missing_executable_preserves_failure_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + record = quality.run_command(["./missing-quality-scene"], Path(directory), 10.0) + self.assertEqual(record.returncode, 127) + self.assertIn("cannot start command", record.stderr) + self.assertFalse(record.timed_out) + + class HostLoadPreflightTests(unittest.TestCase): + def test_windows_counters_keep_single_core_units(self) -> None: + result = mock.Mock(returncode=0, stdout=json.dumps([ + {"IDProcess": 123, "Name": "compiler", "PercentProcessorTime": 150}, + {"IDProcess": 456, "Name": "editor", "PercentProcessorTime": 30}, + ])) + with mock.patch.object(quality.subprocess, "run", return_value=result): + snapshot = quality.windows_host_load_snapshot(12) + self.assertTrue(snapshot["available"]) + self.assertEqual(snapshot["cpu_fraction"], 0.15) + self.assertEqual(snapshot["top_processes"][0]["cpu_percent"], 150) + accepted, reason = quality.classify_host_snapshot(snapshot, 0.20, 75.0) + self.assertFalse(accepted) + self.assertIn("top process CPU", reason) + + def test_windows_counter_failures_do_not_look_idle(self) -> None: + for stdout in ("", "[]", "null", "invalid", '[{"IDProcess": 1}]'): + with self.subTest(stdout=stdout), mock.patch.object( + quality.subprocess, "run", return_value=mock.Mock(returncode=0, stdout=stdout) + ): + snapshot = quality.windows_host_load_snapshot(12) + self.assertFalse(snapshot["available"]) + self.assertFalse(quality.classify_host_snapshot(snapshot, 0.20, 75.0)[0]) + with mock.patch.object(quality.subprocess, "run", side_effect=OSError("missing")): + self.assertFalse(quality.windows_host_load_snapshot(12)["available"]) + def snapshot(self, fraction: float, top_cpu: float) -> dict[str, object]: return { "available": True, @@ -219,6 +262,27 @@ def test_zero_timeout_records_busy_host_without_waiting(self) -> None: class ReproducibilityTests(unittest.TestCase): + def test_radeon_profile_checks_identity_without_borrowing_budgets(self) -> None: + manifest, _ = quality.load_manifest(MODULE_PATH.with_name("scenes.toml")) + machine = quality.selected_machine_class(manifest, "amd-radeon760m-windows-vulkan") + self.assertFalse(machine["hard_gate"]) + self.assertTrue(machine["check_host_load"]) + self.assertEqual(quality.machine_capture_environment(machine), { + "BLOOM_WGPU_BACKEND": "vulkan", "BLOOM_HW_GI": "1", + }) + telemetry = {"adapter": { + "availability": "reported", "name": "AMD Radeon 760M Graphics", "backend": "Vulkan", + }, "gpu_frame_p95_ms": 1000.0} + self.assertEqual(quality.machine_identity_failures(machine, telemetry), []) + for case in manifest["case"]: + self.assertEqual(quality.performance_failures(case, telemetry, machine), []) + telemetry["adapter"]["backend"] = "Dx12" + self.assertTrue(quality.machine_identity_failures(machine, telemetry)) + telemetry["adapter"]["backend"] = "Vulkan" + telemetry["adapter"]["name"] = "Microsoft Basic Render Driver" + self.assertTrue(quality.machine_identity_failures(machine, telemetry)) + self.assertTrue(quality.machine_identity_failures(machine, None)) + def test_checked_in_manifest_satisfies_contract(self) -> None: manifest, digest = quality.load_manifest(MODULE_PATH.with_name("scenes.toml")) self.assertEqual(manifest["schema_version"], 1) From 8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 17:32:16 +0200 Subject: [PATCH 2/5] windows: honor headless qualification pixel dimensions --- native/windows/src/lib.rs | 44 +++++++++++++++++++++++++++++++++------ tools/quality/README.md | 5 +++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index a5cc7bdd..f9ef14af 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -15,6 +15,12 @@ static mut ENGINE: OnceLock = OnceLock::new(); static mut EMBEDDED: bool = false; #[cfg(windows)] +fn env_flag(name: &str) -> bool { + std::env::var(name) + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + fn configured_backends() -> wgpu::Backends { match std::env::var("BLOOM_WGPU_BACKEND") .unwrap_or_default() @@ -255,6 +261,9 @@ mod win32 { }; pub fn set_fullscreen(fullscreen: bool) { + if super::env_flag("BLOOM_HEADLESS") || super::env_flag("BLOOM_NO_FULLSCREEN") { + return; + } unsafe { let Some(hwnd) = HWND_GLOBAL else { return }; @@ -442,8 +451,9 @@ mod win32 { let phys_h = ((lparam.0 >> 16) & 0xFFFF) as u32; if phys_w > 0 && phys_h > 0 { if let Some(eng) = ENGINE.get_mut() { - if phys_w != eng.renderer.physical_width() - || phys_h != eng.renderer.physical_height() + if eng.renderer.surface.is_some() + && (phys_w != eng.renderer.physical_width() + || phys_h != eng.renderer.physical_height()) { let scale = dpi_scale(hwnd); let log_w = ((phys_w as f64) / scale).round() as u32; @@ -480,6 +490,7 @@ mod win32 { /// scaled-up physical pixels for the renderer to fill. pub fn create_window(width: f64, height: f64, title: &str) -> (HWND, u32, u32) { unsafe { + let headless = super::env_flag("BLOOM_HEADLESS"); // Per-Monitor-Aware-V2: the window's DPI tracks the monitor // it's currently on, and Windows fires WM_DPICHANGED when // it moves between monitors of different DPI. Without this @@ -518,7 +529,7 @@ mod win32 { WINDOW_EX_STYLE::default(), class_name, PCWSTR(title_wide.as_ptr()), - WS_OVERLAPPEDWINDOW | WS_VISIBLE, + if headless { WS_OVERLAPPEDWINDOW } else { WS_OVERLAPPEDWINDOW | WS_VISIBLE }, CW_USEDEFAULT, CW_USEDEFAULT, phys_w, @@ -530,7 +541,9 @@ mod win32 { ) .unwrap(); - ShowWindow(hwnd, SW_SHOW); + if !headless { + ShowWindow(hwnd, SW_SHOW); + } HWND_GLOBAL = Some(hwnd); // After the window exists, query the actual client-area @@ -589,8 +602,9 @@ mod win32 { let phys_h = ((lparam.0 >> 16) & 0xFFFF) as u32; if phys_w > 0 && phys_h > 0 { if let Some(eng) = ENGINE.get_mut() { - if phys_w != eng.renderer.physical_width() - || phys_h != eng.renderer.physical_height() + if eng.renderer.surface.is_some() + && (phys_w != eng.renderer.physical_width() + || phys_h != eng.renderer.physical_height()) { let scale = dpi_scale(hwnd); let log_w = ((phys_w as f64) / scale).round() as u32; @@ -833,6 +847,24 @@ unsafe fn init_engine_for_hwnd( let device = negotiated.device; let queue = negotiated.queue; + if env_flag("BLOOM_HEADLESS") { + // Render qualification frames offscreen. DWM borders, DPI changes, and + // queued WM_SIZE events must not change the requested capture size. + let pixel_exact = env_flag("BLOOM_HEADLESS_PIXEL_EXACT"); + let (width, height) = if pixel_exact { + (logical_w, logical_h) + } else { + (phys_w, phys_h) + }; + let mut renderer = Renderer::new_headless(device, queue, width, height); + if !pixel_exact { + renderer.resize(width, height, logical_w, logical_h); + } + renderer.set_device_negotiation_report(negotiation_report); + let _ = ENGINE.set(EngineState::new(renderer)); + return; + } + let surface_caps = surface.get_capabilities(&adapter); let format = surface_caps.formats[0]; // Surface is configured at the *physical* client-area size we diff --git a/tools/quality/README.md b/tools/quality/README.md index 5ae83da5..074204c3 100644 --- a/tools/quality/README.md +++ b/tools/quality/README.md @@ -63,6 +63,11 @@ process limits; aggregate CPU load is normalized by the logical CPU count. Missing or unreadable counters fail the idle check. These snapshots identify CPU pressure before and after the run, not continuous background GPU load. +Windows qualification uses a hidden window for adapter negotiation and an +offscreen renderer at the manifest's pixel dimensions. Display DPI, window +borders, and window resize events do not resize these captures. Final PNG +export follows the actual RGBA/BGRA texture format. + Before the Windows run, use `python tools/quality/run.py check` from a clean checkout and confirm that Python, Rust/Cargo, Node/npm, Perry, Vulkan, and the pinned Bistro assets are available. The runner records `windows` as the host From 2c4af1948ab4a408b9c28f9aa568a372fd630b52 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 17:39:21 +0200 Subject: [PATCH 3/5] docs: record repeatable Radeon Windows qualification evidence --- .../issue-153-radeon760m-windows-vulkan.json | 234 ++++++++++++++++++ .../issue-153-radeon760m-windows-vulkan.md | 78 ++++++ 2 files changed, 312 insertions(+) create mode 100644 docs/evidence/issue-153-radeon760m-windows-vulkan.json create mode 100644 docs/evidence/issue-153-radeon760m-windows-vulkan.md diff --git a/docs/evidence/issue-153-radeon760m-windows-vulkan.json b/docs/evidence/issue-153-radeon760m-windows-vulkan.json new file mode 100644 index 00000000..72f03712 --- /dev/null +++ b/docs/evidence/issue-153-radeon760m-windows-vulkan.json @@ -0,0 +1,234 @@ +{ + "schema": "bloom-issue-153-radeon-measurement-v1", + "date": "2026-09-10", + "source_commit": "8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8", + "base_commit": "4508eaff082b849203ac60f9d8f3a327b71d95c9", + "manifest_sha256": "95ef665ecd2734d527bd55738f69c9ab16bd472ad2239ab861c1bcc8a61ef320", + "host": { + "captured_at": "2026-09-10T15:27:31.5714756Z", + "os": { + "Caption": "Microsoft Windows 11 Pro", + "Version": "10.0.26200", + "BuildNumber": "26200", + "OSArchitecture": "64-bit" + }, + "cpu": { + "Name": "AMD Ryzen 5 7640HS w/ Radeon 760M Graphics ", + "NumberOfCores": 6, + "NumberOfLogicalProcessors": 12 + }, + "memory_bytes": 27704946688, + "display_adapters": [ + { + "Name": "AMD Radeon 760M Graphics", + "DriverVersion": "32.0.12033.1030", + "DriverDate": "/Date(1732665600000)/" + } + ], + "base_commit": "4508eaff082b849203ac60f9d8f3a327b71d95c9", + "measured_commit": "8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8", + "perry_version": "0.5.1182", + "perry_archive_sha256": "961d0789a50043b3e441c8826cfb8cc0106b3c4124d8fb9ca2381880961405d9", + "cargo": "cargo 1.96.1 (356927216 2026-06-26)", + "python": "Python 3.12.10", + "node": "v24.21.0", + "unrelated_process_monitoring": "CPU snapshots before and after capture; no continuous background GPU or process-start monitoring" + }, + "strict_run_exit_codes": [ + 1, + 1 + ], + "performance_budget_mode": "measurement-only", + "pass_count": 7, + "fail_count": 2, + "host_checks_all_passed": true, + "repro_passed": true, + "repro_metadata_identical": true, + "repro_identical_artifacts": 257, + "cases": [ + { + "id": "pbr-spheres-high", + "resolution": [ + 512, + 512 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 1.3251, + 1.4304 + ], + "gpu_frame_p95_ms": [ + 1.82872, + 2.38584 + ], + "ssim": 0.998786986, + "gi_path": "hw-ray-query" + }, + { + "id": "pbr-spheres-constrained", + "resolution": [ + 512, + 512 + ], + "render_scale": 0.75, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 0.8891, + 0.5481 + ], + "gpu_frame_p95_ms": [ + 0.94148, + 0.64516 + ], + "ssim": 0.999385297, + "gi_path": "hw-ray-query-pending" + }, + { + "id": "damaged-helmet", + "resolution": [ + 512, + 512 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 1.2616, + 1.3202 + ], + "gpu_frame_p95_ms": [ + 2.20392, + 2.01828 + ], + "ssim": 0.998638809, + "gi_path": "hw-ray-query" + }, + { + "id": "sponza-interior", + "resolution": [ + 800, + 450 + ], + "render_scale": 1.0, + "status": "fail", + "failures": [ + "visual thresholds failed (exit 1)" + ], + "cpu_frame_p95_ms": [ + 1.7121, + 1.7501 + ], + "gpu_frame_p95_ms": [ + 3.4954, + 3.4564 + ], + "ssim": 0.972333014, + "gi_path": "hw-ray-query" + }, + { + "id": "bistro-exterior", + "resolution": [ + 800, + 450 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 2.395, + 2.3341 + ], + "gpu_frame_p95_ms": [ + 7.82228, + 7.8018 + ], + "ssim": 0.987192452, + "gi_path": "hw-ray-query" + }, + { + "id": "skinned-alpha-motion", + "resolution": [ + 800, + 450 + ], + "render_scale": 1.0, + "status": "fail", + "failures": [ + "visual thresholds failed (exit 1)" + ], + "cpu_frame_p95_ms": [ + 1.8231, + 1.8414 + ], + "gpu_frame_p95_ms": [ + 1.63108, + 1.62324 + ], + "ssim": 0.931997895, + "gi_path": "hw-ray-query" + }, + { + "id": "draw-light-stress", + "resolution": [ + 1280, + 720 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 9.8948, + 9.4298 + ], + "gpu_frame_p95_ms": [ + 11.7286, + 11.63692 + ], + "ssim": 0.999032915, + "gi_path": "hw-ray-query" + }, + { + "id": "weighted-transparency", + "resolution": [ + 960, + 540 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 2.695, + 1.9926 + ], + "gpu_frame_p95_ms": [ + 5.94512, + 5.94484 + ], + "ssim": 0.999737084, + "gi_path": "hiz-screen" + }, + { + "id": "masked-alpha-coverage", + "resolution": [ + 960, + 540 + ], + "render_scale": 1.0, + "status": "pass", + "failures": [], + "cpu_frame_p95_ms": [ + 1.9706, + 1.9333 + ], + "gpu_frame_p95_ms": [ + 4.10788, + 4.09928 + ], + "ssim": 0.983674943, + "gi_path": "hiz-screen" + } + ] +} diff --git a/docs/evidence/issue-153-radeon760m-windows-vulkan.md b/docs/evidence/issue-153-radeon760m-windows-vulkan.md new file mode 100644 index 00000000..762cf225 --- /dev/null +++ b/docs/evidence/issue-153-radeon760m-windows-vulkan.md @@ -0,0 +1,78 @@ +# Issue #153: Radeon 760M Windows/Vulkan measurement + +Two complete strict nine-case runs on the available Radeon 760M produced **7 passes and 2 visual failures** each. Both exited **1**; `repro-check` exited **0 (PASS)**. All 257 compared final/intermediate artifacts were byte-identical between runs, stable metadata matched, and timings stayed within the existing reproducibility bounds. Both host preflights and all 18 per-case postflights passed. + +This is useful local qualification evidence for the available integrated GPU. It is not an RTX 4080 qualification or approval of Radeon performance budgets. No baseline images, visual thresholds, existing hardware budgets, or noise bounds were changed; neither run used `--report-only`. + +## Source and host + +- Measured commit: `8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8`; both runs recorded a clean worktree. +- Issue's starting commit: `4508eaff082b849203ac60f9d8f3a327b71d95c9`. Windows execution/capture fixes were necessary before measurement. +- Branch: `codex/issue-153-radeon760m`. +- Manifest SHA-256: `95ef665ecd2734d527bd55738f69c9ab16bd472ad2239ab861c1bcc8a61ef320`. +- Windows 11 Pro x64, version 10.0.26200; Ryzen 5 7640HS, 6 cores / 12 logical CPUs; 27,704,946,688 bytes RAM reported. +- AMD Radeon 760M Graphics; Vulkan 1.3.292; AMD driver 24.12.1 (LLPC), Windows driver 32.0.12033.1030. +- Native telemetry confirms `hw-ray-query` GI in Sponza and Bistro. Other cases record their own selected GI path in telemetry, including `hiz-screen` and `hw-ray-query-pending`. +- Perry 0.5.1182, Cargo 1.96.1, Python 3.12.10, Node 24.21.0. The official Perry 0.5.1182 Windows archive was SHA-256 verified: `961d0789a50043b3e441c8826cfb8cc0106b3c4124d8fb9ca2381880961405d9`. +- The system Perry launcher points to a missing executable. The isolated toolchain lives under `tools/quality/out/toolchain/v0.5.1182/perry`. Perry 0.5.1220 was tried first, but its prebuilt standard library failed to link with undefined HTTP extension symbols; the failure log is included. + +## Measurements + +CPU/GPU frame p95 ranges below span the two final runs, in milliseconds. These are fixed scene workloads at the listed resolutions, not predictions of full-game FPS or 1080p performance. GPU work and CPU work overlap and should not be added together. Performance is observational until Radeon-specific budgets are reviewed. + +| Case | Resolution / render scale | Result | CPU p95 ms | GPU p95 ms | SSIM | +| --- | --- | --- | --- | --- | --- | +| pbr-spheres-high | 512x512 / 1.0 | pass | 1.33-1.43 | 1.83-2.39 | 0.998787 | +| pbr-spheres-constrained | 512x512 / 0.75 | pass | 0.55-0.89 | 0.65-0.94 | 0.999385 | +| damaged-helmet | 512x512 / 1.0 | pass | 1.26-1.32 | 2.02-2.20 | 0.998639 | +| sponza-interior | 800x450 / 1.0 | fail | 1.71-1.75 | 3.46-3.50 | 0.972333 | +| bistro-exterior | 800x450 / 1.0 | pass | 2.33-2.40 | 7.80-7.82 | 0.987192 | +| skinned-alpha-motion | 800x450 / 1.0 | fail | 1.82-1.84 | 1.62-1.63 | 0.931998 | +| draw-light-stress | 1280x720 / 1.0 | pass | 9.43-9.89 | 11.64-11.73 | 0.999033 | +| weighted-transparency | 960x540 / 1.0 | pass | 1.99-2.69 | 5.94-5.95 | 0.999737 | +| masked-alpha-coverage | 960x540 / 1.0 | pass | 1.93-1.97 | 4.10-4.11 | 0.983675 | + +## Remaining failures + +- **Sponza:** SSIM `0.972333014`, required `>= 0.975000024`. Other visual metrics pass. Differences are concentrated around vegetation and lighting detail; the responsible renderer path has not yet been isolated. +- **Skinned/alpha motion:** SSIM `0.931997895`, required `>= 0.970000029`; luminance RMSE `0.047519531`, allowed `<= 0.039999999`. The largest visible differences are in the alpha-tested foliage surrounding the animated model. Repeat captures are identical, so this is reproducible rather than run-to-run timing noise. + +The next useful renderer investigation is an owner-isolation comparison of alpha-tested foliage and its GI/shadow contribution in these two scenes, retaining the same assets, cameras, and thresholds. This evidence does not justify replacing the approved images. + +## Fixes made while measuring + +1. Resolve scene executables against their working directory on Windows and retain launch-failure evidence instead of aborting the runner. +2. Supply the owning HINSTANCE for Vulkan surfaces, including the native attachment path. +3. Export PNG colors according to the texture's actual RGBA/BGRA format. The first sphere capture had red and blue swapped; after the fix it passes the existing reference. +4. Honor headless capture on Windows using an offscreen renderer and fixed pixel dimensions. Before this fix, DPI/window borders changed 800x450 cases to 1178x619 and the 1280x720 stress case to 1898x1024. Those earlier timings describe different workloads and are diagnostic only. +5. Add a Radeon profile with adapter validation, native Windows CPU preflight/postflight counters, and explicitly unqualified performance measurements. +6. Keep the two hash-governed textual glTF fixtures at LF on Windows. + +## Validation and evidence + +- Asset/baseline check: PASS for all nine cases. +- Python qualification tests: 23 passed. +- Rust PNG round-trip test: passed for RGBA/BGRA, linear/sRGB formats and padded rows. +- Release native library and all eight unique scene executables built successfully. +- Two complete final runs, unchanged image checks: 7 pass / 2 fail each; no capture or telemetry errors. +- Reproducibility: PASS, 257/257 artifact comparisons byte-identical. +- Repository file-line gate still fails in nine unchanged files at the issue's pinned base; none of the edited files is a new violation. The exact output is included. + +[First final run](../../tools/quality/out/windows-radeon760m-vulkan-corrected/summary.html), [repeat](../../tools/quality/out/windows-radeon760m-vulkan-repeat/summary.html), [reproducibility result](../../tools/quality/out/windows-radeon760m-repro/result.json). + +The ZIP preserves repository-relative paths, approved references, all complete run directories, early failed-capture diagnostics, console/build/test logs, host inventory, the manifest, and a source patch. Generated toolchain binaries and third-party scene assets are excluded; their versions/revisions and hashes are recorded for fetching them again. + +CPU snapshots passed before and after capture. Unrelated process starts and background GPU activity were not continuously monitored; no claim is made that absolutely no unrelated process started. The Radeon shares system memory, so dedicated-VRAM budgets are not inferred. + +## Repeat locally + +From the isolated worktree in PowerShell: + +```powershell +$env:PATH = (Resolve-Path tools/quality/out/toolchain/v0.5.1182/perry).Path + ';' + $env:PATH +$env:PYTHONUTF8 = '1' +python tools/quality/run.py check +python tools/quality/run.py run full --machine-class amd-radeon760m-windows-vulkan --host-idle-timeout 600 --out tools/quality/out/radeon-next +``` + +Preserve exit code 1 and the full bundle when the known visual differences remain. The existing full RTX 4080 gate is still pending suitable hardware. From 5dc8277e7f6b0ca69908e8b34c33f99cdbe15ba0 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 17:55:56 +0200 Subject: [PATCH 4/5] quality: verify Windows baseline portability and complete controls --- .../issue-153-radeon760m-completion-audit.md | 23 +++ .../issue-153-radeon760m-owner-isolation.json | 143 ++++++++++++++++++ .../issue-153-radeon760m-owner-isolation.md | 30 ++++ .../issue-153-radeon760m-windows-vulkan.md | 3 +- 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/issue-153-radeon760m-completion-audit.md create mode 100644 docs/evidence/issue-153-radeon760m-owner-isolation.json create mode 100644 docs/evidence/issue-153-radeon760m-owner-isolation.md diff --git a/docs/evidence/issue-153-radeon760m-completion-audit.md b/docs/evidence/issue-153-radeon760m-completion-audit.md new file mode 100644 index 00000000..563faa46 --- /dev/null +++ b/docs/evidence/issue-153-radeon760m-completion-audit.md @@ -0,0 +1,23 @@ +# Issue #153 Windows completion audit + +Scope: the user replaced the unavailable RTX 4080 machine with this Windows Radeon 760M host. The requested qualification accepts strict failures when complete evidence is returned. It does not require silently making every image pass, weakening thresholds, or claiming RTX 4080 certification. + +| Requirement | Authoritative evidence | State | +| --- | --- | --- | +| Start from requested renderer revision | Branch ancestry includes `4508eaff082b849203ac60f9d8f3a327b71d95c9`; source.patch records Windows corrections | Verified, with necessary execution fixes | +| Clean measured source | Both final result.json files name `8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8`, `git_dirty=false` | Verified | +| Complete pinned assets and approved baselines | Asset check PASS; original hashes unchanged; Bistro revision `7c9f9f9ac0915024ccf3dddbccd8bfc643a42607` | Verified | +| Strict full nine-case run without report-only | Two full result.json files, all nine IDs, `report_only=false`, exit code 1 each | Verified; 7 pass, 2 visual failures | +| Correct available host identity | Native telemetry: Windows, Vulkan, AMD Radeon 760M Graphics, driver 24.12.1 | Verified for user-approved substitute hardware | +| Hardware ray-query in required cases | Sponza/Bistro native `ssgi_trace_backend=hw-ray-query` | Verified | +| Correct pixel dimensions and measurement evidence | Final PNG dimensions match every manifest case; telemetry and intermediate captures exist for all nine | Verified | +| Host quietness records | Both preflights and all 18 postflights accepted | Verified CPU snapshots; continuous background GPU/process-start monitoring not claimed | +| Preserve baselines, thresholds and noise bounds | Git diff against pinned source changes none; full result records strict visual failures | Verified | +| Repeatability | repro-check PASS, stable metadata equal, 257/257 artifacts byte-identical | Verified | +| Seeded regressions remain detectable | Five negative controls DETECTED, exit 0 | Verified | +| Diagnose remaining strict failures | Original baseline-source renderer produces byte-identical Sponza/skinned-alpha images on this host; controls preserved | Verified that failures predate intervening source changes under this configuration; precise portability cause unresolved | +| Runnable final local state | Temporary anisotropy reduction reverted; affected executables rebuilt; restored captures byte-identical to final qualification | Verified | +| Complete downloadable evidence | Versioned ZIP, CRC check, SHA-256 sidecar; report and source patch included | Local bundle prepared; public/durable GitHub upload awaits explicit authorization | +| GitHub review and issue handoff | Draft PR body, release notes and comments prepared under tools/quality/out/github-handoff | Publication awaits explicit authorization | + +The original RTX 4080 certification remains unperformed; this hardware substitution is documented rather than represented as that certification. Radeon performance numbers are measured, with no invented hard budget. The evidence permits renderer work to proceed on this Windows machine. diff --git a/docs/evidence/issue-153-radeon760m-owner-isolation.json b/docs/evidence/issue-153-radeon760m-owner-isolation.json new file mode 100644 index 00000000..5ecf148d --- /dev/null +++ b/docs/evidence/issue-153-radeon760m-owner-isolation.json @@ -0,0 +1,143 @@ +{ + "schema": "bloom-issue153-windows-owner-isolation-v1", + "baseline_source_commit": "09ad0b755af9f10083712327d7f0edb1d88f228b", + "current_renderer_commit": "8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8", + "baseline_source_images_identical_to_current": true, + "negative_controls_passed": true, + "negative_control_count": 5, + "runs": [ + { + "run": "windows-radeon760m-vulkan-corrected", + "git_commit": "8309985bf9f5358c38e5dd2cbc0e328d2bccb6f8", + "git_dirty": false, + "backend": "vulkan", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.972333014, + "rmse_luminance": 0.012245284, + "image_sha256": "a02b5309c5b346d26b85cc599a9b50a423a8ec79bf8c27089a0206f30bfa01ca", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.931997895, + "rmse_luminance": 0.047519531, + "image_sha256": "dabb8e43ccbfd606d8e4fe8f5f5f9cb2e0ec4f59f9da4a6b16c2fe12c7ccd8df", + "status": "fail" + } + ] + }, + { + "run": "radeon-owner-software-gi", + "git_commit": "2c4af1948ab4a408b9c28f9aa568a372fd630b52", + "git_dirty": false, + "backend": "vulkan", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.969276249, + "rmse_luminance": 0.01358667, + "image_sha256": "b67f7e5d40875f1fff630525fd696e458e1ff974dea387765f49bee74e3db2a9", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.931530952, + "rmse_luminance": 0.047676153, + "image_sha256": "78fc7ad35a89a1c256a2b8fece63b0474459c3249f5c556afd3f94ff7655832b", + "status": "fail" + } + ] + }, + { + "run": "radeon-owner-bound-materials", + "git_commit": "2c4af1948ab4a408b9c28f9aa568a372fd630b52", + "git_dirty": false, + "backend": "vulkan", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.972333014, + "rmse_luminance": 0.012245284, + "image_sha256": "a02b5309c5b346d26b85cc599a9b50a423a8ec79bf8c27089a0206f30bfa01ca", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.931997895, + "rmse_luminance": 0.047519531, + "image_sha256": "dabb8e43ccbfd606d8e4fe8f5f5f9cb2e0ec4f59f9da4a6b16c2fe12c7ccd8df", + "status": "fail" + } + ] + }, + { + "run": "radeon-owner-dx12-software-gi", + "git_commit": "2c4af1948ab4a408b9c28f9aa568a372fd630b52", + "git_dirty": false, + "backend": "dx12", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.970000505, + "rmse_luminance": 0.013674281, + "image_sha256": "075b3df8d0a253391f1a1f8ff9534fb84ac4dd63d5757cba0b3a8cad8667ade2", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.931594729, + "rmse_luminance": 0.047674358, + "image_sha256": "02e6e354ba286b80e6d0b4dc67748fa5795c24d08e8d519f571704d0966ea61d", + "status": "fail" + } + ] + }, + { + "run": "radeon-owner-anisotropy-1", + "git_commit": "2c4af1948ab4a408b9c28f9aa568a372fd630b52", + "git_dirty": true, + "backend": "vulkan", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.95216471, + "rmse_luminance": 0.014432787, + "image_sha256": "4c829636947bfa841b38873b14e162f9565cbeddeb2f2127222bb46c436f6758", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.923400104, + "rmse_luminance": 0.048658907, + "image_sha256": "d0d90bf6c5ada752e4e181baf812f274c46bf196dd8eb56c6f225da4dfbd1a9c", + "status": "fail" + } + ] + }, + { + "run": "radeon-owner-baseline-source", + "git_commit": "09ad0b755af9f10083712327d7f0edb1d88f228b", + "git_dirty": true, + "backend": "vulkan", + "cases": [ + { + "id": "sponza-interior", + "ssim": 0.972333014, + "rmse_luminance": 0.012245284, + "image_sha256": "a02b5309c5b346d26b85cc599a9b50a423a8ec79bf8c27089a0206f30bfa01ca", + "status": "fail" + }, + { + "id": "skinned-alpha-motion", + "ssim": 0.931997895, + "rmse_luminance": 0.047519531, + "image_sha256": "dabb8e43ccbfd606d8e4fe8f5f5f9cb2e0ec4f59f9da4a6b16c2fe12c7ccd8df", + "status": "fail" + } + ] + } + ], + "conclusion": "Both failures predate changes since the approved baseline source, under the tested Windows/Radeon/compiler configuration. The precise cause of the cross-platform baseline discrepancy is not isolated." +} diff --git a/docs/evidence/issue-153-radeon760m-owner-isolation.md b/docs/evidence/issue-153-radeon760m-owner-isolation.md new file mode 100644 index 00000000..f97504d9 --- /dev/null +++ b/docs/evidence/issue-153-radeon760m-owner-isolation.md @@ -0,0 +1,30 @@ +# Issue #153: Windows baseline portability investigation + +The source revision that produced the approved baselines (`09ad0b755af9f10083712327d7f0edb1d88f228b`) and the current renderer produce **byte-identical final PNGs** for Sponza and skinned/alpha motion on this Windows Radeon 760M host. Both retain the same strict failures against the approved portable images. Under this tested configuration, these are not regressions introduced since that baseline source. + +This comparison uses the old renderer, loaders, shaders and scene sources from its checkout, with the current Windows launch/headless corrections and qualification orchestrator overlaid to execute it on this host. The current corpus manifest and approved PNGs were copied in so the comparison uses identical inputs and thresholds. The original/current resolution, frame counts, seed, timestep, render scale, camera, settings and thresholds were independently compared and match for both cases. The exact adaptation patch is preserved at `tools/quality/out/radeon-evidence/windows-oracle-adaptations.patch`. Native build records identify `engine-quality-baseline/native/shared` and `engine-quality-baseline/native/windows`. The old-source checkout is a diagnostic oracle, not a clean qualification commit. + +## Controls + +| Configuration | Sponza SSIM | Skinned/alpha SSIM | Skinned/alpha luminance RMSE | +| --- | --- | --- | --- | +| windows-radeon760m-vulkan-corrected | 0.972333014 | 0.931997895 | 0.047519531 | +| radeon-owner-software-gi | 0.969276249 | 0.931530952 | 0.047676153 | +| radeon-owner-bound-materials | 0.972333014 | 0.931997895 | 0.047519531 | +| radeon-owner-dx12-software-gi | 0.970000505 | 0.931594729 | 0.047674358 | +| radeon-owner-anisotropy-1 | 0.952164710 | 0.923400104 | 0.048658907 | +| radeon-owner-baseline-source | 0.972333014 | 0.931997895 | 0.047519531 | + +- Forcing software GI retains both errors, so simply changing GI is not a fix. +- Forcing bound material bindings produces exactly the current qualification images. +- DirectX 12 with software GI produces essentially the same failures as Vulkan with software GI. This is an image-only diagnostic; it does not claim a governed DirectX timing run. +- Reducing the material sampler from 16x to 1x anisotropy worsens both images. This temporary diagnostic was reverted. Its patch is preserved; no reduced-quality sampler setting is shipped. +- The old-source/current byte equality rules out the intervening Three.js material compatibility changes as the cause in these cases. + +The remaining difference is between the approved portable baseline and this Windows/GPU/compiler configuration. This does not yet identify a specific driver, compiler or rasterization cause, and it does not authorize a baseline or threshold update. Further cross-platform normalization work needs a separate renderer investigation or a human-reviewed backend-specific baseline decision. The qualification task explicitly accepts a complete strict failure bundle. + +## Detection and final state + +The negative-control command detected all five governed seeded faults: BRDF energy, shadow placement, GI leakage, motion history and texture orientation. Its exit code was 0 and `result.json` records all five as detected. + +The Windows fixes remain on `codex/issue-153-radeon760m`. No experimental rendering changes remain in that branch. The two full runs still provide the definitive nine-case Windows/Radeon qualification: seven passes, two strict visual failures, all required hardware ray-query cases measured, and reproducibility PASS with 257 byte-identical artifacts. diff --git a/docs/evidence/issue-153-radeon760m-windows-vulkan.md b/docs/evidence/issue-153-radeon760m-windows-vulkan.md index 762cf225..721710b3 100644 --- a/docs/evidence/issue-153-radeon760m-windows-vulkan.md +++ b/docs/evidence/issue-153-radeon760m-windows-vulkan.md @@ -37,7 +37,7 @@ CPU/GPU frame p95 ranges below span the two final runs, in milliseconds. These a - **Sponza:** SSIM `0.972333014`, required `>= 0.975000024`. Other visual metrics pass. Differences are concentrated around vegetation and lighting detail; the responsible renderer path has not yet been isolated. - **Skinned/alpha motion:** SSIM `0.931997895`, required `>= 0.970000029`; luminance RMSE `0.047519531`, allowed `<= 0.039999999`. The largest visible differences are in the alpha-tested foliage surrounding the animated model. Repeat captures are identical, so this is reproducible rather than run-to-run timing noise. -The next useful renderer investigation is an owner-isolation comparison of alpha-tested foliage and its GI/shadow contribution in these two scenes, retaining the same assets, cameras, and thresholds. This evidence does not justify replacing the approved images. +A subsequent [owner-isolation investigation](issue-153-radeon760m-owner-isolation.md) reproduced both images byte-for-byte using the renderer revision that created the approved baselines. Software GI, bound materials, and DirectX controls do not remove the failures. Reduced anisotropy worsens them and was reverted. The remaining portability difference predates the intervening renderer changes in this tested configuration; its precise cause is still unisolated. This evidence does not justify replacing the approved images. ## Fixes made while measuring @@ -52,6 +52,7 @@ The next useful renderer investigation is an owner-isolation comparison of alpha - Asset/baseline check: PASS for all nine cases. - Python qualification tests: 23 passed. +- All five governed seeded negative controls detected their injected regressions. - Rust PNG round-trip test: passed for RGBA/BGRA, linear/sRGB formats and padded rows. - Release native library and all eight unique scene executables built successfully. - Two complete final runs, unchanged image checks: 7 pass / 2 fail each; no capture or telemetry errors. From e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 10 Sep 2026 19:26:15 +0200 Subject: [PATCH 5/5] docs: record Radeon evidence publication and draft PR --- docs/evidence/issue-153-radeon760m-completion-audit.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/evidence/issue-153-radeon760m-completion-audit.md b/docs/evidence/issue-153-radeon760m-completion-audit.md index 563faa46..88fdb18e 100644 --- a/docs/evidence/issue-153-radeon760m-completion-audit.md +++ b/docs/evidence/issue-153-radeon760m-completion-audit.md @@ -17,7 +17,11 @@ Scope: the user replaced the unavailable RTX 4080 machine with this Windows Rade | Seeded regressions remain detectable | Five negative controls DETECTED, exit 0 | Verified | | Diagnose remaining strict failures | Original baseline-source renderer produces byte-identical Sponza/skinned-alpha images on this host; controls preserved | Verified that failures predate intervening source changes under this configuration; precise portability cause unresolved | | Runnable final local state | Temporary anisotropy reduction reverted; affected executables rebuilt; restored captures byte-identical to final qualification | Verified | -| Complete downloadable evidence | Versioned ZIP, CRC check, SHA-256 sidecar; report and source patch included | Local bundle prepared; public/durable GitHub upload awaits explicit authorization | -| GitHub review and issue handoff | Draft PR body, release notes and comments prepared under tools/quality/out/github-handoff | Publication awaits explicit authorization | +| Complete downloadable evidence | Versioned ZIP, CRC check, SHA-256 sidecar; report and source patch included | Published as a GitHub prerelease; uploaded size and SHA-256 verified against GitHub asset metadata | +| GitHub review and issue handoff | Draft PR #154 and reports posted on #153 and #128 | Published with explicit user authorization; PR draft state, integration base, and published bodies verified through the GitHub API | The original RTX 4080 certification remains unperformed; this hardware substitution is documented rather than represented as that certification. Radeon performance numbers are measured, with no invented hard budget. The evidence permits renderer work to proceed on this Windows machine. + +Published on 2026-09-10: [draft PR #154](https://github.com/Bloom-Engine/engine/pull/154), [evidence prerelease](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-153-radeon760m-20260910), [#153 report](https://github.com/Bloom-Engine/engine/issues/153#issuecomment-5622704289), and [#128 report](https://github.com/Bloom-Engine/engine/issues/128#issuecomment-5622704740). + +The uploaded v2 ZIP is 267,221,734 bytes with SHA-256 `aa099676ecd801ebb7ed586ddb9f9732b8f913f13a6d926604ad193b078d82a4`. Its source snapshot and release tag remain pinned to `5dc8277e7f6b0ca69908e8b34c33f99cdbe15ba0`; this publication audit was updated afterward. The archive therefore preserves the audit as it stood before publication.