paavohtl Claude Opus 4.6 (1M context) commited on
Commit
0179a59
·
1 Parent(s): c0e3602

refactor: split input.rs into frame_hook, replay_test, debug_utils

Browse files

The old input.rs was a grab-bag of unrelated concerns. Split into:

- frame_hook.rs: TurnManager_ProcessFrame hook, debug_sync, watchpoint
arming. Always installed (including baseline/trace-desync mode).
- replay_test.rs: Fast-forward + gameplay milestone tracking. Only for
headful replay testing — headless runs at max CPU speed already.
- debug_utils.rs: dump_region memory inspection (used by validation).

Moved get_ddgame()/get_wrapper() to openwa-core::engine::game_session
as shared #[cfg(x86)] accessors, eliminating duplicates in wormkit
and debugui.

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

crates/openwa-core/src/engine/game_session.rs CHANGED
@@ -93,3 +93,39 @@ pub struct GameSession {
93
  }
94
 
95
  const _: () = assert!(core::mem::size_of::<GameSession>() == 0x120);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
94
 
95
  const _: () = assert!(core::mem::size_of::<GameSession>() == 0x120);
96
+
97
+ // ─── Runtime accessors (DLL-injected context only) ──────────────────────
98
+
99
+ #[cfg(target_arch = "x86")]
100
+ use crate::address::va;
101
+ #[cfg(target_arch = "x86")]
102
+ use crate::engine::ddgame::DDGame;
103
+ #[cfg(target_arch = "x86")]
104
+ use crate::rebase::rb;
105
+
106
+ /// Get the DDGameWrapper pointer from the global game session.
107
+ ///
108
+ /// Returns null if the session or wrapper hasn't been initialized yet.
109
+ #[cfg(target_arch = "x86")]
110
+ #[inline]
111
+ pub unsafe fn get_wrapper() -> *mut DDGameWrapper {
112
+ let session = *(rb(va::G_GAME_SESSION) as *const *mut GameSession);
113
+ if session.is_null() {
114
+ return core::ptr::null_mut();
115
+ }
116
+ (*session).ddgame_wrapper
117
+ }
118
+
119
+ /// Get the DDGame pointer from the global game session.
120
+ ///
121
+ /// Follows the chain: G_GAME_SESSION → GameSession.ddgame_wrapper → DDGameWrapper.ddgame.
122
+ /// Returns null if any link in the chain is uninitialized.
123
+ #[cfg(target_arch = "x86")]
124
+ #[inline]
125
+ pub unsafe fn get_ddgame() -> *mut DDGame {
126
+ let wrapper = get_wrapper();
127
+ if wrapper.is_null() {
128
+ return core::ptr::null_mut();
129
+ }
130
+ (*wrapper).ddgame
131
+ }
crates/openwa-debugui/src/app.rs CHANGED
@@ -1,6 +1,6 @@
1
  use eframe::egui;
2
  use openwa_core::address::va;
3
- use openwa_core::engine::{DDGame, DDGameWrapper};
4
  use openwa_core::rebase::rb;
5
  use openwa_core::registry;
6
  use openwa_core::task::{
@@ -51,19 +51,8 @@ unsafe fn entity_label(addr: u32) -> String {
51
 
52
  /// Returns a pointer to DDGame, or None if not in-game.
53
  unsafe fn get_ddgame() -> Option<*const DDGame> {
54
- let session_ptr = *(rb(va::G_GAME_SESSION) as *const u32);
55
- if session_ptr == 0 {
56
- return None;
57
- }
58
- let wrapper_addr = *((session_ptr + 0xA0) as *const u32);
59
- if wrapper_addr == 0 {
60
- return None;
61
- }
62
- let ddgame_ptr = (*(wrapper_addr as *const DDGameWrapper)).ddgame;
63
- if ddgame_ptr.is_null() {
64
- return None;
65
- }
66
- Some(ddgame_ptr)
67
  }
68
 
69
  /// Unlock all weapons: set ammo to unlimited (-1) and delays to 0 for all teams.
 
1
  use eframe::egui;
2
  use openwa_core::address::va;
3
+ use openwa_core::engine::{game_session, DDGame};
4
  use openwa_core::rebase::rb;
5
  use openwa_core::registry;
6
  use openwa_core::task::{
 
51
 
52
  /// Returns a pointer to DDGame, or None if not in-game.
53
  unsafe fn get_ddgame() -> Option<*const DDGame> {
54
+ let ptr = game_session::get_ddgame();
55
+ if ptr.is_null() { None } else { Some(ptr) }
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
 
58
  /// Unlock all weapons: set ammo to unlimited (-1) and delays to 0 for all teams.
crates/openwa-wormkit/src/replacements/debug_utils.rs ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Debug memory inspection utilities.
2
+ //!
3
+ //! Contains `dump_region` for logging classified DWORD dumps of memory regions.
4
+ //! Used by the validation module for struct inspection.
5
+
6
+ use crate::log_line;
7
+ use openwa_core::address::va;
8
+ use openwa_core::rebase::rb;
9
+
10
+ /// Dump a memory region as DWORDs with automatic pointer classification.
11
+ ///
12
+ /// Uses `openwa_core::mem::classify_pointer` for pointer detection.
13
+ pub unsafe fn dump_region(base_ptr: *const u8, offset: usize, size: usize, struct_name: &str) {
14
+ use openwa_core::mem;
15
+ use openwa_debug_proto::PointerKind;
16
+
17
+ let wa_base = rb(va::IMAGE_BASE);
18
+ let delta = wa_base.wrapping_sub(va::IMAGE_BASE);
19
+
20
+ let _ = log_line(&format!(
21
+ "\n=== {}+0x{:04X}..0x{:04X} ===",
22
+ struct_name,
23
+ offset,
24
+ offset + size
25
+ ));
26
+
27
+ let dword_count = size / 4;
28
+ for i in 0..dword_count {
29
+ let field_offset = offset + i * 4;
30
+ let val = *(base_ptr.add(field_offset) as *const u32);
31
+ if val == 0 {
32
+ continue;
33
+ }
34
+
35
+ if let Some(info) = mem::classify_pointer(val, delta) {
36
+ let detail_str = info.detail.as_deref().unwrap_or("");
37
+ match info.kind {
38
+ PointerKind::Vtable => {
39
+ let _ = log_line(&format!(
40
+ " +0x{:04X}: 0x{:08X} [VTABLE] g:0x{:08X} {}",
41
+ field_offset, val, info.ghidra_value, detail_str
42
+ ));
43
+ }
44
+ PointerKind::Code => {
45
+ let _ = log_line(&format!(
46
+ " +0x{:04X}: 0x{:08X} [CODE] g:0x{:08X}",
47
+ field_offset, val, info.ghidra_value
48
+ ));
49
+ }
50
+ PointerKind::Data => {
51
+ let _ = log_line(&format!(
52
+ " +0x{:04X}: 0x{:08X} [DATA] g:0x{:08X}",
53
+ field_offset, val, info.ghidra_value
54
+ ));
55
+ }
56
+ PointerKind::Object => {
57
+ let _ = log_line(&format!(
58
+ " +0x{:04X}: 0x{:08X} [OBJECT] {}",
59
+ field_offset, val, detail_str
60
+ ));
61
+ }
62
+ PointerKind::Heap => {
63
+ let _ = log_line(&format!(
64
+ " +0x{:04X}: 0x{:08X} [ptr] {}",
65
+ field_offset, val, detail_str
66
+ ));
67
+ }
68
+ }
69
+ } else if val < 0x10000 {
70
+ let _ = log_line(&format!(
71
+ " +0x{:04X}: 0x{:08X} [small={}]",
72
+ field_offset, val, val
73
+ ));
74
+ } else {
75
+ let _ = log_line(&format!(" +0x{:04X}: 0x{:08X} [value]", field_offset, val));
76
+ }
77
+ }
78
+ }
crates/openwa-wormkit/src/replacements/frame_hook.rs ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Per-frame hook on TurnManager_ProcessFrame (0x55FDA0).
2
+ //!
3
+ //! Provides the central frame boundary hook used by multiple subsystems:
4
+ //! - Debug synchronization (pause/resume/step via debug CLI)
5
+ //! - Hardware watchpoint arming at specific frames
6
+ //!
7
+ //! This hook is always installed (both normal and baseline modes).
8
+
9
+ use std::sync::atomic::{AtomicU32, Ordering};
10
+
11
+ use crate::hook;
12
+ use crate::log_line;
13
+ use openwa_core::address::va;
14
+ use openwa_core::engine::game_session;
15
+
16
+ /// Trampoline to the original TurnManager_ProcessFrame.
17
+ static ORIG_TURN_MANAGER: AtomicU32 = AtomicU32::new(0);
18
+
19
+ /// Total frames processed by TurnManager_ProcessFrame.
20
+ static FRAMES_PROCESSED: AtomicU32 = AtomicU32::new(0);
21
+
22
+ /// Get the number of frames processed so far.
23
+ pub fn frames_processed() -> u32 {
24
+ FRAMES_PROCESSED.load(Ordering::Relaxed)
25
+ }
26
+
27
+ /// Hook for TurnManager_ProcessFrame (stdcall, 1 param = TurnGame*).
28
+ unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
29
+ // Check debug sync BEFORE processing the frame — allows pausing at frame boundary
30
+ let ddgame = game_session::get_ddgame();
31
+ if !ddgame.is_null() {
32
+ let game_frame = (*ddgame).frame_counter;
33
+ crate::debug_sync::on_frame_start(game_frame);
34
+
35
+ // Hardware watchpoint: arm once at the watch frame
36
+ static WATCH_ARMED: core::sync::atomic::AtomicBool =
37
+ core::sync::atomic::AtomicBool::new(false);
38
+ if !WATCH_ARMED.load(Ordering::Relaxed) {
39
+ if let Ok(val) = std::env::var("OPENWA_WATCH_FRAME") {
40
+ let target: i32 = val.parse().unwrap_or(0);
41
+ if game_frame >= target {
42
+ WATCH_ARMED.store(true, Ordering::Relaxed);
43
+ crate::debug_watchpoint::prepare();
44
+ // Select watchpoint base: DDGame, DDGameWrapper, or Display
45
+ let watch_base = if std::env::var("OPENWA_WATCH_DISPLAY").is_ok() {
46
+ let wrapper = game_session::get_wrapper();
47
+ *(wrapper.byte_add(0x4D0) as *const *mut u8)
48
+ } else if std::env::var("OPENWA_WATCH_WRAPPER").is_ok() {
49
+ game_session::get_wrapper() as *mut u8
50
+ } else {
51
+ ddgame as *mut u8
52
+ };
53
+ crate::debug_watchpoint::on_ddgame_alloc(watch_base);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ // Call original
60
+ let orig: unsafe extern "stdcall" fn(u32) =
61
+ core::mem::transmute(ORIG_TURN_MANAGER.load(Ordering::Relaxed));
62
+ orig(turngame);
63
+
64
+ let frame = FRAMES_PROCESSED.fetch_add(1, Ordering::Relaxed) + 1;
65
+
66
+ // Replay test: fast-forward + milestone tracking (no-op if not in replay test mode)
67
+ super::replay_test::on_frame(frame);
68
+ }
69
+
70
+ pub fn install() -> Result<(), String> {
71
+ let _ = log_line("[FrameHook] Hooking TurnManager_ProcessFrame");
72
+
73
+ unsafe {
74
+ let trampoline = hook::install(
75
+ "TurnManager_ProcessFrame",
76
+ va::TURN_MANAGER_PROCESS_FRAME,
77
+ hook_turn_manager as *const (),
78
+ )?;
79
+ ORIG_TURN_MANAGER.store(trampoline as u32, Ordering::Relaxed);
80
+ }
81
+
82
+ Ok(())
83
+ }
crates/openwa-wormkit/src/replacements/mod.rs CHANGED
@@ -1,15 +1,17 @@
1
  mod config;
 
2
  mod ddgame_init;
3
  mod display;
4
  pub(crate) mod file_isolation;
 
5
  mod frontend;
6
  mod game_session;
7
  mod game_state_hooks;
8
  mod hardware_init;
9
  mod headless;
10
- pub(crate) mod input;
11
  mod render;
12
  mod replay;
 
13
  mod scheme;
14
  mod sound;
15
  mod speech;
@@ -21,7 +23,7 @@ mod weapon_release;
21
 
22
  /// Write gameplay milestone report and clean up. Called from DLL_PROCESS_DETACH.
23
  pub fn write_gameplay_report() {
24
- input::write_gameplay_report();
25
  trace_desync::flush();
26
  file_isolation::cleanup();
27
  }
@@ -30,17 +32,18 @@ pub fn install_all() -> Result<(), String> {
30
  // Infrastructure hooks — always installed
31
  headless::install()?;
32
  file_isolation::install()?;
 
33
  trace_desync::install()?;
34
 
35
  // Baseline mode: only install minimal hooks needed for replay playback.
36
  // Skips all gameplay hooks to provide a "nearly vanilla" reference run.
37
  if std::env::var("OPENWA_TRACE_BASELINE").is_ok() {
38
- input::install()?;
39
  replay::install()?;
40
  return Ok(());
41
  }
42
 
43
  // Normal mode: install all hooks
 
44
  display::install()?;
45
  game_session::install()?;
46
  hardware_init::install()?;
@@ -52,7 +55,6 @@ pub fn install_all() -> Result<(), String> {
52
  render::install()?;
53
  sound::install()?;
54
  speech::install()?;
55
- input::install()?;
56
  ddgame_init::install()?;
57
  game_state_hooks::install()?;
58
  replay::install()?;
 
1
  mod config;
2
+ pub(crate) mod debug_utils;
3
  mod ddgame_init;
4
  mod display;
5
  pub(crate) mod file_isolation;
6
+ mod frame_hook;
7
  mod frontend;
8
  mod game_session;
9
  mod game_state_hooks;
10
  mod hardware_init;
11
  mod headless;
 
12
  mod render;
13
  mod replay;
14
+ mod replay_test;
15
  mod scheme;
16
  mod sound;
17
  mod speech;
 
23
 
24
  /// Write gameplay milestone report and clean up. Called from DLL_PROCESS_DETACH.
25
  pub fn write_gameplay_report() {
26
+ replay_test::write_gameplay_report();
27
  trace_desync::flush();
28
  file_isolation::cleanup();
29
  }
 
32
  // Infrastructure hooks — always installed
33
  headless::install()?;
34
  file_isolation::install()?;
35
+ frame_hook::install()?;
36
  trace_desync::install()?;
37
 
38
  // Baseline mode: only install minimal hooks needed for replay playback.
39
  // Skips all gameplay hooks to provide a "nearly vanilla" reference run.
40
  if std::env::var("OPENWA_TRACE_BASELINE").is_ok() {
 
41
  replay::install()?;
42
  return Ok(());
43
  }
44
 
45
  // Normal mode: install all hooks
46
+ replay_test::install()?;
47
  display::install()?;
48
  game_session::install()?;
49
  hardware_init::install()?;
 
55
  render::install()?;
56
  sound::install()?;
57
  speech::install()?;
 
58
  ddgame_init::install()?;
59
  game_state_hooks::install()?;
60
  replay::install()?;
crates/openwa-wormkit/src/replacements/{input.rs → replay_test.rs} RENAMED
@@ -1,49 +1,33 @@
1
- //! Replay fast-forward via DDGame+0x98B0 and gameplay milestone tracking.
2
  //!
3
- //! When `OPENWA_REPLAY_TEST=1`, hooks TurnManager_ProcessFrame (0x55FDA0)
4
- //! and sets DDGame+0x98B0 (fast-forward active flag) each frame.
 
 
 
5
  //!
6
- //! When this flag is set, FUN_005307A0 processes up to 50 game frames per
7
- //! render cycle. Sound is suppressed (FUN_00546B50) and rendering is skipped
8
- //! (FUN_00529F30). The flag gets cleared at turn boundaries (FUN_00534540,
9
- //! FUN_0055BDD0), so we re-set it every frame.
10
- //!
11
- //! This is the same mechanism triggered by key 0x35 (spacebar) during replay.
12
- //!
13
- //! ## Gameplay Milestones
14
- //!
15
- //! The frame hook also tracks gameplay milestones via atomic flags:
16
- //! - **Game initialized**: TurnManager_ProcessFrame called at least once
17
- //! - **Match started**: Multiple teams with alive worms detected
18
- //! - **Match completed**: Only one (or zero) teams have alive worms remaining
19
- //!
20
- //! These milestones are reported by `write_gameplay_report()` at DLL detach,
21
- //! providing reliable hook-based game progress detection without timers.
22
-
23
- #![allow(dead_code)]
24
 
25
  use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
26
 
27
- use crate::hook;
28
  use crate::log_line;
29
- use openwa_core::address::va;
30
  use openwa_core::engine::ddgame::{offsets, TeamArenaRef};
31
- use openwa_core::engine::{DDGame, DDGameWrapper};
32
- use openwa_core::rebase::rb;
33
 
34
- /// Trampoline to the original TurnManager_ProcessFrame.
35
- static ORIG_TURN_MANAGER: AtomicU32 = AtomicU32::new(0);
 
36
 
37
- /// Whether to set fast-forward flag (only in replay test mode).
38
  static FAST_FORWARD: AtomicBool = AtomicBool::new(false);
39
 
40
  // ---------------------------------------------------------------------------
41
  // Gameplay milestone tracking
42
  // ---------------------------------------------------------------------------
43
 
44
- /// Total frames processed by TurnManager_ProcessFrame.
45
- static FRAMES_PROCESSED: AtomicU32 = AtomicU32::new(0);
46
-
47
  /// Milestone: at least 2 teams with alive worms detected.
48
  static MATCH_STARTED: AtomicBool = AtomicBool::new(false);
49
 
@@ -59,99 +43,29 @@ static COMPLETION_FRAME: AtomicU32 = AtomicU32::new(0);
59
  /// Number of alive teams when match completed (0 = draw, 1 = winner).
60
  static ALIVE_AT_END: AtomicI32 = AtomicI32::new(-1);
61
 
62
- /// Get the DDGame pointer (session+0xA0 → DDGameWrapper.ddgame).
63
- #[inline]
64
- unsafe fn get_ddgame() -> *mut DDGame {
65
- let session = *(rb(va::G_GAME_SESSION) as *const u32);
66
- if session == 0 {
67
- return core::ptr::null_mut();
68
- }
69
- let wrapper_ptr = *((session + 0xA0) as *const *const DDGameWrapper);
70
- if wrapper_ptr.is_null() {
71
- return core::ptr::null_mut();
72
- }
73
- (*wrapper_ptr).ddgame
74
- }
75
-
76
- /// Dump a memory region as DWORDs with automatic classification.
77
  ///
78
- /// Uses `openwa_core::mem::classify_pointer` for pointer detection.
79
- /// See that module for the classification categories.
80
- pub unsafe fn dump_region(base_ptr: *const u8, offset: usize, size: usize, struct_name: &str) {
81
- use openwa_core::address::va;
82
- use openwa_core::mem;
83
- use openwa_core::rebase::rb;
84
- use openwa_debug_proto::PointerKind;
85
-
86
- let wa_base = rb(va::IMAGE_BASE);
87
- let delta = wa_base.wrapping_sub(va::IMAGE_BASE);
88
-
89
- let _ = log_line(&format!(
90
- "\n=== {}+0x{:04X}..0x{:04X} ===",
91
- struct_name,
92
- offset,
93
- offset + size
94
- ));
95
 
96
- let dword_count = size / 4;
97
- for i in 0..dword_count {
98
- let field_offset = offset + i * 4;
99
- let val = *(base_ptr.add(field_offset) as *const u32);
100
- if val == 0 {
101
- continue;
102
- }
103
 
104
- if let Some(info) = mem::classify_pointer(val, delta) {
105
- let detail_str = info.detail.as_deref().unwrap_or("");
106
- match info.kind {
107
- PointerKind::Vtable => {
108
- let _ = log_line(&format!(
109
- " +0x{:04X}: 0x{:08X} [VTABLE] g:0x{:08X} {}",
110
- field_offset, val, info.ghidra_value, detail_str
111
- ));
112
- }
113
- PointerKind::Code => {
114
- let _ = log_line(&format!(
115
- " +0x{:04X}: 0x{:08X} [CODE] g:0x{:08X}",
116
- field_offset, val, info.ghidra_value
117
- ));
118
- }
119
- PointerKind::Data => {
120
- let _ = log_line(&format!(
121
- " +0x{:04X}: 0x{:08X} [DATA] g:0x{:08X}",
122
- field_offset, val, info.ghidra_value
123
- ));
124
- }
125
- PointerKind::Object => {
126
- let _ = log_line(&format!(
127
- " +0x{:04X}: 0x{:08X} [OBJECT] {}",
128
- field_offset, val, detail_str
129
- ));
130
- }
131
- PointerKind::Heap => {
132
- let _ = log_line(&format!(
133
- " +0x{:04X}: 0x{:08X} [ptr] {}",
134
- field_offset, val, detail_str
135
- ));
136
- }
137
- }
138
- } else if val < 0x10000 {
139
- let _ = log_line(&format!(
140
- " +0x{:04X}: 0x{:08X} [small={}]",
141
- field_offset, val, val
142
- ));
143
- } else {
144
- let _ = log_line(&format!(" +0x{:04X}: 0x{:08X} [value]", field_offset, val));
145
- }
146
  }
147
  }
148
 
149
  /// Count how many teams have at least one alive worm.
150
  ///
151
- /// Iterates all 8 worm slots per team and checks `health > 0` (reliable
152
- /// even before sentinel headers are fully populated). This is more
153
- /// conservative than checking headers, which may be populated lazily.
154
- ///
155
  /// Returns (alive_team_count, total_team_count).
156
  unsafe fn count_alive_teams(ddgame: *const DDGame) -> (i32, i32) {
157
  let arena_base = (ddgame as u32) + offsets::TEAM_ARENA_STATE as u32;
@@ -162,7 +76,6 @@ unsafe fn count_alive_teams(ddgame: *const DDGame) -> (i32, i32) {
162
  }
163
 
164
  let mut alive_teams = 0i32;
165
- // Teams are 1-indexed in the arena (index 0 is unused preamble).
166
  for t in 1..=team_count as usize {
167
  let mut team_has_alive = false;
168
  for w in 1..=8usize {
@@ -179,63 +92,6 @@ unsafe fn count_alive_teams(ddgame: *const DDGame) -> (i32, i32) {
179
  (alive_teams, team_count)
180
  }
181
 
182
- /// Hook for TurnManager_ProcessFrame (stdcall, 1 param = TurnGame*).
183
- unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
184
- // Check debug sync BEFORE processing the frame — allows pausing at frame boundary
185
- let ddgame = get_ddgame();
186
- if !ddgame.is_null() {
187
- let game_frame = (*ddgame).frame_counter;
188
- crate::debug_sync::on_frame_start(game_frame);
189
-
190
- // Hardware watchpoint: arm once at the watch frame
191
- static WATCH_ARMED: core::sync::atomic::AtomicBool =
192
- core::sync::atomic::AtomicBool::new(false);
193
- if !WATCH_ARMED.load(Ordering::Relaxed) {
194
- if let Ok(val) = std::env::var("OPENWA_WATCH_FRAME") {
195
- let target: i32 = val.parse().unwrap_or(0);
196
- if game_frame >= target {
197
- WATCH_ARMED.store(true, Ordering::Relaxed);
198
- crate::debug_watchpoint::prepare();
199
- // Select watchpoint base: DDGame, DDGameWrapper, or Display
200
- let watch_base = if std::env::var("OPENWA_WATCH_DISPLAY").is_ok() {
201
- let session = *(rb(va::G_GAME_SESSION) as *const u32);
202
- let wrapper = *((session + 0xA0) as *const *const u8);
203
- *(wrapper.add(0x4D0) as *const *mut u8)
204
- } else if std::env::var("OPENWA_WATCH_WRAPPER").is_ok() {
205
- let session = *(rb(va::G_GAME_SESSION) as *const u32);
206
- *((session + 0xA0) as *const *mut u8)
207
- } else {
208
- ddgame as *mut u8
209
- };
210
- crate::debug_watchpoint::on_ddgame_alloc(watch_base);
211
- }
212
- }
213
- }
214
- }
215
-
216
- // Call original
217
- let orig: unsafe extern "stdcall" fn(u32) =
218
- core::mem::transmute(ORIG_TURN_MANAGER.load(Ordering::Relaxed));
219
- orig(turngame);
220
-
221
- let frame = FRAMES_PROCESSED.fetch_add(1, Ordering::Relaxed) + 1;
222
-
223
- if ddgame.is_null() {
224
- return;
225
- }
226
-
227
- // Fast-forward for replay test
228
- if FAST_FORWARD.load(Ordering::Relaxed) {
229
- (*ddgame).fast_forward_active = 1;
230
- }
231
-
232
- // Milestone detection (check every 50 frames to reduce overhead).
233
- // Skip the first 100 frames to let game state fully initialize.
234
- if frame >= 100 && frame.is_multiple_of(50) {
235
- check_milestones(ddgame, frame);
236
- }
237
- }
238
-
239
  /// Check and update gameplay milestones.
240
  unsafe fn check_milestones(ddgame: *const DDGame, frame: u32) {
241
  if MATCH_COMPLETED.load(Ordering::Relaxed) {
@@ -249,32 +105,26 @@ unsafe fn check_milestones(ddgame: *const DDGame, frame: u32) {
249
  MATCH_STARTED.store(true, Ordering::Relaxed);
250
  TEAMS_AT_START.store(alive as u32, Ordering::Relaxed);
251
  }
252
- } else {
253
- // Match started — check if it's now decided
254
- if alive <= 1 {
255
- MATCH_COMPLETED.store(true, Ordering::Relaxed);
256
- COMPLETION_FRAME.store(frame, Ordering::Relaxed);
257
- ALIVE_AT_END.store(alive, Ordering::Relaxed);
258
- }
259
  }
260
  }
261
 
262
  /// Write gameplay milestone report to the validation log.
263
  ///
264
  /// Called from DLL_PROCESS_DETACH to provide a final summary of game progress.
265
- /// Uses `[GAMEPLAY PASS]` / `[GAMEPLAY FAIL]` markers so the PowerShell script
266
- /// can distinguish these from static checks.
267
  pub fn write_gameplay_report() {
268
  use crate::validation::log_validation;
269
 
270
- let frames = FRAMES_PROCESSED.load(Ordering::Relaxed);
271
  let started = MATCH_STARTED.load(Ordering::Relaxed);
272
  let completed = MATCH_COMPLETED.load(Ordering::Relaxed);
273
 
274
  let _ = log_validation("");
275
  let _ = log_validation("--- Gameplay Checks ---");
276
 
277
- // Milestone 1: Game initialized (frame hook was called)
278
  if frames > 0 {
279
  let _ = log_validation(&format!(
280
  "[GAMEPLAY PASS] Game initialized - {} frames processed",
@@ -286,7 +136,6 @@ pub fn write_gameplay_report() {
286
  );
287
  }
288
 
289
- // Milestone 2: Match started (multiple teams with alive worms)
290
  if started {
291
  let teams = TEAMS_AT_START.load(Ordering::Relaxed);
292
  let _ = log_validation(&format!(
@@ -300,7 +149,6 @@ pub fn write_gameplay_report() {
300
  let _ = log_validation("[GAMEPLAY FAIL] Match started - game never initialized");
301
  }
302
 
303
- // Milestone 3: Match completed (one or zero teams remain)
304
  if completed {
305
  let end_frame = COMPLETION_FRAME.load(Ordering::Relaxed);
306
  let alive = ALIVE_AT_END.load(Ordering::Relaxed);
@@ -324,18 +172,7 @@ pub fn write_gameplay_report() {
324
  pub fn install() -> Result<(), String> {
325
  if std::env::var("OPENWA_REPLAY_TEST").is_ok() {
326
  FAST_FORWARD.store(true, Ordering::Relaxed);
327
- let _ = log_line("[Input] Replay test mode — fast-forward enabled");
328
- }
329
-
330
- let _ = log_line("[Input] Hooking TurnManager_ProcessFrame");
331
-
332
- unsafe {
333
- let trampoline = hook::install(
334
- "TurnManager_ProcessFrame",
335
- va::TURN_MANAGER_PROCESS_FRAME,
336
- hook_turn_manager as *const (),
337
- )?;
338
- ORIG_TURN_MANAGER.store(trampoline as u32, Ordering::Relaxed);
339
  }
340
 
341
  Ok(())
 
1
+ //! Headful replay test support: fast-forward and gameplay milestone tracking.
2
  //!
3
+ //! When `OPENWA_REPLAY_TEST=1`, enables:
4
+ //! - **Fast-forward**: Sets DDGame+0x98B0 each frame so WA processes up to 50
5
+ //! game frames per render cycle (same as spacebar during replay playback).
6
+ //! - **Gameplay milestones**: Tracks match start/completion via alive team counting,
7
+ //! reported at DLL detach for machine-readable test validation.
8
  //!
9
+ //! These features are specific to headful (interactive) replay testing.
10
+ //! Headless tests run at maximum CPU speed without needing fast-forward,
11
+ //! and use log comparison instead of milestone markers.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
14
 
 
15
  use crate::log_line;
 
16
  use openwa_core::engine::ddgame::{offsets, TeamArenaRef};
17
+ use openwa_core::engine::game_session;
18
+ use openwa_core::engine::DDGame;
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Fast-forward
22
+ // ---------------------------------------------------------------------------
23
 
24
+ /// Whether fast-forward is active (set once at install, read every frame).
25
  static FAST_FORWARD: AtomicBool = AtomicBool::new(false);
26
 
27
  // ---------------------------------------------------------------------------
28
  // Gameplay milestone tracking
29
  // ---------------------------------------------------------------------------
30
 
 
 
 
31
  /// Milestone: at least 2 teams with alive worms detected.
32
  static MATCH_STARTED: AtomicBool = AtomicBool::new(false);
33
 
 
43
  /// Number of alive teams when match completed (0 = draw, 1 = winner).
44
  static ALIVE_AT_END: AtomicI32 = AtomicI32::new(-1);
45
 
46
+ /// Called every frame from frame_hook (after the original TurnManager runs).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  ///
48
+ /// Handles fast-forward flag setting and periodic milestone checks.
49
+ pub unsafe fn on_frame(frame: u32) {
50
+ let ddgame = game_session::get_ddgame();
51
+ if ddgame.is_null() {
52
+ return;
53
+ }
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ // Re-set fast-forward each frame (WA clears it at turn boundaries)
56
+ if FAST_FORWARD.load(Ordering::Relaxed) {
57
+ (*ddgame).fast_forward_active = 1;
58
+ }
 
 
 
59
 
60
+ // Milestone detection (check every 50 frames to reduce overhead).
61
+ // Skip the first 100 frames to let game state fully initialize.
62
+ if frame >= 100 && frame.is_multiple_of(50) {
63
+ check_milestones(ddgame, frame);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
  }
66
 
67
  /// Count how many teams have at least one alive worm.
68
  ///
 
 
 
 
69
  /// Returns (alive_team_count, total_team_count).
70
  unsafe fn count_alive_teams(ddgame: *const DDGame) -> (i32, i32) {
71
  let arena_base = (ddgame as u32) + offsets::TEAM_ARENA_STATE as u32;
 
76
  }
77
 
78
  let mut alive_teams = 0i32;
 
79
  for t in 1..=team_count as usize {
80
  let mut team_has_alive = false;
81
  for w in 1..=8usize {
 
92
  (alive_teams, team_count)
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  /// Check and update gameplay milestones.
96
  unsafe fn check_milestones(ddgame: *const DDGame, frame: u32) {
97
  if MATCH_COMPLETED.load(Ordering::Relaxed) {
 
105
  MATCH_STARTED.store(true, Ordering::Relaxed);
106
  TEAMS_AT_START.store(alive as u32, Ordering::Relaxed);
107
  }
108
+ } else if alive <= 1 {
109
+ MATCH_COMPLETED.store(true, Ordering::Relaxed);
110
+ COMPLETION_FRAME.store(frame, Ordering::Relaxed);
111
+ ALIVE_AT_END.store(alive, Ordering::Relaxed);
 
 
 
112
  }
113
  }
114
 
115
  /// Write gameplay milestone report to the validation log.
116
  ///
117
  /// Called from DLL_PROCESS_DETACH to provide a final summary of game progress.
 
 
118
  pub fn write_gameplay_report() {
119
  use crate::validation::log_validation;
120
 
121
+ let frames = super::frame_hook::frames_processed();
122
  let started = MATCH_STARTED.load(Ordering::Relaxed);
123
  let completed = MATCH_COMPLETED.load(Ordering::Relaxed);
124
 
125
  let _ = log_validation("");
126
  let _ = log_validation("--- Gameplay Checks ---");
127
 
 
128
  if frames > 0 {
129
  let _ = log_validation(&format!(
130
  "[GAMEPLAY PASS] Game initialized - {} frames processed",
 
136
  );
137
  }
138
 
 
139
  if started {
140
  let teams = TEAMS_AT_START.load(Ordering::Relaxed);
141
  let _ = log_validation(&format!(
 
149
  let _ = log_validation("[GAMEPLAY FAIL] Match started - game never initialized");
150
  }
151
 
 
152
  if completed {
153
  let end_frame = COMPLETION_FRAME.load(Ordering::Relaxed);
154
  let alive = ALIVE_AT_END.load(Ordering::Relaxed);
 
172
  pub fn install() -> Result<(), String> {
173
  if std::env::var("OPENWA_REPLAY_TEST").is_ok() {
174
  FAST_FORWARD.store(true, Ordering::Relaxed);
175
+ let _ = log_line("[ReplayTest] Replay test mode — fast-forward enabled");
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
 
178
  Ok(())
crates/openwa-wormkit/src/validation/mod.rs CHANGED
@@ -1190,7 +1190,7 @@ fn dump_worm_tasks() {
1190
  // ---------------------------------------------------------------------------
1191
 
1192
  fn dump_turngame() {
1193
- use crate::replacements::input::dump_region;
1194
  use openwa_core::task::CTaskTurnGame;
1195
 
1196
  let _ = log_validation("");
@@ -1366,7 +1366,7 @@ fn dump_turngame() {
1366
  // we can verify against what WA actually stores at runtime.
1367
 
1368
  fn dump_ctask_children() {
1369
- use crate::replacements::input::dump_region;
1370
 
1371
  let _ = log_validation("");
1372
  let _ = log_validation("--- CTask children sub-struct dump ---");
@@ -1458,7 +1458,7 @@ fn dump_ctask_children() {
1458
  // ---------------------------------------------------------------------------
1459
 
1460
  fn dump_ctaskteam_entities() {
1461
- use crate::replacements::input::dump_region;
1462
  use openwa_core::task::CTaskTeam;
1463
 
1464
  let _ = log_validation("");
 
1190
  // ---------------------------------------------------------------------------
1191
 
1192
  fn dump_turngame() {
1193
+ use crate::replacements::debug_utils::dump_region;
1194
  use openwa_core::task::CTaskTurnGame;
1195
 
1196
  let _ = log_validation("");
 
1366
  // we can verify against what WA actually stores at runtime.
1367
 
1368
  fn dump_ctask_children() {
1369
+ use crate::replacements::debug_utils::dump_region;
1370
 
1371
  let _ = log_validation("");
1372
  let _ = log_validation("--- CTask children sub-struct dump ---");
 
1458
  // ---------------------------------------------------------------------------
1459
 
1460
  fn dump_ctaskteam_entities() {
1461
+ use crate::replacements::debug_utils::dump_region;
1462
  use openwa_core::task::CTaskTeam;
1463
 
1464
  let _ = log_validation("");