feat: hook CreateFileA for per-instance landscape file isolation
Browse filesHook 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 |
-
|
| 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
|
|
|
|
| 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(
|
| 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()?;
|