fix: suppress loading dialog and focus stealing in headless mode
Browse filesSkip the "Loading, please wait..." splash dialog entirely by patching
a JNZ→JMP at 0x4E9F20 in Frontend__MainNavigationLoop, so the game
calls ReplayLoader() directly without creating windows or threads.
Hook SetForegroundWindow and SetFocus in user32 as no-ops to prevent
headless WA.exe instances from stealing keyboard focus from other
applications during parallel test runs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crates/openwa-dll/src/replacements/headless.rs
CHANGED
|
@@ -70,6 +70,16 @@ unsafe extern "system" fn hook_messagebox_w(
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
pub fn install() -> Result<(), String> {
|
| 74 |
if std::env::var("OPENWA_HEADLESS").is_err() {
|
| 75 |
return Ok(());
|
|
@@ -77,6 +87,52 @@ pub fn install() -> Result<(), String> {
|
|
| 77 |
|
| 78 |
let _ = log_line("[Headless] Suppressing all message boxes");
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
unsafe {
|
| 81 |
let module = windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(
|
| 82 |
c"user32.dll".as_ptr().cast(),
|
|
@@ -94,6 +150,11 @@ pub fn install() -> Result<(), String> {
|
|
| 94 |
&b"MessageBoxW\0"[..],
|
| 95 |
hook_messagebox_w as *mut core::ffi::c_void,
|
| 96 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
] {
|
| 98 |
let proc =
|
| 99 |
windows_sys::Win32::System::LibraryLoader::GetProcAddress(module, name.as_ptr());
|
|
@@ -111,7 +172,7 @@ pub fn install() -> Result<(), String> {
|
|
| 111 |
}
|
| 112 |
}
|
| 113 |
|
| 114 |
-
let _ = log_line("[Headless] All message box paths suppressed");
|
| 115 |
|
| 116 |
// Hook CreateSemaphoreA to rename the "Worms Armageddon" instance
|
| 117 |
// semaphore per-PID. Without this, concurrent WA.exe instances detect
|
|
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
+
/// Suppress focus stealing — headless processes should never grab foreground.
|
| 74 |
+
unsafe extern "system" fn hook_set_foreground_window(_hwnd: u32) -> i32 {
|
| 75 |
+
1 // pretend success
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/// Suppress SetFocus — headless processes don't need keyboard focus.
|
| 79 |
+
unsafe extern "system" fn hook_set_focus(_hwnd: u32) -> u32 {
|
| 80 |
+
0 // "previous focus window" = NULL
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
pub fn install() -> Result<(), String> {
|
| 84 |
if std::env::var("OPENWA_HEADLESS").is_err() {
|
| 85 |
return Ok(());
|
|
|
|
| 87 |
|
| 88 |
let _ = log_line("[Headless] Suppressing all message boxes");
|
| 89 |
|
| 90 |
+
// Skip the "Loading, please wait..." splash dialog entirely.
|
| 91 |
+
//
|
| 92 |
+
// In Frontend__MainNavigationLoop at VA 0x004E9F19, a flag at
|
| 93 |
+
// CWinApp+0x651 controls whether the loading dialog is created.
|
| 94 |
+
// When zero (default), the code creates a dialog (resource 0x135D),
|
| 95 |
+
// shows it, spawns a loading thread gated on WM_PAINT, and runs a
|
| 96 |
+
// message pump. When non-zero, it calls ReplayLoader() directly —
|
| 97 |
+
// no window, no thread, no focus steal.
|
| 98 |
+
//
|
| 99 |
+
// At VA 0x004E9F20 there's a `JNZ +0x1C7` (0F 85 C7 01 00 00) that
|
| 100 |
+
// skips the dialog path. Patch it to an unconditional JMP so the
|
| 101 |
+
// dialog is never created in headless mode.
|
| 102 |
+
unsafe {
|
| 103 |
+
use openwa_core::rebase::rb;
|
| 104 |
+
let patch_addr = rb(0x004E_9F20) as *mut u8;
|
| 105 |
+
let mut old_protect: u32 = 0;
|
| 106 |
+
let ok = windows_sys::Win32::System::Memory::VirtualProtect(
|
| 107 |
+
patch_addr as *mut core::ffi::c_void,
|
| 108 |
+
6,
|
| 109 |
+
0x40, // PAGE_EXECUTE_READWRITE
|
| 110 |
+
&mut old_protect,
|
| 111 |
+
);
|
| 112 |
+
if ok != 0 {
|
| 113 |
+
assert_eq!(
|
| 114 |
+
core::slice::from_raw_parts(patch_addr, 2),
|
| 115 |
+
&[0x0F, 0x85],
|
| 116 |
+
"expected JNZ (0F 85) at patch site"
|
| 117 |
+
);
|
| 118 |
+
// JNZ rel32 (6 bytes) → JMP rel32 (5 bytes) + NOP (1 byte)
|
| 119 |
+
// Offset adjusts +1 because JMP is 1 byte shorter than JNZ.
|
| 120 |
+
*patch_addr = 0xE9; // JMP rel32
|
| 121 |
+
*patch_addr.add(1) = 0xC8;
|
| 122 |
+
*patch_addr.add(2) = 0x01;
|
| 123 |
+
*patch_addr.add(3) = 0x00;
|
| 124 |
+
*patch_addr.add(4) = 0x00;
|
| 125 |
+
*patch_addr.add(5) = 0x90; // NOP
|
| 126 |
+
windows_sys::Win32::System::Memory::VirtualProtect(
|
| 127 |
+
patch_addr as *mut core::ffi::c_void,
|
| 128 |
+
6,
|
| 129 |
+
old_protect,
|
| 130 |
+
&mut old_protect,
|
| 131 |
+
);
|
| 132 |
+
let _ = log_line("[Headless] Patched loading dialog: JNZ → JMP (dialog skipped)");
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
unsafe {
|
| 137 |
let module = windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(
|
| 138 |
c"user32.dll".as_ptr().cast(),
|
|
|
|
| 150 |
&b"MessageBoxW\0"[..],
|
| 151 |
hook_messagebox_w as *mut core::ffi::c_void,
|
| 152 |
),
|
| 153 |
+
(
|
| 154 |
+
&b"SetForegroundWindow\0"[..],
|
| 155 |
+
hook_set_foreground_window as *mut core::ffi::c_void,
|
| 156 |
+
),
|
| 157 |
+
(&b"SetFocus\0"[..], hook_set_focus as *mut core::ffi::c_void),
|
| 158 |
] {
|
| 159 |
let proc =
|
| 160 |
windows_sys::Win32::System::LibraryLoader::GetProcAddress(module, name.as_ptr());
|
|
|
|
| 172 |
}
|
| 173 |
}
|
| 174 |
|
| 175 |
+
let _ = log_line("[Headless] All message box and focus paths suppressed");
|
| 176 |
|
| 177 |
// Hook CreateSemaphoreA to rename the "Worms Armageddon" instance
|
| 178 |
// semaphore per-PID. Without this, concurrent WA.exe instances detect
|