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