#820: count only quick deaths, two in a row; notice with reset and --graphics

Review on #827: a start marker left pending by any early exit (force-quit,
hang, unrelated crash, two launches racing) moved the user down a rung for
good, with no way back but deleting a file.

- Each start writes its own attempt file (graphics-start/<pid>-<n>.json)
  that is removed after two painted frames, on a normal exit, or 15 s after
  launch whatever happened. A later launch only counts attempts that are
  older than that window, so a hang, a later crash or force-quit, and an
  instance that is still starting are never failures.
- graphics-start.json now holds {mode, failures}: two failed starts in a row
  in the same mode move on to the next one; a start that paints resets the
  count. A wgpu start-up error still moves on at once.
- All files are written to a per-process temp name and renamed into place,
  so concurrent launches never see or leave half a file.
- A fallback mode shows a notice (graphics.notice) with Use Default Graphics
  (graphics.notice.reset) and Close; es, fr, ja and zh-cn strings.
- --graphics default|dx12|software picks a mode by hand and keeps it;
  `--graphics default` undoes a fallback. Modes this platform lacks are
  refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 09:14:08 +02:00
parent 00ad651814
commit 6dd829dd61
11 changed files with 885 additions and 139 deletions

View File

@ -15,6 +15,9 @@ Options:
--no-recover start without asking about unsaved changes
--data-dir <dir> folder for auto-save, crash recovery and logs
(also: FILMCRAFT_DATA_DIR)
--graphics <mode> start with this graphics mode and keep it: default,
dx12 (Windows), software (Windows, Linux);
`--graphics default` undoes an automatic fallback
--version print the version and exit
-h, --help print this help and exit
";
@ -39,6 +42,8 @@ pub struct Launch {
pub startup_flag: bool,
pub recover: Option<bool>,
pub data_dir: Option<std::path::PathBuf>,
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
pub graphics: Option<crate::graphics::Mode>,
}
/// Parse the arguments after the program name. `env_control_port` is the value of
@ -49,7 +54,16 @@ pub struct Launch {
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
/// too, and for a port from the environment exactly as for one from `--control`.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
let mut l = Launch {
control_port: None,
control_input_only: false,
files: Vec::new(),
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None,
};
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(a) = args.next() {
@ -66,6 +80,10 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
"--graphics" => {
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
l.graphics = Some(graphics_mode(&name)?);
}
// everything after `--` is a file, whatever it looks like
"--" => l.files.extend(args.by_ref()),
// macOS adds a process serial number when it launches an app bundle
@ -91,6 +109,17 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
Ok(Cli::Run(l))
}
/// A `--graphics` value: one of this platform's modes.
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
use crate::graphics::{LADDER, Mode};
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
if !LADDER.contains(&mode) {
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
}
Ok(mode)
}
#[cfg(test)]
mod tests {
use super::*;
@ -166,7 +195,16 @@ mod tests {
let none = launch(&[]);
assert_eq!(
none,
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
Launch {
control_port: None,
control_input_only: false,
files: vec![],
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None
}
);
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
@ -188,4 +226,23 @@ mod tests {
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
assert!(USAGE.contains("--control-input-only"));
}
#[test]
fn graphics_picks_one_of_this_platforms_modes() {
use crate::graphics::{LADDER, Mode};
assert_eq!(launch(&[]).graphics, None);
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
for mode in LADDER {
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
}
// a mode this platform has not is refused, not ignored
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
}
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
assert!(USAGE.contains("--graphics"));
}
}

View File

@ -4,15 +4,36 @@
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the
//! window has painted. A marker still pending at the next launch means that start died: the launch
//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that
//! wgpu does report (no adapter, device creation failed) moves on within the same process. An
//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge.
//!
//! # When a start counts as failed
//! The data folder keeps the mode to start with and the failed starts in a row in it
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
//! so concurrent launches never write the same file) that is removed once the window has painted
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
//! by then. An attempt file found by a later launch therefore means a process that died within
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
//! within the same process.
//!
//! # Getting back
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
//! the saved state alone: the user is in charge.
//!
//! All files are written beside and renamed into place, so a reader never sees half a file; two
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
//! once less, a confirmation repeated by the next start), never corrupt it.
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use eframe::{
NativeOptions,
@ -20,9 +41,19 @@ use eframe::{
wgpu::{self, Backends},
};
/// The start marker in the data folder: `{"mode": "<mode>", "pending": <bool>}`. Delete it to
/// start with the default graphics mode again.
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
pub const STATE_FILE: &str = "graphics-start.json";
/// The folder in the data folder with one attempt file per start that has not painted yet.
pub const ATTEMPTS_DIR: &str = "graphics-start";
/// How long after launch a start that has not painted may die and still count as failed. A
/// driver crash in instance or device creation happens within a second or two.
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
/// Failed starts in a row, in the same mode, that move on to the next mode.
pub const FAILURES_TO_MOVE_ON: u32 = 2;
/// Attempt files looked at per launch; anything beyond is left for a later launch.
const MAX_ATTEMPTS: usize = 64;
/// The state and attempt files are a few bytes; anything longer is not ours.
const MAX_FILE: u64 = 4096;
/// How the window's graphics device is created.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -38,6 +69,7 @@ pub enum Mode {
}
impl Mode {
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
pub fn name(self) -> &'static str {
match self {
Mode::Default => "default",
@ -46,7 +78,16 @@ impl Mode {
}
}
fn parse(name: &str) -> Option<Mode> {
/// The English name shown in the interface (translated there).
pub fn label(self) -> &'static str {
match self {
Mode::Default => "default graphics",
Mode::Dx12 => "DirectX 12 only",
Mode::Software => "software rendering",
}
}
pub fn parse(name: &str) -> Option<Mode> {
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
}
}
@ -66,107 +107,316 @@ fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
ladder.get(i.checked_add(1)?).copied()
}
/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it
/// is unreadable), and whether it moved on because the last start died.
fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) {
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Saved {
mode: Mode,
failures: u32,
}
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Attempt {
pid: u32,
mode: Mode,
/// Seconds since the Unix epoch.
started: u64,
}
/// What a launch makes of the saved state and the attempt files it found.
#[derive(Debug, PartialEq, Eq)]
struct Decision {
/// The state to start with and save.
saved: Saved,
/// Moved on to the next mode because the last starts failed.
moved_on: bool,
/// Per attempt: done with (counted, or not ours to count), delete it.
delete: Vec<bool>,
}
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
let first = ladder.first().copied().unwrap_or(Mode::Default);
match saved {
// A mode this platform does not use (a data folder copied from another OS): start over.
Some((mode, _)) if !ladder.contains(&mode) => (first, false),
Some((mode, true)) => match next_in(ladder, mode) {
Some(next) => (next, true),
// Nothing left to try: the last mode is still the best chance.
None => (mode, false),
},
Some((mode, false)) => (mode, false),
None => (first, false),
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
let mut failures = saved.failures;
let mut delete = Vec::with_capacity(attempts.len());
for a in attempts {
if a.started > now.saturating_add(window) {
// From the future (a clock that jumped back): unknown, and not worth keeping.
delete.push(true);
continue;
}
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
// A younger attempt is another instance starting right now: leave it to that instance.
delete.push(dead);
// Only failures of the mode we would start in count; older ones are history.
if dead && a.mode == saved.mode {
failures = failures.saturating_add(1);
}
}
if failures >= FAILURES_TO_MOVE_ON {
return match next_in(ladder, saved.mode) {
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
// Nothing left to try: the last mode is still the best chance.
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
};
}
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
}
/// `(mode, pending)` from the marker's text; `None` for anything else.
fn parse_state(text: &str) -> Option<(Mode, bool)> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?))
}
fn read_state(path: &Path) -> Option<(Mode, bool)> {
// The marker is a few bytes; anything longer is not ours.
fn read_small(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?;
parse_state(&text)
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
Some(text)
}
fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> {
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
fn parse_state(text: &str) -> Option<Saved> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
let failures = match v.get("failures") {
None => 0,
Some(n) => u32::try_from(n.as_u64()?).ok()?,
};
Some(Saved { mode, failures })
}
fn state_json(s: Saved) -> String {
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
}
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
}
fn attempt_json(mode: Mode, started: u64) -> String {
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
}
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
/// start that dies mid-write, or another instance reading, never sees half a file.
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string();
// Written beside and renamed, so a start that dies mid-write leaves the old marker whole.
let tmp = path.with_extension("json.tmp");
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".{}.tmp", std::process::id()));
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
/// This launch's graphics mode and its start marker.
fn remove_quietly(path: &Path) {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
_ => {}
}
}
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
name.ends_with(".json").then(|| {
let path = e.path();
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
(path, attempt)
})
})
.take(MAX_ATTEMPTS)
.collect()
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Attempt numbers within this process, so each attempt file has its own name.
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
/// This launch's graphics mode and its files in the data folder.
#[derive(Clone, Debug)]
pub struct GraphicsStart {
/// The marker; `None` without a data folder or with `WGPU_BACKEND` set.
path: Option<PathBuf>,
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
state: Option<PathBuf>,
/// This start's attempt file (with `state`).
attempt: Option<PathBuf>,
mode: Mode,
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
reset: Arc<AtomicBool>,
}
impl GraphicsStart {
/// Pick this launch's mode from the marker in `data_dir` and mark the start pending.
/// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker
/// alone.
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self {
let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) };
let saved = path.as_deref().and_then(read_state);
let (mode, moved_on) = launch_mode(saved, LADDER);
let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default();
if moved_on {
let failed = saved.map_or("default", |(m, _)| m.name());
log::warn!(
"the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)",
mode.name()
);
} else if mode != Mode::Default {
log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name());
/// Pick this launch's mode from the data folder and record the attempt. `forced`
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
if let Some(path) = start.attempt.clone() {
expire_later(path);
}
let start = Self { path, mode };
start.save(true);
start
}
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
let first = LADDER.first().copied().unwrap_or(Mode::Default);
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
let reset = Arc::new(AtomicBool::new(false));
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
};
let state = dir.join(STATE_FILE);
let attempts_dir = dir.join(ATTEMPTS_DIR);
let saved = read_small(&state).and_then(|t| parse_state(&t));
let found = list_attempts(&attempts_dir);
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
let mut to_delete = decision.delete.iter();
for (path, attempt) in &found {
// unreadable files in our folder are debris (a different version, a damaged disk)
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
remove_quietly(path);
}
}
let file = state.display();
let next = match forced {
Some(mode) => {
log::info!("graphics mode {} chosen with --graphics", mode.name());
Saved { mode, failures: 0 }
}
None => {
if decision.moved_on {
let failed = saved.map_or(first, |s| s.mode).name();
log::warn!(
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
decision.saved.mode.name()
);
} else if decision.saved.failures > 0 {
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
} else if decision.saved.mode != Mode::Default {
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
}
decision.saved
}
};
if saved != Some(next)
&& let Err(e) = write_atomic(&state, &state_json(next))
{
log::warn!("can't write {file}: {e}");
}
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
start.with_new_attempt(&attempts_dir, now, pid)
}
/// Record a new attempt file for the current mode.
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{pid}-{n}.json"));
match write_atomic(&path, &attempt_json(self.mode, now)) {
Ok(()) => self.attempt = Some(path),
Err(e) => log::warn!("can't write {}: {e}", path.display()),
}
self
}
pub fn mode(&self) -> Mode {
self.mode
}
/// Running in a fallback mode, i.e. not the platform default.
pub fn degraded(&self) -> bool {
self.mode != Mode::Default
}
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
/// The error is certain, so the next mode is saved at once.
pub fn step_down(&mut self) -> bool {
let Some(next) = next_in(LADDER, self.mode) else {
return false;
};
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
self.mode = next;
self.save(true);
if let Some(state) = self.state.clone() {
self.save(&state, Saved { mode: next, failures: 0 });
if let Some(old) = self.attempt.take() {
remove_quietly(&old);
if let Some(dir) = old.parent() {
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
if let Some(path) = &path {
expire_later(path.clone());
}
self.attempt = path;
}
}
}
true
}
/// The window is up: keep this mode for the next launches.
/// The window is up: keep this mode, and the failed starts in a row are over.
pub fn confirm(&self) {
self.save(false);
self.finish();
let Some(state) = &self.state else { return };
if self.reset.load(Ordering::Relaxed) {
return;
}
let ok = Saved { mode: self.mode, failures: 0 };
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
self.save(state, ok);
}
}
fn save(&self, pending: bool) {
if let Some(path) = &self.path
&& let Err(e) = write_state(path, self.mode, pending)
{
/// This start is over, whether it painted or not (the window closed, or every mode failed
/// with an error that was reported): its attempt no longer means a crash.
pub fn finish(&self) {
if let Some(path) = &self.attempt {
remove_quietly(path);
}
}
/// Start with the default mode next time (the notice's reset button). This process keeps
/// its mode: the graphics device can't be swapped under a running window.
pub fn reset_to_default(&self) -> Result<(), String> {
self.reset.store(true, Ordering::Relaxed);
let Some(state) = &self.state else { return Ok(()) };
let first = LADDER.first().copied().unwrap_or(Mode::Default);
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
log::info!("graphics mode reset to {} for the next start", first.name());
Ok(())
}
fn save(&self, path: &Path, s: Saved) {
if let Err(e) = write_atomic(path, &state_json(s)) {
log::warn!("can't write {}: {e}", path.display());
}
}
}
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
/// counted as a failed one, painted or not.
fn expire_later(path: PathBuf) {
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
let _ = std::panic::catch_unwind(move || {
std::thread::sleep(DEATH_WINDOW);
remove_quietly(&path);
});
});
if let Err(e) = spawned {
log::warn!("can't start the graphics start timer: {e}");
}
}
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
@ -269,46 +519,103 @@ mod tests {
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
}
#[test]
fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() {
// first launch
assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false));
// the default start died in the driver: DX12 next, then software
assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true));
assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true));
assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true));
// a confirmed mode is kept
assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false));
assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false));
// nothing left to try: stay
assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false));
assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false));
// a mode this platform has not: start over
assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false));
assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false));
assert_eq!(launch_mode(None, &[]), (Mode::Default, false));
const W: u64 = 15;
const NOW: u64 = 1_000_000;
const ME: u32 = 7;
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
Some(Saved { mode, failures })
}
fn dead(mode: Mode) -> Attempt {
Attempt { pid: 1, mode, started: NOW - 100 }
}
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
let d = decide(s, attempts, NOW, ME, W, ladder);
(d.saved.mode, d.saved.failures, d.moved_on)
}
#[test]
fn the_marker_round_trips_and_rejects_anything_else() {
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
// first launch
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
// one start died: counted, but the same mode is tried again
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
// the second in a row moves on: DX12, then software
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
// two dead instances found at once count as two
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
// a saved mode is kept
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
// nothing left to try: stay
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
// a mode this platform has not: start over
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
// a hostile count doesn't overflow
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
}
#[test]
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
let older_mode = dead(Mode::Dx12);
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
// another instance still starting is left alone; one from the future is dropped; a
// failure in another mode is history; our own process id can only be a dead process
assert_eq!(d.delete, [false, false, true, true, true]);
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
// the clock a little behind another instance's: still starting
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
}
#[test]
fn the_files_round_trip_and_reject_anything_else() {
for mode in WINDOWS {
for pending in [false, true] {
let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string();
assert_eq!(parse_state(&text), Some((*mode, pending)));
for failures in [0, 1, 2, u32::MAX] {
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
}
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
}
// without a count (an earlier build of this change wrote `pending`): none
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
for bad in [
"",
"{",
"null",
"[]",
r#"{"mode":"vulkan","pending":true}"#,
r#"{"mode":"dx12"}"#,
r#"{"mode":1,"pending":true}"#,
r#"{"mode":"dx12","pending":"yes"}"#,
r#"{"mode":"vulkan","failures":1}"#,
r#"{"failures":1}"#,
r#"{"mode":1,"failures":1}"#,
r#"{"mode":"dx12","failures":"one"}"#,
r#"{"mode":"dx12","failures":-1}"#,
r#"{"mode":"dx12","failures":4294967296}"#,
] {
assert_eq!(parse_state(bad), None, "{bad}");
}
let good = attempt_json(Mode::Default, NOW);
for (name, text) in [
("12-0.json", "{"),
("12-0.json", r#"{"mode":"default"}"#),
("12-0.json", r#"{"mode":"default","started":-5}"#),
("x-0.json", good.as_str()),
("12.json", good.as_str()),
("-0.json", good.as_str()),
("99999999999-0.json", good.as_str()),
("12-0.txt", good.as_str()),
] {
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
}
}
fn temp_dir(name: &str) -> PathBuf {
@ -317,65 +624,198 @@ mod tests {
dir
}
fn state_of(dir: &Path) -> Option<Saved> {
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
}
fn attempt_files(dir: &Path) -> usize {
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
}
/// A launch as another process (`pid`) at `now`.
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
}
#[test]
fn begin_marks_the_start_pending_and_confirm_clears_it() {
let dir = temp_dir("marker");
let file = dir.join(STATE_FILE);
let start = GraphicsStart::begin(Some(&dir), false);
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
let dir = temp_dir("paint");
let start = launch(&dir, 10, NOW);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(read_state(&file), Some((Mode::Default, true)));
assert!(!start.degraded());
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 1);
start.confirm();
assert_eq!(read_state(&file), Some((Mode::Default, false)));
// a confirmed start is kept on the next launch
assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default);
// ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go)
let next = GraphicsStart::begin(Some(&dir), false);
assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default));
assert_eq!(attempt_files(&dir), 0);
// a window closed before it painted is no failure either
let start = launch(&dir, 11, NOW + 60);
start.finish();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_corrupt_or_huge_marker_starts_with_the_default() {
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
let dir = temp_dir("dead");
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
// dies without painting
let _ = launch(&dir, 20, NOW);
// the next launch counts it, tries the same mode again and dies too
let again = launch(&dir, 21, NOW + 60);
assert_eq!(again.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
// two in a row: the next mode, kept
let moved = launch(&dir, 22, NOW + 120);
assert_eq!(moved.mode(), second);
assert_eq!(moved.degraded(), second != Mode::Default);
moved.confirm();
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
// one failure, then a start that paints: the count starts over
let _ = std::fs::remove_dir_all(&dir);
let _ = launch(&dir, 30, NOW);
let painted = launch(&dir, 31, NOW + 60);
painted.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = launch(&dir, 32, NOW + 120);
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_launches_neither_count_nor_disturb_each_other() {
let dir = temp_dir("race");
// two instances start within the same seconds; neither has painted yet
let a = launch(&dir, 40, NOW);
let b = launch(&dir, 41, NOW + 1);
let c = launch(&dir, 42, NOW + 2);
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
b.confirm();
a.confirm();
c.confirm();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_huge_or_foreign_files_start_with_the_default() {
let dir = temp_dir("corrupt");
let file = dir.join(STATE_FILE);
let _ = std::fs::create_dir_all(&dir);
for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] {
let _ = std::fs::write(&file, text);
let start = GraphicsStart::begin(Some(&dir), false);
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
for text in ["garbage", huge.as_str()] {
let _ = std::fs::write(dir.join(STATE_FILE), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
let start = launch(&dir, 51, NOW);
assert_eq!(start.mode(), Mode::Default);
// and it is replaced with a well-formed pending marker
assert_eq!(read_state(&file), Some((Mode::Default, true)));
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
start.finish();
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_backend_leaves_the_marker_alone() {
let dir = temp_dir("explicit");
let file = dir.join(STATE_FILE);
let _ = std::fs::create_dir_all(&dir);
let _ = write_state(&file, Mode::Software, true);
let mut start = GraphicsStart::begin(Some(&dir), true);
// a data folder that is a file: nothing can be written, the default still starts
let file = dir.join("not-a-folder");
let _ = std::fs::write(&file, "x");
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert_eq!(read_state(&file), Some((Mode::Software, true)));
// no data folder: nothing is written, the default still starts
assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default);
assert!(start.reset_to_default().is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn step_down_walks_the_ladder_and_records_each_attempt() {
fn many_attempt_files_are_bounded_per_launch() {
let dir = temp_dir("many");
let attempts = dir.join(ATTEMPTS_DIR);
let _ = std::fs::create_dir_all(&attempts);
let n = MAX_ATTEMPTS + 10;
for pid in 0..n {
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
}
// all of them belong to instances still starting: none is counted or removed
let start = launch(&dir, 60, NOW);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(attempt_files(&dir), n + 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
let dir = temp_dir("forced");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
launch(&dir, 70, NOW).confirm();
// `--graphics default`
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
// `--graphics <the last mode>` is kept for the next launches
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
// a mode this platform has not starts with the first one
if !LADDER.contains(&Mode::Dx12) {
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reset_to_default_wins_over_a_later_confirm() {
let dir = temp_dir("reset");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
let start = launch(&dir, 80, NOW);
assert_eq!(start.mode(), last);
// the notice's button, from a clone of the start (the app holds one)
assert_eq!(start.clone().reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_backend_leaves_the_data_folder_alone() {
let dir = temp_dir("explicit");
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert_eq!(start.reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
assert_eq!(attempt_files(&dir), 0);
// with --graphics too, that mode starts, still without touching the folder
let last = LADDER.last().copied().unwrap_or(Mode::Default);
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
// no data folder: nothing is written, the default still starts
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
let dir = temp_dir("step");
let file = dir.join(STATE_FILE);
let mut start = GraphicsStart::begin(Some(&dir), false);
let mut start = launch(&dir, ME, NOW);
for expected in LADDER.iter().skip(1) {
assert!(start.step_down());
assert_eq!(start.mode(), *expected);
assert_eq!(read_state(&file), Some((*expected, true)));
assert_eq!(state_of(&dir), saved(*expected, 0));
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
}
assert!(!start.step_down(), "nothing after the last mode");
start.finish();
assert_eq!(attempt_files(&dir), 0);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -120,10 +120,11 @@ fn main() -> eframe::Result {
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
register_hardware_decoders();
// Which graphics backends and adapter the window starts with: the mode after the last one
// if the previous start died before its window painted (a driver crash), see graphics.rs.
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
// mode, which moves on after starts that died before their window painted (a driver crash),
// see graphics.rs.
let backend_override = eframe::wgpu::Backends::from_env();
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some());
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
// The GPU export renderer creates its own wgpu instance with every backend; after a start
// fell back, that would load the driver that failed. Exports then composite on the CPU.
if graphics.mode() == graphics::Mode::Default {
@ -149,6 +150,8 @@ fn main() -> eframe::Result {
}
break result;
};
// However it ended, this start no longer counts as one that died.
graphics.finish();
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
@ -184,9 +187,14 @@ fn native_options(agent: bool) -> eframe::NativeOptions {
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
/// and confirms the graphics mode once two frames have painted.
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = launch;
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
let start = graphics.clone();
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
});
let confirmed = AtomicBool::new(false);
cc.egui_ctx.on_end_pass(
"graphics-start",
@ -240,6 +248,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
}
}
let mut app = FilmcraftApp::new(session);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
}

View File

@ -0,0 +1,83 @@
//! The notice shown while the window runs in a fallback graphics mode (#820): the desktop app
//! moved on to DirectX 12 only or a software adapter after earlier starts died in the graphics
//! driver. It says so and offers a one-click way back to the default mode for the next start.
//!
//! Automation ids: `graphics.notice` (the notice), `graphics.notice.reset` (Use Default Graphics),
//! `graphics.notice.close`.
use crate::{FilmcraftApp, i18n};
/// Puts the default graphics mode back for the next start; the error says what could not be saved.
pub type ResetFn = Box<dyn FnMut() -> Result<(), String>>;
/// A fallback graphics mode, as the host reports it.
pub struct GraphicsNotice {
/// The mode's English name ("DirectX 12 only", "software rendering"), translated when shown.
pub mode: String,
reset: ResetFn,
/// The default mode is saved for the next start.
pub reset_done: bool,
/// Closed for this session.
pub closed: bool,
}
impl GraphicsNotice {
pub fn new(mode: &str, reset: ResetFn) -> Self {
Self { mode: mode.to_string(), reset, reset_done: false, closed: false }
}
/// Use Default Graphics: save the default mode for the next start.
pub fn reset(&mut self) -> Result<(), String> {
(self.reset)()?;
self.reset_done = true;
Ok(())
}
}
/// Draw the notice (bottom right, above the status bar) until it is closed.
pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut notice) = app.graphics_notice.take() else { return };
if notice.closed {
app.graphics_notice = Some(notice);
return;
}
let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(&notice.mode));
let (mut reset, mut close) = (false, false);
let shown = egui::Window::new("graphics-notice")
.id(egui::Id::new("graphics-notice"))
.title_bar(false)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -32.0])
.show(ctx, |ui| {
ui.set_max_width(360.0);
ui.label(egui::RichText::new(&title).strong());
ui.add_space(4.0);
if notice.reset_done {
ui.label(tl!("The default graphics are used from the next start."));
} else {
ui.label(tl!("An earlier start failed with the default graphics. Updating the graphics driver usually fixes this."));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if !notice.reset_done {
let label = tl!("Use Default Graphics");
let r = ui.button(label);
app.auto.add_response("graphics.notice.reset", &r, label);
reset = r.clicked();
}
let label = tl!("Close");
let c = ui.button(label);
app.auto.add_response("graphics.notice.close", &c, label);
close = c.clicked();
});
});
if let Some(shown) = shown {
app.auto.add("graphics.notice", shown.response.rect, &title);
}
if reset && let Err(e) = notice.reset() {
app.ui.status = e;
}
notice.closed |= close;
app.graphics_notice = Some(notice);
}

View File

@ -3562,3 +3562,9 @@
Edit Project Notes Editar notas del proyecto
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
Safe graphics mode: {mode} Modo gráfico seguro: {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo.
Use Default Graphics Usar gráficos predeterminados
The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio.
DirectX 12 only Solo DirectX 12
software rendering renderizado por software

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3545,3 +3545,9 @@
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet.
Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet
Settings Préférences
Safe graphics mode: {mode} Mode graphique sans échec : {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un démarrage précédent a échoué avec les graphismes par défaut. Mettre à jour le pilote graphique résout généralement le problème.
Use Default Graphics Utiliser les graphismes par défaut
The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage.
DirectX 12 only DirectX 12 uniquement
software rendering rendu logiciel

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -3544,3 +3544,9 @@
Edit Project Notes プロジェクトメモを編集
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます
Safe graphics mode: {mode} セーフグラフィックモード: {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 以前の起動がデフォルトのグラフィックで失敗しました。グラフィックドライバーを更新すると、通常は解決します。
Use Default Graphics デフォルトのグラフィックを使用
The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。
DirectX 12 only DirectX 12 のみ
software rendering ソフトウェアレンダリング

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -3540,3 +3540,9 @@
Delete Caption Track 删除字幕轨道
Delete Caption Tracks 删除多个字幕轨道
Delete Empty Caption Tracks 删除空字幕轨道
Safe graphics mode: {mode} 安全图形模式:{mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 上次启动时默认图形模式失败。更新显卡驱动程序通常可以解决此问题。
Use Default Graphics 使用默认图形
The default graphics are used from the next start. 下次启动时将使用默认图形。
DirectX 12 only 仅 DirectX 12
software rendering 软件渲染

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -39,6 +39,7 @@ pub mod dock;
mod dock_shared;
mod dock_ui;
pub mod frames;
pub mod graphics_notice;
pub mod header;
pub mod i18n;
pub mod icons;
@ -302,6 +303,9 @@ pub struct FilmcraftApp {
styled: bool,
/// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]).
pub ui_error: Option<String>,
/// The window runs in a fallback graphics mode (set by the desktop host): a notice with a
/// button back to the default mode (see [`graphics_notice`]).
pub graphics_notice: Option<graphics_notice::GraphicsNotice>,
/// Fault injection for robustness tests: the next UI pass panics.
#[doc(hidden)]
pub panic_next_frame: bool,
@ -546,6 +550,7 @@ impl FilmcraftApp {
bindings_rev: 0,
shortcut_editor: Default::default(),
toast: None,
graphics_notice: None,
tl: Default::default(),
command_inbox: None,
v_down: false,
@ -1935,6 +1940,10 @@ impl eframe::App for FilmcraftApp {
self.playback.playing = false;
}
self.error_window(ui.ctx());
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() {
// a broken notice must not take the session down: drop it
self.graphics_notice = None;
}
let ctx = ui.ctx().clone();
self.last_ui_time = ctx.input(|i| i.time);
if !self.synthetic.is_empty() {

View File

@ -0,0 +1,123 @@
//! The fallback graphics notice (#820): shown while the desktop app runs in a fallback graphics
//! mode, its button saves the default mode for the next start, and it can be closed.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::graphics_notice::GraphicsNotice;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn new(notice: Option<GraphicsNotice>) -> Self {
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(Session::default()).with_control(rx);
app.graphics_notice = notice;
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ids(&mut self) -> Vec<String> {
let v = self.call("ui.elements", json!({"prefix": "graphics.notice"}));
assert_eq!(v["ok"], json!(true), "{v}");
v["result"].as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect()
}
fn label(&mut self, id: &str) -> String {
let v = self.call("ui.elements", json!({"prefix": id}));
v["result"].as_array().unwrap().iter().find(|e| e["id"] == id).map(|e| e["label"].as_str().unwrap().to_string()).unwrap_or_default()
}
fn click(&mut self, id: &str) {
let v = self.call("ui.click", json!({"id": id}));
assert_eq!(v["ok"], json!(true), "{id}: {v}");
self.frames(3);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
}
#[test]
fn no_notice_in_the_default_graphics_mode() {
let mut d = Driver::new(None);
assert!(d.ids().is_empty());
}
#[test]
fn the_reset_button_saves_the_default_mode_once_and_close_hides_the_notice() {
let resets = Rc::new(Cell::new(0));
let counter = Rc::clone(&resets);
let notice = GraphicsNotice::new(
"software rendering",
Box::new(move || {
counter.set(counter.get() + 1);
Ok(())
}),
);
let mut d = Driver::new(Some(notice));
let ids = d.ids();
for id in ["graphics.notice", "graphics.notice.reset", "graphics.notice.close"] {
assert!(ids.iter().any(|i| i == id), "{id} in {ids:?}");
}
assert!(d.label("graphics.notice").contains("software rendering"));
d.click("graphics.notice.reset");
assert_eq!(resets.get(), 1);
assert!(d.app().graphics_notice.as_ref().is_some_and(|n| n.reset_done));
// done: the button is gone, the notice says when it takes effect
let ids = d.ids();
assert!(!ids.iter().any(|i| i == "graphics.notice.reset"), "{ids:?}");
assert!(ids.iter().any(|i| i == "graphics.notice"));
d.click("graphics.notice.close");
assert!(d.ids().is_empty());
assert_eq!(resets.get(), 1);
}
#[test]
fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() {
let notice = GraphicsNotice::new("DirectX 12 only", Box::new(|| Err("can't write graphics-start.json: read-only".into())));
let mut d = Driver::new(Some(notice));
d.click("graphics.notice.reset");
assert!(d.app().ui.status.contains("read-only"), "{}", d.app().ui.status);
assert!(d.app().graphics_notice.as_ref().is_some_and(|n| !n.reset_done));
assert!(d.ids().iter().any(|i| i == "graphics.notice.reset"));
}
#[test]
fn the_notice_follows_the_interface_language() {
let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(())));
let mut d = Driver::new(Some(notice));
d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es;
d.frames(3);
assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: renderizado por software");
assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados");
filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En);
}

View File

@ -79,11 +79,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G4. Crashes at start-up, and no test CI on pull requests
- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a
driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next
launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe)
with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in
the same process. An explicit `WGPU_BACKEND` stays in charge.
- **Have:** a graphics start-up fallback (#820): after two starts in a row that died within 15 s
of launch without painting (a driver crash in wgpu instance / device creation; hangs, later
crashes and concurrent instances don't count), the next launch moves on to DirectX 12 only
(Windows), then a software adapter (WARP, lavapipe, llvmpipe) with CPU compositing, and keeps
the mode that works; a start-up error wgpu reports moves on in the same process. A fallback mode
shows a notice with Use Default Graphics; `--graphics default|dx12|software` picks a mode by
hand. An explicit `WGPU_BACKEND` stays in charge.
- **Missing:** a launch that never fails, and CI that runs the gates.
- **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468,
#661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup
@ -93,8 +95,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
contributors' local runs (main needed "green again after the batch merges" on 10-10).
- **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed.
- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode
start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is
still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci`
start when the GPU adapter fails~~ (done, #820), a PR workflow running `cargo xtask ci`
on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.**
## G5. Hardware acceleration is partial