paavohtl Claude Opus 4.6 commited on
Commit
6adcfaf
·
1 Parent(s): c9c7d08

refactor: use FullTeamBlock structs in team/weapon hooks, add F9 dump hotkey

Browse files

Refactor team.rs and weapon.rs replacements to use WormEntry and
FullTeamBlock struct field access instead of raw pointer arithmetic.
Rename HasFullHealthWorm → CheckWormState0x64 (reads state, not health).
Add named worm state constants (IDLE=0x65, ACTIVE=0x67, DEAD=0x87).
Add F9 hotkey to validator for on-demand team block dumps.
Cross-validate struct access against old raw pointer patterns (8/8 pass).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

crates/openwa-types/src/address.rs CHANGED
@@ -144,9 +144,10 @@ pub mod va {
144
  /// Reads worm X,Y position into output pointers.
145
  /// usercall(EAX=team_index, ECX=worm_index, [ESP+4]=base, [ESP+8]=&x, [ESP+C]=&y), RET 0xC.
146
  pub const GET_WORM_POSITION: u32 = 0x0052_2700;
147
- /// Checks if any team has a worm at full health (100). 11 xrefs.
 
148
  /// usercall(EAX=base) → EAX=bool, plain RET.
149
- pub const HAS_FULL_HEALTH_WORM: u32 = 0x0052_28D0;
150
 
151
  // === Graphics / rendering ===
152
 
 
144
  /// Reads worm X,Y position into output pointers.
145
  /// usercall(EAX=team_index, ECX=worm_index, [ESP+4]=base, [ESP+8]=&x, [ESP+C]=&y), RET 0xC.
146
  pub const GET_WORM_POSITION: u32 = 0x0052_2700;
147
+ /// Checks if any worm has state 0x64 (100). 11 xrefs in gameplay code.
148
+ /// Despite the comparison to 100, reads worms[].state NOT .health.
149
  /// usercall(EAX=base) → EAX=bool, plain RET.
150
+ pub const CHECK_WORM_STATE_0X64: u32 = 0x0052_28D0;
151
 
152
  // === Graphics / rendering ===
153
 
crates/openwa-types/src/ddgame.rs CHANGED
@@ -119,6 +119,19 @@ pub mod offsets {
119
  /// GetAmmo/AddAmmo/SubtractAmmo.
120
  pub const TEAM_WEAPON_STATE: usize = 0x4628;
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  // === FUN_00526120 init offsets (stride 0x194, 10 entries) ===
123
  pub const INIT_TABLE_BASE: usize = 0x379C;
124
  pub const INIT_TABLE_STRIDE: usize = 0x194;
@@ -156,6 +169,106 @@ pub struct TeamEntry {
156
 
157
  const _: () = assert!(core::mem::size_of::<TeamEntry>() == 0x51C);
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  /// Team weapon state area within DDGame (at DDGame + 0x4628).
160
  ///
161
  /// Contains per-team entries and an interleaved ammo/delay table.
@@ -211,27 +324,23 @@ pub struct TeamWeaponState {
211
 
212
  const _: () = assert!(core::mem::size_of::<TeamWeaponState>() == 0x2C40);
213
 
214
- /// Worm data layout relative to TeamEntry.
215
- /// Worm data lives at NEGATIVE offsets from the TeamEntry base pointer.
216
  pub mod worm {
217
- /// Stride between consecutive worm entries
218
- pub const STRIDE: usize = 0x9C;
219
- /// Worm count at team_entry - COUNT_OFFSET
220
- pub const COUNT_OFFSET: usize = 0x4;
221
- /// Worm array start at team_entry - ARRAY_OFFSET (stride STRIDE, health at [0])
222
- pub const ARRAY_OFFSET: usize = 0x4A0;
223
- /// Worm position X at team+worm addr - POS_X_OFFSET
224
- pub const POS_X_OFFSET: usize = 0x508;
225
- /// Worm position Y at team+worm addr - POS_Y_OFFSET
226
- pub const POS_Y_OFFSET: usize = 0x504;
227
- /// Worm state flag at team+worm addr - STATE_OFFSET
228
- pub const STATE_OFFSET: usize = 0x598;
229
- /// Full health value (used in HasFullHealthWorm)
230
- pub const FULL_HEALTH: i32 = 100;
231
 
232
  /// Special worm states — worm is dying/drowning/in special animation.
233
  /// Checked by IsWormInSpecialState (0x5226B0).
234
- /// No documented names exist in third-party sources for individual states.
235
  pub const SPECIAL_STATES: [u32; 6] = [0x80, 0x81, 0x82, 0x83, 0x85, 0x89];
236
 
237
  /// Check if a worm state value is a "special" state.
@@ -245,19 +354,15 @@ pub const GAME_PHASE_SUDDEN_DEATH: i32 = 0x1E4; // 484
245
  pub const GAME_PHASE_NORMAL_MIN: i32 = -2;
246
 
247
  /// Team data offsets within TeamWeaponState (relative to base pointer).
 
 
248
  pub mod team_data {
249
- /// Offset to first team's per-team data block
 
 
250
  pub const BASE_OFFSET: usize = 0x510;
251
  /// Alive flag within per-team data block (at +4 from team data start)
252
  pub const ALIVE_FLAG: usize = 4;
253
- /// Eliminated flag — at team_entry - 0x10
254
- pub const ELIMINATED_OFFSET: usize = 0x10;
255
- /// Alternative team pointer base used in HasFullHealthWorm
256
- pub const ALT_BASE_OFFSET: usize = 0x518;
257
- /// Worm data offset from alt base (negative: team_ptr - 0x4F8)
258
- pub const ALT_WORM_ARRAY_OFFSET: usize = 0x4F8;
259
- /// Eliminated flag from alt base (negative: team_ptr - 0xC)
260
- pub const ALT_ELIMINATED_OFFSET: usize = 0x0C;
261
  }
262
 
263
  impl TeamWeaponState {
 
119
  /// GetAmmo/AddAmmo/SubtractAmmo.
120
  pub const TEAM_WEAPON_STATE: usize = 0x4628;
121
 
122
+ // === Team block array (7 × FullTeamBlock, stride 0x51C) ===
123
+ /// Start of team block array within DDGame (7 blocks, stride 0x51C).
124
+ /// Derived: entry_ptr(team=0) - 0x598 = 0x4628 - 0x598 = 0x4090.
125
+ /// Runtime-confirmed: block[0] is zeroed preamble, blocks[1-6] hold team data.
126
+ pub const TEAM_BLOCKS: usize = 0x4090;
127
+
128
+ /// Byte offset from TeamWeaponState base back to FullTeamBlock array start.
129
+ /// `blocks_ptr = (tws_base as *const u8).sub(TWS_TO_BLOCKS) as *const FullTeamBlock`
130
+ ///
131
+ /// entry_ptr(0) = DDGame+0x4628 = TEAM_BLOCKS + 0x598.
132
+ /// 0x598 = sizeof(FullTeamBlock) + 0x7C = one block + offset into sentinel worm[0].
133
+ pub const TWS_TO_BLOCKS: usize = 0x598;
134
+
135
  // === FUN_00526120 init offsets (stride 0x194, 10 entries) ===
136
  pub const INIT_TABLE_BASE: usize = 0x379C;
137
  pub const INIT_TABLE_STRIDE: usize = 0x194;
 
169
 
170
  const _: () = assert!(core::mem::size_of::<TeamEntry>() == 0x51C);
171
 
172
+ // ============================================================
173
+ // Per-worm and per-team block structs
174
+ // ============================================================
175
+
176
+ /// Per-worm data entry (0x9C bytes, stride between consecutive worms).
177
+ ///
178
+ /// 8 slots per team. Slot 0 is a sentinel/header (team metadata), slots 1-7
179
+ /// hold playable worm data. GetTeamTotalHealth iterates starting from slot 1.
180
+ ///
181
+ /// Field offsets confirmed by runtime memory dump (validator DLL):
182
+ /// - state at 0x00: 0x67 = active/selected, 0x65 = idle, 0x80+ = special
183
+ /// - active_flag at 0x0C: 1 for alive worms
184
+ /// - max_health at 0x58: initial health value (100)
185
+ /// - health at 0x5C: current health (100 = full)
186
+ /// - name at 0x78: null-terminated worm name string (~20 bytes)
187
+ ///
188
+ /// Sentinel (slot 0) has different layout — see `sentinel_*` methods:
189
+ /// - +0x6C (_unknown_60\[0x0C\]): eliminated flag
190
+ /// - +0x78 (name\[0..4\]): worm_count
191
+ /// - +0x80 (name\[8..12\]): alliance_id
192
+ /// - +0x84 (name\[12..\]): team name string
193
+ /// - +0x98 (_unknown_90\[8..12\]): value 0x190 (400), unknown purpose
194
+ #[repr(C)]
195
+ pub struct WormEntry {
196
+ /// 0x00: Worm state machine state.
197
+ /// Values: 0x65=idle, 0x67=active/selected, 0x68=active variant.
198
+ /// Special states {0x80..0x85, 0x89} = dying/drowning/special animation.
199
+ pub state: u32,
200
+ /// 0x04-0x0B: Unknown
201
+ pub _unknown_04: [u8; 8],
202
+ /// 0x0C: Active/alive flag (1 for alive worms in game, 0 otherwise).
203
+ pub active_flag: i32,
204
+ /// 0x10-0x57: Unknown
205
+ pub _unknown_10: [u8; 0x48],
206
+ /// 0x58: Max health (initial health value, typically 100).
207
+ pub max_health: i32,
208
+ /// 0x5C: Current health. Used by GetTeamTotalHealth.
209
+ pub health: i32,
210
+ /// 0x60-0x77: Unknown.
211
+ /// In sentinel: +0x6C = eliminated flag for the team.
212
+ pub _unknown_60: [u8; 0x18],
213
+ /// 0x78: Worm name, null-terminated ASCII string (~20 bytes).
214
+ /// In sentinel: +0x78 = worm_count (i32), +0x80 = alliance_id (i32),
215
+ /// +0x84 = team name string.
216
+ pub name: [u8; 0x18],
217
+ /// 0x90-0x9B: Unknown (zeroed in runtime dump for playable worms).
218
+ /// GetWormPosition reads pos_x/pos_y from +0x90/+0x94 via negative entry_ptr
219
+ /// arithmetic, but values appear transient — not populated at rest.
220
+ /// Actual worm positions live in CGameTask objects (+0x84/+0x88).
221
+ pub _unknown_90: [u8; 0x0C],
222
+ }
223
+
224
+ const _: () = assert!(core::mem::size_of::<WormEntry>() == 0x9C);
225
+
226
+ impl WormEntry {
227
+ /// Read worm_count from this entry when it's a sentinel (slot 0).
228
+ /// Stored at self.name[0..4] (= WormEntry offset 0x78) as little-endian i32.
229
+ ///
230
+ /// # Safety
231
+ /// Only valid when called on a sentinel worm (slot 0 of a FullTeamBlock).
232
+ pub unsafe fn sentinel_worm_count(&self) -> i32 {
233
+ *(self.name.as_ptr() as *const i32)
234
+ }
235
+
236
+ /// Read eliminated flag from this entry when it's a sentinel (slot 0).
237
+ /// Stored at self._unknown_60[0x0C] (= WormEntry offset 0x6C) as i32.
238
+ /// Nonzero = team is eliminated.
239
+ ///
240
+ /// # Safety
241
+ /// Only valid when called on a sentinel worm (slot 0 of a FullTeamBlock).
242
+ pub unsafe fn sentinel_eliminated(&self) -> i32 {
243
+ *(self._unknown_60.as_ptr().add(0x0C) as *const i32)
244
+ }
245
+ }
246
+
247
+ /// Full per-team data block (0x51C bytes, 6 teams in DDGame).
248
+ ///
249
+ /// Contains 8 WormEntry slots (0x4E0 bytes) followed by 0x3C bytes of
250
+ /// team metadata. Slot 0 is a sentinel/header; slots 1-7 are playable worms.
251
+ ///
252
+ /// **Block indexing**: Block 0 is unused (preamble, all zeros). Actual team
253
+ /// data starts at block 1. entry_ptr(team=N) = DDGame+0x4628+N*0x51C, which
254
+ /// lands at block\[N+1\].worm\[0\]+0x7C. Negative offsets reach back into
255
+ /// block\[N\]'s worm data. So entry_ptr(1) accesses block\[1\]'s worms.
256
+ ///
257
+ /// **Sentinel worm\[0\]** stores metadata for the team accessed by the
258
+ /// PREVIOUS entry_ptr index:
259
+ /// - +0x78: worm_count (accessed as entry_ptr-4)
260
+ /// - +0x80: alliance_id (accessed as entry_ptr+4)
261
+ /// - +0x84: team name string
262
+ #[repr(C)]
263
+ pub struct FullTeamBlock {
264
+ /// 0x000-0x4DF: 8 worm entries (stride 0x9C)
265
+ pub worms: [WormEntry; 8],
266
+ /// 0x4E0-0x51B: Team metadata (0x3C bytes)
267
+ pub _metadata: [u8; 0x3C],
268
+ }
269
+
270
+ const _: () = assert!(core::mem::size_of::<FullTeamBlock>() == 0x51C);
271
+
272
  /// Team weapon state area within DDGame (at DDGame + 0x4628).
273
  ///
274
  /// Contains per-team entries and an interleaved ammo/delay table.
 
324
 
325
  const _: () = assert!(core::mem::size_of::<TeamWeaponState>() == 0x2C40);
326
 
327
+ /// Worm state constants and helpers.
 
328
  pub mod worm {
329
+ // --- Known state values (runtime-validated via validator dumps) ---
330
+
331
+ /// Transitional state checked by CheckWormState0x64 (0x5228D0). 11 xrefs.
332
+ /// Appears briefly during turn transitions.
333
+ pub const STATE_TRANSITIONAL: u32 = 0x64;
334
+ /// Idle — worm is waiting, not currently active.
335
+ pub const STATE_IDLE: u32 = 0x65;
336
+ /// Active — worm is currently selected and taking its turn.
337
+ pub const STATE_ACTIVE: u32 = 0x67;
338
+ /// Dead — worm has been killed (hp=0). Persists across turns.
339
+ pub const STATE_DEAD: u32 = 0x87;
 
 
 
340
 
341
  /// Special worm states — worm is dying/drowning/in special animation.
342
  /// Checked by IsWormInSpecialState (0x5226B0).
343
+ /// All values in the 0x80+ range. 0x87 (dead) is also in this set.
344
  pub const SPECIAL_STATES: [u32; 6] = [0x80, 0x81, 0x82, 0x83, 0x85, 0x89];
345
 
346
  /// Check if a worm state value is a "special" state.
 
354
  pub const GAME_PHASE_NORMAL_MIN: i32 = -2;
355
 
356
  /// Team data offsets within TeamWeaponState (relative to base pointer).
357
+ /// Used by CountTeamsByAlliance which accesses a different sentinel layout
358
+ /// than the entry_ptr-based functions (offset +0x70/+0x74 vs +0x78/+0x80).
359
  pub mod team_data {
360
+ /// Offset to first team's per-team data block (from TWS base).
361
+ /// Maps to block[2].worms[0]+0x70 — a separate alliance/alive pair
362
+ /// distinct from the entry_ptr-based alliance_id at worms[0]+0x80.
363
  pub const BASE_OFFSET: usize = 0x510;
364
  /// Alive flag within per-team data block (at +4 from team data start)
365
  pub const ALIVE_FLAG: usize = 4;
 
 
 
 
 
 
 
 
366
  }
367
 
368
  impl TeamWeaponState {
crates/openwa-validator/Cargo.toml CHANGED
@@ -12,4 +12,8 @@ openwa-types = { path = "../openwa-types" }
12
  minhook = "0.9"
13
 
14
  [target.'cfg(target_os = "windows")'.dependencies]
15
- windows-sys = { version = "0.59", features = ["Win32_System_Memory"] }
 
 
 
 
 
12
  minhook = "0.9"
13
 
14
  [target.'cfg(target_os = "windows")'.dependencies]
15
+ windows-sys = { version = "0.59", features = [
16
+ "Win32_System_Memory",
17
+ "Win32_System_LibraryLoader",
18
+ "Win32_UI_Input_KeyboardAndMouse",
19
+ ] }
crates/openwa-validator/src/lib.rs CHANGED
@@ -10,6 +10,9 @@ use openwa_types::task::{CTask, CGameTask};
10
  use openwa_types::ddgame::DDGame;
11
  use openwa_types::ddgame_wrapper::DDGameWrapper;
12
 
 
 
 
13
  // ---------------------------------------------------------------------------
14
  // ASLR rebasing
15
  // ---------------------------------------------------------------------------
@@ -17,6 +20,7 @@ use openwa_types::ddgame_wrapper::DDGameWrapper;
17
  /// Delta to add to Ghidra addresses to get runtime addresses.
18
  /// Computed as: actual_base - IMAGE_BASE (0x400000)
19
  static REBASE_DELTA: AtomicU32 = AtomicU32::new(0);
 
20
 
21
  /// Rebase a Ghidra VA to the actual runtime address.
22
  #[inline]
@@ -24,10 +28,6 @@ pub(crate) fn rb(ghidra_addr: u32) -> u32 {
24
  ghidra_addr.wrapping_add(REBASE_DELTA.load(Ordering::Relaxed))
25
  }
26
 
27
- extern "system" {
28
- fn GetModuleHandleA(lpModuleName: *const u8) -> *mut c_void;
29
- }
30
-
31
  fn init_rebase() -> i32 {
32
  let base = unsafe { GetModuleHandleA(std::ptr::null()) } as u32;
33
  let delta = base.wrapping_sub(va::IMAGE_BASE);
@@ -472,6 +472,151 @@ fn deferred_global_validation() {
472
  let _ = log_line(&format!(" Deferred {}", result.summary_line()));
473
  }
474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  // ---------------------------------------------------------------------------
476
  // Main validation entry point
477
  // ---------------------------------------------------------------------------
@@ -526,5 +671,26 @@ fn run_validation() -> Result<(), Box<dyn std::error::Error>> {
526
  });
527
  let _ = log_line(" Polling thread started (10s delay).");
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  Ok(())
530
  }
 
10
  use openwa_types::ddgame::DDGame;
11
  use openwa_types::ddgame_wrapper::DDGameWrapper;
12
 
13
+ use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
14
+ use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
15
+
16
  // ---------------------------------------------------------------------------
17
  // ASLR rebasing
18
  // ---------------------------------------------------------------------------
 
20
  /// Delta to add to Ghidra addresses to get runtime addresses.
21
  /// Computed as: actual_base - IMAGE_BASE (0x400000)
22
  static REBASE_DELTA: AtomicU32 = AtomicU32::new(0);
23
+ static DUMP_COUNTER: AtomicU32 = AtomicU32::new(0);
24
 
25
  /// Rebase a Ghidra VA to the actual runtime address.
26
  #[inline]
 
28
  ghidra_addr.wrapping_add(REBASE_DELTA.load(Ordering::Relaxed))
29
  }
30
 
 
 
 
 
31
  fn init_rebase() -> i32 {
32
  let base = unsafe { GetModuleHandleA(std::ptr::null()) } as u32;
33
  let delta = base.wrapping_sub(va::IMAGE_BASE);
 
472
  let _ = log_line(&format!(" Deferred {}", result.summary_line()));
473
  }
474
 
475
+ // ---------------------------------------------------------------------------
476
+ // Team block memory dump (for WormEntry/FullTeamBlock verification)
477
+ // ---------------------------------------------------------------------------
478
+
479
+ fn dump_team_blocks() {
480
+ use openwa_types::ddgame::{offsets, FullTeamBlock, TeamWeaponState};
481
+
482
+ let dump_num = DUMP_COUNTER.fetch_add(1, Ordering::Relaxed);
483
+ let _ = log_line("");
484
+ let _ = log_line(&format!("--- Team Block Dump #{} ---", dump_num));
485
+
486
+ unsafe {
487
+ let session_ptr = read_u32(rb(va::G_GAME_SESSION));
488
+ if session_ptr == 0 {
489
+ let _ = log_line(" No game session — skipping.");
490
+ return;
491
+ }
492
+
493
+ let wrapper_addr = read_u32(session_ptr + 0xA0);
494
+ if wrapper_addr == 0 {
495
+ let _ = log_line(" No DDGameWrapper — need to start a game first.");
496
+ return;
497
+ }
498
+
499
+ let ddgame_ptr = read_u32(wrapper_addr + 0x488);
500
+ if ddgame_ptr == 0 {
501
+ let _ = log_line(" No DDGame — need to be in gameplay.");
502
+ return;
503
+ }
504
+
505
+ let _ = log_line(&format!(" DDGame = 0x{:08X}", ddgame_ptr));
506
+
507
+ // Get TeamWeaponState via struct
508
+ let tws_base = (ddgame_ptr + offsets::TEAM_WEAPON_STATE as u32) as *const u8;
509
+ let tws = &*(tws_base as *const TeamWeaponState);
510
+ let _ = log_line(&format!(" team_count = {} (TeamWeaponState.team_count)", tws.team_count));
511
+
512
+ // Get FullTeamBlock array via TWS_TO_BLOCKS offset
513
+ let blocks = tws_base.sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
514
+ let blocks_addr = blocks as u32;
515
+ let _ = log_line(&format!(" blocks_base = 0x{:08X} (DDGame+0x{:X})",
516
+ blocks_addr, blocks_addr - ddgame_ptr));
517
+
518
+ let mut result = ValidationResult::new();
519
+
520
+ // Validate TWS_TO_BLOCKS: blocks_base should == DDGame + TEAM_BLOCKS
521
+ let expected_blocks = ddgame_ptr + offsets::TEAM_BLOCKS as u32;
522
+ result.check("TWS_TO_BLOCKS derivation",
523
+ blocks_addr == expected_blocks,
524
+ &format!("got 0x{:08X}, expected 0x{:08X}", blocks_addr, expected_blocks));
525
+
526
+ // Validate each real team (1-indexed: blocks[1..=team_count])
527
+ let num_blocks = (tws.team_count as u32 + 1).max(3).min(7);
528
+ for b in 0..num_blocks {
529
+ let block = &*blocks.add(b as usize);
530
+ let _ = log_line(&format!("\n === Block {} (0x{:08X}) ===",
531
+ b, blocks_addr + b * 0x51C));
532
+
533
+ // Sentinel: block[b+1].worms[0] holds metadata for entry_ptr(b)
534
+ if (b + 1) < 7 {
535
+ let sentinel = &(*blocks.add(b as usize + 1)).worms[0];
536
+ let worm_count = sentinel.sentinel_worm_count();
537
+ let eliminated = sentinel.sentinel_eliminated();
538
+
539
+ // Cross-check sentinel vs raw entry_ptr reads
540
+ let entry_ptr = tws_base.add(b as usize * 0x51C);
541
+ let raw_worm_count = *(entry_ptr.sub(4) as *const i32);
542
+ let raw_alliance = *(entry_ptr.add(4) as *const i32);
543
+
544
+ result.check(
545
+ &format!("block[{}] sentinel_worm_count vs raw", b),
546
+ worm_count == raw_worm_count,
547
+ &format!("struct={}, raw={}", worm_count, raw_worm_count),
548
+ );
549
+
550
+ let _ = log_line(&format!(" sentinel: worm_count={}, eliminated={}, alliance(entry_ptr+4)={}",
551
+ worm_count, eliminated, raw_alliance));
552
+
553
+ // Dump playable worms using struct field access
554
+ for w in 0..8usize {
555
+ let worm = &block.worms[w];
556
+ let active = worm.active_flag;
557
+ let name_bytes = &worm.name;
558
+ let name_len = name_bytes.iter().position(|&c| c == 0).unwrap_or(name_bytes.len());
559
+ let name_str = core::str::from_utf8(&name_bytes[..name_len]).unwrap_or("?");
560
+
561
+ if worm.state != 0 || worm.health != 0 || active != 0 || w == 0 {
562
+ let _ = log_line(&format!(
563
+ " worm[{}]: state=0x{:04X} active={} max_hp={} hp={} name=\"{}\"",
564
+ w, worm.state, active, worm.max_health, worm.health, name_str
565
+ ));
566
+ }
567
+ }
568
+
569
+ // Validate GetTeamTotalHealth pattern: sum block.worms[1..=count].health
570
+ if worm_count > 0 && worm_count <= 7 {
571
+ let struct_total: i32 = (1..=worm_count as usize)
572
+ .map(|w| block.worms[w].health)
573
+ .sum();
574
+
575
+ // Cross-check vs raw pointer method (old ARRAY_OFFSET=0x4A0 pattern)
576
+ let raw_health_ptr = entry_ptr.sub(0x4A0) as *const i32;
577
+ let mut raw_total = 0i32;
578
+ for i in 0..worm_count {
579
+ raw_total += *raw_health_ptr.add(i as usize * (0x9C / 4));
580
+ }
581
+
582
+ result.check(
583
+ &format!("block[{}] health sum (struct vs raw)", b),
584
+ struct_total == raw_total,
585
+ &format!("struct={}, raw={}", struct_total, raw_total),
586
+ );
587
+ }
588
+
589
+ // Validate IsWormInSpecialState pattern: block.worms[w].state
590
+ // Cross-check worm[1].state via struct vs raw (STATE_OFFSET=0x598)
591
+ if worm_count > 0 {
592
+ let struct_state = block.worms[1].state;
593
+ let raw_state = *(entry_ptr.sub(0x598).add(0x9C) as *const u32);
594
+ result.check(
595
+ &format!("block[{}] worm[1].state (struct vs raw)", b),
596
+ struct_state == raw_state,
597
+ &format!("struct=0x{:X}, raw=0x{:X}", struct_state, raw_state),
598
+ );
599
+ }
600
+
601
+ // Validate CheckWormState0x64 pattern: reads worms[].state, NOT .health
602
+ // Show both values for each worm so we can verify
603
+ if worm_count > 0 {
604
+ let _ = log_line(&format!(" CheckWormState0x64 field check (state vs health):"));
605
+ for w in 1..=worm_count as usize {
606
+ let worm = &block.worms[w];
607
+ let _ = log_line(&format!(
608
+ " worm[{}]: state=0x{:04X}({}) health=0x{:04X}({})",
609
+ w, worm.state, worm.state, worm.health, worm.health
610
+ ));
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ let _ = log_line(&format!("\n Struct Validation {}", result.summary_line()));
617
+ }
618
+ }
619
+
620
  // ---------------------------------------------------------------------------
621
  // Main validation entry point
622
  // ---------------------------------------------------------------------------
 
671
  });
672
  let _ = log_line(" Polling thread started (10s delay).");
673
 
674
+ // 8. Team block dump — wait 30s for user to start a game
675
+ std::thread::spawn(move || {
676
+ std::thread::sleep(std::time::Duration::from_secs(30));
677
+ dump_team_blocks();
678
+ });
679
+ let _ = log_line(" Team block dump thread started (30s delay).");
680
+
681
+ // 9. Hotkey listener — F9 triggers a team block dump on demand
682
+ std::thread::spawn(|| {
683
+ const VK_F9: i32 = 0x78;
684
+ let _ = log_line(" Hotkey listener started (press F9 to dump team blocks).");
685
+ loop {
686
+ std::thread::sleep(std::time::Duration::from_millis(100));
687
+ let state = unsafe { GetAsyncKeyState(VK_F9) };
688
+ // Bit 0 = key was pressed since last call
689
+ if state & 1 != 0 {
690
+ dump_team_blocks();
691
+ }
692
+ }
693
+ });
694
+
695
  Ok(())
696
  }
crates/openwa-wormkit/src/replacements/team.rs CHANGED
@@ -5,10 +5,10 @@
5
  //! - GetTeamTotalHealth (0x5224D0): sum worm health for a team
6
  //! - IsWormInSpecialState (0x5226B0): check worm state flag
7
  //! - GetWormPosition (0x522700): read worm X,Y coordinates
8
- //! - HasFullHealthWorm (0x5228D0): check if any worm has full health
9
 
10
  use openwa_types::address::va;
11
- use openwa_types::ddgame::{self, TeamWeaponState};
12
 
13
  use crate::hook::{self, usercall_trampoline};
14
 
@@ -17,6 +17,11 @@ use crate::hook::{self, usercall_trampoline};
17
  // ============================================================
18
  // __usercall: EAX = base, EDI = alliance_id
19
  // plain RET
 
 
 
 
 
20
 
21
  unsafe extern "cdecl" fn count_teams_by_alliance_impl(base: u32, alliance_id: i32) {
22
  let state = &mut *(base as *mut TeamWeaponState);
@@ -25,9 +30,6 @@ unsafe extern "cdecl" fn count_teams_by_alliance_impl(base: u32, alliance_id: i3
25
  state.same_alliance_count = 0;
26
  state.enemy_team_count = 0;
27
 
28
- // Teams are 1-indexed; iterate 1..=team_count
29
- // Per-team data: base + 0x510 + (team_1based - 1) * 0x51C
30
- // At [+0]: alliance field, [+4]: alive flag
31
  let base_ptr = base as *const u8;
32
  for i in 0..state.team_count {
33
  let team_data = base_ptr.add(ddgame::team_data::BASE_OFFSET + i as usize * 0x51C);
@@ -54,22 +56,18 @@ usercall_trampoline!(fn trampoline_count_teams_by_alliance; impl_fn = count_team
54
  // plain RET, returns EAX = total health
55
 
56
  unsafe extern "cdecl" fn get_team_total_health_impl(team_index: u32, base: u32) -> i32 {
57
- let base_ptr = base as *const u8;
58
- let team_entry = base_ptr.add(team_index as usize * 0x51C) as usize;
 
59
 
60
- // Check eliminated flag at team_entry - ELIMINATED_OFFSET
61
- if *((team_entry - ddgame::team_data::ELIMINATED_OFFSET) as *const i32) != 0 {
62
  return 0;
63
  }
64
 
65
- let worm_count = *((team_entry - ddgame::worm::COUNT_OFFSET) as *const i32);
66
  let mut total = 0i32;
67
- if worm_count > 0 {
68
- let mut ptr = (team_entry - ddgame::worm::ARRAY_OFFSET) as *const u8;
69
- for _ in 0..worm_count {
70
- total += *(ptr as *const i32);
71
- ptr = ptr.add(ddgame::worm::STRIDE);
72
- }
73
  }
74
  total
75
  }
@@ -86,11 +84,9 @@ usercall_trampoline!(fn trampoline_get_team_total_health; impl_fn = get_team_tot
86
  unsafe extern "cdecl" fn is_worm_in_special_state_impl(
87
  team_index: u32, worm_index: u32, base: u32,
88
  ) -> u32 {
89
- let addr = base as usize
90
- + team_index as usize * 0x51C
91
- + worm_index as usize * ddgame::worm::STRIDE;
92
- let state_val = *((addr.wrapping_sub(ddgame::worm::STATE_OFFSET)) as *const u32);
93
- if ddgame::worm::is_special_state(state_val) { 1 } else { 0 }
94
  }
95
 
96
  usercall_trampoline!(fn trampoline_is_worm_in_special_state; impl_fn = is_worm_in_special_state_impl;
@@ -102,50 +98,56 @@ usercall_trampoline!(fn trampoline_is_worm_in_special_state; impl_fn = is_worm_i
102
  // __usercall: EAX = team_index, ECX = worm_index,
103
  // [ESP+4] = base, [ESP+8] = out_x, [ESP+C] = out_y
104
  // RET 0xC
 
 
 
105
 
106
  unsafe extern "cdecl" fn get_worm_position_impl(
107
  team_index: u32, worm_index: u32, base: u32, out_x: *mut i32, out_y: *mut i32,
108
  ) {
109
- let addr = base as usize
110
- + team_index as usize * 0x51C
111
- + worm_index as usize * ddgame::worm::STRIDE;
112
- *out_x = *((addr.wrapping_sub(ddgame::worm::POS_X_OFFSET)) as *const i32);
113
- *out_y = *((addr.wrapping_sub(ddgame::worm::POS_Y_OFFSET)) as *const i32);
114
  }
115
 
116
  usercall_trampoline!(fn trampoline_get_worm_position; impl_fn = get_worm_position_impl;
117
  regs = [eax, ecx]; stack_params = 3; ret_bytes = "0xC");
118
 
119
  // ============================================================
120
- // HasFullHealthWorm replacement (0x5228D0)
121
  // ============================================================
122
  // __usercall: EAX = base
123
- // plain RET, returns EAX = bool (1 if any team has a worm at full health)
124
-
125
- unsafe extern "cdecl" fn has_full_health_worm_impl(base: u32) -> u32 {
 
 
 
 
 
126
  let state = &*(base as *const TeamWeaponState);
127
- let base_ptr = base as *const u8;
128
 
129
- for i in 0..state.team_count {
130
- let team_ptr = base_ptr.add(ddgame::team_data::ALT_BASE_OFFSET + i as usize * 0x51C);
131
- if *(team_ptr.sub(ddgame::team_data::ALT_ELIMINATED_OFFSET) as *const i32) != 0 {
 
132
  continue;
133
  }
134
- let worm_count = *(team_ptr as *const i32);
135
- if worm_count > 0 {
136
- let mut worm_ptr = team_ptr.sub(ddgame::team_data::ALT_WORM_ARRAY_OFFSET);
137
- for _ in 0..worm_count {
138
- if *(worm_ptr as *const i32) == ddgame::worm::FULL_HEALTH {
139
- return 1;
140
- }
141
- worm_ptr = worm_ptr.add(ddgame::worm::STRIDE);
142
  }
143
  }
144
  }
145
  0
146
  }
147
 
148
- usercall_trampoline!(fn trampoline_has_full_health_worm; impl_fn = has_full_health_worm_impl;
149
  reg = eax);
150
 
151
  // ============================================================
@@ -179,9 +181,9 @@ pub fn install() -> Result<(), String> {
179
  )?;
180
 
181
  let _ = hook::install(
182
- "HasFullHealthWorm",
183
- va::HAS_FULL_HEALTH_WORM,
184
- trampoline_has_full_health_worm as *const (),
185
  )?;
186
  }
187
 
 
5
  //! - GetTeamTotalHealth (0x5224D0): sum worm health for a team
6
  //! - IsWormInSpecialState (0x5226B0): check worm state flag
7
  //! - GetWormPosition (0x522700): read worm X,Y coordinates
8
+ //! - CheckWormState0x64 (0x5228D0): check if any worm has state 0x64
9
 
10
  use openwa_types::address::va;
11
+ use openwa_types::ddgame::{self, offsets, FullTeamBlock, TeamWeaponState};
12
 
13
  use crate::hook::{self, usercall_trampoline};
14
 
 
17
  // ============================================================
18
  // __usercall: EAX = base, EDI = alliance_id
19
  // plain RET
20
+ //
21
+ // NOTE: This function uses a DIFFERENT sentinel layout than entry_ptr-based
22
+ // functions. It reads alliance/alive at TWS+0x510+i*0x51C (= sentinel+0x70/0x74),
23
+ // not the entry_ptr-based sentinel+0x78/0x80. Left as raw pointer math until
24
+ // the +0x70/+0x74 sentinel fields are better understood.
25
 
26
  unsafe extern "cdecl" fn count_teams_by_alliance_impl(base: u32, alliance_id: i32) {
27
  let state = &mut *(base as *mut TeamWeaponState);
 
30
  state.same_alliance_count = 0;
31
  state.enemy_team_count = 0;
32
 
 
 
 
33
  let base_ptr = base as *const u8;
34
  for i in 0..state.team_count {
35
  let team_data = base_ptr.add(ddgame::team_data::BASE_OFFSET + i as usize * 0x51C);
 
56
  // plain RET, returns EAX = total health
57
 
58
  unsafe extern "cdecl" fn get_team_total_health_impl(team_index: u32, base: u32) -> i32 {
59
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
60
+ let block = &*blocks.add(team_index as usize);
61
+ let sentinel = &(*blocks.add(team_index as usize + 1)).worms[0];
62
 
63
+ if sentinel.sentinel_eliminated() != 0 {
 
64
  return 0;
65
  }
66
 
67
+ let worm_count = sentinel.sentinel_worm_count();
68
  let mut total = 0i32;
69
+ for w in 1..=worm_count as usize {
70
+ total += block.worms[w].health;
 
 
 
 
71
  }
72
  total
73
  }
 
84
  unsafe extern "cdecl" fn is_worm_in_special_state_impl(
85
  team_index: u32, worm_index: u32, base: u32,
86
  ) -> u32 {
87
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
88
+ let block = &*blocks.add(team_index as usize);
89
+ if ddgame::worm::is_special_state(block.worms[worm_index as usize].state) { 1 } else { 0 }
 
 
90
  }
91
 
92
  usercall_trampoline!(fn trampoline_is_worm_in_special_state; impl_fn = is_worm_in_special_state_impl;
 
98
  // __usercall: EAX = team_index, ECX = worm_index,
99
  // [ESP+4] = base, [ESP+8] = out_x, [ESP+C] = out_y
100
  // RET 0xC
101
+ //
102
+ // Reads pos_x/pos_y from WormEntry._unknown_90[0..4] / [4..8].
103
+ // These values appear transient — actual worm positions live in CGameTask objects.
104
 
105
  unsafe extern "cdecl" fn get_worm_position_impl(
106
  team_index: u32, worm_index: u32, base: u32, out_x: *mut i32, out_y: *mut i32,
107
  ) {
108
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
109
+ let block = &*blocks.add(team_index as usize);
110
+ let worm = &block.worms[worm_index as usize];
111
+ *out_x = *(worm._unknown_90.as_ptr() as *const i32);
112
+ *out_y = *(worm._unknown_90.as_ptr().add(4) as *const i32);
113
  }
114
 
115
  usercall_trampoline!(fn trampoline_get_worm_position; impl_fn = get_worm_position_impl;
116
  regs = [eax, ecx]; stack_params = 3; ret_bytes = "0xC");
117
 
118
  // ============================================================
119
+ // CheckWormState0x64 replacement (0x5228D0)
120
  // ============================================================
121
  // __usercall: EAX = base
122
+ // plain RET, returns EAX = bool (1 if any worm has state 0x64)
123
+ //
124
+ // Previously named "HasFullHealthWorm" but Ghidra disassembly confirms it
125
+ // reads worms[].state (offset +0x00), NOT worms[].health (offset +0x5C).
126
+ // It compares to 0x64 (100 decimal). State 0x64 is likely a transitional
127
+ // state distinct from 0x65 (idle). 11 xrefs in gameplay code.
128
+
129
+ unsafe extern "cdecl" fn check_worm_state_0x64_impl(base: u32) -> u32 {
130
  let state = &*(base as *const TeamWeaponState);
131
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
132
 
133
+ // Iterate real teams (1-indexed: team 1..=team_count)
134
+ for i in 1..=state.team_count as usize {
135
+ let sentinel = &(*blocks.add(i + 1)).worms[0];
136
+ if sentinel.sentinel_eliminated() != 0 {
137
  continue;
138
  }
139
+ let worm_count = sentinel.sentinel_worm_count();
140
+ let worm_block = &*blocks.add(i);
141
+ for w in 1..=worm_count as usize {
142
+ if worm_block.worms[w].state == 0x64 {
143
+ return 1;
 
 
 
144
  }
145
  }
146
  }
147
  0
148
  }
149
 
150
+ usercall_trampoline!(fn trampoline_check_worm_state_0x64; impl_fn = check_worm_state_0x64_impl;
151
  reg = eax);
152
 
153
  // ============================================================
 
181
  )?;
182
 
183
  let _ = hook::install(
184
+ "CheckWormState0x64",
185
+ va::CHECK_WORM_STATE_0X64,
186
+ trampoline_check_worm_state_0x64 as *const (),
187
  )?;
188
  }
189
 
crates/openwa-wormkit/src/replacements/weapon.rs CHANGED
@@ -5,7 +5,7 @@
5
  //! - AddAmmo (0x522640): add ammo to a weapon slot
6
 
7
  use openwa_types::address::va;
8
- use openwa_types::ddgame::{self, TeamWeaponState};
9
  use openwa_types::weapon::Weapon;
10
 
11
  use crate::hook::{self, usercall_trampoline};
@@ -64,23 +64,17 @@ unsafe extern "cdecl" fn get_ammo_impl(team_index: u32, base: u32, weapon_id: u3
64
  state.get_ammo(idx) as u32
65
  }
66
 
67
- /// Returns true if more than 1 worm is alive on the team.
68
- ///
69
- /// Worm data lives at negative offsets from the TeamEntry:
70
- /// - worm_count at team_entry_addr - 0x4
71
- /// - worm array at team_entry_addr - 0x4A0 (stride 0x9C, health at [0])
72
  unsafe fn count_alive_worms(state: &TeamWeaponState, team_index: usize) -> bool {
73
  let base = state as *const TeamWeaponState as *const u8;
74
- let team_entry = base.add(team_index * 0x51C) as usize;
75
- let worm_count = *((team_entry - ddgame::worm::COUNT_OFFSET) as *const i32);
 
 
76
  let mut alive = 0i32;
77
- if worm_count > 0 {
78
- let mut ptr = (team_entry - ddgame::worm::ARRAY_OFFSET) as *const u8;
79
- for _ in 0..worm_count {
80
- if *(ptr as *const i32) > 0 {
81
- alive += 1;
82
- }
83
- ptr = ptr.add(ddgame::worm::STRIDE);
84
  }
85
  }
86
  alive > 1
 
5
  //! - AddAmmo (0x522640): add ammo to a weapon slot
6
 
7
  use openwa_types::address::va;
8
+ use openwa_types::ddgame::{self, offsets, FullTeamBlock, TeamWeaponState};
9
  use openwa_types::weapon::Weapon;
10
 
11
  use crate::hook::{self, usercall_trampoline};
 
64
  state.get_ammo(idx) as u32
65
  }
66
 
67
+ /// Returns true if more than 1 worm is alive on the team (health > 0).
 
 
 
 
68
  unsafe fn count_alive_worms(state: &TeamWeaponState, team_index: usize) -> bool {
69
  let base = state as *const TeamWeaponState as *const u8;
70
+ let blocks = base.sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
71
+ let block = &*blocks.add(team_index);
72
+ let sentinel = &(*blocks.add(team_index + 1)).worms[0];
73
+ let worm_count = sentinel.sentinel_worm_count();
74
  let mut alive = 0i32;
75
+ for w in 1..=worm_count as usize {
76
+ if block.worms[w].health > 0 {
77
+ alive += 1;
 
 
 
 
78
  }
79
  }
80
  alive > 1