mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add independent crash supervision and bounded diagnostic reports
This commit is contained in:
1 parent
657eec2d93
commit
e5151cb573
9 files changed
+390
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user