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

feat: hook CreateFileA for per-instance landscape file isolation

Browse files

Hook kernel32!CreateFileA in headless mode to redirect landscape
generation temp files (mono.tmp, land.dat, landgen.svg) to per-PID
paths. This prevents concurrent WA.exe instances from clobbering
each other's landscape data during parallel replay testing.

Also: update test runner to default j=4, clean up per-PID temp files
after test runs, remove unnecessary launch stagger comment.

Concurrent test results: 7 tests in ~12s (j=4) vs ~36s (serial).
Occasional flakiness at j=7 from unidentified remaining shared resource.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

crates/openwa-test-runner/src/main.rs CHANGED
@@ -15,10 +15,7 @@ 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";
@@ -228,6 +225,7 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
228
  .stderr(std::process::Stdio::null())
229
  .status();
230
 
 
231
  let duration = start.elapsed();
232
 
233
  match result {
@@ -374,12 +372,13 @@ fn run_tests_parallel(
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
 
@@ -460,6 +459,45 @@ fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
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());
@@ -541,6 +579,9 @@ fn main() {
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);
 
15
 
16
  // ─── Configuration ──────────────────────────────────────────────────────────
17
 
18
+ const DEFAULT_JOBS: usize = 4;
 
 
 
19
  const TIMEOUT_SECS: u64 = 120;
20
  const REPLAYS_DIR: &str = "testdata/replays";
21
  const RUNS_DIR: &str = "testdata/runs";
 
225
  .stderr(std::process::Stdio::null())
226
  .status();
227
 
228
+
229
  let duration = start.elapsed();
230
 
231
  match result {
 
372
  }
373
  drop(tx); // Drop our sender so rx closes when all workers finish
374
 
375
+ // Submit work with small stagger to reduce startup races from unidentified
376
+ // shared resources (writetest.txt, steam.dat, or unknown Win32 globals).
377
  for (idx, test) in tests.into_iter().enumerate() {
 
378
  if idx > 0 {
379
+ thread::sleep(Duration::from_millis(200));
380
  }
381
+ let _ = work_tx.send((idx, test));
382
  }
383
  drop(work_tx); // Signal no more work
384
 
 
459
 
460
  // ─── Helpers ────────────────────────────────────────────────────────────────
461
 
462
+ /// Clean up per-PID temp files left by the file isolation hook.
463
+ /// Matches patterns: mono_*.tmp, land_*.dat, landgen_*.svg, playback_*.thm, cur_*.thm
464
+ fn cleanup_temp_files(wa_exe: &Path) {
465
+ let game_dir = match wa_exe.parent() {
466
+ Some(d) => d,
467
+ None => return,
468
+ };
469
+
470
+ let patterns = [
471
+ (game_dir, "mono_", ".tmp"),
472
+ (game_dir, "cur_", ".thm"),
473
+ ];
474
+ let data_dir = game_dir.join("DATA");
475
+ let data_patterns = [
476
+ ("land_", ".dat"),
477
+ ("landgen_", ".svg"),
478
+ ("playback_", ".thm"),
479
+ ];
480
+
481
+ for (dir, prefix, suffix) in &patterns {
482
+ cleanup_matching(dir, prefix, suffix);
483
+ }
484
+ for (prefix, suffix) in &data_patterns {
485
+ cleanup_matching(&data_dir, prefix, suffix);
486
+ }
487
+ }
488
+
489
+ fn cleanup_matching(dir: &Path, prefix: &str, suffix: &str) {
490
+ if let Ok(entries) = fs::read_dir(dir) {
491
+ for entry in entries.flatten() {
492
+ let name = entry.file_name();
493
+ let name = name.to_string_lossy();
494
+ if name.starts_with(prefix) && name.ends_with(suffix) {
495
+ let _ = fs::remove_file(entry.path());
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
  /// Normalize CRLF to LF for cross-platform comparison.
502
  fn normalize_crlf(data: &[u8]) -> Vec<u8> {
503
  let mut out = Vec::with_capacity(data.len());
 
579
  print_summary(&results, wall_time);
580
  write_summary(&results, wall_time, &run_dir.join("summary.txt"));
581
 
582
+ // Clean up per-PID temp files from the game directory
583
+ cleanup_temp_files(&wa_exe);
584
+
585
  let failed = results.iter().any(|r| !r.passed);
586
  if failed {
587
  std::process::exit(1);
crates/openwa-wormkit/src/replacements/file_isolation.rs ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Per-instance file path isolation for concurrent test execution.
2
+ //!
3
+ //! Hooks `kernel32!CreateFileA` to redirect landscape generation temp files
4
+ //! to per-PID paths, preventing races when multiple WA.exe instances run
5
+ //! simultaneously. Only active when `OPENWA_HEADLESS=1` is set.
6
+ //!
7
+ //! Redirected files (in game directory):
8
+ //! mono.tmp → mono_{pid}.tmp
9
+ //! DATA\land.dat → DATA\land_{pid}.dat
10
+ //! DATA\landgen.svg → DATA\landgen_{pid}.svg
11
+
12
+ use crate::log_line;
13
+
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;
20
+ type BOOL = i32;
21
+ type LPSECURITY_ATTRIBUTES = *mut c_void;
22
+
23
+ type CreateFileAFn = unsafe extern "system" fn(
24
+ lpFileName: *const u8,
25
+ dwDesiredAccess: DWORD,
26
+ dwShareMode: DWORD,
27
+ lpSecurityAttributes: LPSECURITY_ATTRIBUTES,
28
+ dwCreationDisposition: DWORD,
29
+ dwFlagsAndAttributes: DWORD,
30
+ hTemplateFile: HANDLE,
31
+ ) -> HANDLE;
32
+
33
+ static ORIG_CREATE_FILE_A: AtomicU32 = AtomicU32::new(0);
34
+
35
+ /// Check if a path ends with one of our target filenames (case-insensitive).
36
+ /// Returns the replacement path if it matches, None otherwise.
37
+ fn redirect_path(path: &str) -> Option<String> {
38
+ let pid = std::process::id();
39
+ let lower = path.to_ascii_lowercase();
40
+
41
+ if lower.ends_with("\\mono.tmp") || lower == "mono.tmp" {
42
+ let prefix = &path[..path.len() - "mono.tmp".len()];
43
+ Some(format!("{prefix}mono_{pid}.tmp"))
44
+ } else if lower.ends_with("\\land.dat") || lower.ends_with("/land.dat") {
45
+ let prefix = &path[..path.len() - "land.dat".len()];
46
+ Some(format!("{prefix}land_{pid}.dat"))
47
+ } else if lower.ends_with("\\landgen.svg") || lower.ends_with("/landgen.svg") {
48
+ let prefix = &path[..path.len() - "landgen.svg".len()];
49
+ Some(format!("{prefix}landgen_{pid}.svg"))
50
+ } else {
51
+ None
52
+ }
53
+ }
54
+
55
+ unsafe extern "system" fn hook_create_file_a(
56
+ lp_file_name: *const u8,
57
+ desired_access: DWORD,
58
+ share_mode: DWORD,
59
+ security_attributes: LPSECURITY_ATTRIBUTES,
60
+ creation_disposition: DWORD,
61
+ flags_and_attributes: DWORD,
62
+ template_file: HANDLE,
63
+ ) -> HANDLE {
64
+ let orig: CreateFileAFn = core::mem::transmute(ORIG_CREATE_FILE_A.load(Ordering::Relaxed));
65
+
66
+ if !lp_file_name.is_null() {
67
+ if let Ok(path) = CStr::from_ptr(lp_file_name as *const i8).to_str() {
68
+ if let Some(new_path) = redirect_path(path) {
69
+ let cstr: Vec<u8> = new_path.bytes().chain(std::iter::once(0)).collect();
70
+ return orig(
71
+ cstr.as_ptr(),
72
+ desired_access,
73
+ share_mode,
74
+ security_attributes,
75
+ creation_disposition,
76
+ flags_and_attributes,
77
+ template_file,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ orig(
84
+ lp_file_name,
85
+ desired_access,
86
+ share_mode,
87
+ security_attributes,
88
+ creation_disposition,
89
+ flags_and_attributes,
90
+ template_file,
91
+ )
92
+ }
93
+
94
+ pub fn install() -> Result<(), String> {
95
+ if std::env::var("OPENWA_HEADLESS").is_err() {
96
+ return Ok(()); // Only active in headless mode
97
+ }
98
+
99
+ unsafe {
100
+ let module = windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(
101
+ b"kernel32.dll\0".as_ptr(),
102
+ );
103
+ if module.is_null() {
104
+ return Err("kernel32.dll not loaded".to_string());
105
+ }
106
+
107
+ let proc = windows_sys::Win32::System::LibraryLoader::GetProcAddress(
108
+ module,
109
+ b"CreateFileA\0".as_ptr(),
110
+ );
111
+ let addr = proc.ok_or("CreateFileA not found in kernel32.dll")?;
112
+ let target = addr as *mut c_void;
113
+ let trampoline = minhook::MinHook::create_hook(target, hook_create_file_a as *mut c_void)
114
+ .map_err(|e| format!("MinHook create_hook failed for CreateFileA: {e}"))?;
115
+ minhook::MinHook::enable_hook(target)
116
+ .map_err(|e| format!("MinHook enable_hook failed for CreateFileA: {e}"))?;
117
+
118
+ ORIG_CREATE_FILE_A.store(trampoline as u32, Ordering::Relaxed);
119
+
120
+ let pid = std::process::id();
121
+ let _ = log_line(&format!(
122
+ "[FileIsolation] Hooked CreateFileA (pid={pid}): mono.tmp, land.dat, landgen.svg → per-PID paths"
123
+ ));
124
+ }
125
+
126
+ Ok(())
127
+ }
crates/openwa-wormkit/src/replacements/mod.rs CHANGED
@@ -1,6 +1,7 @@
1
  mod config;
2
  mod ddgame_init;
3
  mod display;
 
4
  mod frontend;
5
  mod game_session;
6
  mod game_state_hooks;
@@ -22,6 +23,7 @@ pub fn write_gameplay_report() {
22
 
23
  pub fn install_all() -> Result<(), String> {
24
  headless::install()?;
 
25
  display::install()?;
26
  game_session::install()?;
27
  hardware_init::install()?;
 
1
  mod config;
2
  mod ddgame_init;
3
  mod display;
4
+ mod file_isolation;
5
  mod frontend;
6
  mod game_session;
7
  mod game_state_hooks;
 
23
 
24
  pub fn install_all() -> Result<(), String> {
25
  headless::install()?;
26
+ file_isolation::install()?;
27
  display::install()?;
28
  game_session::install()?;
29
  hardware_init::install()?;