paavohtl Claude Opus 4.6 (1M context) commited on
Commit
7f84ca7
Β·
1 Parent(s): d70a71d

feat: add Rust replay test runner with concurrent execution support

Browse files

New 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 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
- /// Append a line to `OpenWA.log`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("OpenWA.log")?;
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
- let event_name = b"OpenWA_HooksReady\0";
 
 
 
 
 
 
 
 
 
 
 
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(&timestamp);
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 launcher waits on this event
23
- /// after DLL injection before resuming WA.exe's main thread, guaranteeing
24
- /// all hooks are installed before any WA code runs.
25
- const HOOKS_READY_EVENT: &[u8] = b"OpenWA_HooksReady\0";
 
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::fs::write("OpenWA.log", "")?;
 
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 _ = std::fs::write("OpenWA_validation.log", "");
 
 
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 `OpenWA_HooksReady` named event so the launcher knows it's
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
- HOOKS_READY_EVENT.as_ptr(),
122
  );
123
  if !handle.is_null() {
124
  SetEvent(handle);
125
  CloseHandle(handle);
126
- let _ = log_line("=== Signalled OpenWA_HooksReady ===");
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 playback.thm using Rust File
141
- let _ = std::fs::write("data\\playback.thm", payload_slice);
 
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 ok = load(map, b"data\\playback.thm\0".as_ptr(), 0);
 
 
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() {