feat: test runner crash detection, ERRORLOG capture, and dialog suppression
Browse files- Launcher passes /silentcrash to WA.exe in headless mode, preventing
the crash handler from relaunching with /handlecrash and showing the
"Fatal error" dialog that blocks test execution
- Hook ERRORLOG.TXT creation via OPENWA_ERRORLOG_PATH env var to
redirect crash logs to per-test paths in the run directory
- Detect crashes by NTSTATUS exit codes or ERRORLOG.TXT presence,
show CRASH status with exception details and first 20 lines of
the error log (using lossy UTF-8 for binary memory dumps)
- Strip ANSI color codes when stdout is not a terminal
- Add RE notes for Frontend__MainNavigationLoop (crash handler mechanism)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crates/openwa-launcher/src/main.rs
CHANGED
|
@@ -139,6 +139,13 @@ fn run() -> Result<(), String> {
|
|
| 139 |
.parent()
|
| 140 |
.ok_or("WA.exe path has no parent directory")?;
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
let cmdline = build_cmdline(&wa_exe, &wa_args);
|
| 143 |
|
| 144 |
println!(
|
|
|
|
| 139 |
.parent()
|
| 140 |
.ok_or("WA.exe path has no parent directory")?;
|
| 141 |
|
| 142 |
+
// In headless mode, suppress WA.exe's crash dialog. The crash handler
|
| 143 |
+
// (SEH at 0x5A5A20) checks this flag before deciding to relaunch with
|
| 144 |
+
// /handlecrash β if set, it silently writes ERRORLOG.TXT and returns.
|
| 145 |
+
if std::env::var("OPENWA_HEADLESS").is_ok() {
|
| 146 |
+
wa_args.push("/silentcrash".to_string());
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
let cmdline = build_cmdline(&wa_exe, &wa_args);
|
| 150 |
|
| 151 |
println!(
|
crates/openwa-test-runner/src/main.rs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 7 |
use std::env;
|
| 8 |
use std::fmt::Write as FmtWrite;
|
| 9 |
use std::fs;
|
|
|
|
| 10 |
use std::os::windows::process::CommandExt;
|
| 11 |
use std::path::{Path, PathBuf};
|
| 12 |
use std::process::Command;
|
|
@@ -38,6 +39,13 @@ struct TestCase {
|
|
| 38 |
output_log: PathBuf,
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
#[derive(Clone)]
|
| 42 |
struct TestResult {
|
| 43 |
name: String,
|
|
@@ -45,6 +53,7 @@ struct TestResult {
|
|
| 45 |
duration: Duration,
|
| 46 |
diff_lines: Vec<String>,
|
| 47 |
error: Option<String>,
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
struct Args {
|
|
@@ -222,6 +231,7 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 222 |
|
| 223 |
// Per-instance log paths
|
| 224 |
let openwa_log = run_dir.join(format!("{}.openwa.log", test.name));
|
|
|
|
| 225 |
|
| 226 |
let result = Command::new(launcher)
|
| 227 |
.arg(wa_exe)
|
|
@@ -229,6 +239,7 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 229 |
.arg(&test.replay_path)
|
| 230 |
.env("OPENWA_HEADLESS", "1")
|
| 231 |
.env("OPENWA_LOG_PATH", &openwa_log)
|
|
|
|
| 232 |
.stdout(std::process::Stdio::null())
|
| 233 |
.stderr(std::process::Stdio::null())
|
| 234 |
.creation_flags(CREATE_NO_WINDOW)
|
|
@@ -243,8 +254,11 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 243 |
duration,
|
| 244 |
diff_lines: Vec::new(),
|
| 245 |
error: Some(format!("Failed to launch: {e}")),
|
|
|
|
| 246 |
},
|
| 247 |
Ok(status) => {
|
|
|
|
|
|
|
| 248 |
if duration >= Duration::from_secs(TIMEOUT_SECS) {
|
| 249 |
return TestResult {
|
| 250 |
name: test.name.clone(),
|
|
@@ -252,6 +266,40 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 252 |
duration,
|
| 253 |
diff_lines: Vec::new(),
|
| 254 |
error: Some("Timeout".to_string()),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
};
|
| 256 |
}
|
| 257 |
|
|
@@ -269,9 +317,9 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 269 |
duration,
|
| 270 |
diff_lines: Vec::new(),
|
| 271 |
error: Some(format!(
|
| 272 |
-
"No output log generated (exit code: {})"
|
| 273 |
-
status.code().unwrap_or(-1)
|
| 274 |
)),
|
|
|
|
| 275 |
};
|
| 276 |
}
|
| 277 |
|
|
@@ -288,6 +336,7 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 288 |
duration,
|
| 289 |
diff_lines: Vec::new(),
|
| 290 |
error: None,
|
|
|
|
| 291 |
}
|
| 292 |
} else {
|
| 293 |
let mut diff = compute_diff(&expected, &actual);
|
|
@@ -305,6 +354,7 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
|
|
| 305 |
duration,
|
| 306 |
diff_lines: diff,
|
| 307 |
error: None,
|
|
|
|
| 308 |
}
|
| 309 |
}
|
| 310 |
}
|
|
@@ -415,15 +465,53 @@ fn run_tests_parallel(
|
|
| 415 |
|
| 416 |
// βββ Output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
fn print_result(result: &TestResult) {
|
| 419 |
let status = if result.passed {
|
| 420 |
-
"
|
|
|
|
|
|
|
| 421 |
} else {
|
| 422 |
-
"
|
| 423 |
};
|
| 424 |
let secs = result.duration.as_secs_f64();
|
| 425 |
println!("{status} {:<28} ({secs:.1}s)", result.name);
|
| 426 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
if let Some(err) = &result.error {
|
| 428 |
println!(" {err}");
|
| 429 |
}
|
|
@@ -434,34 +522,62 @@ fn print_result(result: &TestResult) {
|
|
| 434 |
|
| 435 |
fn print_summary(results: &[TestResult], wall_time: Duration) {
|
| 436 |
let passed = results.iter().filter(|r| r.passed).count();
|
|
|
|
| 437 |
let failed = results.len() - passed;
|
| 438 |
let cpu_time: f64 = results.iter().map(|r| r.duration.as_secs_f64()).sum();
|
| 439 |
let wall = wall_time.as_secs_f64();
|
| 440 |
|
| 441 |
println!();
|
| 442 |
if failed == 0 {
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
results.len()
|
| 446 |
-
);
|
| 447 |
} else {
|
| 448 |
-
|
| 449 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
results.len()
|
| 451 |
);
|
|
|
|
| 452 |
}
|
| 453 |
}
|
| 454 |
|
| 455 |
fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
|
| 456 |
let mut s = String::new();
|
| 457 |
for r in results {
|
| 458 |
-
let status = if r.passed {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
let _ = writeln!(
|
| 460 |
s,
|
| 461 |
"{status} {:<28} ({:.1}s)",
|
| 462 |
r.name,
|
| 463 |
r.duration.as_secs_f64()
|
| 464 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
if let Some(err) = &r.error {
|
| 466 |
let _ = writeln!(s, " {err}");
|
| 467 |
}
|
|
@@ -470,10 +586,16 @@ fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
|
|
| 470 |
}
|
| 471 |
}
|
| 472 |
let passed = results.iter().filter(|r| r.passed).count();
|
|
|
|
| 473 |
let failed = results.len() - passed;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
let _ = writeln!(
|
| 475 |
s,
|
| 476 |
-
"\n{} tests: {passed} passed, {failed} failed (wall {:.1}s)",
|
| 477 |
results.len(),
|
| 478 |
wall_time.as_secs_f64()
|
| 479 |
);
|
|
@@ -505,6 +627,10 @@ fn cleanup_temp_files(wa_exe: &Path) {
|
|
| 505 |
for (prefix, suffix) in &data_patterns {
|
| 506 |
cleanup_matching(&data_dir, prefix, suffix);
|
| 507 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
}
|
| 509 |
|
| 510 |
fn cleanup_matching(dir: &Path, prefix: &str, suffix: &str) {
|
|
@@ -540,6 +666,45 @@ fn strip_unc(p: PathBuf) -> PathBuf {
|
|
| 540 |
}
|
| 541 |
}
|
| 542 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 544 |
|
| 545 |
fn main() {
|
|
|
|
| 7 |
use std::env;
|
| 8 |
use std::fmt::Write as FmtWrite;
|
| 9 |
use std::fs;
|
| 10 |
+
use std::io::IsTerminal;
|
| 11 |
use std::os::windows::process::CommandExt;
|
| 12 |
use std::path::{Path, PathBuf};
|
| 13 |
use std::process::Command;
|
|
|
|
| 39 |
output_log: PathBuf,
|
| 40 |
}
|
| 41 |
|
| 42 |
+
#[derive(Clone)]
|
| 43 |
+
struct CrashInfo {
|
| 44 |
+
exit_code: u32,
|
| 45 |
+
name: &'static str,
|
| 46 |
+
errorlog_content: Option<String>,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
#[derive(Clone)]
|
| 50 |
struct TestResult {
|
| 51 |
name: String,
|
|
|
|
| 53 |
duration: Duration,
|
| 54 |
diff_lines: Vec<String>,
|
| 55 |
error: Option<String>,
|
| 56 |
+
crashed: Option<CrashInfo>,
|
| 57 |
}
|
| 58 |
|
| 59 |
struct Args {
|
|
|
|
| 231 |
|
| 232 |
// Per-instance log paths
|
| 233 |
let openwa_log = run_dir.join(format!("{}.openwa.log", test.name));
|
| 234 |
+
let errorlog_path = run_dir.join(format!("{}.errorlog.txt", test.name));
|
| 235 |
|
| 236 |
let result = Command::new(launcher)
|
| 237 |
.arg(wa_exe)
|
|
|
|
| 239 |
.arg(&test.replay_path)
|
| 240 |
.env("OPENWA_HEADLESS", "1")
|
| 241 |
.env("OPENWA_LOG_PATH", &openwa_log)
|
| 242 |
+
.env("OPENWA_ERRORLOG_PATH", &errorlog_path)
|
| 243 |
.stdout(std::process::Stdio::null())
|
| 244 |
.stderr(std::process::Stdio::null())
|
| 245 |
.creation_flags(CREATE_NO_WINDOW)
|
|
|
|
| 254 |
duration,
|
| 255 |
diff_lines: Vec::new(),
|
| 256 |
error: Some(format!("Failed to launch: {e}")),
|
| 257 |
+
crashed: None,
|
| 258 |
},
|
| 259 |
Ok(status) => {
|
| 260 |
+
let exit_code = status.code().unwrap_or(0);
|
| 261 |
+
|
| 262 |
if duration >= Duration::from_secs(TIMEOUT_SECS) {
|
| 263 |
return TestResult {
|
| 264 |
name: test.name.clone(),
|
|
|
|
| 266 |
duration,
|
| 267 |
diff_lines: Vec::new(),
|
| 268 |
error: Some("Timeout".to_string()),
|
| 269 |
+
crashed: None,
|
| 270 |
+
};
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// Check for crash: either an NTSTATUS exit code (negative i32)
|
| 274 |
+
// or the presence of ERRORLOG.TXT (WA.exe's SEH handler catches
|
| 275 |
+
// exceptions and exits cleanly, so exit code may be 0).
|
| 276 |
+
// Use lossy conversion β ERRORLOG.TXT may contain binary
|
| 277 |
+
// memory dump data that isn't valid UTF-8.
|
| 278 |
+
let errorlog_content = fs::read(&errorlog_path)
|
| 279 |
+
.ok()
|
| 280 |
+
.filter(|b| !b.is_empty())
|
| 281 |
+
.map(|b| String::from_utf8_lossy(&b).into_owned());
|
| 282 |
+
|
| 283 |
+
if is_crash_exit_code(exit_code) || errorlog_content.is_some() {
|
| 284 |
+
let unsigned = exit_code as u32;
|
| 285 |
+
let name = if is_crash_exit_code(exit_code) {
|
| 286 |
+
ntstatus_name(unsigned)
|
| 287 |
+
} else {
|
| 288 |
+
// Parse exception from ERRORLOG first line, e.g.
|
| 289 |
+
// "WA caused an Access Violation (0xc0000005)"
|
| 290 |
+
parse_errorlog_exception(errorlog_content.as_deref())
|
| 291 |
+
};
|
| 292 |
+
return TestResult {
|
| 293 |
+
name: test.name.clone(),
|
| 294 |
+
passed: false,
|
| 295 |
+
duration,
|
| 296 |
+
diff_lines: Vec::new(),
|
| 297 |
+
error: None,
|
| 298 |
+
crashed: Some(CrashInfo {
|
| 299 |
+
exit_code: unsigned,
|
| 300 |
+
name,
|
| 301 |
+
errorlog_content,
|
| 302 |
+
}),
|
| 303 |
};
|
| 304 |
}
|
| 305 |
|
|
|
|
| 317 |
duration,
|
| 318 |
diff_lines: Vec::new(),
|
| 319 |
error: Some(format!(
|
| 320 |
+
"No output log generated (exit code: {exit_code})"
|
|
|
|
| 321 |
)),
|
| 322 |
+
crashed: None,
|
| 323 |
};
|
| 324 |
}
|
| 325 |
|
|
|
|
| 336 |
duration,
|
| 337 |
diff_lines: Vec::new(),
|
| 338 |
error: None,
|
| 339 |
+
crashed: None,
|
| 340 |
}
|
| 341 |
} else {
|
| 342 |
let mut diff = compute_diff(&expected, &actual);
|
|
|
|
| 354 |
duration,
|
| 355 |
diff_lines: diff,
|
| 356 |
error: None,
|
| 357 |
+
crashed: None,
|
| 358 |
}
|
| 359 |
}
|
| 360 |
}
|
|
|
|
| 465 |
|
| 466 |
// βββ Output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 467 |
|
| 468 |
+
fn use_color() -> bool {
|
| 469 |
+
std::io::stdout().is_terminal()
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
fn green(text: &str) -> String {
|
| 473 |
+
if use_color() {
|
| 474 |
+
format!("\x1b[32m{text}\x1b[0m")
|
| 475 |
+
} else {
|
| 476 |
+
text.to_string()
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
fn red(text: &str) -> String {
|
| 481 |
+
if use_color() {
|
| 482 |
+
format!("\x1b[31m{text}\x1b[0m")
|
| 483 |
+
} else {
|
| 484 |
+
text.to_string()
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
fn print_result(result: &TestResult) {
|
| 489 |
let status = if result.passed {
|
| 490 |
+
green(" PASS")
|
| 491 |
+
} else if result.crashed.is_some() {
|
| 492 |
+
red(" CRASH")
|
| 493 |
} else {
|
| 494 |
+
red(" FAIL")
|
| 495 |
};
|
| 496 |
let secs = result.duration.as_secs_f64();
|
| 497 |
println!("{status} {:<28} ({secs:.1}s)", result.name);
|
| 498 |
|
| 499 |
+
if let Some(crash) = &result.crashed {
|
| 500 |
+
println!(
|
| 501 |
+
" {} (exit code: 0x{:08X})",
|
| 502 |
+
crash.name, crash.exit_code
|
| 503 |
+
);
|
| 504 |
+
if let Some(content) = &crash.errorlog_content {
|
| 505 |
+
println!(" --- ERRORLOG.TXT ---");
|
| 506 |
+
let lines: Vec<&str> = content.lines().collect();
|
| 507 |
+
for line in lines.iter().take(20) {
|
| 508 |
+
println!(" | {line}");
|
| 509 |
+
}
|
| 510 |
+
if lines.len() > 20 {
|
| 511 |
+
println!(" | ... ({} lines truncated)", lines.len() - 20);
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
if let Some(err) = &result.error {
|
| 516 |
println!(" {err}");
|
| 517 |
}
|
|
|
|
| 522 |
|
| 523 |
fn print_summary(results: &[TestResult], wall_time: Duration) {
|
| 524 |
let passed = results.iter().filter(|r| r.passed).count();
|
| 525 |
+
let crashed = results.iter().filter(|r| r.crashed.is_some()).count();
|
| 526 |
let failed = results.len() - passed;
|
| 527 |
let cpu_time: f64 = results.iter().map(|r| r.duration.as_secs_f64()).sum();
|
| 528 |
let wall = wall_time.as_secs_f64();
|
| 529 |
|
| 530 |
println!();
|
| 531 |
if failed == 0 {
|
| 532 |
+
let msg = format!("{} tests: all passed", results.len());
|
| 533 |
+
println!("{} (wall {wall:.1}s, cpu {cpu_time:.1}s)", green(&msg));
|
|
|
|
|
|
|
| 534 |
} else {
|
| 535 |
+
let crash_info = if crashed > 0 {
|
| 536 |
+
format!(" ({crashed} crashed)")
|
| 537 |
+
} else {
|
| 538 |
+
String::new()
|
| 539 |
+
};
|
| 540 |
+
let msg = format!(
|
| 541 |
+
"{} tests: {passed} passed, {failed} failed{crash_info}",
|
| 542 |
results.len()
|
| 543 |
);
|
| 544 |
+
println!("{} (wall {wall:.1}s, cpu {cpu_time:.1}s)", red(&msg));
|
| 545 |
}
|
| 546 |
}
|
| 547 |
|
| 548 |
fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
|
| 549 |
let mut s = String::new();
|
| 550 |
for r in results {
|
| 551 |
+
let status = if r.passed {
|
| 552 |
+
"PASS"
|
| 553 |
+
} else if r.crashed.is_some() {
|
| 554 |
+
"CRASH"
|
| 555 |
+
} else {
|
| 556 |
+
"FAIL"
|
| 557 |
+
};
|
| 558 |
let _ = writeln!(
|
| 559 |
s,
|
| 560 |
"{status} {:<28} ({:.1}s)",
|
| 561 |
r.name,
|
| 562 |
r.duration.as_secs_f64()
|
| 563 |
);
|
| 564 |
+
if let Some(crash) = &r.crashed {
|
| 565 |
+
let _ = writeln!(
|
| 566 |
+
s,
|
| 567 |
+
" {} (exit code: 0x{:08X})",
|
| 568 |
+
crash.name, crash.exit_code
|
| 569 |
+
);
|
| 570 |
+
if let Some(content) = &crash.errorlog_content {
|
| 571 |
+
let _ = writeln!(s, " --- ERRORLOG.TXT ---");
|
| 572 |
+
for line in content.lines().take(20) {
|
| 573 |
+
let _ = writeln!(s, " | {line}");
|
| 574 |
+
}
|
| 575 |
+
let line_count = content.lines().count();
|
| 576 |
+
if line_count > 20 {
|
| 577 |
+
let _ = writeln!(s, " | ... ({} lines truncated)", line_count - 20);
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
}
|
| 581 |
if let Some(err) = &r.error {
|
| 582 |
let _ = writeln!(s, " {err}");
|
| 583 |
}
|
|
|
|
| 586 |
}
|
| 587 |
}
|
| 588 |
let passed = results.iter().filter(|r| r.passed).count();
|
| 589 |
+
let crashed = results.iter().filter(|r| r.crashed.is_some()).count();
|
| 590 |
let failed = results.len() - passed;
|
| 591 |
+
let crash_info = if crashed > 0 {
|
| 592 |
+
format!(" ({crashed} crashed)")
|
| 593 |
+
} else {
|
| 594 |
+
String::new()
|
| 595 |
+
};
|
| 596 |
let _ = writeln!(
|
| 597 |
s,
|
| 598 |
+
"\n{} tests: {passed} passed, {failed} failed{crash_info} (wall {:.1}s)",
|
| 599 |
results.len(),
|
| 600 |
wall_time.as_secs_f64()
|
| 601 |
);
|
|
|
|
| 627 |
for (prefix, suffix) in &data_patterns {
|
| 628 |
cleanup_matching(&data_dir, prefix, suffix);
|
| 629 |
}
|
| 630 |
+
|
| 631 |
+
// Clean up any stale ERRORLOG.TXT / CRASH.DMP in the game directory
|
| 632 |
+
let _ = fs::remove_file(game_dir.join("ERRORLOG.TXT"));
|
| 633 |
+
let _ = fs::remove_file(game_dir.join("CRASH.DMP"));
|
| 634 |
}
|
| 635 |
|
| 636 |
fn cleanup_matching(dir: &Path, prefix: &str, suffix: &str) {
|
|
|
|
| 666 |
}
|
| 667 |
}
|
| 668 |
|
| 669 |
+
/// Map common NTSTATUS exception codes to human-readable names.
|
| 670 |
+
fn ntstatus_name(code: u32) -> &'static str {
|
| 671 |
+
match code {
|
| 672 |
+
0xC0000005 => "access violation",
|
| 673 |
+
0xC0000374 => "heap corruption",
|
| 674 |
+
0xC00000FD => "stack overflow",
|
| 675 |
+
0xC0000409 => "stack buffer overrun",
|
| 676 |
+
0xC000001D => "illegal instruction",
|
| 677 |
+
0xC0000135 => "DLL not found",
|
| 678 |
+
0xC0000142 => "DLL init failed",
|
| 679 |
+
0x80000003 => "breakpoint",
|
| 680 |
+
0xE06D7363 => "C++ exception", // MSVC __CxxThrowException
|
| 681 |
+
_ => "exception",
|
| 682 |
+
}
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
/// NTSTATUS exception codes have the high bit set, so they're negative as i32.
|
| 686 |
+
fn is_crash_exit_code(code: i32) -> bool {
|
| 687 |
+
code < 0
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
/// Parse the exception name from the first line of ERRORLOG.TXT.
|
| 691 |
+
/// Expected format: "WA caused an Access Violation (0xc0000005)"
|
| 692 |
+
fn parse_errorlog_exception(content: Option<&str>) -> &'static str {
|
| 693 |
+
if let Some(line) = content.and_then(|c| c.lines().next()) {
|
| 694 |
+
// Try to extract the NTSTATUS code from parentheses
|
| 695 |
+
if let Some(start) = line.find("(0x") {
|
| 696 |
+
let hex_start = start + 1; // skip '('
|
| 697 |
+
if let Some(end) = line[hex_start..].find(')') {
|
| 698 |
+
let hex_str = &line[hex_start + 2..hex_start + end]; // skip "0x"
|
| 699 |
+
if let Ok(code) = u32::from_str_radix(hex_str, 16) {
|
| 700 |
+
return ntstatus_name(code);
|
| 701 |
+
}
|
| 702 |
+
}
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
"crash (SEH handled)"
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 709 |
|
| 710 |
fn main() {
|
crates/openwa-wormkit/src/replacements/file_isolation.rs
CHANGED
|
@@ -14,6 +14,7 @@ use crate::log_line;
|
|
| 14 |
use core::ffi::c_void;
|
| 15 |
use core::sync::atomic::{AtomicU32, Ordering};
|
| 16 |
use std::ffi::CStr;
|
|
|
|
| 17 |
|
| 18 |
type HANDLE = *mut c_void;
|
| 19 |
type DWORD = u32;
|
|
@@ -31,9 +32,27 @@ type CreateFileAFn = unsafe extern "system" fn(
|
|
| 31 |
|
| 32 |
static ORIG_CREATE_FILE_A: AtomicU32 = AtomicU32::new(0);
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
/// Check if a path ends with one of our target filenames (case-insensitive).
|
| 35 |
/// Returns the replacement path if it matches, None otherwise.
|
| 36 |
fn redirect_path(path: &str) -> Option<String> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
let pid = std::process::id();
|
| 38 |
let lower = path.to_ascii_lowercase();
|
| 39 |
|
|
@@ -116,8 +135,13 @@ pub fn install() -> Result<(), String> {
|
|
| 116 |
ORIG_CREATE_FILE_A.store(trampoline as u32, Ordering::Relaxed);
|
| 117 |
|
| 118 |
let pid = std::process::id();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
let _ = log_line(&format!(
|
| 120 |
-
"[FileIsolation] Hooked CreateFileA (pid={pid}): mono.tmp, land.dat, landgen.svg β per-PID paths"
|
| 121 |
));
|
| 122 |
}
|
| 123 |
|
|
|
|
| 14 |
use core::ffi::c_void;
|
| 15 |
use core::sync::atomic::{AtomicU32, Ordering};
|
| 16 |
use std::ffi::CStr;
|
| 17 |
+
use std::sync::OnceLock;
|
| 18 |
|
| 19 |
type HANDLE = *mut c_void;
|
| 20 |
type DWORD = u32;
|
|
|
|
| 32 |
|
| 33 |
static ORIG_CREATE_FILE_A: AtomicU32 = AtomicU32::new(0);
|
| 34 |
|
| 35 |
+
static ERRORLOG_PATH: OnceLock<Option<String>> = OnceLock::new();
|
| 36 |
+
|
| 37 |
+
fn errorlog_redirect() -> Option<&'static str> {
|
| 38 |
+
ERRORLOG_PATH
|
| 39 |
+
.get_or_init(|| std::env::var("OPENWA_ERRORLOG_PATH").ok())
|
| 40 |
+
.as_deref()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
/// Check if a path ends with one of our target filenames (case-insensitive).
|
| 44 |
/// Returns the replacement path if it matches, None otherwise.
|
| 45 |
fn redirect_path(path: &str) -> Option<String> {
|
| 46 |
+
// ERRORLOG.TXT β env var path (if set by test runner)
|
| 47 |
+
if let Some(target) = errorlog_redirect() {
|
| 48 |
+
let lower = path.to_ascii_lowercase();
|
| 49 |
+
if lower.ends_with("\\errorlog.txt") || lower.ends_with("/errorlog.txt")
|
| 50 |
+
|| lower == "errorlog.txt"
|
| 51 |
+
{
|
| 52 |
+
return Some(target.to_string());
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
let pid = std::process::id();
|
| 57 |
let lower = path.to_ascii_lowercase();
|
| 58 |
|
|
|
|
| 135 |
ORIG_CREATE_FILE_A.store(trampoline as u32, Ordering::Relaxed);
|
| 136 |
|
| 137 |
let pid = std::process::id();
|
| 138 |
+
let errorlog_msg = if let Some(target) = errorlog_redirect() {
|
| 139 |
+
format!(", ERRORLOG.TXT β {target}")
|
| 140 |
+
} else {
|
| 141 |
+
String::new()
|
| 142 |
+
};
|
| 143 |
let _ = log_line(&format!(
|
| 144 |
+
"[FileIsolation] Hooked CreateFileA (pid={pid}): mono.tmp, land.dat, landgen.svg β per-PID paths{errorlog_msg}"
|
| 145 |
));
|
| 146 |
}
|
| 147 |
|
docs/re-notes/frontend-main-navigation-loop.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend__MainNavigationLoop (0x4E6700)
|
| 2 |
+
|
| 3 |
+
Ghidra address: `0x004E6700`
|
| 4 |
+
Calling convention: `__fastcall` (ECX = CWinApp*)
|
| 5 |
+
Size: ~3500 lines of decompiled C
|
| 6 |
+
|
| 7 |
+
This is the main entry point / event loop of WA.exe. It handles:
|
| 8 |
+
- Command-line argument parsing
|
| 9 |
+
- Crash handling (`/handlecrash`, `/silentcrash`, `/disablecrashhandler`)
|
| 10 |
+
- Replay playback (`/play`, `/playat`, `/getlog`, `/getmap`, `/getscheme`, `/getvideo`, `/getlogs`)
|
| 11 |
+
- Sanitize/repair modes (`/sanitise`, `/repair`)
|
| 12 |
+
- Network hosting (`/host`, `wa:` protocol)
|
| 13 |
+
- WormKit module loading (`/wk`, `/nowk`, `/wkargs`)
|
| 14 |
+
- Frontend screen navigation loop (main menu, multiplayer, campaigns, etc.)
|
| 15 |
+
- Renderer selection (DirectDraw, OpenGL, D3D9, etc.)
|
| 16 |
+
|
| 17 |
+
## Crash Handler Mechanism
|
| 18 |
+
|
| 19 |
+
WA.exe's crash handler works by **relaunching itself** with `/handlecrash`:
|
| 20 |
+
|
| 21 |
+
1. Original WA.exe crashes (C++ exception via `__CxxThrowException`)
|
| 22 |
+
2. The crash handler (likely SEH-based) writes `ERRORLOG.TXT` and relaunches WA.exe with `/handlecrash`
|
| 23 |
+
3. The new WA.exe instance processes `/handlecrash` in this function
|
| 24 |
+
4. It checks `Options__GetCrashReportURL()` β if crash reporting is available, shows a dialog offering to send the report
|
| 25 |
+
5. It checks `DAT_0088e410` (WormKit modules loaded flag) β if set, shows the "Fatal error" dialog with Yes/No asking to disable WormKit modules
|
| 26 |
+
|
| 27 |
+
### Key flags:
|
| 28 |
+
- `/silentcrash` β sets `DAT_007a0f10+1 = 1` β suppresses the crash dialog
|
| 29 |
+
- `/disablecrashhandler` β sets `DAT_007a0f10+0 = 1` β disables the crash handler entirely
|
| 30 |
+
- `/crash` β intentionally crashes via `__CxxThrowException("Goodbye, cruel world!")`
|
| 31 |
+
|
| 32 |
+
### Crash dialog code (at ~0x4E6730):
|
| 33 |
+
```c
|
| 34 |
+
// Check for crash report URL
|
| 35 |
+
iVar11 = Options__GetCrashReportURL();
|
| 36 |
+
if (iVar11 != 0) {
|
| 37 |
+
// Show "send bug report?" dialog (MB_YESNO | MB_ICONQUESTION = 0x14)
|
| 38 |
+
iVar10 = MessageBoxA(NULL,
|
| 39 |
+
"Worms Armageddon has encountered an error...\nWould you like to send the bug report?",
|
| 40 |
+
"Fatal error", 0x14);
|
| 41 |
+
if (iVar10 == IDYES) FUN_005a6470(); // send crash report
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Check if WormKit modules were loaded
|
| 45 |
+
if (DAT_0088e410 == 0) {
|
| 46 |
+
// No WormKit β just show error (MB_ICONERROR = 0x10)
|
| 47 |
+
MessageBoxA(NULL, error_msg, "Fatal error", 0x10);
|
| 48 |
+
} else {
|
| 49 |
+
// WormKit loaded β ask to disable (MB_YESNO | MB_ICONQUESTION = 0x14)
|
| 50 |
+
FUN_00444d30(); // builds error message mentioning WormKit
|
| 51 |
+
iVar11 = MessageBoxA(NULL, error_msg, "Fatal error", 0x14);
|
| 52 |
+
if (iVar11 == IDYES) {
|
| 53 |
+
DAT_0088e410 = 0;
|
| 54 |
+
FUN_004c8c70(); // disable WormKit in registry
|
| 55 |
+
MessageBoxA(NULL, "WormKit modules have been disabled.", "Worms Armageddon", 0x40);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## ERRORLOG.TXT
|
| 61 |
+
|
| 62 |
+
String at `0x00677FE0`: `"ERRORLOG.TXT"`
|
| 63 |
+
String at `0x00678484`: `"Can't open ERRORLOG.TXT"`
|
| 64 |
+
Written by the crash handler before relaunching (separate from this function).
|
| 65 |
+
|
| 66 |
+
## `/getlog` Mode
|
| 67 |
+
|
| 68 |
+
The `/getlog` flag sets:
|
| 69 |
+
- `DAT_0088cd4c = 1` (headless replay mode)
|
| 70 |
+
- `local_31 = 1` (getlog flag)
|
| 71 |
+
- Copies replay path to `DAT_0088af58`
|
| 72 |
+
|
| 73 |
+
Later in the function, `/getlog` mode opens the replay for writing the game log output.
|
| 74 |
+
|
| 75 |
+
## WormKit Module Loading
|
| 76 |
+
|
| 77 |
+
Around line 1200 of decompilation:
|
| 78 |
+
- Searches for `wk*.dll` files in the game directory
|
| 79 |
+
- Loads each via `LoadLibraryA`
|
| 80 |
+
- If loading fails, shows a MessageBox with the error
|
| 81 |
+
- Flag at `DAT_0088e410` tracks whether WormKit modules are loaded
|
| 82 |
+
|
| 83 |
+
## Key Addresses
|
| 84 |
+
|
| 85 |
+
| Address | Purpose |
|
| 86 |
+
|---------|---------|
|
| 87 |
+
| `0x007A0F10+0` | `/disablecrashhandler` flag |
|
| 88 |
+
| `0x007A0F10+1` | `/silentcrash` flag |
|
| 89 |
+
| `0x0088E410` | WormKit modules loaded flag |
|
| 90 |
+
| `0x0088AF58` | Replay path buffer |
|
| 91 |
+
| `0x0088CD4C` | Headless replay mode flag |
|
| 92 |
+
| `0x0088E17D` | Current directory buffer |
|
| 93 |
+
| `0x006B1FEC` | argc |
|
| 94 |
+
| `0x006B1FF0` | argv |
|