paavohtl Claude Opus 4.6 commited on
Commit
801b4f8
·
1 Parent(s): 3675951

feat: port remaining 5 team/weapon accessor hooks (32 total)

Browse files

Add SubtractAmmo, CountAliveWorms, CheckTeamWormState0x64,
CheckAnyWormState0x8b, and SetActiveWorm_Maybe replacements.
Extend TeamWeaponState with active_worm_count, last_active_team,
and last_active_alliance fields. Rename SetTeamAliveFlag to
SetActiveWorm_Maybe based on runtime behavior analysis.

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

CLAUDE.md CHANGED
@@ -46,6 +46,8 @@ WormKit auto-loads any `wk*.dll` from the game directory. Logs appear in the gam
46
 
47
  Use the `/replay-test` skill to automatically build, deploy, launch WA.exe with a replay file, capture validation logs, and present results. This is the fastest way to validate struct layouts, hooks, and game state against live WA.exe.
48
 
 
 
49
  ```bash
50
  # Manual invocation (skill runs this automatically):
51
  powershell -ExecutionPolicy Bypass -File replay-test.ps1
@@ -116,6 +118,9 @@ For `__usercall` functions, use a naked trampoline to capture register params be
116
  A Ghidra MCP bridge is configured in `.mcp.json`. When using Ghidra tools:
117
  - **Prefer batch tools** (`batch_create_labels`, `batch_rename_function_components`) — single-item tools have address parsing bugs.
118
  - WA.exe is loaded at image base 0x400000 in Ghidra.
 
 
 
119
 
120
  ## Third-Party RE Sources
121
 
@@ -124,8 +129,6 @@ A Ghidra MCP bridge is configured in `.mcp.json`. When using Ghidra tools:
124
 
125
  ## Design Conventions
126
 
127
- - `Ptr32 = u32` for pointer fields (compiles on 64-bit host, correct sizes on 32-bit target)
128
- - `#[repr(u32)]` enums with `TryFrom<u32>` for safe conversion from game memory
129
  - Unknown struct fields as `_unknown_XX` padding arrays
130
  - Fixed-point: `Fixed(i32)` newtype, 16.16 format (0x10000 = 1.0)
131
  - Naked asm uses `naked_asm!` (Rust 1.79+ syntax), not `asm!`
 
46
 
47
  Use the `/replay-test` skill to automatically build, deploy, launch WA.exe with a replay file, capture validation logs, and present results. This is the fastest way to validate struct layouts, hooks, and game state against live WA.exe.
48
 
49
+ **Use replay testing to validate assumptions and test theories!** You don't have to figure out everything from disassembly and static analysis. Make a hypothesis, implement it in the DLL, then use replay testing to see if it holds up against the real game. This iterative approach is much faster than trying to get everything right on the first try.
50
+
51
  ```bash
52
  # Manual invocation (skill runs this automatically):
53
  powershell -ExecutionPolicy Bypass -File replay-test.ps1
 
118
  A Ghidra MCP bridge is configured in `.mcp.json`. When using Ghidra tools:
119
  - **Prefer batch tools** (`batch_create_labels`, `batch_rename_function_components`) — single-item tools have address parsing bugs.
120
  - WA.exe is loaded at image base 0x400000 in Ghidra.
121
+ - When you encounter unnamed functions, globals or structs, name them in Ghidra if you know their purpose. Even a guess is helpful for future reference, but add `_Maybe` suffix if uncertain.
122
+ - Remove `_Maybe` suffix when you confirm the purpose.
123
+ - When you learn more about a function or address, update both the Ghidra database (rename function / label and update signature) and the corresponding Rust code.
124
 
125
  ## Third-Party RE Sources
126
 
 
129
 
130
  ## Design Conventions
131
 
 
 
132
  - Unknown struct fields as `_unknown_XX` padding arrays
133
  - Fixed-point: `Fixed(i32)` newtype, 16.16 format (0x10000 = 1.0)
134
  - Naked asm uses `naked_asm!` (Rust 1.79+ syntax), not `asm!`
crates/openwa-types/src/address.rs CHANGED
@@ -150,6 +150,17 @@ pub mod va {
150
  /// Despite the comparison to 100, reads worms[].state NOT .health.
151
  /// usercall(EAX=base) → EAX=bool, plain RET.
152
  pub const CHECK_WORM_STATE_0X64: u32 = 0x0052_28D0;
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  // === Graphics / rendering ===
155
 
 
150
  /// Despite the comparison to 100, reads worms[].state NOT .health.
151
  /// usercall(EAX=base) → EAX=bool, plain RET.
152
  pub const CHECK_WORM_STATE_0X64: u32 = 0x0052_28D0;
153
+ /// Per-team version of CheckWormState0x64. Returns 1 if any worm on the
154
+ /// specified team has state==0x64. 1 xref.
155
+ /// usercall(EAX=team_idx, ECX=base) → EAX=bool, plain RET.
156
+ pub const CHECK_TEAM_WORM_STATE_0X64: u32 = 0x0052_2930;
157
+ /// Scans all teams for any worm with state 0x8b. 1 xref.
158
+ /// usercall(EAX=base) → EAX=bool, plain RET.
159
+ pub const CHECK_ANY_WORM_STATE_0X8B: u32 = 0x0052_2970;
160
+ /// Sets the active worm for a team. flag=0 deactivates, flag=N sets worm N active.
161
+ /// Called on turn transitions and worm selection (Tab key).
162
+ /// usercall(EAX=base, EDX=team_idx, ESI=worm_index) → void, plain RET. 3 xrefs.
163
+ pub const SET_ACTIVE_WORM_MAYBE: u32 = 0x0052_2500;
164
 
165
  // === Graphics / rendering ===
166
 
crates/openwa-types/src/ddgame.rs CHANGED
@@ -324,21 +324,25 @@ pub struct TeamWeaponState {
324
  /// 0x2C28: Game phase counter (>=484 = sudden death, >=-2 = normal game)
325
  pub game_phase: i32,
326
 
327
- // === Alliance tracking (set by CountTeamsByAlliance, 0x522030) ===
328
 
329
  /// 0x2C2C: Current alliance being evaluated
330
  pub current_alliance: i32,
331
- /// 0x2C30: Unknown padding
332
- pub _pad_2c30: [u8; 4],
333
- /// 0x2C34: Count of teams alive with valid alliance (>=0)
334
  pub active_team_count: i32,
335
  /// 0x2C38: Count of teams matching current_alliance
336
  pub same_alliance_count: i32,
337
  /// 0x2C3C: Count of teams not matching current_alliance
338
  pub enemy_team_count: i32,
 
 
 
 
339
  }
340
 
341
- const _: () = assert!(core::mem::size_of::<TeamWeaponState>() == 0x2C40);
342
 
343
  /// Worm state constants and helpers.
344
  pub mod worm {
 
324
  /// 0x2C28: Game phase counter (>=484 = sudden death, >=-2 = normal game)
325
  pub game_phase: i32,
326
 
327
+ // === Alliance tracking (set by CountTeamsByAlliance + SetActiveWorm_Maybe) ===
328
 
329
  /// 0x2C2C: Current alliance being evaluated
330
  pub current_alliance: i32,
331
+ /// 0x2C30: Count of teams with an active worm (set by SetActiveWorm_Maybe)
332
+ pub active_worm_count: i32,
333
+ /// 0x2C34: Count of active teams with valid alliance (>=0)
334
  pub active_team_count: i32,
335
  /// 0x2C38: Count of teams matching current_alliance
336
  pub same_alliance_count: i32,
337
  /// 0x2C3C: Count of teams not matching current_alliance
338
  pub enemy_team_count: i32,
339
+ /// 0x2C40: Last team index set active (written by SetActiveWorm_Maybe)
340
+ pub last_active_team: i32,
341
+ /// 0x2C44: Alliance of last activated team (written by SetActiveWorm_Maybe)
342
+ pub last_active_alliance: i32,
343
  }
344
 
345
+ const _: () = assert!(core::mem::size_of::<TeamWeaponState>() == 0x2C48);
346
 
347
  /// Worm state constants and helpers.
348
  pub mod worm {
crates/openwa-wormkit/src/replacements/team.rs CHANGED
@@ -6,6 +6,9 @@
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};
@@ -150,6 +153,125 @@ unsafe extern "cdecl" fn check_worm_state_0x64_impl(base: u32) -> u32 {
150
  usercall_trampoline!(fn trampoline_check_worm_state_0x64; impl_fn = check_worm_state_0x64_impl;
151
  reg = eax);
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  // ============================================================
154
  // Hook installation
155
  // ============================================================
@@ -185,6 +307,24 @@ pub fn install() -> Result<(), String> {
185
  va::CHECK_WORM_STATE_0X64,
186
  trampoline_check_worm_state_0x64 as *const (),
187
  )?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  }
189
 
190
  Ok(())
 
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
+ //! - CheckTeamWormState0x64 (0x522930): per-team version of above
10
+ //! - CheckAnyWormState0x8b (0x522970): scan all teams for state 0x8b
11
+ //! - SetActiveWorm_Maybe (0x522500): update team active state and counters
12
 
13
  use openwa_types::address::va;
14
  use openwa_types::ddgame::{self, offsets, FullTeamBlock, TeamWeaponState};
 
153
  usercall_trampoline!(fn trampoline_check_worm_state_0x64; impl_fn = check_worm_state_0x64_impl;
154
  reg = eax);
155
 
156
+ // ============================================================
157
+ // CheckTeamWormState0x64 replacement (0x522930)
158
+ // ============================================================
159
+ // __usercall: EAX = base, EDX = team_idx
160
+ // plain RET, returns EAX = bool (1 if any worm on team has state 0x64)
161
+ //
162
+ // Per-team version of CheckWormState0x64. 1 xref (FUN_00556ad0).
163
+
164
+ unsafe extern "cdecl" fn check_team_worm_state_0x64_impl(base: u32, team_idx: u32) -> u32 {
165
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
166
+ let block = &*blocks.add(team_idx as usize);
167
+ let sentinel = &(*blocks.add(team_idx as usize + 1)).worms[0];
168
+
169
+ if sentinel.sentinel_eliminated() != 0 {
170
+ return 0;
171
+ }
172
+
173
+ let worm_count = sentinel.sentinel_worm_count();
174
+ for w in 1..=worm_count as usize {
175
+ if block.worms[w].state == 0x64 {
176
+ return 1;
177
+ }
178
+ }
179
+ 0
180
+ }
181
+
182
+ usercall_trampoline!(fn trampoline_check_team_worm_state_0x64; impl_fn = check_team_worm_state_0x64_impl;
183
+ regs = [eax, edx]);
184
+
185
+ // ============================================================
186
+ // CheckAnyWormState0x8b replacement (0x522970)
187
+ // ============================================================
188
+ // __usercall: EAX = base
189
+ // plain RET, returns EAX = bool (1 if any worm on any team has state 0x8b)
190
+ //
191
+ // Scans all teams. 1 xref (FUN_00557310).
192
+
193
+ unsafe extern "cdecl" fn check_any_worm_state_0x8b_impl(base: u32) -> u32 {
194
+ let state = &*(base as *const TeamWeaponState);
195
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
196
+
197
+ for i in 1..=state.team_count as usize {
198
+ let sentinel = &(*blocks.add(i + 1)).worms[0];
199
+ if sentinel.sentinel_eliminated() != 0 {
200
+ continue;
201
+ }
202
+ let worm_count = sentinel.sentinel_worm_count();
203
+ let worm_block = &*blocks.add(i);
204
+ for w in 1..=worm_count as usize {
205
+ if worm_block.worms[w].state == 0x8b {
206
+ return 1;
207
+ }
208
+ }
209
+ }
210
+ 0
211
+ }
212
+
213
+ usercall_trampoline!(fn trampoline_check_any_worm_state_0x8b; impl_fn = check_any_worm_state_0x8b_impl;
214
+ reg = eax);
215
+
216
+ // ============================================================
217
+ // SetActiveWorm_Maybe replacement (0x522500)
218
+ // ============================================================
219
+ // __usercall: EAX = base, EDX = team_idx (1-indexed), ESI = worm_index
220
+ // plain RET (4 RET sites in original)
221
+ //
222
+ // Sets the active worm for a team. worm_index=0 deactivates, worm_index=N
223
+ // sets worm N as active. Called on turn transitions and worm selection (Tab).
224
+ // Updates active counters and records last_active_team/alliance. 3 xrefs.
225
+
226
+ unsafe extern "cdecl" fn set_active_worm_impl(base: u32, team_idx: u32, worm_index: i32) {
227
+ let state = &mut *(base as *mut TeamWeaponState);
228
+ let base_ptr = base as *mut u8;
229
+
230
+ // Access team_data fields: alliance at (team_idx * 0x51C - 0xC),
231
+ // alive_flag at (team_idx * 0x51C - 0x8) — matches team_data::BASE_OFFSET pattern
232
+ let team_offset = team_idx as usize * 0x51C;
233
+ let alliance_ptr = base_ptr.add(team_offset).sub(0xC) as *const i32;
234
+ let alive_ptr = base_ptr.add(team_offset).sub(0x8) as *mut i32;
235
+
236
+ if worm_index == 0 {
237
+ // Deactivate team
238
+ if *alive_ptr != 0 {
239
+ state.active_worm_count -= 1;
240
+ let alliance = *alliance_ptr;
241
+ if alliance >= 0 {
242
+ state.active_team_count -= 1;
243
+ if alliance == state.current_alliance {
244
+ state.same_alliance_count -= 1;
245
+ } else {
246
+ state.enemy_team_count -= 1;
247
+ }
248
+ }
249
+ *alive_ptr = 0;
250
+ }
251
+ } else {
252
+ // Activate team — only update counters if not already active
253
+ if *alive_ptr == 0 {
254
+ state.active_worm_count += 1;
255
+ state.last_active_team = team_idx as i32;
256
+ let alliance = *alliance_ptr;
257
+ state.last_active_alliance = alliance;
258
+ if alliance >= 0 {
259
+ state.active_team_count += 1;
260
+ if alliance == state.current_alliance {
261
+ state.same_alliance_count += 1;
262
+ } else {
263
+ state.enemy_team_count += 1;
264
+ }
265
+ }
266
+ }
267
+ // Always write worm_index (original writes ESI unconditionally)
268
+ *alive_ptr = worm_index;
269
+ }
270
+ }
271
+
272
+ usercall_trampoline!(fn trampoline_set_active_worm; impl_fn = set_active_worm_impl;
273
+ regs = [eax, edx, esi]);
274
+
275
  // ============================================================
276
  // Hook installation
277
  // ============================================================
 
307
  va::CHECK_WORM_STATE_0X64,
308
  trampoline_check_worm_state_0x64 as *const (),
309
  )?;
310
+
311
+ let _ = hook::install(
312
+ "CheckTeamWormState0x64",
313
+ va::CHECK_TEAM_WORM_STATE_0X64,
314
+ trampoline_check_team_worm_state_0x64 as *const (),
315
+ )?;
316
+
317
+ let _ = hook::install(
318
+ "CheckAnyWormState0x8b",
319
+ va::CHECK_ANY_WORM_STATE_0X8B,
320
+ trampoline_check_any_worm_state_0x8b as *const (),
321
+ )?;
322
+
323
+ let _ = hook::install(
324
+ "SetActiveWorm_Maybe",
325
+ va::SET_ACTIVE_WORM_MAYBE,
326
+ trampoline_set_active_worm as *const (),
327
+ )?;
328
  }
329
 
330
  Ok(())
crates/openwa-wormkit/src/replacements/weapon.rs CHANGED
@@ -3,6 +3,8 @@
3
  //! Replaces WA.exe functions that manage weapon ammo in the TeamWeaponState area (DDGame + 0x4628):
4
  //! - GetAmmo (0x5225E0): query ammo count with delay/phase checks
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};
@@ -32,6 +34,24 @@ unsafe extern "cdecl" fn add_ammo_impl(team_index: u32, amount: i32, base: u32,
32
  usercall_trampoline!(fn trampoline_add_ammo; impl_fn = add_ammo_impl;
33
  regs = [eax, edx]; stack_params = 2; ret_bytes = "0x8");
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  // ============================================================
36
  // GetAmmo replacement (0x5225E0)
37
  // ============================================================
@@ -56,7 +76,8 @@ unsafe extern "cdecl" fn get_ammo_impl(team_index: u32, base: u32, weapon_id: u3
56
 
57
  // SelectWorm (0x3B) requires >1 alive worm on the team
58
  if state.game_phase >= ddgame::GAME_PHASE_NORMAL_MIN && weapon_id == Weapon::SelectWorm as u32 {
59
- if !count_alive_worms(state, team_index as usize) {
 
60
  return 0;
61
  }
62
  }
@@ -64,12 +85,19 @@ 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 (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 {
@@ -77,11 +105,11 @@ unsafe fn count_alive_worms(state: &TeamWeaponState, team_index: usize) -> bool
77
  alive += 1;
78
  }
79
  }
80
- alive > 1
81
  }
82
 
83
- usercall_trampoline!(fn trampoline_get_ammo; impl_fn = get_ammo_impl;
84
- regs = [eax, esi, edx]);
85
 
86
  // ============================================================
87
  // Hook installation
@@ -100,6 +128,18 @@ pub fn install() -> Result<(), String> {
100
  va::GET_AMMO,
101
  trampoline_get_ammo as *const (),
102
  )?;
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
  Ok(())
 
3
  //! Replaces WA.exe functions that manage weapon ammo in the TeamWeaponState area (DDGame + 0x4628):
4
  //! - GetAmmo (0x5225E0): query ammo count with delay/phase checks
5
  //! - AddAmmo (0x522640): add ammo to a weapon slot
6
+ //! - SubtractAmmo (0x522680): decrement ammo count
7
+ //! - CountAliveWorms (0x5225A0): check if >1 worm alive on team
8
 
9
  use openwa_types::address::va;
10
  use openwa_types::ddgame::{self, offsets, FullTeamBlock, TeamWeaponState};
 
34
  usercall_trampoline!(fn trampoline_add_ammo; impl_fn = add_ammo_impl;
35
  regs = [eax, edx]; stack_params = 2; ret_bytes = "0x8");
36
 
37
+ // ============================================================
38
+ // SubtractAmmo replacement (0x522680)
39
+ // ============================================================
40
+ // __usercall: EAX = team_index, ECX = team_info_base, [ESP+4] = weapon_id
41
+ // RET 0x4
42
+
43
+ unsafe extern "cdecl" fn subtract_ammo_impl(team_index: u32, base: u32, weapon_id: u32) {
44
+ let state = &mut *(base as *mut TeamWeaponState);
45
+ let idx = state.ammo_index(team_index as usize, weapon_id);
46
+ let ammo = state.get_ammo(idx);
47
+ if ammo > 0 {
48
+ *state.ammo_mut(idx) = ammo - 1;
49
+ }
50
+ }
51
+
52
+ usercall_trampoline!(fn trampoline_subtract_ammo; impl_fn = subtract_ammo_impl;
53
+ regs = [eax, ecx]; stack_params = 1; ret_bytes = "0x4");
54
+
55
  // ============================================================
56
  // GetAmmo replacement (0x5225E0)
57
  // ============================================================
 
76
 
77
  // SelectWorm (0x3B) requires >1 alive worm on the team
78
  if state.game_phase >= ddgame::GAME_PHASE_NORMAL_MIN && weapon_id == Weapon::SelectWorm as u32 {
79
+ let base = state as *const TeamWeaponState as u32;
80
+ if count_alive_worms_impl(team_index, base) == 0 {
81
  return 0;
82
  }
83
  }
 
85
  state.get_ammo(idx) as u32
86
  }
87
 
88
+ usercall_trampoline!(fn trampoline_get_ammo; impl_fn = get_ammo_impl;
89
+ regs = [eax, esi, edx]);
90
+
91
+ // ============================================================
92
+ // CountAliveWorms replacement (0x5225A0)
93
+ // ============================================================
94
+ // __usercall: EAX = team_index, ECX = base
95
+ // plain RET, returns EAX = bool (1 if >1 worm alive on team)
96
+
97
+ unsafe extern "cdecl" fn count_alive_worms_impl(team_index: u32, base: u32) -> u32 {
98
+ let blocks = (base as *const u8).sub(offsets::TWS_TO_BLOCKS) as *const FullTeamBlock;
99
+ let block = &*blocks.add(team_index as usize);
100
+ let sentinel = &(*blocks.add(team_index as usize + 1)).worms[0];
101
  let worm_count = sentinel.sentinel_worm_count();
102
  let mut alive = 0i32;
103
  for w in 1..=worm_count as usize {
 
105
  alive += 1;
106
  }
107
  }
108
+ if alive > 1 { 1 } else { 0 }
109
  }
110
 
111
+ usercall_trampoline!(fn trampoline_count_alive_worms; impl_fn = count_alive_worms_impl;
112
+ regs = [eax, ecx]);
113
 
114
  // ============================================================
115
  // Hook installation
 
128
  va::GET_AMMO,
129
  trampoline_get_ammo as *const (),
130
  )?;
131
+
132
+ let _ = hook::install(
133
+ "SubtractAmmo",
134
+ va::SUBTRACT_AMMO,
135
+ trampoline_subtract_ammo as *const (),
136
+ )?;
137
+
138
+ let _ = hook::install(
139
+ "CountAliveWorms",
140
+ va::COUNT_ALIVE_WORMS,
141
+ trampoline_count_alive_worms as *const (),
142
+ )?;
143
  }
144
 
145
  Ok(())