paavohtl Claude Opus 4.6 (1M context) commited on
Commit
0c33b64
·
1 Parent(s): 9d57e27

feat: add gameplay milestone checks to replay testing

Browse files

Replace misleading "133 PASS" output (all static checks) with two
distinct categories:

- Static checks ([STATIC PASS/FAIL]): vtables, struct offsets, prologues
- Gameplay checks ([GAMEPLAY PASS/FAIL]): game init, match start, match end

Gameplay milestones are detected hook-based via TurnManager_ProcessFrame
(no timers): frame counter for init, health-based alive-team counting
for match start/completion. Report written from DLL_PROCESS_DETACH.

Key finding: team arena indices are 1-based (index 0 is unused preamble),
and sentinel header fields like worm_count are populated lazily — direct
worm health checks are more reliable for early-frame detection.

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

.claude/skills/replay-test/SKILL.md CHANGED
@@ -33,9 +33,11 @@ powershell -ExecutionPolicy Bypass -File replay-test.ps1 -Headless
33
  - If `testdata/logs/errorlog_latest.txt` exists, WA.exe crashed. Read it and report the crash (exception type, address, registers)
34
 
35
  **Headful mode:**
36
- - Read `testdata/logs/validation_latest.log` and summarize total PASS/FAIL counts
37
- - Quote any [FAIL] lines exactly
38
- - Note whether validation completed or safety timeout triggered
 
 
39
  - If `testdata/logs/openwa_latest.log` exists, check for errors or interesting hook activity
40
 
41
  **Headless mode:**
 
33
  - If `testdata/logs/errorlog_latest.txt` exists, WA.exe crashed. Read it and report the crash (exception type, address, registers)
34
 
35
  **Headful mode:**
36
+ - Read `testdata/logs/validation_latest.log` and check two categories:
37
+ - **Static checks** (`[STATIC PASS]`/`[STATIC FAIL]`): vtable addresses, struct offsets, function prologues. These verify memory layout correctness but NOT that the game actually ran.
38
+ - **Gameplay checks** (`[GAMEPLAY PASS]`/`[GAMEPLAY FAIL]`): milestone-based detection of game initialization, match start (multiple alive teams), and match completion (winner decided). These confirm the replay actually played through.
39
+ - Quote any FAIL lines exactly
40
+ - Note whether safety timeout triggered
41
  - If `testdata/logs/openwa_latest.log` exists, check for errors or interesting hook activity
42
 
43
  **Headless mode:**
crates/openwa-wormkit/src/lib.rs CHANGED
@@ -14,6 +14,7 @@ mod validation;
14
  // ---------------------------------------------------------------------------
15
 
16
  const DLL_PROCESS_ATTACH: u32 = 1;
 
17
 
18
  /// Named event shared with the launcher. The launcher waits on this event
19
  /// after DLL injection before resuming WA.exe's main thread, guaranteeing
@@ -32,6 +33,10 @@ unsafe extern "system" fn DllMain(
32
  let _ = log_line(&format!("[FATAL] {e}"));
33
  }
34
  });
 
 
 
 
35
  }
36
  1 // TRUE
37
  }
 
14
  // ---------------------------------------------------------------------------
15
 
16
  const DLL_PROCESS_ATTACH: u32 = 1;
17
+ const DLL_PROCESS_DETACH: u32 = 0;
18
 
19
  /// Named event shared with the launcher. The launcher waits on this event
20
  /// after DLL injection before resuming WA.exe's main thread, guaranteeing
 
33
  let _ = log_line(&format!("[FATAL] {e}"));
34
  }
35
  });
36
+ } else if reason == DLL_PROCESS_DETACH {
37
+ // Write gameplay milestone report before the process exits.
38
+ // This fires on natural exit, safety timeout, and headless mode.
39
+ replacements::write_gameplay_report();
40
  }
41
  1 // TRUE
42
  }
crates/openwa-wormkit/src/replacements/input.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! Replay fast-forward via DDGame+0x98B0.
2
  //!
3
  //! When `OPENWA_REPLAY_TEST=1`, hooks TurnManager_ProcessFrame (0x55FDA0)
4
  //! and sets DDGame+0x98B0 (fast-forward active flag) each frame.
@@ -9,14 +9,25 @@
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
  #![allow(dead_code)]
14
 
15
- use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
 
17
  use crate::hook;
18
  use crate::log_line;
19
  use openwa_core::address::va;
 
20
  use openwa_core::engine::{DDGame, DDGameWrapper};
21
  use openwa_core::rebase::rb;
22
 
@@ -30,6 +41,28 @@ static ORIG_TURN_MANAGER: AtomicU32 = AtomicU32::new(0);
30
  /// Whether to set fast-forward flag (only in replay test mode).
31
  static FAST_FORWARD: AtomicBool = AtomicBool::new(false);
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  /// Get the DDGame pointer (session+0xA0 → DDGameWrapper.ddgame).
34
  #[inline]
35
  unsafe fn get_ddgame() -> *mut DDGame {
@@ -148,6 +181,39 @@ pub unsafe fn dump_region(base_ptr: *const u8, offset: usize, size: usize, struc
148
  }
149
  }
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  /// Hook for TurnManager_ProcessFrame (stdcall, 1 param = TurnGame*).
152
  unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
153
  // Call original first
@@ -155,6 +221,8 @@ unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
155
  core::mem::transmute(ORIG_TURN_MANAGER.load(Ordering::Relaxed));
156
  orig(turngame);
157
 
 
 
158
  let ddgame = get_ddgame();
159
  if ddgame.is_null() {
160
  return;
@@ -164,6 +232,89 @@ unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
164
  if FAST_FORWARD.load(Ordering::Relaxed) {
165
  (*ddgame).fast_forward_active = 1;
166
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
 
169
  pub fn install() -> Result<(), String> {
 
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.
 
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
 
 
41
  /// Whether to set fast-forward flag (only in replay test mode).
42
  static FAST_FORWARD: AtomicBool = AtomicBool::new(false);
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Gameplay milestone tracking
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /// Total frames processed by TurnManager_ProcessFrame.
49
+ static FRAMES_PROCESSED: AtomicU32 = AtomicU32::new(0);
50
+
51
+ /// Milestone: at least 2 teams with alive worms detected.
52
+ static MATCH_STARTED: AtomicBool = AtomicBool::new(false);
53
+
54
+ /// Milestone: match decided — only 0 or 1 teams have alive worms.
55
+ static MATCH_COMPLETED: AtomicBool = AtomicBool::new(false);
56
+
57
+ /// Number of teams that had alive worms when match started.
58
+ static TEAMS_AT_START: AtomicU32 = AtomicU32::new(0);
59
+
60
+ /// Frame number when match completion was detected.
61
+ static COMPLETION_FRAME: AtomicU32 = AtomicU32::new(0);
62
+
63
+ /// Number of alive teams when match completed (0 = draw, 1 = winner).
64
+ static ALIVE_AT_END: AtomicI32 = AtomicI32::new(-1);
65
+
66
  /// Get the DDGame pointer (session+0xA0 → DDGameWrapper.ddgame).
67
  #[inline]
68
  unsafe fn get_ddgame() -> *mut DDGame {
 
181
  }
182
  }
183
 
184
+ /// Count how many teams have at least one alive worm.
185
+ ///
186
+ /// Iterates all 8 worm slots per team and checks `health > 0` (reliable
187
+ /// even before sentinel headers are fully populated). This is more
188
+ /// conservative than checking headers, which may be populated lazily.
189
+ ///
190
+ /// Returns (alive_team_count, total_team_count).
191
+ unsafe fn count_alive_teams(ddgame: *const DDGame) -> (i32, i32) {
192
+ let arena_base = (ddgame as u32) + offsets::TEAM_ARENA_STATE as u32;
193
+ let arena = TeamArenaRef::from_raw(arena_base);
194
+ let team_count = arena.state().team_count;
195
+ if team_count <= 0 || team_count > 6 {
196
+ return (0, 0);
197
+ }
198
+
199
+ let mut alive_teams = 0i32;
200
+ // Teams are 1-indexed in the arena (index 0 is unused preamble).
201
+ for t in 1..=team_count as usize {
202
+ let mut team_has_alive = false;
203
+ for w in 1..=8usize {
204
+ let worm = arena.team_worm(t, w);
205
+ if worm.health > 0 {
206
+ team_has_alive = true;
207
+ break;
208
+ }
209
+ }
210
+ if team_has_alive {
211
+ alive_teams += 1;
212
+ }
213
+ }
214
+ (alive_teams, team_count)
215
+ }
216
+
217
  /// Hook for TurnManager_ProcessFrame (stdcall, 1 param = TurnGame*).
218
  unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
219
  // Call original first
 
221
  core::mem::transmute(ORIG_TURN_MANAGER.load(Ordering::Relaxed));
222
  orig(turngame);
223
 
224
+ let frame = FRAMES_PROCESSED.fetch_add(1, Ordering::Relaxed) + 1;
225
+
226
  let ddgame = get_ddgame();
227
  if ddgame.is_null() {
228
  return;
 
232
  if FAST_FORWARD.load(Ordering::Relaxed) {
233
  (*ddgame).fast_forward_active = 1;
234
  }
235
+
236
+ // Milestone detection (check every 50 frames to reduce overhead).
237
+ // Skip the first 100 frames to let game state fully initialize.
238
+ if frame >= 100 && frame % 50 == 0 {
239
+ check_milestones(ddgame, frame);
240
+ }
241
+ }
242
+
243
+ /// Check and update gameplay milestones.
244
+ unsafe fn check_milestones(ddgame: *const DDGame, frame: u32) {
245
+ if MATCH_COMPLETED.load(Ordering::Relaxed) {
246
+ return;
247
+ }
248
+
249
+ let (alive, _total) = count_alive_teams(ddgame);
250
+
251
+ if !MATCH_STARTED.load(Ordering::Relaxed) {
252
+ if alive >= 2 {
253
+ MATCH_STARTED.store(true, Ordering::Relaxed);
254
+ TEAMS_AT_START.store(alive as u32, Ordering::Relaxed);
255
+ }
256
+ } else {
257
+ // Match started — check if it's now decided
258
+ if alive <= 1 {
259
+ MATCH_COMPLETED.store(true, Ordering::Relaxed);
260
+ COMPLETION_FRAME.store(frame, Ordering::Relaxed);
261
+ ALIVE_AT_END.store(alive, Ordering::Relaxed);
262
+ }
263
+ }
264
+ }
265
+
266
+ /// Write gameplay milestone report to the validation log.
267
+ ///
268
+ /// Called from DLL_PROCESS_DETACH to provide a final summary of game progress.
269
+ /// Uses `[GAMEPLAY PASS]` / `[GAMEPLAY FAIL]` markers so the PowerShell script
270
+ /// can distinguish these from static checks.
271
+ pub fn write_gameplay_report() {
272
+ use crate::validation::log_validation;
273
+
274
+ let frames = FRAMES_PROCESSED.load(Ordering::Relaxed);
275
+ let started = MATCH_STARTED.load(Ordering::Relaxed);
276
+ let completed = MATCH_COMPLETED.load(Ordering::Relaxed);
277
+
278
+ let _ = log_validation("");
279
+ let _ = log_validation("--- Gameplay Checks ---");
280
+
281
+ // Milestone 1: Game initialized (frame hook was called)
282
+ if frames > 0 {
283
+ let _ = log_validation(&format!(
284
+ "[GAMEPLAY PASS] Game initialized - {} frames processed",
285
+ frames
286
+ ));
287
+ } else {
288
+ let _ = log_validation("[GAMEPLAY FAIL] Game initialized - no frames processed (game may not have started)");
289
+ }
290
+
291
+ // Milestone 2: Match started (multiple teams with alive worms)
292
+ if started {
293
+ let teams = TEAMS_AT_START.load(Ordering::Relaxed);
294
+ let _ = log_validation(&format!(
295
+ "[GAMEPLAY PASS] Match started - {} teams with alive worms detected",
296
+ teams
297
+ ));
298
+ } else if frames > 0 {
299
+ let _ = log_validation("[GAMEPLAY FAIL] Match started - never detected multiple alive teams");
300
+ } else {
301
+ let _ = log_validation("[GAMEPLAY FAIL] Match started - game never initialized");
302
+ }
303
+
304
+ // Milestone 3: Match completed (one or zero teams remain)
305
+ if completed {
306
+ let end_frame = COMPLETION_FRAME.load(Ordering::Relaxed);
307
+ let alive = ALIVE_AT_END.load(Ordering::Relaxed);
308
+ let outcome = if alive == 1 { "winner decided" } else { "draw (all eliminated)" };
309
+ let _ = log_validation(&format!(
310
+ "[GAMEPLAY PASS] Match completed - {} at frame {}",
311
+ outcome, end_frame
312
+ ));
313
+ } else if started {
314
+ let _ = log_validation("[GAMEPLAY FAIL] Match completed - match started but never finished");
315
+ } else {
316
+ let _ = log_validation("[GAMEPLAY FAIL] Match completed - match never started");
317
+ }
318
  }
319
 
320
  pub fn install() -> Result<(), String> {
crates/openwa-wormkit/src/replacements/mod.rs CHANGED
@@ -15,6 +15,11 @@ mod speech;
15
  mod team;
16
  mod weapon;
17
 
 
 
 
 
 
18
  pub fn install_all() -> Result<(), String> {
19
  headless::install()?;
20
  display::install()?;
 
15
  mod team;
16
  mod weapon;
17
 
18
+ /// Write gameplay milestone report. Called from DLL_PROCESS_DETACH.
19
+ pub fn write_gameplay_report() {
20
+ input::write_gameplay_report();
21
+ }
22
+
23
  pub fn install_all() -> Result<(), String> {
24
  headless::install()?;
25
  display::install()?;
crates/openwa-wormkit/src/validation/mod.rs CHANGED
@@ -55,10 +55,10 @@ impl ValidationResult {
55
  fn check(&mut self, name: &str, ok: bool, detail: &str) {
56
  if ok {
57
  self.pass += 1;
58
- let _ = log_validation(&format!("[PASS] {} - {}", name, detail));
59
  } else {
60
  self.fail += 1;
61
- let _ = log_validation(&format!("[FAIL] {} - {}", name, detail));
62
  }
63
  }
64
 
@@ -1705,7 +1705,7 @@ pub fn run() -> Result<(), String> {
1705
  validate_struct_offsets(&mut result);
1706
 
1707
  let _ = log_validation("");
1708
- let _ = log_validation("--- Static Validation Summary ---");
1709
  let _ = log_validation(&format!(" {}", result.summary_line()));
1710
 
1711
  match hooks::install_all() {
 
55
  fn check(&mut self, name: &str, ok: bool, detail: &str) {
56
  if ok {
57
  self.pass += 1;
58
+ let _ = log_validation(&format!("[STATIC PASS] {} - {}", name, detail));
59
  } else {
60
  self.fail += 1;
61
+ let _ = log_validation(&format!("[STATIC FAIL] {} - {}", name, detail));
62
  }
63
  }
64
 
 
1705
  validate_struct_offsets(&mut result);
1706
 
1707
  let _ = log_validation("");
1708
+ let _ = log_validation("--- Static Checks (vtables, struct offsets, prologues) ---");
1709
  let _ = log_validation(&format!(" {}", result.summary_line()));
1710
 
1711
  match hooks::install_all() {
replay-test.ps1 CHANGED
@@ -166,17 +166,25 @@ if ($Headless) {
166
  Write-Host "=== Validation Summary ===" -ForegroundColor Cyan
167
  if (Test-Path "$logDir\validation_latest.log") {
168
  $content = Get-Content "$logDir\validation_latest.log" -Raw
169
- $passes = ([regex]::Matches($content, "\[PASS\]")).Count
170
- $fails = ([regex]::Matches($content, "\[FAIL\]")).Count
171
- Write-Host " PASS: $passes" -ForegroundColor Green
172
- Write-Host " FAIL: $fails" -ForegroundColor $(if ($fails -gt 0) { "Red" } else { "Green" })
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  if ($content -match "Safety timeout reached") {
175
  Write-Host " Replay: TIMEOUT (safety timeout triggered)" -ForegroundColor Red
176
- } elseif ($content -match "deferred global validation") {
177
- Write-Host " Validation: completed" -ForegroundColor Green
178
- } else {
179
- Write-Host " Validation: NOT found in log (may not have reached gameplay)" -ForegroundColor Yellow
180
  }
181
  }
182
  # Check for Rust panics in OpenWA log
 
166
  Write-Host "=== Validation Summary ===" -ForegroundColor Cyan
167
  if (Test-Path "$logDir\validation_latest.log") {
168
  $content = Get-Content "$logDir\validation_latest.log" -Raw
169
+
170
+ # Static checks (vtables, struct offsets, prologues)
171
+ $staticPasses = ([regex]::Matches($content, "\[STATIC PASS\]")).Count
172
+ $staticFails = ([regex]::Matches($content, "\[STATIC FAIL\]")).Count
173
+ $staticColor = if ($staticFails -gt 0) { "Red" } else { "Green" }
174
+ Write-Host " Static checks: $staticPasses pass, $staticFails fail (vtables, struct offsets, prologues)" -ForegroundColor $staticColor
175
+
176
+ # Gameplay checks (milestones from frame hook)
177
+ $gameplayPasses = ([regex]::Matches($content, "\[GAMEPLAY PASS\]")).Count
178
+ $gameplayFails = ([regex]::Matches($content, "\[GAMEPLAY FAIL\]")).Count
179
+ if ($gameplayPasses + $gameplayFails -gt 0) {
180
+ $gameplayColor = if ($gameplayFails -gt 0) { "Red" } else { "Green" }
181
+ Write-Host " Gameplay checks: $gameplayPasses pass, $gameplayFails fail (init, match start, match end)" -ForegroundColor $gameplayColor
182
+ } else {
183
+ Write-Host " Gameplay checks: not found (DLL may not have detached cleanly)" -ForegroundColor Yellow
184
+ }
185
 
186
  if ($content -match "Safety timeout reached") {
187
  Write-Host " Replay: TIMEOUT (safety timeout triggered)" -ForegroundColor Red
 
 
 
 
188
  }
189
  }
190
  # Check for Rust panics in OpenWA log