Add independent crash supervision and bounded diagnostic reports

This commit is contained in:
chazmm committed 2026-09-08 18:59:09 +01:00
1 parent 657eec2d93
commit e5151cb573
9 files changed
+390

No files matched your search

+4
View File
@@ -17,6 +17,10 @@ skate-data = { path = "crates/skate-data" }
[profile.dev] [profile.dev]
opt-level = 3 opt-level = 3
[profile.release]
debug = 1
strip = false
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3
+17
View File
@@ -1,6 +1,23 @@
use std::{env, fs, path::PathBuf, process::Command}; use std::{env, fs, path::PathBuf, process::Command};
fn main() { 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-changed=../../docs/images/skating-crab.ico");
println!("cargo:rerun-if-env-changed=RC"); println!("cargo:rerun-if-env-changed=RC");
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+1
View File
@@ -109,5 +109,6 @@ pub(crate) fn build(
)); ));
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app.add_plugins(crate::multiplayer::MultiplayerPlugin); app.add_plugins(crate::multiplayer::MultiplayerPlugin);
app.add_systems(Last, crate::crash_context::sample);
app app
} }
+48
View File
@@ -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<Time<Real>>,
config: Res<crate::config::Config>,
physics: Res<crate::physics::GamePhysics>,
skater: Res<crate::physics::SkaterRuntime>,
map: Res<crate::map_transition::CurrentMap>,
transition: Res<crate::map_transition::MapTransition>,
menu: Option<Res<crate::graphics_menu::Menu>>,
multiplayer: Option<Res<crate::multiplayer::Multiplayer>>,
adapter: Option<Res<bevy::render::renderer::RenderAdapterInfo>>,
mut previous: Local<Snapshot>,
) {
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;
}
}
}
+66
View File
@@ -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::<usize>() * 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
}
+191
View File
@@ -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<String>,
transitions: VecDeque<String>,
metadata: std::collections::BTreeMap<String, String>,
panic: VecDeque<String>,
}
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<String>, 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<Mutex<Capture>>, 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<i32> {
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::<String>().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::<Vec<_>>()), ("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<PathBuf> {
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);
}
}
+46
View File
@@ -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()
+3
View File
@@ -88,6 +88,9 @@ pub(crate) struct Menu {
browser: bool, browser: bool,
} }
impl Menu { impl Menu {
pub(crate) fn diagnostic_settings(&self) -> String {
format!("{:?}", self.settings)
}
pub(crate) fn transition_finished(&mut self, status: String, resume: bool) { pub(crate) fn transition_finished(&mut self, status: String, resume: bool) {
self.status = status; self.status = status;
self.open = !resume; self.open = !resume;
+14
View File
@@ -1,4 +1,6 @@
mod animation; mod animation;
mod crash_report;
mod crash_context;
mod multiplayer; mod multiplayer;
mod animation_pose; mod animation_pose;
mod app; mod app;
@@ -31,6 +33,8 @@ mod grind_world;
mod skate_world; mod skate_world;
fn main() -> bevy::app::AppExit { 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() { let config = match config::Config::from_env() {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
@@ -38,10 +42,13 @@ fn main() -> bevy::app::AppExit {
return bevy::app::AppExit::error(); 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) { if let Err(error) = skate_data::input_config::StockGameplayConfig::load(&config.asset_root) {
eprintln!("{error}"); eprintln!("{error}");
return bevy::app::AppExit::error(); return bevy::app::AppExit::error();
} }
eprintln!("REPORT_META stage=asset_manifest");
let manifest = match skate_data::GameAssets::load(&config.asset_root) { let manifest = match skate_data::GameAssets::load(&config.asset_root) {
Ok(manifest) => manifest, Ok(manifest) => manifest,
Err(error) => { Err(error) => {
@@ -49,6 +56,7 @@ fn main() -> bevy::app::AppExit {
return bevy::app::AppExit::error(); return bevy::app::AppExit::error();
} }
}; };
eprintln!("REPORT_META stage=stock_graphs");
let graphs = match graph_runtime::StockGraphs::load(&config.asset_root, &manifest) { let graphs = match graph_runtime::StockGraphs::load(&config.asset_root, &manifest) {
Ok(graphs) => graphs, Ok(graphs) => graphs,
Err(error) => { Err(error) => {
@@ -56,6 +64,7 @@ fn main() -> bevy::app::AppExit {
return bevy::app::AppExit::error(); return bevy::app::AppExit::error();
} }
}; };
eprintln!("REPORT_META stage=map_validation");
if let Some(map) = &config.map { if let Some(map) = &config.map {
if let Err(error) = skate_world::validate_runtime(map) { if let Err(error) = skate_world::validate_runtime(map) {
eprintln!("{error}"); 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!("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) { let mut physics = match physics::GamePhysics::load_with_difficulty(&config.asset_root, config.map.as_ref(), config.difficulty) {
Ok(physics) => physics, Ok(physics) => physics,
Err(error) => { Err(error) => {
@@ -75,6 +85,7 @@ fn main() -> bevy::app::AppExit {
spawn.translation.x += config.multiplayer.spawn_offset; spawn.translation.x += config.multiplayer.spawn_offset;
physics.board.set_transform(spawn); 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()) { let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, config.difficulty.key()) {
Ok(skater) => skater, Ok(skater) => skater,
Err(error) => { Err(error) => {
@@ -82,6 +93,7 @@ fn main() -> bevy::app::AppExit {
return bevy::app::AppExit::error(); return bevy::app::AppExit::error();
} }
}; };
eprintln!("REPORT_META stage=controls_initialization");
let controls = match physics::PlayerControls::load(&config.asset_root) { let controls = match physics::PlayerControls::load(&config.asset_root) {
Ok(controls) => controls, Ok(controls) => controls,
Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); } Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); }
@@ -94,8 +106,10 @@ fn main() -> bevy::app::AppExit {
eprintln!("SKATE_ASSETS_READY"); eprintln!("SKATE_ASSETS_READY");
return bevy::app::AppExit::Success; return bevy::app::AppExit::Success;
} }
eprintln!("REPORT_META stage=renderer_and_app_initialization");
let mut app = app::build(config, manifest, graphs, physics, skater); let mut app = app::build(config, manifest, graphs, physics, skater);
app.insert_resource(controls); app.insert_resource(controls);
eprintln!("REPORT_META stage=app_run");
app.run() app.run()
} }