feat: add Rust replay test runner with concurrent execution support
Browse filesNew crate: openwa-test-runner β discovers and runs all headless replay
tests, compares output logs byte-for-byte, prints pass/fail summary.
Concurrency infrastructure:
- Per-instance event names (OpenWA_HooksReady_{pid}) in launcher + DLL
- Configurable OpenWA.log path via OPENWA_LOG_PATH env var
- Per-instance playback.thm paths to avoid replay map data races
- 1s launch stagger for concurrent mode stability
Usage: openwa-test [-j N] [--no-build] [filter]
Default is serial (j=1). Concurrent mode (-j 4) gives ~3x speedup
but has a known flaky race in WA.exe startup (needs procmon investigation).
Also normalizes CRLF in log comparison for cross-platform robustness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- .gitignore +1 -0
- Cargo.lock +4 -0
- crates/openwa-core/src/log.rs +18 -3
- crates/openwa-launcher/src/main.rs +12 -1
- crates/openwa-test-runner/Cargo.toml +9 -0
- crates/openwa-test-runner/src/main.rs +584 -0
- crates/openwa-wormkit/src/lib.rs +17 -11
- crates/openwa-wormkit/src/replacements/replay.rs +15 -3
.gitignore
CHANGED
|
@@ -4,3 +4,4 @@
|
|
| 4 |
start-mcp.ps1
|
| 5 |
docs/re-notes/decompiled/
|
| 6 |
testdata/logs/
|
|
|
|
|
|
| 4 |
start-mcp.ps1
|
| 5 |
docs/re-notes/decompiled/
|
| 6 |
testdata/logs/
|
| 7 |
+
testdata/runs/
|
Cargo.lock
CHANGED
|
@@ -1462,6 +1462,10 @@ dependencies = [
|
|
| 1462 |
name = "openwa-launcher"
|
| 1463 |
version = "0.1.0"
|
| 1464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1465 |
[[package]]
|
| 1466 |
name = "openwa-wormkit"
|
| 1467 |
version = "0.1.0"
|
|
|
|
| 1462 |
name = "openwa-launcher"
|
| 1463 |
version = "0.1.0"
|
| 1464 |
|
| 1465 |
+
[[package]]
|
| 1466 |
+
name = "openwa-test-runner"
|
| 1467 |
+
version = "0.1.0"
|
| 1468 |
+
|
| 1469 |
[[package]]
|
| 1470 |
name = "openwa-wormkit"
|
| 1471 |
version = "0.1.0"
|
crates/openwa-core/src/log.rs
CHANGED
|
@@ -1,12 +1,27 @@
|
|
| 1 |
-
//! Shared logging β writes to `OpenWA.log` in the working directory
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
pub fn log_line(msg: &str) -> std::io::Result<()> {
|
| 5 |
use std::io::Write;
|
| 6 |
let mut f = std::fs::OpenOptions::new()
|
| 7 |
.create(true)
|
| 8 |
.append(true)
|
| 9 |
-
.open(
|
| 10 |
writeln!(f, "{msg}")?;
|
| 11 |
Ok(())
|
| 12 |
}
|
|
|
|
| 1 |
+
//! Shared logging β writes to `OpenWA.log` in the working directory,
|
| 2 |
+
//! or to the path specified by `OPENWA_LOG_PATH` env var.
|
| 3 |
|
| 4 |
+
use std::path::PathBuf;
|
| 5 |
+
use std::sync::OnceLock;
|
| 6 |
+
|
| 7 |
+
/// Resolve the log file path once. Uses `OPENWA_LOG_PATH` env var if set,
|
| 8 |
+
/// otherwise defaults to `"OpenWA.log"`.
|
| 9 |
+
fn log_path() -> &'static PathBuf {
|
| 10 |
+
static PATH: OnceLock<PathBuf> = OnceLock::new();
|
| 11 |
+
PATH.get_or_init(|| {
|
| 12 |
+
std::env::var_os("OPENWA_LOG_PATH")
|
| 13 |
+
.map(PathBuf::from)
|
| 14 |
+
.unwrap_or_else(|| PathBuf::from("OpenWA.log"))
|
| 15 |
+
})
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/// Append a line to the OpenWA log file.
|
| 19 |
pub fn log_line(msg: &str) -> std::io::Result<()> {
|
| 20 |
use std::io::Write;
|
| 21 |
let mut f = std::fs::OpenOptions::new()
|
| 22 |
.create(true)
|
| 23 |
.append(true)
|
| 24 |
+
.open(log_path())?;
|
| 25 |
writeln!(f, "{msg}")?;
|
| 26 |
Ok(())
|
| 27 |
}
|
crates/openwa-launcher/src/main.rs
CHANGED
|
@@ -190,7 +190,18 @@ unsafe fn launch(
|
|
| 190 |
// Create a named event that the DLL will signal after all hooks are
|
| 191 |
// installed. This ensures the main thread doesn't run any WA code
|
| 192 |
// before our hooks are in place.
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
let event = CreateEventA(ptr::null_mut(), 1, 0, event_name.as_ptr());
|
| 195 |
// event may be null if CreateEventA fails β we'll fall through gracefully.
|
| 196 |
|
|
|
|
| 190 |
// Create a named event that the DLL will signal after all hooks are
|
| 191 |
// installed. This ensures the main thread doesn't run any WA code
|
| 192 |
// before our hooks are in place.
|
| 193 |
+
//
|
| 194 |
+
// Use a per-instance event name based on the child PID to allow
|
| 195 |
+
// concurrent launcher instances (e.g., parallel test runner).
|
| 196 |
+
let event_name_str = format!("OpenWA_HooksReady_{}\0", pi.dwProcessId);
|
| 197 |
+
let event_name = event_name_str.as_bytes();
|
| 198 |
+
// Also set env var so the DLL (in the child process) knows the event name.
|
| 199 |
+
// The child inherits our env, but it was created before we set this var.
|
| 200 |
+
// Instead, we write the event name into a small shared memory region:
|
| 201 |
+
// we use SetEnvironmentVariableA in the child's context β but that's not
|
| 202 |
+
// possible for a suspended process. So we rely on the DLL reading the
|
| 203 |
+
// event name from a fixed pattern: OpenWA_HooksReady_{its_own_pid}.
|
| 204 |
+
// The DLL can call GetCurrentProcessId() to reconstruct the same name.
|
| 205 |
let event = CreateEventA(ptr::null_mut(), 1, 0, event_name.as_ptr());
|
| 206 |
// event may be null if CreateEventA fails β we'll fall through gracefully.
|
| 207 |
|
crates/openwa-test-runner/Cargo.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "openwa-test-runner"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
description = "Headless replay test runner with concurrent execution"
|
| 6 |
+
|
| 7 |
+
[[bin]]
|
| 8 |
+
name = "openwa-test"
|
| 9 |
+
path = "src/main.rs"
|
crates/openwa-test-runner/src/main.rs
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Headless replay test runner with concurrent execution.
|
| 2 |
+
//!
|
| 3 |
+
//! Discovers all `testdata/replays/*.WAgame` files with matching `*_expected.log`,
|
| 4 |
+
//! builds the DLL + launcher, then runs each replay through WA.exe's `/getlog` mode
|
| 5 |
+
//! and compares output byte-for-byte.
|
| 6 |
+
|
| 7 |
+
use std::env;
|
| 8 |
+
use std::fmt::Write as FmtWrite;
|
| 9 |
+
use std::fs;
|
| 10 |
+
use std::path::{Path, PathBuf};
|
| 11 |
+
use std::process::Command;
|
| 12 |
+
use std::sync::mpsc;
|
| 13 |
+
use std::thread;
|
| 14 |
+
use std::time::{Duration, Instant};
|
| 15 |
+
|
| 16 |
+
// βββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
/// Default to serial β concurrent mode (-j N) has a known flaky race in WA.exe
|
| 19 |
+
/// startup that causes occasional checksum mismatches. Use procmon to identify
|
| 20 |
+
/// the shared resource. The 1s launch stagger helps but doesn't fully fix it.
|
| 21 |
+
const DEFAULT_JOBS: usize = 1;
|
| 22 |
+
const TIMEOUT_SECS: u64 = 120;
|
| 23 |
+
const REPLAYS_DIR: &str = "testdata/replays";
|
| 24 |
+
const RUNS_DIR: &str = "testdata/runs";
|
| 25 |
+
|
| 26 |
+
/// Known WA.exe locations to try if not specified.
|
| 27 |
+
const WA_CANDIDATES: &[&str] = &[
|
| 28 |
+
"I:/games/SteamLibrary/steamapps/common/Worms Armageddon/WA.exe",
|
| 29 |
+
"C:/Program Files (x86)/Steam/steamapps/common/Worms Armageddon/WA.exe",
|
| 30 |
+
];
|
| 31 |
+
|
| 32 |
+
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
struct TestCase {
|
| 35 |
+
name: String,
|
| 36 |
+
replay_path: PathBuf,
|
| 37 |
+
expected_log: PathBuf,
|
| 38 |
+
output_log: PathBuf,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#[derive(Clone)]
|
| 42 |
+
struct TestResult {
|
| 43 |
+
name: String,
|
| 44 |
+
passed: bool,
|
| 45 |
+
duration: Duration,
|
| 46 |
+
diff_lines: Vec<String>,
|
| 47 |
+
error: Option<String>,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
struct Args {
|
| 51 |
+
filter: Option<String>,
|
| 52 |
+
jobs: usize,
|
| 53 |
+
no_build: bool,
|
| 54 |
+
wa_path: Option<PathBuf>,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// βββ Argument parsing βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
fn parse_args() -> Args {
|
| 60 |
+
let mut args = Args {
|
| 61 |
+
filter: None,
|
| 62 |
+
jobs: DEFAULT_JOBS,
|
| 63 |
+
no_build: false,
|
| 64 |
+
wa_path: None,
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
let argv: Vec<String> = env::args().skip(1).collect();
|
| 68 |
+
let mut i = 0;
|
| 69 |
+
while i < argv.len() {
|
| 70 |
+
match argv[i].as_str() {
|
| 71 |
+
"-j" | "--jobs" => {
|
| 72 |
+
i += 1;
|
| 73 |
+
if i < argv.len() {
|
| 74 |
+
args.jobs = argv[i].parse().unwrap_or(DEFAULT_JOBS);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
"--no-build" => args.no_build = true,
|
| 78 |
+
"--wa-path" => {
|
| 79 |
+
i += 1;
|
| 80 |
+
if i < argv.len() {
|
| 81 |
+
args.wa_path = Some(PathBuf::from(&argv[i]));
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
s if !s.starts_with('-') => {
|
| 85 |
+
args.filter = Some(s.to_string());
|
| 86 |
+
}
|
| 87 |
+
other => {
|
| 88 |
+
eprintln!("Unknown argument: {other}");
|
| 89 |
+
eprintln!("Usage: openwa-test [filter] [-j N] [--no-build] [--wa-path PATH]");
|
| 90 |
+
std::process::exit(1);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
i += 1;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
args
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// βββ Test discovery βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
fn discover_tests(filter: Option<&str>) -> Vec<TestCase> {
|
| 102 |
+
let replays_dir = Path::new(REPLAYS_DIR);
|
| 103 |
+
let mut tests = Vec::new();
|
| 104 |
+
|
| 105 |
+
let entries = match fs::read_dir(replays_dir) {
|
| 106 |
+
Ok(e) => e,
|
| 107 |
+
Err(e) => {
|
| 108 |
+
eprintln!("Cannot read {REPLAYS_DIR}: {e}");
|
| 109 |
+
return tests;
|
| 110 |
+
}
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
for entry in entries.flatten() {
|
| 114 |
+
let path = entry.path();
|
| 115 |
+
if path.extension().and_then(|e| e.to_str()) != Some("WAgame") {
|
| 116 |
+
continue;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
| 120 |
+
Some(s) => s.to_string(),
|
| 121 |
+
None => continue,
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
if let Some(filter) = filter {
|
| 125 |
+
if !stem.contains(filter) {
|
| 126 |
+
continue;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
let expected = replays_dir.join(format!("{stem}_expected.log"));
|
| 131 |
+
if !expected.exists() {
|
| 132 |
+
continue; // Skip replays without expected logs
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
let output = path.with_extension("log");
|
| 136 |
+
|
| 137 |
+
// WA.exe runs in the game directory, so replay/log paths must be absolute.
|
| 138 |
+
// Strip \\?\ UNC prefix that canonicalize adds on Windows β WA.exe can't handle it.
|
| 139 |
+
let replay_abs = strip_unc(fs::canonicalize(&path).unwrap_or(path.clone()));
|
| 140 |
+
let expected_abs = strip_unc(fs::canonicalize(&expected).unwrap_or(expected.clone()));
|
| 141 |
+
let output_abs = replay_abs.with_extension("log");
|
| 142 |
+
|
| 143 |
+
tests.push(TestCase {
|
| 144 |
+
name: stem,
|
| 145 |
+
replay_path: replay_abs,
|
| 146 |
+
expected_log: expected_abs,
|
| 147 |
+
output_log: output_abs,
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
tests.sort_by(|a, b| a.name.cmp(&b.name));
|
| 152 |
+
tests
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// βββ WA.exe location ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
|
| 157 |
+
fn find_wa_exe(override_path: Option<&Path>) -> Option<PathBuf> {
|
| 158 |
+
if let Some(p) = override_path {
|
| 159 |
+
if p.exists() {
|
| 160 |
+
return Some(p.to_path_buf());
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if let Ok(p) = env::var("OPENWA_WA_PATH") {
|
| 165 |
+
let path = PathBuf::from(p);
|
| 166 |
+
if path.exists() {
|
| 167 |
+
return Some(path);
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
for candidate in WA_CANDIDATES {
|
| 172 |
+
let path = PathBuf::from(candidate);
|
| 173 |
+
if path.exists() {
|
| 174 |
+
return Some(path);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
None
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
fn find_launcher() -> Option<PathBuf> {
|
| 182 |
+
// Look for the launcher in the release build output
|
| 183 |
+
let p = PathBuf::from("target/i686-pc-windows-msvc/release/openwa-launcher.exe");
|
| 184 |
+
if p.exists() {
|
| 185 |
+
return Some(p);
|
| 186 |
+
}
|
| 187 |
+
let p = PathBuf::from("target/release/openwa-launcher.exe");
|
| 188 |
+
if p.exists() {
|
| 189 |
+
return Some(p);
|
| 190 |
+
}
|
| 191 |
+
None
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// βββ Build ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
+
|
| 196 |
+
fn build() -> Result<Duration, String> {
|
| 197 |
+
let start = Instant::now();
|
| 198 |
+
let status = Command::new("cargo")
|
| 199 |
+
.args(["build", "--release", "-p", "openwa-wormkit", "-p", "openwa-launcher"])
|
| 200 |
+
.status()
|
| 201 |
+
.map_err(|e| format!("Failed to run cargo: {e}"))?;
|
| 202 |
+
|
| 203 |
+
if !status.success() {
|
| 204 |
+
return Err("Build failed".to_string());
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
Ok(start.elapsed())
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// βββ Single test execution ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
+
|
| 212 |
+
fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) -> TestResult {
|
| 213 |
+
let start = Instant::now();
|
| 214 |
+
|
| 215 |
+
// Remove stale output log
|
| 216 |
+
let _ = fs::remove_file(&test.output_log);
|
| 217 |
+
|
| 218 |
+
// Per-instance log paths
|
| 219 |
+
let openwa_log = run_dir.join(format!("{}.openwa.log", test.name));
|
| 220 |
+
|
| 221 |
+
let result = Command::new(launcher)
|
| 222 |
+
.arg(wa_exe)
|
| 223 |
+
.arg("/getlog")
|
| 224 |
+
.arg(&test.replay_path)
|
| 225 |
+
.env("OPENWA_HEADLESS", "1")
|
| 226 |
+
.env("OPENWA_LOG_PATH", &openwa_log)
|
| 227 |
+
.stdout(std::process::Stdio::null())
|
| 228 |
+
.stderr(std::process::Stdio::null())
|
| 229 |
+
.status();
|
| 230 |
+
|
| 231 |
+
let duration = start.elapsed();
|
| 232 |
+
|
| 233 |
+
match result {
|
| 234 |
+
Err(e) => TestResult {
|
| 235 |
+
name: test.name.clone(),
|
| 236 |
+
passed: false,
|
| 237 |
+
duration,
|
| 238 |
+
diff_lines: Vec::new(),
|
| 239 |
+
error: Some(format!("Failed to launch: {e}")),
|
| 240 |
+
},
|
| 241 |
+
Ok(status) => {
|
| 242 |
+
if duration >= Duration::from_secs(TIMEOUT_SECS) {
|
| 243 |
+
return TestResult {
|
| 244 |
+
name: test.name.clone(),
|
| 245 |
+
passed: false,
|
| 246 |
+
duration,
|
| 247 |
+
diff_lines: Vec::new(),
|
| 248 |
+
error: Some("Timeout".to_string()),
|
| 249 |
+
};
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Copy output log to run dir
|
| 253 |
+
if test.output_log.exists() {
|
| 254 |
+
let dest = run_dir.join(format!("{}.log", test.name));
|
| 255 |
+
let _ = fs::copy(&test.output_log, &dest);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// Compare output
|
| 259 |
+
if !test.output_log.exists() {
|
| 260 |
+
return TestResult {
|
| 261 |
+
name: test.name.clone(),
|
| 262 |
+
passed: false,
|
| 263 |
+
duration,
|
| 264 |
+
diff_lines: Vec::new(),
|
| 265 |
+
error: Some(format!("No output log generated (exit code: {})",
|
| 266 |
+
status.code().unwrap_or(-1))),
|
| 267 |
+
};
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// Normalize CRLF β LF for comparison (WA mixes line endings)
|
| 271 |
+
let expected = normalize_crlf(&fs::read(&test.expected_log).unwrap_or_default());
|
| 272 |
+
let actual = normalize_crlf(&fs::read(&test.output_log).unwrap_or_default());
|
| 273 |
+
|
| 274 |
+
if expected == actual {
|
| 275 |
+
// Clean up the output log on success
|
| 276 |
+
let _ = fs::remove_file(&test.output_log);
|
| 277 |
+
TestResult {
|
| 278 |
+
name: test.name.clone(),
|
| 279 |
+
passed: true,
|
| 280 |
+
duration,
|
| 281 |
+
diff_lines: Vec::new(),
|
| 282 |
+
error: None,
|
| 283 |
+
}
|
| 284 |
+
} else {
|
| 285 |
+
let mut diff = compute_diff(&expected, &actual);
|
| 286 |
+
if diff.is_empty() {
|
| 287 |
+
diff.push(format!(
|
| 288 |
+
" (byte-level difference: expected {} bytes, actual {} bytes)",
|
| 289 |
+
expected.len(), actual.len()
|
| 290 |
+
));
|
| 291 |
+
}
|
| 292 |
+
let _ = fs::remove_file(&test.output_log);
|
| 293 |
+
TestResult {
|
| 294 |
+
name: test.name.clone(),
|
| 295 |
+
passed: false,
|
| 296 |
+
duration,
|
| 297 |
+
diff_lines: diff,
|
| 298 |
+
error: None,
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
/// Compute a simple line diff (first 10 differing lines).
|
| 306 |
+
fn compute_diff(expected: &[u8], actual: &[u8]) -> Vec<String> {
|
| 307 |
+
let exp_str = String::from_utf8_lossy(expected);
|
| 308 |
+
let act_str = String::from_utf8_lossy(actual);
|
| 309 |
+
let exp_lines: Vec<&str> = exp_str.lines().collect();
|
| 310 |
+
let act_lines: Vec<&str> = act_str.lines().collect();
|
| 311 |
+
|
| 312 |
+
let mut diffs = Vec::new();
|
| 313 |
+
let max = exp_lines.len().max(act_lines.len());
|
| 314 |
+
|
| 315 |
+
for i in 0..max {
|
| 316 |
+
let e = exp_lines.get(i).copied().unwrap_or("");
|
| 317 |
+
let a = act_lines.get(i).copied().unwrap_or("");
|
| 318 |
+
if e != a {
|
| 319 |
+
if !e.is_empty() {
|
| 320 |
+
diffs.push(format!(" - expected: {e}"));
|
| 321 |
+
}
|
| 322 |
+
if !a.is_empty() {
|
| 323 |
+
diffs.push(format!(" + actual: {a}"));
|
| 324 |
+
}
|
| 325 |
+
if diffs.len() >= 10 {
|
| 326 |
+
diffs.push(" ... (truncated)".to_string());
|
| 327 |
+
break;
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
diffs
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// βββ Thread pool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 336 |
+
|
| 337 |
+
fn run_tests_parallel(
|
| 338 |
+
tests: Vec<TestCase>,
|
| 339 |
+
jobs: usize,
|
| 340 |
+
launcher: &Path,
|
| 341 |
+
wa_exe: &Path,
|
| 342 |
+
run_dir: &Path,
|
| 343 |
+
) -> Vec<TestResult> {
|
| 344 |
+
let (tx, rx) = mpsc::channel::<(usize, TestResult)>();
|
| 345 |
+
let (work_tx, work_rx) = mpsc::channel::<(usize, TestCase)>();
|
| 346 |
+
let work_rx = std::sync::Arc::new(std::sync::Mutex::new(work_rx));
|
| 347 |
+
|
| 348 |
+
let launcher = launcher.to_path_buf();
|
| 349 |
+
let wa_exe = wa_exe.to_path_buf();
|
| 350 |
+
let run_dir = run_dir.to_path_buf();
|
| 351 |
+
|
| 352 |
+
// Spawn worker threads
|
| 353 |
+
let mut handles = Vec::new();
|
| 354 |
+
for _ in 0..jobs.min(tests.len()) {
|
| 355 |
+
let rx = work_rx.clone();
|
| 356 |
+
let tx = tx.clone();
|
| 357 |
+
let launcher = launcher.clone();
|
| 358 |
+
let wa_exe = wa_exe.clone();
|
| 359 |
+
let run_dir = run_dir.clone();
|
| 360 |
+
|
| 361 |
+
handles.push(thread::spawn(move || {
|
| 362 |
+
loop {
|
| 363 |
+
// Lock only long enough to recv, then drop the guard before running the test
|
| 364 |
+
let item = rx.lock().unwrap().recv();
|
| 365 |
+
match item {
|
| 366 |
+
Ok((idx, test)) => {
|
| 367 |
+
let result = run_test(&test, &launcher, &wa_exe, &run_dir);
|
| 368 |
+
let _ = tx.send((idx, result));
|
| 369 |
+
}
|
| 370 |
+
Err(_) => break, // Channel closed, no more work
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
}));
|
| 374 |
+
}
|
| 375 |
+
drop(tx); // Drop our sender so rx closes when all workers finish
|
| 376 |
+
|
| 377 |
+
// Submit work with small stagger to avoid startup races
|
| 378 |
+
for (idx, test) in tests.into_iter().enumerate() {
|
| 379 |
+
let _ = work_tx.send((idx, test));
|
| 380 |
+
if idx > 0 {
|
| 381 |
+
thread::sleep(Duration::from_millis(1000));
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
drop(work_tx); // Signal no more work
|
| 385 |
+
|
| 386 |
+
// Collect results
|
| 387 |
+
let mut results: Vec<Option<TestResult>> = vec![None; handles.len() + 100]; // oversize
|
| 388 |
+
let mut count = 0;
|
| 389 |
+
for (idx, result) in rx {
|
| 390 |
+
if idx >= results.len() {
|
| 391 |
+
results.resize(idx + 1, None);
|
| 392 |
+
}
|
| 393 |
+
// Print result as it arrives
|
| 394 |
+
print_result(&result);
|
| 395 |
+
results[idx] = Some(result);
|
| 396 |
+
count += 1;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
for h in handles {
|
| 400 |
+
let _ = h.join();
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
results.into_iter().take(count).flatten().collect()
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// βββ Output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 407 |
+
|
| 408 |
+
fn print_result(result: &TestResult) {
|
| 409 |
+
let status = if result.passed { "\x1b[32m PASS\x1b[0m" } else { "\x1b[31m FAIL\x1b[0m" };
|
| 410 |
+
let secs = result.duration.as_secs_f64();
|
| 411 |
+
println!("{status} {:<28} ({secs:.1}s)", result.name);
|
| 412 |
+
|
| 413 |
+
if let Some(err) = &result.error {
|
| 414 |
+
println!(" {err}");
|
| 415 |
+
}
|
| 416 |
+
for line in &result.diff_lines {
|
| 417 |
+
println!(" {line}");
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
fn print_summary(results: &[TestResult], wall_time: Duration) {
|
| 422 |
+
let passed = results.iter().filter(|r| r.passed).count();
|
| 423 |
+
let failed = results.len() - passed;
|
| 424 |
+
let cpu_time: f64 = results.iter().map(|r| r.duration.as_secs_f64()).sum();
|
| 425 |
+
let wall = wall_time.as_secs_f64();
|
| 426 |
+
|
| 427 |
+
println!();
|
| 428 |
+
if failed == 0 {
|
| 429 |
+
println!(
|
| 430 |
+
"\x1b[32m{} tests: all passed\x1b[0m (wall {wall:.1}s, cpu {cpu_time:.1}s)",
|
| 431 |
+
results.len()
|
| 432 |
+
);
|
| 433 |
+
} else {
|
| 434 |
+
println!(
|
| 435 |
+
"\x1b[31m{} tests: {passed} passed, {failed} failed\x1b[0m (wall {wall:.1}s, cpu {cpu_time:.1}s)",
|
| 436 |
+
results.len()
|
| 437 |
+
);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
|
| 442 |
+
let mut s = String::new();
|
| 443 |
+
for r in results {
|
| 444 |
+
let status = if r.passed { "PASS" } else { "FAIL" };
|
| 445 |
+
let _ = writeln!(s, "{status} {:<28} ({:.1}s)", r.name, r.duration.as_secs_f64());
|
| 446 |
+
if let Some(err) = &r.error {
|
| 447 |
+
let _ = writeln!(s, " {err}");
|
| 448 |
+
}
|
| 449 |
+
for line in &r.diff_lines {
|
| 450 |
+
let _ = writeln!(s, " {line}");
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
let passed = results.iter().filter(|r| r.passed).count();
|
| 454 |
+
let failed = results.len() - passed;
|
| 455 |
+
let _ = writeln!(s, "\n{} tests: {passed} passed, {failed} failed (wall {:.1}s)",
|
| 456 |
+
results.len(), wall_time.as_secs_f64());
|
| 457 |
+
|
| 458 |
+
let _ = fs::write(path, s);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 462 |
+
|
| 463 |
+
/// Normalize CRLF to LF for cross-platform comparison.
|
| 464 |
+
fn normalize_crlf(data: &[u8]) -> Vec<u8> {
|
| 465 |
+
let mut out = Vec::with_capacity(data.len());
|
| 466 |
+
for &b in data {
|
| 467 |
+
if b != b'\r' {
|
| 468 |
+
out.push(b);
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
out
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/// Strip the `\\?\` UNC prefix that `fs::canonicalize` adds on Windows.
|
| 475 |
+
fn strip_unc(p: PathBuf) -> PathBuf {
|
| 476 |
+
let s = p.to_string_lossy();
|
| 477 |
+
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
| 478 |
+
PathBuf::from(stripped)
|
| 479 |
+
} else {
|
| 480 |
+
p
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
+
|
| 486 |
+
fn main() {
|
| 487 |
+
let args = parse_args();
|
| 488 |
+
|
| 489 |
+
// Discover tests
|
| 490 |
+
let tests = discover_tests(args.filter.as_deref());
|
| 491 |
+
if tests.is_empty() {
|
| 492 |
+
eprintln!("No replay tests found in {REPLAYS_DIR}/");
|
| 493 |
+
if let Some(f) = &args.filter {
|
| 494 |
+
eprintln!(" (filter: \"{f}\")");
|
| 495 |
+
}
|
| 496 |
+
std::process::exit(1);
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// Build
|
| 500 |
+
if !args.no_build {
|
| 501 |
+
eprint!("Building... ");
|
| 502 |
+
match build() {
|
| 503 |
+
Ok(d) => eprintln!("done ({:.1}s)", d.as_secs_f64()),
|
| 504 |
+
Err(e) => {
|
| 505 |
+
eprintln!("FAILED: {e}");
|
| 506 |
+
std::process::exit(1);
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// Find executables
|
| 512 |
+
let wa_exe = find_wa_exe(args.wa_path.as_deref()).unwrap_or_else(|| {
|
| 513 |
+
eprintln!("Cannot find WA.exe. Use --wa-path or set OPENWA_WA_PATH.");
|
| 514 |
+
std::process::exit(1);
|
| 515 |
+
});
|
| 516 |
+
let launcher = find_launcher().unwrap_or_else(|| {
|
| 517 |
+
eprintln!("Cannot find openwa-launcher.exe. Run cargo build first.");
|
| 518 |
+
std::process::exit(1);
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
// Create run directory
|
| 522 |
+
let timestamp = chrono_lite();
|
| 523 |
+
let run_dir = PathBuf::from(RUNS_DIR).join(×tamp);
|
| 524 |
+
let _ = fs::create_dir_all(&run_dir);
|
| 525 |
+
let run_dir = strip_unc(fs::canonicalize(&run_dir).unwrap_or(run_dir));
|
| 526 |
+
|
| 527 |
+
let jobs = args.jobs.max(1);
|
| 528 |
+
println!(
|
| 529 |
+
"Running {} test{} ({} concurrent)...\n",
|
| 530 |
+
tests.len(),
|
| 531 |
+
if tests.len() == 1 { "" } else { "s" },
|
| 532 |
+
jobs
|
| 533 |
+
);
|
| 534 |
+
|
| 535 |
+
// Run
|
| 536 |
+
let wall_start = Instant::now();
|
| 537 |
+
let results = run_tests_parallel(tests, jobs, &launcher, &wa_exe, &run_dir);
|
| 538 |
+
let wall_time = wall_start.elapsed();
|
| 539 |
+
|
| 540 |
+
// Summary
|
| 541 |
+
print_summary(&results, wall_time);
|
| 542 |
+
write_summary(&results, wall_time, &run_dir.join("summary.txt"));
|
| 543 |
+
|
| 544 |
+
let failed = results.iter().any(|r| !r.passed);
|
| 545 |
+
if failed {
|
| 546 |
+
std::process::exit(1);
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
/// Simple timestamp without chrono dependency.
|
| 551 |
+
fn chrono_lite() -> String {
|
| 552 |
+
use std::time::SystemTime;
|
| 553 |
+
let d = SystemTime::now()
|
| 554 |
+
.duration_since(SystemTime::UNIX_EPOCH)
|
| 555 |
+
.unwrap_or_default();
|
| 556 |
+
let secs = d.as_secs();
|
| 557 |
+
// Convert to roughly readable format (not perfect but unique)
|
| 558 |
+
let s = secs % 60;
|
| 559 |
+
let m = (secs / 60) % 60;
|
| 560 |
+
let h = (secs / 3600) % 24;
|
| 561 |
+
let days = secs / 86400;
|
| 562 |
+
// Days since epoch β approximate date
|
| 563 |
+
let (y, mo, day) = days_to_ymd(days);
|
| 564 |
+
format!("{y:04}-{mo:02}-{day:02}_{h:02}-{m:02}-{s:02}")
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
| 568 |
+
// Rough Gregorian approximation
|
| 569 |
+
let mut y = 1970;
|
| 570 |
+
loop {
|
| 571 |
+
let ydays = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
|
| 572 |
+
if days < ydays { break; }
|
| 573 |
+
days -= ydays;
|
| 574 |
+
y += 1;
|
| 575 |
+
}
|
| 576 |
+
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
|
| 577 |
+
let mdays = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
| 578 |
+
let mut m = 0;
|
| 579 |
+
while m < 12 && days >= mdays[m] {
|
| 580 |
+
days -= mdays[m];
|
| 581 |
+
m += 1;
|
| 582 |
+
}
|
| 583 |
+
(y, m as u64 + 1, days + 1)
|
| 584 |
+
}
|
crates/openwa-wormkit/src/lib.rs
CHANGED
|
@@ -19,10 +19,11 @@ mod validation;
|
|
| 19 |
const DLL_PROCESS_ATTACH: u32 = 1;
|
| 20 |
const DLL_PROCESS_DETACH: u32 = 0;
|
| 21 |
|
| 22 |
-
/// Named event shared with the launcher. The
|
| 23 |
-
///
|
| 24 |
-
///
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
#[no_mangle]
|
| 28 |
unsafe extern "system" fn DllMain(
|
|
@@ -51,7 +52,8 @@ unsafe extern "system" fn DllMain(
|
|
| 51 |
pub use openwa_core::log::log_line;
|
| 52 |
|
| 53 |
fn clear_log() -> std::io::Result<()> {
|
| 54 |
-
std::
|
|
|
|
| 55 |
Ok(())
|
| 56 |
}
|
| 57 |
|
|
@@ -61,7 +63,9 @@ fn clear_log() -> std::io::Result<()> {
|
|
| 61 |
|
| 62 |
fn run() -> Result<(), String> {
|
| 63 |
let _ = clear_log();
|
| 64 |
-
let
|
|
|
|
|
|
|
| 65 |
|
| 66 |
// Install panic hook that writes to our log file
|
| 67 |
std::panic::set_hook(Box::new(|info| {
|
|
@@ -106,24 +110,26 @@ fn run() -> Result<(), String> {
|
|
| 106 |
Ok(())
|
| 107 |
}
|
| 108 |
|
| 109 |
-
/// Signal the `
|
| 110 |
-
/// safe to resume WA.exe's main thread.
|
| 111 |
fn signal_hooks_ready() {
|
| 112 |
use windows_sys::Win32::Foundation::CloseHandle;
|
| 113 |
-
use windows_sys::Win32::System::Threading::{OpenEventA, SetEvent};
|
| 114 |
|
| 115 |
const EVENT_MODIFY_STATE: u32 = 0x0002;
|
| 116 |
|
| 117 |
unsafe {
|
|
|
|
|
|
|
| 118 |
let handle = OpenEventA(
|
| 119 |
EVENT_MODIFY_STATE,
|
| 120 |
0, // bInheritHandle = FALSE
|
| 121 |
-
|
| 122 |
);
|
| 123 |
if !handle.is_null() {
|
| 124 |
SetEvent(handle);
|
| 125 |
CloseHandle(handle);
|
| 126 |
-
let _ = log_line("=== Signalled
|
| 127 |
}
|
| 128 |
// If handle is null, we weren't launched by our launcher β that's fine.
|
| 129 |
}
|
|
|
|
| 19 |
const DLL_PROCESS_ATTACH: u32 = 1;
|
| 20 |
const DLL_PROCESS_DETACH: u32 = 0;
|
| 21 |
|
| 22 |
+
/// Named event prefix shared with the launcher. The full event name is
|
| 23 |
+
/// `OpenWA_HooksReady_{pid}` where pid is the WA.exe process ID. Both the
|
| 24 |
+
/// launcher (which knows the child PID) and the DLL (via GetCurrentProcessId)
|
| 25 |
+
/// independently construct the same name, enabling concurrent instances.
|
| 26 |
+
const HOOKS_READY_EVENT_PREFIX: &str = "OpenWA_HooksReady_";
|
| 27 |
|
| 28 |
#[no_mangle]
|
| 29 |
unsafe extern "system" fn DllMain(
|
|
|
|
| 52 |
pub use openwa_core::log::log_line;
|
| 53 |
|
| 54 |
fn clear_log() -> std::io::Result<()> {
|
| 55 |
+
let path = std::env::var_os("OPENWA_LOG_PATH").unwrap_or("OpenWA.log".into());
|
| 56 |
+
std::fs::write(path, "")?;
|
| 57 |
Ok(())
|
| 58 |
}
|
| 59 |
|
|
|
|
| 63 |
|
| 64 |
fn run() -> Result<(), String> {
|
| 65 |
let _ = clear_log();
|
| 66 |
+
let validation_path = std::env::var_os("OPENWA_VALIDATION_LOG_PATH")
|
| 67 |
+
.unwrap_or("OpenWA_validation.log".into());
|
| 68 |
+
let _ = std::fs::write(validation_path, "");
|
| 69 |
|
| 70 |
// Install panic hook that writes to our log file
|
| 71 |
std::panic::set_hook(Box::new(|info| {
|
|
|
|
| 110 |
Ok(())
|
| 111 |
}
|
| 112 |
|
| 113 |
+
/// Signal the `OpenWA_HooksReady_{pid}` named event so the launcher knows
|
| 114 |
+
/// it's safe to resume WA.exe's main thread.
|
| 115 |
fn signal_hooks_ready() {
|
| 116 |
use windows_sys::Win32::Foundation::CloseHandle;
|
| 117 |
+
use windows_sys::Win32::System::Threading::{GetCurrentProcessId, OpenEventA, SetEvent};
|
| 118 |
|
| 119 |
const EVENT_MODIFY_STATE: u32 = 0x0002;
|
| 120 |
|
| 121 |
unsafe {
|
| 122 |
+
let pid = GetCurrentProcessId();
|
| 123 |
+
let event_name = format!("{}{}\0", HOOKS_READY_EVENT_PREFIX, pid);
|
| 124 |
let handle = OpenEventA(
|
| 125 |
EVENT_MODIFY_STATE,
|
| 126 |
0, // bInheritHandle = FALSE
|
| 127 |
+
event_name.as_ptr(),
|
| 128 |
);
|
| 129 |
if !handle.is_null() {
|
| 130 |
SetEvent(handle);
|
| 131 |
CloseHandle(handle);
|
| 132 |
+
let _ = log_line(&format!("=== Signalled {}{} ===", HOOKS_READY_EVENT_PREFIX, pid));
|
| 133 |
}
|
| 134 |
// If handle is null, we weren't launched by our launcher β that's fine.
|
| 135 |
}
|
crates/openwa-wormkit/src/replacements/replay.rs
CHANGED
|
@@ -23,6 +23,15 @@ use std::os::windows::io::FromRawHandle;
|
|
| 23 |
use std::path::Path;
|
| 24 |
use std::path::PathBuf;
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
// βββ WA CRT FILE* conversion ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
//
|
| 28 |
// WA's CRT FILE* can't be used with Rust's std::fs::File directly, but we can
|
|
@@ -137,8 +146,9 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
|
|
| 137 |
let first_dword = *(payload as *const i32);
|
| 138 |
(*gi).replay_map_type = first_dword;
|
| 139 |
if first_dword >= 1 {
|
| 140 |
-
// Write payload to
|
| 141 |
-
let
|
|
|
|
| 142 |
} else {
|
| 143 |
(*gi).replay_payload_2 = *(payload.add(4) as *const i32);
|
| 144 |
if first_dword >= -4 && first_dword < -2 {
|
|
@@ -571,7 +581,9 @@ unsafe fn parse_and_write_v2plus(
|
|
| 571 |
|
| 572 |
let load: unsafe extern "stdcall" fn(*mut MapView, *const u8, i32) -> i32 =
|
| 573 |
core::mem::transmute(rb(va::MAP_VIEW_LOAD));
|
| 574 |
-
let
|
|
|
|
|
|
|
| 575 |
|
| 576 |
if ok == 0 {
|
| 577 |
if !map.is_null() {
|
|
|
|
| 23 |
use std::path::Path;
|
| 24 |
use std::path::PathBuf;
|
| 25 |
|
| 26 |
+
// βββ Per-instance playback.thm path βββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
|
| 28 |
+
/// Returns a per-process playback.thm path to avoid races during concurrent tests.
|
| 29 |
+
/// Uses PID suffix so multiple WA instances don't clobber each other's map data.
|
| 30 |
+
fn playback_thm_path() -> String {
|
| 31 |
+
let pid = std::process::id();
|
| 32 |
+
format!("data\\playback_{pid}.thm")
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
// βββ WA CRT FILE* conversion ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
//
|
| 37 |
// WA's CRT FILE* can't be used with Rust's std::fs::File directly, but we can
|
|
|
|
| 146 |
let first_dword = *(payload as *const i32);
|
| 147 |
(*gi).replay_map_type = first_dword;
|
| 148 |
if first_dword >= 1 {
|
| 149 |
+
// Write payload to a per-instance temp file to avoid races during concurrent testing.
|
| 150 |
+
let thm_path = playback_thm_path();
|
| 151 |
+
let _ = std::fs::write(&thm_path, payload_slice);
|
| 152 |
} else {
|
| 153 |
(*gi).replay_payload_2 = *(payload.add(4) as *const i32);
|
| 154 |
if first_dword >= -4 && first_dword < -2 {
|
|
|
|
| 581 |
|
| 582 |
let load: unsafe extern "stdcall" fn(*mut MapView, *const u8, i32) -> i32 =
|
| 583 |
core::mem::transmute(rb(va::MAP_VIEW_LOAD));
|
| 584 |
+
let thm_path = playback_thm_path();
|
| 585 |
+
let thm_cstr: Vec<u8> = thm_path.bytes().chain(std::iter::once(0)).collect();
|
| 586 |
+
let ok = load(map, thm_cstr.as_ptr(), 0);
|
| 587 |
|
| 588 |
if ok == 0 {
|
| 589 |
if !map.is_null() {
|