From d554724aa48ba72d784ff459cdc3974b61bec967 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 13:57:21 +0700 Subject: [PATCH] App: a launch whose window never drew makes the next use OpenGL (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On some older Intel integrated GPUs (UHD 600, UHD with 2020 drivers) the graphics driver crashes EffectCraft as its window's DirectX 12 or Vulkan device is created (exit code 0xC0000005): the process disappears with no message, and only WGPU_BACKEND=gl helped. A driver crash can't be caught, so each launch now leaves a marker in the settings folder until its window draws. A launch that finds the previous one's marker switches the new Settings ▸ Startup & Repair ▸ Window Graphics (Automatic / OpenGL) to OpenGL and says so in the status bar. A window that fails to open says the next launch will try OpenGL. WGPU_BACKEND still wins; macOS has no OpenGL backend, so it never switches there. Tests: a_launch_that_never_drew_switches_the_next_to_opengl (app), window_graphics_is_validated (engine). Checked end to end with a leftover marker: the app opened on OpenGL with the notice and cleared the marker. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/launch_guard.rs | 111 +++++++++++++++++++++++++++ apps/effectcraft/src/main.rs | 31 ++++++-- crates/engine/src/prefs.rs | 37 +++++++-- crates/engine/src/tests_settings.rs | 13 ++++ docs/preferences.md | 1 + 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 apps/effectcraft/src/launch_guard.rs diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs new file mode 100644 index 00000000..f80c066f --- /dev/null +++ b/apps/effectcraft/src/launch_guard.rs @@ -0,0 +1,111 @@ +//! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it. +//! +//! Some graphics drivers crash EffectCraft as its window's device is created (an access +//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver +//! can't be caught, so each launch leaves a marker in the settings folder until its window has +//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to +//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's +//! own override) still wins over both. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::prefs::{PREFS_FILE, Prefs}; +use eframe::wgpu::Backends; + +/// Left in the settings folder from a launch until its window draws. +const MARKER: &str = "launch-pending"; + +/// This launch's window graphics. +#[derive(Debug, Default)] +pub struct Launch { + /// The graphics APIs the window may use (`None`: eframe's default). + pub backends: Option, + /// Switched to OpenGL because the previous launch never drew: the message to show. + pub notice: Option, + marker: Option, +} + +impl Launch { + /// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous + /// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker. + /// `env_override`: `WGPU_BACKEND` is set. + pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { + let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; + let prefs_path = dir.join(PREFS_FILE); + let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default(); + let marker = dir.join(MARKER); + let mut notice = None; + if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { + prefs.startup.window_graphics = "gl".into(); + match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { + Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"), + Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"), + } + notice = Some( + "EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \ + (Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \ + Automatic again." + .to_string(), + ); + } + let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL); + let marker = match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&marker, "")) { + Ok(()) => Some(marker), + Err(e) => { + log::warn!("launch marker: {e}"); + None + } + }; + Launch { backends, notice, marker } + } + + /// The window drew its first frame: the launch made it. + pub fn drawn(&mut self) { + if let Some(m) = self.marker.take() + && let Err(e) = std::fs::remove_file(&m) + { + log::warn!("launch marker: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn window_graphics(dir: &Path) -> String { + Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap_or_default()).startup.window_graphics + } + + /// A launch that never drew switches the next one to OpenGL, which then stays (#243). + #[test] + fn a_launch_that_never_drew_switches_the_next_to_opengl() { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // A first launch that draws: nothing changes. + let mut l = Launch::begin(Some(&dir), false); + assert_eq!((l.backends, l.notice.is_some()), (None, false)); + assert!(dir.join(MARKER).exists()); + l.drawn(); + assert!(!dir.join(MARKER).exists()); + // A launch that crashes before drawing (it never calls `drawn`)… + let _crashed = Launch::begin(Some(&dir), false); + // …makes the next one use OpenGL and say so, once. + let mut l = Launch::begin(Some(&dir), false); + if cfg!(target_os = "macos") { + assert_eq!((l.backends, l.notice.is_some()), (None, false), "no OpenGL on macOS"); + } else { + assert_eq!(l.backends, Some(Backends::GL)); + assert!(l.notice.is_some()); + assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); + l.drawn(); + let l = Launch::begin(Some(&dir), false); + assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); + } + // `WGPU_BACKEND` wins, and leaves no marker. + let _ = std::fs::remove_file(dir.join(MARKER)); + assert_eq!(Launch::begin(Some(&dir), true).backends, None); + assert!(!dir.join(MARKER).exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 9389ad06..0a034f19 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -15,6 +15,7 @@ mod appimage; mod audio_out; mod control_server; +mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -77,6 +78,11 @@ fn main() -> eframe::Result { // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); + // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's + // window never drew (a crashing graphics driver, #243). + let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); + let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); + let notice = launch.notice.take(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("EffectCraft") @@ -89,7 +95,7 @@ fn main() -> eframe::Result { // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID. .with_app_id(appimage::APP_ID), event_loop_builder: agent_event_loop(control_port.is_some()), - wgpu_options: wgpu_options(), + wgpu_options: wgpu_options(launch.backends), ..Default::default() }; // Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never @@ -155,6 +161,9 @@ fn main() -> eframe::Result { let mut app = EffectcraftApp::new(session); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = show_home; + if let Some(n) = notice { + app.ui.status = n; + } if let Some(r) = recovery { app.offer_recovery(r); } @@ -195,13 +204,14 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] menu: native_menu::NativeBar::new(&cc.egui_ctx), app, + launch, drawn: false, })) }), ); match &result { Ok(()) => log::info!("the window closed"), - Err(e) => startup_failed(e), + Err(e) => startup_failed(e, !on_gl && cfg!(not(target_os = "macos"))), } result } @@ -209,11 +219,14 @@ fn main() -> eframe::Result { /// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px) /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to -/// the CPU when the device can't run it. -fn wgpu_options() -> eframe::WgpuConfiguration { +/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; let mut config = eframe::WgpuConfiguration::default(); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { + if let Some(b) = backends { + setup.instance_descriptor.backends = b; + } setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor { label: Some("EffectCraft device"), required_limits: device_limits(adapter.limits()), @@ -230,10 +243,12 @@ fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits { } /// The window couldn't open (no usable graphics adapter or device): say so instead of quitting -/// silently, since release builds on Windows have no console. -fn startup_failed(e: &eframe::Error) { +/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has +/// the next launch try OpenGL. +fn startup_failed(e: &eframe::Error, gl_next: bool) { + let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" }; let message = format!( - "EffectCraft couldn't start its window: {e}\n\nUpdating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message." + "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message." ); log::error!("{message}"); eprintln!("effectcraft: {message}"); @@ -250,6 +265,7 @@ struct Desktop { app: EffectcraftApp, #[cfg(target_os = "macos")] menu: native_menu::NativeBar, + launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, } @@ -265,6 +281,7 @@ impl eframe::App for Desktop { self.app.ui(ui, frame); if !self.drawn { self.drawn = true; + self.launch.drawn(); log::info!("first frame drawn"); } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 376d0d98..b21656ac 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -25,8 +25,20 @@ pub const PREFS_FILE: &str = "prefs.json"; /// Settings ▸ General ▸ Language: (label, `general.language` value). pub const LANGUAGES: &[(&str, &str)] = &[("Match System", "system"), ("English", "en"), ("日本語", "ja")]; -fn is_language(v: &str) -> bool { - LANGUAGES.iter().any(|(_, l)| *l == v) +/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). +pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; + +/// The settings whose value must be one of their choices. +fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { + match key { + "general.language" => Some(LANGUAGES), + "startup.windowGraphics" => Some(WINDOW_GRAPHICS), + _ => None, + } +} + +fn is_choice(choices: &[(&str, &str)], v: &str) -> bool { + choices.iter().any(|(_, c)| *c == v) } /// Unknown keys inside a page, kept so a newer version's settings survive a round trip. @@ -79,6 +91,10 @@ page!(Startup { show_home_on_open_project: bool = false, /// After a crash, offer to open the most recent auto-save. offer_crash_recovery: bool = true, + /// What the desktop window draws with, from the next launch: `auto` (the platform's best + /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose + /// window never drew switches it to `gl` (#243). + window_graphics: String = "auto".into(), }); page!(ProjectPrefs { @@ -424,8 +440,11 @@ impl Prefs { /// Clamp values into their valid ranges and fill missing labels. pub fn normalize(&mut self) { self.version = PREFS_VERSION; + if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { + self.startup.window_graphics = Startup::default().window_graphics; + } let g = &mut self.general; - if !is_language(&g.language) { + if !is_choice(LANGUAGES, &g.language) { g.language = General::default().language; } g.undo_levels = g.undo_levels.clamp(1, 99); @@ -466,9 +485,11 @@ impl Prefs { /// Set the value at a dotted key. The key must exist and the value must have its type. pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> { - if key == "general.language" && !value.as_str().is_some_and(is_language) { - let all: Vec<&str> = LANGUAGES.iter().map(|(_, l)| *l).collect(); - return Err(format!("`general.language` expects one of {}", all.join(", "))); + if let Some(c) = choices(key) + && !value.as_str().is_some_and(|v| is_choice(c, v)) + { + let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); + return Err(format!("`{key}` expects one of {}", all.join(", "))); } let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; let ptr = format!("/{}", key.replace('.', "/")); @@ -918,6 +939,10 @@ pub fn pages() -> Vec { s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true), s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, true), s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), + s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true), + Note( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.", + ), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, Button { label: "Reset Keyboard Shortcuts", command: "shortcuts.reset", params: "{}" }, diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index dc3d9888..69847ca5 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -487,6 +487,19 @@ fn memory_tick_never_waits_for_the_system() { } } +/// Settings ▸ Startup & Repair ▸ Window Graphics takes `auto` or `gl` (#243). +#[test] +fn window_graphics_is_validated() { + let mut s = Session::default(); + assert_eq!(s.prefs.startup.window_graphics, "auto"); + s.execute("prefs.set", json!({"key": "startup.windowGraphics", "value": "gl"})).unwrap(); + for bad in [json!("vulkan"), json!(true)] { + assert!(s.execute("prefs.set", json!({"key": "startup.windowGraphics", "value": bad})).is_err()); + } + assert_eq!(s.prefs.startup.window_graphics, "gl"); + assert_eq!(Prefs::from_json(r#"{"startup":{"windowGraphics":"dx9"}}"#).startup.window_graphics, "auto"); +} + #[test] fn interface_language_is_validated_persisted_and_backward_compatible() { let store = Arc::new(MemoryConfig::default()); diff --git a/docs/preferences.md b/docs/preferences.md index b20ee8b7..dc57595c 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -38,6 +38,7 @@ Commands (CLI, MCP, control channel): - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash +- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project - `autoSave.enabled`: Automatically Save Projects