//! Cross-platform GPU-backend selection with automatic fallback for older GPUs. //! //! Background: older GPUs without usable DirectX 12 Feature Level 12_0 or //! working Vulkan support (e.g. legacy Intel iGPUs and other old discrete or //! integrated parts), and old GLES drivers, can crash the app at startup or //! on the first drawing viewport: //! - DX12 path: iced's wgpu `StagingBelt` hits an invalid staging buffer //! and panics in `Buffer::get_mapped_range` (`panic = "abort"` in //! release, so the process dies silently). //! - GLES path: shader compilation failure (e.g. `no default precision` //! inside iced's own gradient pipeline) on old Mesa. //! Selecting a working backend via `WGPU_BACKEND` before iced boots avoids //! both classes. //! //! How it works: //! - an explicit `--backend` / `--safe-mode` / pre-set `WGPU_BACKEND` always wins; //! - otherwise each candidate backend (Windows: dx12, vulkan, gl; Linux: //! vulkan, gl) is exercised in a short-lived `--gpu-probe ` //! child process: device request, staging-style mapped upload, render //! pipeline creation (catches shader-compile failures), offscreen render //! and readback. The first fully-passing backend wins; //! - backends implicated in the previous run's crash (crash sentinel) are //! skipped, but only when that run died young: one that drew for //! `SENTINEL_PROOF_DELAY` marks the sentinel survived, so a later kill //! (Task Manager, OOM, power cut) costs the user nothing; //! - if no hardware backend passes, one software-adapter probe (`--gpu-probe //! sw`, e.g. WARP / llvmpipe / SwiftShader) is tried: slow but alive; //! - the winning probe also reports adapter limits, so the packed //! compatibility renderer is enabled automatically on GPUs without //! shader storage buffers. //! //! Probes run out-of-process because the failure modes are `panic = "abort"` //! or driver hangs: an in-process probe would die or stall with the app //! instead of reporting failure. use std::path::PathBuf; use std::time::Duration; /// Hidden child-process flag: `--gpu-probe `. pub const PROBE_ARG: &str = "--gpu-probe"; /// Per-backend probe timeout: broken drivers can hang enumeration, so never /// block boot long on a single backend. pub const PROBE_TIMEOUT: Duration = Duration::from_secs(10); /// Test hook: every probe reports failure without spawning a child. pub const FORCE_FAIL_ENV: &str = "OCS_GPU_PROBE_FORCE_FAIL"; /// Test hook: the first candidate probe reports success without spawning. pub const FORCE_OK_ENV: &str = "OCS_GPU_PROBE_FORCE_OK"; /// Test hook: `max_storage_buffers_per_shader_stage` of the synthetic success /// (drives compat-renderer auto-enable in tests). pub const FORCE_CAPS_ENV: &str = "OCS_GPU_PROBE_FORCE_CAPS"; /// Test hook: point the sentinel at a temp dir instead of the user config dir. pub const SENTINEL_DIR_ENV: &str = "OCS_GPU_SENTINEL_DIR"; /// Wire shader path needs one storage buffer; batched hatch needs four (see /// `DeviceCapabilities`). Below one storage buffer the packed compatibility /// renderer is required, so the probe enables it automatically. pub const WIRE_STORAGE_NEEDED: u32 = 1; /// Candidate backends in preference order. Windows keeps DX12/Vulkan first so /// the AMD OpenGL ICD is never touched unless both fail; Linux prefers Vulkan /// because old Mesa GLES is a known crash source. macOS stays on its Metal /// default (guard inactive there). pub fn candidate_backends() -> Vec { if cfg!(target_os = "windows") { vec!["dx12".to_string(), "vulkan".to_string(), "gl".to_string()] } else if cfg!(target_os = "linux") { vec!["vulkan".to_string(), "gl".to_string()] } else { Vec::new() } } /// Whether the resolver is active on this OS (Windows + Linux; macOS keeps /// iced's Metal default, wasm has no native GPU choice to make). pub fn gpu_guard_active() -> bool { !candidate_backends().is_empty() } /// Capabilities reported by a passing probe child (one JSON line on stdout). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProbeCaps { /// Backend that passed (`dx12`, `vulkan` or `gl`). pub backend: String, /// Human-readable adapter name for logs and notices. pub adapter: String, /// `max_storage_buffers_per_shader_stage` of the adapter. pub max_storage: u32, /// True when this is a software rasterizer (WARP / llvmpipe / SwiftShader). pub software: bool, } impl ProbeCaps { fn synthetic(backend: &str) -> Self { let max_storage = std::env::var(FORCE_CAPS_ENV) .ok() .and_then(|s| s.parse().ok()) .unwrap_or(8); Self { backend: backend.to_string(), adapter: "synthetic-test-adapter".to_string(), max_storage, software: false, } } /// Parse the JSON line a probe child prints on success. fn from_json_line(line: &str) -> Option { let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?; Some(Self { backend: v.get("backend")?.as_str()?.to_string(), adapter: v.get("adapter")?.as_str().unwrap_or("unknown").to_string(), max_storage: v.get("max_storage")?.as_u64()? as u32, software: v.get("software")?.as_bool()?, }) } fn to_json_line(&self) -> String { serde_json::json!({ "backend": self.backend, "adapter": self.adapter, "max_storage": self.max_storage, "software": self.software, }) .to_string() } } /// Whether the packed compatibility renderer is needed for these caps. /// Software rasterizers always take it: the packed path works everywhere and /// these GPUs are slow enough already. pub fn needs_compat_renderer(caps: &ProbeCaps) -> bool { caps.software || caps.max_storage < WIRE_STORAGE_NEEDED } /// What the resolver decided, and why (for the startup notice). #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendDecision { /// Value to set `WGPU_BACKEND` to, or `None` to leave the env alone. pub backend_value: Option, /// Human-readable reason when this is not the silent fast path. pub reason: Option, /// Probe says the packed compatibility renderer is needed. pub compat_renderer: bool, /// Adapter name of the winning probe, for logs. pub adapter_name: Option, } /// Pure decision function — all IO (env reads, probes, sentinel) happens in /// the caller so this is unit-testable. /// * `candidates`: platform-ordered backends to consider. /// * `excluded`: backends implicated in the previous run's crash. /// * `results`: probe outcome per tried backend, in any order. /// * `software`: software-adapter probe outcome. #[allow(clippy::too_many_arguments)] pub fn decide_backend( explicit_cli: Option<&str>, env_backend: Option<&str>, safe_mode: bool, candidates: &[String], excluded: &[String], results: &[(String, Option)], software: Option, guard_active: bool, ) -> BackendDecision { if let Some(b) = explicit_cli { return BackendDecision { backend_value: Some(b.to_string()), reason: None, compat_renderer: false, adapter_name: None, }; } if let Some(b) = env_backend { return BackendDecision { backend_value: Some(b.to_string()), reason: None, compat_renderer: false, adapter_name: None, }; } if safe_mode { return BackendDecision { backend_value: Some("gl".to_string()), reason: Some("safe mode requested (--safe-mode)".to_string()), compat_renderer: false, adapter_name: None, }; } if !guard_active { return BackendDecision { backend_value: None, reason: None, compat_renderer: false, adapter_name: None, }; } let failed: Vec<&String> = results .iter() .filter(|(_, r)| r.is_none()) .map(|(b, _)| b) .collect(); // First passing backend in preference order, skipping backends that died // with the previous run. for backend in candidates { if excluded.iter().any(|e| e == backend) { continue; } if let Some((_, Some(caps))) = results.iter().find(|(b, _)| b == backend) { let fell_back = backend != &candidates[0] || !excluded.is_empty(); let reason = if fell_back { Some(failover_reason(&failed, excluded, backend)) } else { None }; return BackendDecision { backend_value: Some(backend.clone()), reason, compat_renderer: needs_compat_renderer(caps), adapter_name: Some(caps.adapter.clone()), }; } } if let Some(caps) = software { return BackendDecision { backend_value: Some(caps.backend.clone()), reason: Some( "no hardware-accelerated backend survived probing, \ so this launch uses software rendering (slow, but stable)" .to_string(), ), compat_renderer: true, adapter_name: Some(caps.adapter.clone()), }; } // Best effort: GL is the most widely available backend on old hardware. BackendDecision { backend_value: Some("gl".to_string()), reason: Some( "every graphics probe failed, so this launch tries the GL \ compatibility backend as a last resort" .to_string(), ), compat_renderer: true, adapter_name: None, } } fn failover_reason(failed: &[&String], excluded: &[String], chosen: &str) -> String { let mut parts: Vec = failed.iter().map(|b| b.to_string()).collect(); parts.extend(excluded.iter().cloned()); parts.sort(); parts.dedup(); let what_failed = if parts.is_empty() { "the preferred backend was skipped after the previous crash".to_string() } else { format!("{} {}", parts.join(", "), "is not usable on this GPU") }; let chosen_text = match chosen { "gl" => "the GL compatibility backend", "vulkan" => "Vulkan", "dx12" => "DirectX 12", other => other, }; format!("{what_failed}, so this launch uses {chosen_text}") } /// Notice text shown in the command line when the backend was switched. pub fn fallback_notice(reason: &str) -> String { format!( "Graphics compatibility mode: {reason}. \ If drawings render slowly, update the GPU driver; \ override with --backend ." ) } /// Notice text when the packed compatibility renderer was auto-enabled. pub fn compat_notice() -> String { "Older GPU detected: using the packed compatibility renderer \ (no shader storage buffers). Override with --compat-renderer / --backend." .to_string() } /// Sentinel file: armed before `app::run`, disarmed on clean exit. A stale /// file at startup means the last run died while the GPU was active; its /// content names the attempted backend(s) so they are skipped next launch. pub fn sentinel_path() -> Option { if let Ok(dir) = std::env::var(SENTINEL_DIR_ENV) { return Some(PathBuf::from(dir).join("gpu_crash_sentinel")); } crate::config::config_dir().map(|d| d.join("gpu_crash_sentinel")) } pub fn previous_run_crashed() -> bool { sentinel_path().is_some_and(|p| p.exists()) } /// How long a backend has to keep the app alive before it stops being a /// suspect. The crashes this guard exists for kill the process at GPU init /// or on the first drawing viewport, well inside this window; a process that /// lived longer has already proved the backend works, so whatever ended it /// later was something else. pub const SENTINEL_PROOF_DELAY: Duration = Duration::from_secs(30); /// Marker line added to the sentinel once the backend has proved itself. const SURVIVED_MARKER: &str = "survived=1"; /// Backends implicated in the previous run's crash, from the stale sentinel. /// /// A sentinel left behind by a run that had already been drawing for /// [`SENTINEL_PROOF_DELAY`] names no suspect. Disarming only happens on a /// clean return from the event loop, so without this every abnormal end — /// the user killing the app from Task Manager, an OOM kill, a power cut, a /// panic with nothing to do with the GPU — used to blacklist whichever /// backend happened to be in use. Backends were then given up one by one /// until the app was left on a software rasterizer, telling the user to /// update a driver that was never at fault. pub fn crashed_backends() -> Vec { let Some(p) = sentinel_path() else { return Vec::new(); }; let text = std::fs::read_to_string(p).unwrap_or_default(); if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { return Vec::new(); } text.lines() .find_map(|l| l.strip_prefix("backend=")) .unwrap_or("") .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string) .collect() } /// Record that the armed backend has kept the app alive long enough to be /// trusted. Appends to the sentinel rather than removing it, so a crash is /// still visible to anyone reading the file. pub fn mark_sentinel_survived() { let Some(p) = sentinel_path() else { return; }; let Ok(text) = std::fs::read_to_string(&p) else { return; }; if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { return; } let _ = std::fs::write(&p, format!("{text}{SURVIVED_MARKER}\n")); } pub fn arm_sentinel(backend: &str) { if let Some(p) = sentinel_path() { if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write( &p, format!( "backend={backend}\npid={}\nt={}\n", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), ), ); } } pub fn disarm_sentinel() { if let Some(p) = sentinel_path() { let _ = std::fs::remove_file(&p); } } /// Outcome of the whole resolution: what to set, what to tell the user, and /// whether the packed renderer was auto-enabled. pub struct GpuResolution { /// Value written to `WGPU_BACKEND` (`None` = env untouched). pub backend_value: Option, /// Fallback reason for the startup notice, if any. pub reason: Option, /// Probe says the packed compatibility renderer is needed. pub compat_renderer: bool, } /// Spawn one `--gpu-probe ` child and return its reported capabilities /// on success. `None` = crashed / failed / timed out. fn run_probe_for(arg: &str) -> Option { if std::env::var_os(FORCE_FAIL_ENV).is_some() { return None; } if std::env::var_os(FORCE_OK_ENV).is_some() { return Some(ProbeCaps::synthetic(arg)); } let exe = std::env::current_exe().ok()?; let mut child = std::process::Command::new(exe) .arg(PROBE_ARG) .arg(arg) .env("RUST_LOG", "error") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .ok()?; let start = std::time::Instant::now(); loop { match child.try_wait() { Ok(Some(status)) => { if !status.success() { return None; } let out = child.wait_with_output().ok()?; let line = String::from_utf8_lossy(&out.stdout); return line.lines().rev().find_map(ProbeCaps::from_json_line); } Ok(None) => { if start.elapsed() > PROBE_TIMEOUT { let _ = child.kill(); let _ = child.wait(); eprintln!("[gpu] {arg} probe timed out; trying the next backend"); return None; } std::thread::sleep(Duration::from_millis(50)); } Err(_) => return None, } } } /// Resolve and apply `WGPU_BACKEND` for this launch. /// /// Precedence: `--backend` > existing `WGPU_BACKEND` > `--safe-mode` > /// per-backend probes (minus crash-implicated backends) > software adapter > /// last-resort `gl`. Inactive on macOS/wasm (env untouched). pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution { let env_backend = std::env::var("WGPU_BACKEND").ok(); if cli_backend.is_some() || env_backend.is_some() || safe_mode || !gpu_guard_active() { let d = decide_backend( cli_backend, env_backend.as_deref(), safe_mode, &[], &[], &[], None, gpu_guard_active(), ); if let Some(v) = &d.backend_value { std::env::set_var("WGPU_BACKEND", v); } return GpuResolution { backend_value: d.backend_value, reason: d.reason, compat_renderer: false, }; } let candidates = candidate_backends(); let excluded = if previous_run_crashed() { crashed_backends() } else { Vec::new() }; let mut results: Vec<(String, Option)> = Vec::new(); for backend in &candidates { if excluded.iter().any(|e| e == backend) { continue; } let caps = run_probe_for(backend); eprintln!( "[gpu] probe {backend}: {}", caps.as_ref() .map(|c| c.adapter.as_str()) .unwrap_or("FAILED") ); results.push((backend.clone(), caps)); if results.iter().any(|(_, c)| c.is_some()) { break; } } let sw = if results.iter().all(|(_, c)| c.is_none()) { run_probe_for("sw") } else { None }; let d = decide_backend( None, None, false, &candidates, &excluded, &results, sw, true, ); if let Some(v) = &d.backend_value { std::env::set_var("WGPU_BACKEND", v); if let Some(adapter) = &d.adapter_name { eprintln!("[gpu] backend: {v} ({adapter})"); } if let Some(reason) = &d.reason { eprintln!("[gpu] {}", fallback_notice(reason)); } if d.compat_renderer { eprintln!("[gpu] {}", compat_notice()); } } GpuResolution { backend_value: d.backend_value, reason: d.reason, compat_renderer: d.compat_renderer, } } /// Child-process entry point for `--gpu-probe `: exercise one /// backend the way the canvas does (device + staging-style mapped upload + /// render pipeline creation + offscreen render + readback). Prints one JSON /// line on success and exits 0; any failure (including a driver abort panics) /// exits non-zero with no output. pub fn run_probe_child(which: &str) -> ! { let code = match which { "dx12" | "vulkan" | "gl" => probe_single_backend(which).map_or(1, |caps| { println!("{}", caps.to_json_line()); 0 }), "sw" => probe_software_adapters().map_or(1, |caps| { println!("{}", caps.to_json_line()); 0 }), _ => { eprintln!("[gpu-probe] unknown backend '{which}' (want dx12|vulkan|gl|sw)"); 2 } }; std::process::exit(code); } fn wgpu_backends_for(name: &str) -> iced::wgpu::Backends { use iced::wgpu::Backends; match name { "dx12" => Backends::DX12, "vulkan" => Backends::VULKAN, "gl" => Backends::GL, _ => Backends::empty(), } } fn backend_name(backend: iced::wgpu::Backend) -> &'static str { use iced::wgpu::Backend; match backend { Backend::Dx12 => "dx12", Backend::Vulkan => "vulkan", Backend::Gl => "gl", Backend::Metal => "metal", _ => "other", } } /// Adapter names that mean "no GPU" (same family as the app's software /// detection): these never count as a passing *hardware* probe. const SOFTWARE_RASTERIZER_NAMES: [&str; 6] = [ "llvmpipe", "lavapipe", "softpipe", "swiftshader", "warp", "microsoft basic render", ]; fn is_software_adapter(info: &iced::wgpu::AdapterInfo) -> bool { if info.device_type == iced::wgpu::DeviceType::Cpu { return true; } let name = info.name.to_ascii_lowercase(); SOFTWARE_RASTERIZER_NAMES .iter() .any(|marker| name.contains(marker)) } fn probe_single_backend(name: &str) -> Option { use iced::wgpu; let backends = wgpu_backends_for(name); let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = backends; let instance = wgpu::Instance::new(desc); let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); // Hardware only: a software rasterizer proving "usable" would hide a // broken hardware path (software gets its own dedicated `sw` probe). let mut candidates: Vec<_> = adapters .into_iter() .filter(|a| !is_software_adapter(&a.get_info())) .collect(); if candidates.is_empty() { eprintln!("[gpu-probe] {name}: no hardware adapters enumerated"); return None; } candidates.sort_by_key(|a| match a.get_info().device_type { wgpu::DeviceType::DiscreteGpu => 0, wgpu::DeviceType::IntegratedGpu => 1, wgpu::DeviceType::Other => 2, _ => 3, }); for adapter in &candidates { if let Some(caps) = exercise_adapter(adapter) { return Some(caps); } } eprintln!("[gpu-probe] {name}: no adapter survived the exercise"); None } fn probe_software_adapters() -> Option { use iced::wgpu; let backends = wgpu::Backends::DX12 | wgpu::Backends::VULKAN | wgpu::Backends::GL; let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = backends; let instance = wgpu::Instance::new(desc); let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); let mut software: Vec<_> = adapters .into_iter() .filter(|a| is_software_adapter(&a.get_info())) .collect(); if software.is_empty() { // No software adapter enumerated: ask wgpu for its fallback adapter. let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::None, force_fallback_adapter: true, compatible_surface: None, })) .ok()?; software.push(adapter); } for adapter in &software { if let Some(caps) = exercise_adapter(adapter) { return Some(caps); } } eprintln!("[gpu-probe] sw: no software adapter survived the exercise"); None } /// Full exercise for one adapter: device request (iced-style limits), staging /// upload, render pipeline creation, offscreen render, readback. Any driver /// abort or validation failure fails the probe. fn exercise_adapter(adapter: &iced::wgpu::Adapter) -> Option { use iced::wgpu; let info = adapter.get_info(); eprintln!( "[gpu-probe] trying {} ({:?}, {:?})", info.name, info.backend, info.device_type ); // Mirror iced's compositor: full limits first, then the downlevel set. let limits = [ wgpu::Limits::default().using_resolution(adapter.limits()), wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ]; for required_limits in limits { let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("ocs-gpu-probe"), required_features: wgpu::Features::empty(), required_limits: required_limits.clone(), memory_hints: wgpu::MemoryHints::MemoryUsage, trace: wgpu::Trace::Off, experimental_features: wgpu::ExperimentalFeatures::disabled(), })) else { continue; }; let errors = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = errors.clone(); device.on_uncaptured_error(std::sync::Arc::new(move |e: wgpu::Error| { eprintln!("[gpu-probe] uncaptured error: {e}"); flag.store(true, std::sync::atomic::Ordering::SeqCst); })); if !staging_upload(&device) || !pipeline_render_readback(&device, &queue) || errors.load(std::sync::atomic::Ordering::SeqCst) { continue; } eprintln!("[gpu-probe] OK via {}", info.name); return Some(ProbeCaps { backend: backend_name(info.backend).to_string(), adapter: info.name.clone(), max_storage: adapter.limits().max_storage_buffers_per_shader_stage, software: is_software_adapter(&info), }); } None } /// Mapped-memory upload through the queue (never `mapped_at_creation`): the /// same host-mapping path `StagingBelt` and glyph uploads take. fn staging_upload(device: &iced::wgpu::Device) -> bool { use iced::wgpu; let staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("ocs-gpu-probe-staging"), size: 4096, usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); let slice = staging.slice(..); let mapped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = mapped.clone(); slice.map_async(wgpu::MapMode::Write, move |r| { flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); }); device.poll(wgpu::PollType::wait_indefinitely()).ok(); if !mapped.load(std::sync::atomic::Ordering::SeqCst) { return false; } { let mut view = slice.get_mapped_range_mut(); view.slice(0..1).copy_from_slice(&[0xABu8]); } staging.unmap(); true } /// Render pipeline creation + offscreen render + readback. /// /// The fragment shader deliberately reads a `private array, 8>`, /// the same pattern iced's gradient pipeline compiles: old GLES drivers /// (Mesa on legacy Intel iGPUs) reject naga's `vec4[8]` GLSL with `no /// default precision`, so pipeline creation fails there — exactly what this /// probe must catch before the editor boots. fn pipeline_render_readback(device: &iced::wgpu::Device, queue: &iced::wgpu::Queue) -> bool { use iced::wgpu; const SHADER: &str = r#" struct VsOut { @builtin(position) pos: vec4, @location(0) colour: vec4, }; var palette: array, 8>; @vertex fn vs(@builtin(vertex_index) idx: u32) -> VsOut { var out: VsOut; let corners = array, 3>( vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); out.pos = vec4(corners[idx], 0.0, 1.0); out.colour = palette[idx % 8u]; return out; } @fragment fn fs(in: VsOut) -> @location(0) vec4 { return in.colour + palette[0]; } "#; let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("ocs-gpu-probe-shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(SHADER)), }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("ocs-gpu-probe-pipeline"), layout: None, vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), targets: &[Some(wgpu::ColorTargetState { format: wgpu::TextureFormat::Bgra8Unorm, blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: wgpu::PipelineCompilationOptions::default(), }), multiview_mask: None, cache: None, }); // 64px offscreen render. 64*4 = 256 bytes per row satisfies // COPY_BYTES_PER_ROW_ALIGNMENT. let target = device.create_texture(&wgpu::TextureDescriptor { label: Some("ocs-gpu-probe-target"), size: wgpu::Extent3d { width: 64, height: 64, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Bgra8Unorm, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = target.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("ocs-gpu-probe-encoder"), }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("ocs-gpu-probe-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], ..Default::default() }); pass.set_pipeline(&pipeline); pass.draw(0..3, 0..1); } let readback = device.create_buffer(&wgpu::BufferDescriptor { label: Some("ocs-gpu-probe-readback"), size: 256 * 64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &readback, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(64 * 4), rows_per_image: Some(64), }, }, wgpu::Extent3d { width: 64, height: 64, depth_or_array_layers: 1, }, ); queue.submit([encoder.finish()]); let slice = readback.slice(..); let ok = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = ok.clone(); slice.map_async(wgpu::MapMode::Read, move |r| { flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); }); device.poll(wgpu::PollType::wait_indefinitely()).ok(); if !ok.load(std::sync::atomic::Ordering::SeqCst) { return false; } let _ = slice.get_mapped_range(); true } #[cfg(test)] mod tests { use super::*; fn cands(list: &[&str]) -> Vec { list.iter().map(|s| s.to_string()).collect() } fn ok_caps(backend: &str, storage: u32) -> ProbeCaps { ProbeCaps { backend: backend.to_string(), adapter: format!("test {backend}"), max_storage: storage, software: false, } } #[test] fn explicit_backend_and_env_beat_everything() { let d = decide_backend( Some("vulkan"), None, true, &cands(&["dx12", "vulkan", "gl"]), &cands(&["dx12"]), &[], None, true, ); assert_eq!(d.backend_value.as_deref(), Some("vulkan")); assert!(d.reason.is_none()); let d = decide_backend(None, Some("gl"), true, &[], &[], &[], None, true); assert_eq!(d.backend_value.as_deref(), Some("gl")); assert!(d.reason.is_none()); } #[test] fn safe_mode_forces_gl() { let d = decide_backend( None, None, true, &cands(&["vulkan", "gl"]), &[], &[], None, true, ); assert_eq!(d.backend_value.as_deref(), Some("gl")); assert!(d.reason.is_some()); } #[test] fn first_passing_backend_wins_silently() { let results = vec![("vulkan".to_string(), Some(ok_caps("vulkan", 8)))]; let d = decide_backend( None, None, false, &cands(&["vulkan", "gl"]), &[], &results, None, true, ); assert_eq!(d.backend_value.as_deref(), Some("vulkan")); assert!(d.reason.is_none()); assert!(!d.compat_renderer); assert_eq!(d.adapter_name.as_deref(), Some("test vulkan")); } #[test] fn failover_picks_next_backend_with_reason() { let results = vec![ ("dx12".to_string(), None), ("vulkan".to_string(), Some(ok_caps("vulkan", 8))), ]; let d = decide_backend( None, None, false, &cands(&["dx12", "vulkan", "gl"]), &[], &results, None, true, ); assert_eq!(d.backend_value.as_deref(), Some("vulkan")); let reason = d.reason.unwrap(); assert!(reason.contains("dx12"), "{reason}"); let notice = fallback_notice(&reason); assert!(notice.contains("--backend"), "{notice}"); } #[test] fn crash_implicated_backends_are_skipped() { // dx12 died last run: even a passing dx12 probe must not win. let results = vec![ ("dx12".to_string(), Some(ok_caps("dx12", 8))), ("gl".to_string(), Some(ok_caps("gl", 0))), ]; let d = decide_backend( None, None, false, &cands(&["dx12", "vulkan", "gl"]), &cands(&["dx12"]), &results, None, true, ); assert_eq!(d.backend_value.as_deref(), Some("gl")); assert!(d.reason.is_some()); // No storage buffers on the GL probe: packed renderer auto-enabled. assert!(d.compat_renderer); } #[test] fn software_adapter_is_last_resort_before_gl() { let results = vec![("vulkan".to_string(), None), ("gl".to_string(), None)]; let sw = ProbeCaps { backend: "vulkan".to_string(), adapter: "llvmpipe".to_string(), max_storage: 8, software: true, }; let d = decide_backend( None, None, false, &cands(&["vulkan", "gl"]), &[], &results, Some(sw), true, ); assert_eq!(d.backend_value.as_deref(), Some("vulkan")); assert!(d.compat_renderer); assert!(d.reason.unwrap().contains("software"),); } #[test] fn total_failure_falls_back_to_gl_with_compat() { let d = decide_backend( None, None, false, &cands(&["vulkan", "gl"]), &[], &[("vulkan".to_string(), None), ("gl".to_string(), None)], None, true, ); assert_eq!(d.backend_value.as_deref(), Some("gl")); assert!(d.compat_renderer); assert!(d.reason.is_some()); } #[test] fn inactive_guard_leaves_the_env_alone() { let results = vec![("vulkan".to_string(), None)]; let d = decide_backend(None, None, false, &[], &[], &results, None, false); assert!(d.backend_value.is_none()); } #[test] fn probe_caps_json_round_trip() { let caps = ProbeCaps { backend: "vulkan".to_string(), adapter: "Intel HD 405 (Braswell)".to_string(), max_storage: 8, software: false, }; let line = caps.to_json_line(); assert_eq!(ProbeCaps::from_json_line(&line), Some(caps)); assert!(ProbeCaps::from_json_line("not json").is_none()); assert!(ProbeCaps::from_json_line("{\"backend\":\"gl\"}").is_none()); } /// `SENTINEL_DIR_ENV` is process-global, so the sentinel tests cannot /// run beside each other. static SENTINEL_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[test] fn sentinel_round_trip_records_backends() { let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("ocs-gpu-test-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); // SAFETY: single-threaded test mutating a process-local test hook. unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; assert!(!previous_run_crashed()); arm_sentinel("dx12"); assert!(previous_run_crashed()); assert_eq!(crashed_backends(), vec!["dx12".to_string()]); disarm_sentinel(); assert!(!previous_run_crashed()); assert!(crashed_backends().is_empty()); unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; let _ = std::fs::remove_dir_all(&dir); } /// A run that drew long enough to prove the backend leaves a sentinel /// behind when it is killed, but that sentinel must accuse nobody — /// otherwise closing the app from Task Manager once costs the user a /// working backend for good. #[test] fn a_backend_that_survived_is_not_blamed_for_a_later_kill() { let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("ocs-gpu-survived-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); // SAFETY: single-threaded test mutating a process-local test hook. unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; arm_sentinel("dx12"); assert_eq!(crashed_backends(), vec!["dx12".to_string()], "a young run blames its backend"); mark_sentinel_survived(); assert!(previous_run_crashed(), "the sentinel still records that the run ended badly"); assert!( crashed_backends().is_empty(), "a backend that kept the app alive must not be skipped at the next launch" ); // Marking twice must not corrupt the file or resurrect the accusation. mark_sentinel_survived(); assert!(crashed_backends().is_empty()); disarm_sentinel(); unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; let _ = std::fs::remove_dir_all(&dir); } #[test] fn force_hooks_drive_probes_without_spawning() { // SAFETY: single-threaded test mutating process-local test hooks. unsafe { std::env::set_var(FORCE_FAIL_ENV, "1") }; assert!(run_probe_for("vulkan").is_none()); unsafe { std::env::remove_var(FORCE_FAIL_ENV) }; unsafe { std::env::set_var(FORCE_OK_ENV, "1") }; let caps = run_probe_for("vulkan").expect("forced success"); assert_eq!(caps.backend, "vulkan"); assert!(!needs_compat_renderer(&caps)); unsafe { std::env::set_var(FORCE_CAPS_ENV, "0") }; let bare = run_probe_for("gl").expect("forced success"); assert!(needs_compat_renderer(&bare)); unsafe { std::env::remove_var(FORCE_OK_ENV) }; unsafe { std::env::remove_var(FORCE_CAPS_ENV) }; } }