From e5151cb573cf1a85877285a473d1bfd8131cf74d Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:59:09 +0100 Subject: [PATCH] Add independent crash supervision and bounded diagnostic reports --- Cargo.toml | 4 + crates/skate-game/build.rs | 17 ++ crates/skate-game/src/app.rs | 1 + crates/skate-game/src/crash_context.rs | 48 ++++++ crates/skate-game/src/crash_native.rs | 66 ++++++++ crates/skate-game/src/crash_report.rs | 191 ++++++++++++++++++++++ crates/skate-game/src/crash_report_ui.ps1 | 46 ++++++ crates/skate-game/src/graphics_menu.rs | 3 + crates/skate-game/src/main.rs | 14 ++ 9 files changed, 390 insertions(+) create mode 100644 crates/skate-game/src/crash_context.rs create mode 100644 crates/skate-game/src/crash_native.rs create mode 100644 crates/skate-game/src/crash_report.rs create mode 100644 crates/skate-game/src/crash_report_ui.ps1 diff --git a/Cargo.toml b/Cargo.toml index 14c18769..8bc30e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,10 @@ skate-data = { path = "crates/skate-data" } [profile.dev] opt-level = 3 +[profile.release] +debug = 1 +strip = false + [profile.dev.package."*"] opt-level = 3 diff --git a/crates/skate-game/build.rs b/crates/skate-game/build.rs index f389f28b..ea63d623 100644 --- a/crates/skate-game/build.rs +++ b/crates/skate-game/build.rs @@ -1,6 +1,23 @@ use std::{env, fs, path::PathBuf, process::Command}; fn main() { + // Cargo must refresh provenance after a commit as well as after source edits. + for name in ["HEAD", "index", "refs"] { + if let Ok(output) = Command::new("git").args(["rev-parse", "--git-path", name]).output() { + if output.status.success() { println!("cargo:rerun-if-changed={}", String::from_utf8_lossy(&output.stdout).trim()); } + } + } + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../skate-core/src"); + println!("cargo:rerun-if-changed=../skate-data/src"); + println!("cargo:rerun-if-changed=../skate-net/src"); + println!("cargo:rerun-if-changed=../../Cargo.lock"); + let git = |args: &[&str]| Command::new("git").args(args).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()); + let revision = git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "revision-unavailable".into()); + let dirty = git(&["status", "--porcelain", "--untracked-files=normal"]).map(|s| !s.is_empty()); + let compiler = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("--version").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default(); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + println!("cargo:rustc-env=SKATE_BUILD_ID={} revision={} dirty={dirty:?} build_unix_ns={stamp} target={} profile={} dynamic={} compiler={compiler}", env::var("CARGO_PKG_VERSION").unwrap(), revision, env::var("TARGET").unwrap(), env::var("PROFILE").unwrap(), env::var_os("CARGO_FEATURE_DEV_DYNAMIC").is_some()); println!("cargo:rerun-if-changed=../../docs/images/skating-crab.ico"); println!("cargo:rerun-if-env-changed=RC"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 6afd85d4..13b76dcd 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -109,5 +109,6 @@ pub(crate) fn build( )); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app.add_plugins(crate::multiplayer::MultiplayerPlugin); + app.add_systems(Last, crate::crash_context::sample); app } diff --git a/crates/skate-game/src/crash_context.rs b/crates/skate-game/src/crash_context.rs new file mode 100644 index 00000000..114f2618 --- /dev/null +++ b/crates/skate-game/src/crash_context.rs @@ -0,0 +1,48 @@ +//! Infrequent, allowlisted snapshots: no player names, addresses or lobby credentials. +use bevy::prelude::*; + +#[derive(Default)] +pub(crate) struct Snapshot { + next: f64, + state: String, + graphics: String, + gpu_recorded: bool, +} + +pub(crate) fn sample( + time: Res>, + config: Res, + physics: Res, + skater: Res, + map: Res, + transition: Res, + menu: Option>, + multiplayer: Option>, + adapter: Option>, + mut previous: Local, +) { + let now = time.elapsed_secs_f64(); + if now < previous.next { return; } + previous.next = now + 1.; + if !previous.gpu_recorded { + if let Some(adapter) = adapter { + eprintln!("REPORT_META gpu={:?}", &**adapter); + previous.gpu_recorded = true; + } + } + let state = format!("map_fingerprint:{:016x} generation:{} difficulty:{} physical:{:?} paused:{} map_loading:{} multiplayer_active:{} physics_failed:{}", + config.map_fingerprint, map.generation, config.difficulty.key(), skater.player_state.current(), + menu.as_ref().is_some_and(|m| m.open), transition.busy(), multiplayer.as_ref().is_some_and(|m| m.active()), physics.failed); + if state != previous.state { + eprintln!("REPORT_TRANSITION {state}"); + previous.state = state.clone(); + } + eprintln!("REPORT_META state={state} physics_tick:{} contacts:{} network_contacts:{}", physics.ticks, physics.contact_count, physics.network_contacts); + if let Some(menu) = menu { + let graphics = menu.diagnostic_settings(); + if graphics != previous.graphics { + eprintln!("REPORT_META graphics={graphics}"); + previous.graphics = graphics; + } + } +} diff --git a/crates/skate-game/src/crash_native.rs b/crates/skate-game/src/crash_native.rs new file mode 100644 index 00000000..3eba8b7a --- /dev/null +++ b/crates/skate-game/src/crash_native.rs @@ -0,0 +1,66 @@ +//! Best-effort Windows unhandled-exception breadcrumb. No allocation, locks, stack +//! walking, file creation or UI from the faulting thread. Supervisor owns reporting. +use std::{ffi::c_void, sync::atomic::{AtomicUsize, Ordering}}; +static IMAGE_BASE: AtomicUsize = AtomicUsize::new(0); +static STDERR: AtomicUsize = AtomicUsize::new(0); + +#[repr(C)] +struct ExceptionRecord { + code: u32, + flags: u32, + nested: *const ExceptionRecord, + address: *const c_void, + count: u32, + information: [usize; 15], +} +#[repr(C)] +struct ExceptionPointers { + record: *const ExceptionRecord, + context: *const c_void, +} +#[link(name = "kernel32")] +unsafe extern "system" { + fn SetUnhandledExceptionFilter(filter: unsafe extern "system" fn(*const ExceptionPointers) -> i32) -> *const c_void; + fn GetModuleHandleW(name: *const u16) -> *const c_void; + fn GetStdHandle(kind: u32) -> *mut c_void; + fn WriteFile(file: *mut c_void, buffer: *const u8, size: u32, written: *mut u32, overlapped: *mut c_void) -> i32; +} +pub(super) fn install() { + // SAFETY: null requests this executable; -12 requests stderr. Handles are + // borrowed for the process lifetime, and our filter has the Windows ABI. + unsafe { + IMAGE_BASE.store(GetModuleHandleW(std::ptr::null()) as usize, Ordering::Relaxed); + STDERR.store(GetStdHandle((-12i32) as u32) as usize, Ordering::Relaxed); + SetUnhandledExceptionFilter(filter); + } +} +unsafe extern "system" fn filter(pointers: *const ExceptionPointers) -> i32 { + if pointers.is_null() { return 0; } + // SAFETY: Windows supplies a live EXCEPTION_POINTERS and EXCEPTION_RECORD + // for the duration of the top-level filter. Do not dereference the context. + let record = unsafe { (*pointers).record }; + if record.is_null() { return 0; } + let record = unsafe { &*record }; + let mut output = [0u8; 256]; + let mut used = 0; + fn append(output: &mut [u8], used: &mut usize, value: &[u8]) { + output[*used..*used + value.len()].copy_from_slice(value); *used += value.len(); + } + fn hex(output: &mut [u8], used: &mut usize, value: usize) { + for shift in (0..std::mem::size_of::() * 2).rev() { + output[*used] = b"0123456789ABCDEF"[(value >> (shift * 4)) & 15]; *used += 1; + } + } + append(&mut output, &mut used, b"\nREPORT_NATIVE code=0x"); + hex(&mut output, &mut used, record.code as usize); + append(&mut output, &mut used, b" fault_pc=0x"); + hex(&mut output, &mut used, record.address as usize); + append(&mut output, &mut used, b" executable_image_base=0x"); + hex(&mut output, &mut used, IMAGE_BASE.load(Ordering::Relaxed)); + append(&mut output, &mut used, b" (address may belong to another module)\n"); + let mut written = 0; + // SAFETY: bounded stack buffer stays live through synchronous WriteFile. + // Failure is intentionally ignored; Windows continues its exception search. + unsafe { WriteFile(STDERR.load(Ordering::Relaxed) as *mut c_void, output.as_ptr(), used as u32, &mut written, std::ptr::null_mut()); } + 0 +} diff --git a/crates/skate-game/src/crash_report.rs b/crates/skate-game/src/crash_report.rs new file mode 100644 index 00000000..911a0cbd --- /dev/null +++ b/crates/skate-game/src/crash_report.rs @@ -0,0 +1,191 @@ +//! Process supervision uses only std; no renderer or game assets are initialized here. +use std::{collections::VecDeque, io::{Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{Arc, Mutex}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}}; + +const CHILD: &str = "SKATE_REPORT_CHILD"; +const LINE_LIMIT: usize = 4096; +const LOG_LIMIT: usize = 256; +#[cfg(windows)] +#[path = "crash_native.rs"] +mod native; + +#[derive(Default)] +struct Capture { + logs: VecDeque, + transitions: VecDeque, + metadata: std::collections::BTreeMap, + panic: VecDeque, +} +impl Capture { + fn line(&mut self, stream: &str, line: &[u8], elapsed: f64) { + let line = sanitize(&String::from_utf8_lossy(line)); + let entry = format!("+{elapsed:.3}s {stream}: {line}"); + if let Some(value) = line.strip_prefix("REPORT_META ") { + if let Some((key, _)) = value.split_once('=') { + // A fixed key vocabulary prevents arbitrary diagnostic accumulation. + if ["startup", "stage", "gpu", "state", "graphics"].contains(&key) { + self.metadata.insert(key.into(), entry.clone()); + } + } + } + if line.starts_with("REPORT_TRANSITION ") { push(&mut self.transitions, entry.clone(), 64); } + if line.starts_with("REPORT_PANIC ") || line.starts_with("REPORT_NATIVE ") { push(&mut self.panic, entry.clone(), 128); } + push(&mut self.logs, entry, LOG_LIMIT); + } +} +fn push(queue: &mut VecDeque, value: String, limit: usize) { + if queue.len() == limit { queue.pop_front(); } + queue.push_back(value); +} + +/// Fail closed for credential/network messages. Never collect args or environment dumps. +fn sanitize(value: &str) -> String { + let lower = value.to_ascii_lowercase(); + if ["token", "ticket", "password", "secret", "authorization", "cookie", "session", "steam", "join_code", "host_code", "peer=", "address=", "appearance", "unknown argument"].iter().any(|key| lower.contains(key)) { + return "[sensitive-context line omitted]".into(); + } + // Omit the entire line when it contains absolute paths (including paths with spaces). + // Keep source locations portable by reporting relative paths from our panic hook. + if value.as_bytes().windows(3).any(|w| w[0].is_ascii_alphabetic() && w[1] == b':' && (w[2] == b'\\' || w[2] == b'/')) + || value.contains("\\\\") || value.split_whitespace().any(|s| s.trim_start_matches(['\'', '"', '(']).starts_with('/')) { + return "[absolute-path line omitted]".into(); + } + value.chars().filter(|c| !c.is_control() || *c == '\t').take(LINE_LIMIT).collect() +} + +fn reader(mut input: impl Read + Send + 'static, stream: &'static str, capture: Arc>, start: Instant) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut buffer = [0; 4096]; + let mut line = Vec::with_capacity(LINE_LIMIT); + let mut truncated = false; + loop { + let count = match input.read(&mut buffer) { Ok(0) | Err(_) => break, Ok(n) => n }; + for &byte in &buffer[..count] { + if byte == b'\n' { + if truncated { line.clear(); line.extend_from_slice(b"[oversized line omitted]"); } + capture.lock().unwrap_or_else(|e| e.into_inner()).line(stream, &line, start.elapsed().as_secs_f64()); + line.clear(); truncated = false; + } else if line.len() < LINE_LIMIT { line.push(byte); } else { truncated = true; } + } + // Preserve existing launcher logs. Capture happens before forwarding. + if stream == "stderr" { let _ = std::io::stderr().write_all(&buffer[..count]); } + else { let _ = std::io::stdout().write_all(&buffer[..count]); } + } + if truncated { line.clear(); line.extend_from_slice(b"[oversized line omitted]"); } + if !line.is_empty() { capture.lock().unwrap_or_else(|e| e.into_inner()).line(stream, &line, start.elapsed().as_secs_f64()); } + }) +} + +pub(crate) fn entry() -> Option { + if std::env::var_os(CHILD).is_some() { + #[cfg(windows)] + native::install(); + install_panic_hook(); + return None; + } + if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--crash-report-preview")) { + present("Skate 3 diagnostic UI preview\nSynthetic report only; no game was started.\n"); + return Some(0); + } + let capture = Arc::new(Mutex::new(Capture::default())); + let start = Instant::now(); + let result = std::env::current_exe().and_then(|exe| Command::new(exe) + .args(std::env::args_os().skip(1)).env(CHILD, "1") + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()); + let mut child = match result { + Ok(child) => child, + Err(error) => { present(&report(&Capture::default(), &format!("Could not start game: {}", sanitize(&error.to_string())), 0.)); return Some(1); } + }; + let out = reader(child.stdout.take().unwrap(), "stdout", capture.clone(), start); + let err = reader(child.stderr.take().unwrap(), "stderr", capture.clone(), start); + let status = child.wait(); + // A relay may inherit a pipe. Never wait indefinitely for its EOF. + let deadline = Instant::now() + Duration::from_secs(2); + while !(out.is_finished() && err.is_finished()) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } + let code = match &status { Ok(s) => s.code().unwrap_or(1), Err(_) => 1 }; + if !status.as_ref().is_ok_and(|s| s.success()) { + let outcome = match status { + Ok(s) => format!("Process status: {s}; exit code hex: 0x{:08X}. Native exception interpretation is platform-dependent.", code as u32), + Err(e) => format!("Process wait failed: {}", sanitize(&e.to_string())), + }; + let text = report(&capture.lock().unwrap_or_else(|e| e.into_inner()), &outcome, start.elapsed().as_secs_f64()); + present(&text); + } + Some(code) +} + +fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + let payload = info.payload().downcast_ref::().map(String::as_str) + .or_else(|| info.payload().downcast_ref::<&str>().copied()).unwrap_or("non-string panic payload"); + eprintln!("REPORT_PANIC {}", sanitize(payload)); + if let Some(location) = info.location() { + let file = location.file().replace('\\', "/"); + let portable = file.find("crates/").map(|i| &file[i..]).unwrap_or_else(|| file.rsplit('/').next().unwrap_or("unknown")); + eprintln!("REPORT_PANIC at {portable}:{}:{}", location.line(), location.column()); + } + // Only walk stacks on a panic. The independent supervisor owns the popup. + for line in std::backtrace::Backtrace::force_capture().to_string().lines().take(120) { + eprintln!("REPORT_PANIC {}", sanitize(line)); + } + })); +} + +fn report(capture: &Capture, outcome: &str, elapsed: f64) -> String { + let mut text = format!("Skate 3 Rust Engine diagnostic report v1\nBuild: {}\nPlatform: {} / {}\nUTC Unix seconds: {}\nRuntime seconds: {elapsed:.3}\n{outcome}\n\nNo automatic upload. Review before sharing. Paths and sensitive-context log lines are omitted.\nNative fault stack/registers: unavailable (no memory dump collected).\nGPU/driver, map and settings: available only if initialized and recorded below.\nMods: no authoritative mod inventory; modified asset contents are not collected.\nLogs: last 256 bounded lines; transitions: last 64; panic: last 128 lines.\nState is sampled every second; brief transitions can be missed. Abrupt exits can lose pending pipe data.\n", env!("SKATE_BUILD_ID"), std::env::consts::OS, std::env::consts::ARCH, SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()); + for (name, entries) in [("Latest metadata", capture.metadata.values().collect::>()), ("Recent state transitions", capture.transitions.iter().collect()), ("Panic, native exception and stack", capture.panic.iter().collect()), ("Recent logs", capture.logs.iter().collect())] { + text.push_str(&format!("\n{name}\n")); + if entries.is_empty() { text.push_str("Unavailable / not recorded\n"); } + for entry in entries { text.push_str(entry); text.push('\n'); } + } + text +} + +fn save(text: &str) -> std::io::Result { + let roots = [std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(std::env::temp_dir), std::env::temp_dir()]; + let mut error = None; + for root in roots { + let result = (|| { + let folder = root.join("Skate3RustEngine/CrashReports"); + std::fs::create_dir_all(&folder)?; + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + let path = folder.join(format!("report-{stamp}-{}.txt", std::process::id())); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?; + file.write_all(text.as_bytes())?; + Ok(path) + })(); + match result { Ok(path) => return Ok(path), Err(e) => error = Some(e) } + } + Err(error.unwrap()) +} +fn present(text: &str) { + match save(text) { + Ok(path) => { + eprintln!("Crash report saved: {}", path.display()); + if let Err(error) = popup(&path) { eprintln!("Report popup unavailable: {error}. Open the saved text report manually."); } + } + Err(error) => { eprintln!("Could not save crash report: {error}\n{text}"); } + } +} +fn popup(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] { + use std::os::windows::process::CommandExt; + let status = Command::new("powershell.exe").args(["-NoProfile", "-STA", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", include_str!("crash_report_ui.ps1")]) + .env("SKATE_REPORT_PATH", path).creation_flags(0x08000000).status()?; + if !status.success() { return Err(std::io::Error::other("PowerShell UI failed")); } + } + #[cfg(not(windows))] { let _ = path; } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] fn bounded_and_private() { + let mut c = Capture::default(); + for i in 0..10000 { c.line("stderr", format!("REPORT_TRANSITION {i}").as_bytes(), 0.); } + assert_eq!(c.logs.len(), LOG_LIMIT); assert_eq!(c.transitions.len(), 64); + for secret in ["ticket abc", "SESSION=123", "C:\\Users\\Some Person\\file", "/home/name/a", "Unknown argument abc"] { assert!(sanitize(secret).contains("omitted")); } + assert_eq!(sanitize("panic at crates/skate-game/src/main.rs:12"), "panic at crates/skate-game/src/main.rs:12"); + assert!(sanitize(&"x".repeat(100000)).len() <= LINE_LIMIT); + } +} diff --git a/crates/skate-game/src/crash_report_ui.ps1 b/crates/skate-game/src/crash_report_ui.ps1 new file mode 100644 index 00000000..9b944faf --- /dev/null +++ b/crates/skate-game/src/crash_report_ui.ps1 @@ -0,0 +1,46 @@ +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$path = $env:SKATE_REPORT_PATH +$report = [IO.File]::ReadAllText($path) +$form = New-Object Windows.Forms.Form +$form.Text = 'Skate 3 Rust Engine — diagnostic report' +$form.ClientSize = New-Object Drawing.Size(570, 310) +$form.StartPosition = 'CenterScreen' +$form.FormBorderStyle = 'FixedDialog' +$form.MaximizeBox = $false +$label = New-Object Windows.Forms.Label +$label.SetBounds(16, 14, 535, 55) +$label.Text = "The game stopped unexpectedly. A text report has been saved.`nReview it before sharing with the team. Nothing is uploaded." +$form.Controls.Add($label) +$preview = New-Object Windows.Forms.TextBox +$preview.SetBounds(16, 73, 535, 175) +$preview.Multiline = $true +$preview.ReadOnly = $true +$preview.ScrollBars = 'Both' +$preview.WordWrap = $false +$preview.Text = $report.Replace("`n", "`r`n") +$form.Controls.Add($preview) +$copy = New-Object Windows.Forms.Button +$copy.SetBounds(16, 264, 130, 30) +$copy.Text = 'Copy report' +$copy.Add_Click({ + try { [Windows.Forms.Clipboard]::SetDataObject($report, $true, 5, 150); $copy.Text = 'Copied' } + catch { [Windows.Forms.MessageBox]::Show('Clipboard unavailable. Select the report text and press Ctrl+C, or open the saved file.') } +}) +$form.Controls.Add($copy) +$folder = New-Object Windows.Forms.Button +$folder.SetBounds(158, 264, 130, 30) +$folder.Text = 'Open folder' +$folder.Add_Click({ + try { Start-Process explorer.exe -ArgumentList ('"' + [IO.Path]::GetDirectoryName($path) + '"') } + catch { [Windows.Forms.MessageBox]::Show('Saved report: ' + $path) } +}) +$form.Controls.Add($folder) +$close = New-Object Windows.Forms.Button +$close.SetBounds(421, 264, 130, 30) +$close.Text = 'Close' +$close.Add_Click({ $form.Close() }) +$form.Controls.Add($close) +$form.CancelButton = $close +[void]$form.ShowDialog() diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index c86578c7..0bf8aa89 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -88,6 +88,9 @@ pub(crate) struct Menu { browser: bool, } impl Menu { + pub(crate) fn diagnostic_settings(&self) -> String { + format!("{:?}", self.settings) + } pub(crate) fn transition_finished(&mut self, status: String, resume: bool) { self.status = status; self.open = !resume; diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 5d1a95cf..8a47e8cc 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -1,4 +1,6 @@ mod animation; +mod crash_report; +mod crash_context; mod multiplayer; mod animation_pose; mod app; @@ -31,6 +33,8 @@ mod grind_world; mod skate_world; fn main() -> bevy::app::AppExit { + if let Some(code) = crash_report::entry() { std::process::exit(code); } + eprintln!("REPORT_META stage=configuration_and_installation"); let config = match config::Config::from_env() { Ok(config) => config, Err(error) => { @@ -38,10 +42,13 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; + eprintln!("REPORT_META startup=map_fingerprint:{:016x} difficulty:{} multiplayer_requested:{} custom_appearance:{} renderer:Vulkan", config.map_fingerprint, config.difficulty.key(), config.multiplayer.host.is_some() || config.multiplayer.direct.is_some(), config.multiplayer.appearance.is_some()); + eprintln!("REPORT_META stage=gameplay_configuration"); if let Err(error) = skate_data::input_config::StockGameplayConfig::load(&config.asset_root) { eprintln!("{error}"); return bevy::app::AppExit::error(); } + eprintln!("REPORT_META stage=asset_manifest"); let manifest = match skate_data::GameAssets::load(&config.asset_root) { Ok(manifest) => manifest, Err(error) => { @@ -49,6 +56,7 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; + eprintln!("REPORT_META stage=stock_graphs"); let graphs = match graph_runtime::StockGraphs::load(&config.asset_root, &manifest) { Ok(graphs) => graphs, Err(error) => { @@ -56,6 +64,7 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; + eprintln!("REPORT_META stage=map_validation"); if let Some(map) = &config.map { if let Err(error) = skate_world::validate_runtime(map) { eprintln!("{error}"); @@ -63,6 +72,7 @@ fn main() -> bevy::app::AppExit { } } eprintln!("SKATE_DIFFICULTY mode={} native_index={}", config.difficulty.key(), config.difficulty as u32); + eprintln!("REPORT_META stage=physics_initialization"); let mut physics = match physics::GamePhysics::load_with_difficulty(&config.asset_root, config.map.as_ref(), config.difficulty) { Ok(physics) => physics, Err(error) => { @@ -75,6 +85,7 @@ fn main() -> bevy::app::AppExit { spawn.translation.x += config.multiplayer.spawn_offset; physics.board.set_transform(spawn); } + eprintln!("REPORT_META stage=skater_initialization"); let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.key()) { Ok(skater) => skater, Err(error) => { @@ -82,6 +93,7 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; + eprintln!("REPORT_META stage=controls_initialization"); let controls = match physics::PlayerControls::load(&config.asset_root) { Ok(controls) => controls, Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); } @@ -94,8 +106,10 @@ fn main() -> bevy::app::AppExit { eprintln!("SKATE_ASSETS_READY"); return bevy::app::AppExit::Success; } + eprintln!("REPORT_META stage=renderer_and_app_initialization"); let mut app = app::build(config, manifest, graphs, physics, skater); app.insert_resource(controls); + eprintln!("REPORT_META stage=app_run"); app.run() }