mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
App: a launch whose window never drew makes the next use OpenGL (#243)
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 <[email protected]>
This commit is contained in:
1 parent
fdb655e78e
commit
d554724aa4
5 files changed
+180
-13
No files matched your search
@@ -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<Backends>,
|
||||
/// Switched to OpenGL because the previous launch never drew: the message to show.
|
||||
pub notice: Option<String>,
|
||||
marker: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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::wgpu::Backends>) -> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Page> {
|
||||
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: "{}" },
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user