paavohtl Claude Sonnet 4.6 commited on
Commit
cff7173
·
1 Parent(s): c7dca4b

feat: add SharedData entity census and CTaskFilter RE

Browse files

- Add SharedDataNode/SharedDataTable/SharedDataIter types to task.rs for
iterating the 256-bucket entity hash table at CTask.shared_data
- Add 8 new CTASK_*_VTABLE constants to address.rs (Worm, Land, TurnGame,
Team, Filter, Dirt, SpriteAnim, CPU)
- Add CTaskFilter struct (0xB4 bytes) with subscription_table[100] at +0x30;
documents the message-subscription filter pattern used per team
- Add dump_entity_census() to validation: iterates SharedData table, groups
by vtable, names all known task types; auto-runs at 5s mark and on F12
- Refactor dump_worm_tasks() to use SharedDataTable::iter()

RE findings:
- CTaskFilter overrides HandleMessage to drop messages not in its
subscription table; 4 instances per team with different message sets
- CTaskCPU is transient (only present during AI turn)
- Dead worms are removed from SharedData on death

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

crates/openwa-core/src/address.rs CHANGED
@@ -47,8 +47,22 @@ pub mod va {
47
  pub const OPENGL_CPU_VTABLE: u32 = 0x0067_74C0;
48
  /// WaterEffect vtable (0xBC-byte object)
49
  pub const WATER_EFFECT_VTABLE: u32 = 0x0066_B268;
50
- /// CTaskLand vtable - 12 entries, landscape/terrain task (DDGame+0x054C)
51
  pub const CTASK_LAND_VTABLE: u32 = 0x0066_4388;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  /// Sprite vtable (0x70-byte objects, 8 entries)
53
  pub const SPRITE_VTABLE: u32 = 0x0066_418C;
54
 
 
47
  pub const OPENGL_CPU_VTABLE: u32 = 0x0067_74C0;
48
  /// WaterEffect vtable (0xBC-byte object)
49
  pub const WATER_EFFECT_VTABLE: u32 = 0x0066_B268;
50
+ /// CTaskLand vtable - landscape/terrain task (DDGame+0x054C)
51
  pub const CTASK_LAND_VTABLE: u32 = 0x0066_4388;
52
+ /// CTaskWorm vtable - worm entity task (constructor 0x50BFB0)
53
+ pub const CTASK_WORM_VTABLE: u32 = 0x0066_44C8;
54
+ /// CTaskTurnGame vtable - global turn flow manager (1 per game)
55
+ pub const CTASK_TURN_GAME_VTABLE: u32 = 0x0066_9F70;
56
+ /// CTaskTeam vtable - per-team task (1 per team, constructor 0x555BF0)
57
+ pub const CTASK_TEAM_VTABLE: u32 = 0x0066_9EE4;
58
+ /// CTaskFilter vtable - role unclear; 4 instances in a 2-team 3-worm game
59
+ pub const CTASK_FILTER_VTABLE: u32 = 0x0066_9DAC;
60
+ /// CTaskDirt vtable - dirt/particle system (1 per game, constructor 0x54EDF0)
61
+ pub const CTASK_DIRT_VTABLE: u32 = 0x0066_9D74;
62
+ /// CTaskSpriteAnim vtable - sprite animation manager (1 per game, constructor 0x5466F0)
63
+ pub const CTASK_SPRITE_ANIM_VTABLE: u32 = 0x0066_9D00;
64
+ /// CTaskCPU vtable - AI/CPU bot controller (1 per game, constructor 0x548620)
65
+ pub const CTASK_CPU_VTABLE: u32 = 0x0066_9D54;
66
  /// Sprite vtable (0x70-byte objects, 8 entries)
67
  pub const SPRITE_VTABLE: u32 = 0x0066_418C;
68
 
crates/openwa-core/src/task.rs CHANGED
@@ -50,6 +50,134 @@ pub struct CTask {
50
 
51
  const _: () = assert!(core::mem::size_of::<CTask>() == 0x30);
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  /// Game task - extends CTask with physics and gameplay data.
54
  ///
55
  /// PARTIAL: Most fields between 0x30-0x83 and 0x98-0xE7 are unknown.
@@ -120,6 +248,207 @@ pub struct SoundEmitterVTable {
120
  pub _base_methods: [*const (); 7],
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  // ============================================================
124
  // Derived task overlays — for accessing task-specific fields
125
  // beyond or within CGameTask that differ per task type.
 
50
 
51
  const _: () = assert!(core::mem::size_of::<CTask>() == 0x30);
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Shared-data entity registry
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /// A 0x30-byte node in CTask's shared-data entity hash table.
58
+ ///
59
+ /// Inserted by `SharedData__Insert` (0x5406A0, called from task constructors).
60
+ /// All game task types (CTaskWorm, CTaskLand, projectiles, …) share the same
61
+ /// 256-bucket table at `CTask.shared_data`. Use the vtable pointer at
62
+ /// `entity[0]` to identify the object type.
63
+ ///
64
+ /// Hash function (from Ghidra decompilation of `SharedData__Insert`):
65
+ /// ```text
66
+ /// bucket = (key_esi * 0x11 + key_edi) & 0x800000ff;
67
+ /// if (int)bucket < 0 { bucket = bucket.wrapping_sub(1) | 0xffffff00; bucket += 1; }
68
+ /// ```
69
+ /// In practice (small positive key values), this reduces to:
70
+ /// `bucket = (key_esi * 0x11 + key_edi) & 0xff`
71
+ ///
72
+ /// Runtime observation: for `CTaskWorm`, `key_esi` encodes a compound worm
73
+ /// identity (e.g. `0x11` = team 1, worm 1) and `key_edi` is a small integer.
74
+ /// Companion remove function: `SharedData__Remove` (0x540700).
75
+ #[repr(C)]
76
+ pub struct SharedDataNode {
77
+ /// +0x00: Next node in this bucket's linked list (null = end).
78
+ pub next: *mut SharedDataNode,
79
+ /// +0x04: EDI register value at registration time.
80
+ pub key_edi: u32,
81
+ /// +0x08: ESI register value at registration time.
82
+ pub key_esi: u32,
83
+ /// +0x0C: Registered entity pointer (first DWORD = vtable).
84
+ pub entity: *mut u8,
85
+ /// +0x10..0x2F: Unused allocation padding.
86
+ pub _padding: [u8; 0x20],
87
+ }
88
+
89
+ const _: () = assert!(core::mem::size_of::<SharedDataNode>() == 0x30);
90
+
91
+ /// View of the 256-bucket entity hash table at `CTask.shared_data`.
92
+ ///
93
+ /// Root tasks own 0x420 bytes of shared data:
94
+ /// - `0x000..0x3FF`: 256 × `*mut SharedDataNode` bucket heads
95
+ /// - `0x400..0x41F`: Other root-task data (layout unknown)
96
+ ///
97
+ /// All tasks in the same game tree inherit the same `shared_data` pointer, so
98
+ /// any task can be used to access the full table. Use [`SharedDataTable::iter`]
99
+ /// to walk all registered entities and filter by vtable address.
100
+ ///
101
+ /// Registered by `SharedData__Insert` (0x5406A0); removed by
102
+ /// `SharedData__Remove` (0x540700).
103
+ pub struct SharedDataTable {
104
+ buckets: *const *mut SharedDataNode,
105
+ }
106
+
107
+ impl SharedDataTable {
108
+ /// Construct from a raw `CTask.shared_data` pointer.
109
+ ///
110
+ /// # Safety
111
+ /// `ptr` must point to a valid shared-data region of at least 256 × 4 = 1024 bytes.
112
+ pub unsafe fn from_ptr(ptr: *mut u8) -> Self {
113
+ Self { buckets: ptr as *const *mut SharedDataNode }
114
+ }
115
+
116
+ /// Construct from a `CTask` pointer (reads `task.shared_data`).
117
+ ///
118
+ /// # Safety
119
+ /// `task` must be a valid, aligned `CTask` pointer.
120
+ pub unsafe fn from_task(task: *const CTask) -> Self {
121
+ Self::from_ptr((*task).shared_data)
122
+ }
123
+
124
+ /// Compute the bucket index for a (key_esi, key_edi) pair.
125
+ ///
126
+ /// Exact transcription of the hash in `FUN_005406a0`.
127
+ pub fn bucket_for(key_esi: u32, key_edi: u32) -> u32 {
128
+ let mut h = key_esi.wrapping_mul(0x11).wrapping_add(key_edi) & 0x800000ff;
129
+ if (h as i32) < 0 {
130
+ h = h.wrapping_sub(1) | 0xffffff00;
131
+ h = h.wrapping_add(1);
132
+ }
133
+ h
134
+ }
135
+
136
+ /// Iterate all nodes across all 256 buckets.
137
+ ///
138
+ /// # Safety
139
+ /// The table and all linked nodes must be valid and not concurrently modified.
140
+ pub unsafe fn iter(&self) -> SharedDataIter {
141
+ SharedDataIter {
142
+ buckets: self.buckets,
143
+ bucket: 0,
144
+ node: core::ptr::null_mut(),
145
+ }
146
+ }
147
+ }
148
+
149
+ /// Iterator over all [`SharedDataNode`]s in a [`SharedDataTable`].
150
+ ///
151
+ /// Created by [`SharedDataTable::iter`]. Walks all 256 buckets in order,
152
+ /// following `next` pointers within each bucket.
153
+ pub struct SharedDataIter {
154
+ buckets: *const *mut SharedDataNode,
155
+ bucket: usize,
156
+ node: *mut SharedDataNode,
157
+ }
158
+
159
+ impl Iterator for SharedDataIter {
160
+ type Item = *mut SharedDataNode;
161
+
162
+ fn next(&mut self) -> Option<Self::Item> {
163
+ // SAFETY: caller of SharedDataTable::iter() guarantees table validity.
164
+ unsafe {
165
+ loop {
166
+ if !self.node.is_null() {
167
+ let current = self.node;
168
+ self.node = (*self.node).next;
169
+ return Some(current);
170
+ }
171
+ if self.bucket >= 256 {
172
+ return None;
173
+ }
174
+ self.node = *self.buckets.add(self.bucket);
175
+ self.bucket += 1;
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
  /// Game task - extends CTask with physics and gameplay data.
182
  ///
183
  /// PARTIAL: Most fields between 0x30-0x83 and 0x98-0xE7 are unknown.
 
248
  pub _base_methods: [*const (); 7],
249
  }
250
 
251
+ /// Virtual method table for CTaskWorm (vtable at 0x6644C8, 20 slots).
252
+ ///
253
+ /// CTaskWorm overrides 14 of the 20 inherited CTask/CGameTask slots;
254
+ /// 6 slots pass through unchanged. Slot layout by vtable byte offset:
255
+ ///
256
+ /// ```text
257
+ /// 0x00 WriteReplayState 0x04 Free 0x08 HandleMessage
258
+ /// 0x0C GetEntityData 0x10-0x18 inherited 0x1C OnContactEntity
259
+ /// 0x20 OnWormPush 0x24 OnLandBounce 0x28 OnLandSlide
260
+ /// 0x2C OnSink 0x30 inherited 0x34 OnKilled
261
+ /// 0x38 SetState 0x3C CheckPendingAction 0x40 IsNotOnRope
262
+ /// 0x44 inherited 0x48 GetTeamIndex 0x4C inherited
263
+ /// ```
264
+ #[repr(C)]
265
+ pub struct CTaskWormVTable {
266
+ /// [0] 0x0050CAA0: WriteReplayState — serializes worm state to a replay stream;
267
+ /// writes entity type byte 0x12, then packs field groups based on current state
268
+ pub write_replay_state: unsafe extern "thiscall" fn(*mut CTaskWorm, *mut u8),
269
+ /// [1] 0x0050C7E0: Free — calls inner destructor, then `_free(this)` if flags & 1
270
+ pub free: unsafe extern "thiscall" fn(*mut CTaskWorm, u8) -> *mut CTaskWorm,
271
+ /// [2] 0x00510B40: HandleMessage — processes all TaskMessages sent to this worm
272
+ pub handle_message:
273
+ unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CTask, u32, u32, *const u8),
274
+ /// [3] 0x00516780: GetEntityData — returns worm data by query code:
275
+ /// 0x7D0=(pos_x, pos_y), 0x7D1/0x7D2=collision test, 0x7D4=full state dump
276
+ pub get_entity_data: unsafe extern "thiscall" fn(*mut CTaskWorm, u32, u32, *mut u32) -> u32,
277
+ /// [4]-[6]: Inherited CTask stubs (not overridden by CTaskWorm)
278
+ pub _inherited_4_to_6: [*const (); 3],
279
+ /// [7] 0x0050D5D0: OnContactEntity — handles physical contact with another entity;
280
+ /// worm-on-dying-worm → apply crush damage; projectile close-pass → SetState(0x80)
281
+ pub on_contact_entity:
282
+ unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CGameTask, u32) -> u32,
283
+ /// [8] 0x0050D9A0: OnWormPush — post-contact worm-worm push impulse;
284
+ /// deduplicates via a recent-collision table, then adjusts pos_x or pos_y
285
+ pub on_worm_push: unsafe extern "thiscall" fn(*mut CTaskWorm, *mut CGameTask, u32) -> u32,
286
+ /// [9] 0x0050D810: OnLandBounce — worm lands on terrain; plays thud sound, bounce physics
287
+ pub on_land_bounce: unsafe extern "thiscall" fn(*mut CTaskWorm),
288
+ /// [10] 0x0050D820: OnLandSlide — secondary landing callback; sliding/friction physics
289
+ pub on_land_slide: unsafe extern "thiscall" fn(*mut CTaskWorm),
290
+ /// [11] 0x0050D570: OnSink — worm sinks in water/acid; applies (dx, dy) displacement,
291
+ /// transitions to drowning state (0x7F) unless already flying/rope/dead
292
+ pub on_sink: unsafe extern "thiscall" fn(*mut CTaskWorm, i32, i32) -> u32,
293
+ /// [12]: Inherited (vtable30 from CGameTask — not overridden)
294
+ pub _inherited_12: *const (),
295
+ /// [13] 0x0050D3B0: OnKilled — worm death; plays death sound (0x3A),
296
+ /// transitions to dead state (0x81 or 0x89) based on game round count
297
+ pub on_killed: unsafe extern "thiscall" fn(*mut CTaskWorm),
298
+ /// [14] 0x0050E850: SetState — worm state machine; handles all state transitions
299
+ /// (0x65=idle, 0x67=active turn, 0x7F=drowning, 0x80=hurt, 0x81/0x86=dead, …)
300
+ pub set_state: unsafe extern "thiscall" fn(*mut CTaskWorm, u32),
301
+ /// [15] 0x00516900: CheckPendingAction — if field +0xBC is set, calls SetState(0x73)
302
+ pub check_pending_action: unsafe extern "thiscall" fn(*mut CTaskWorm),
303
+ /// [16] 0x00516920: IsNotOnRope — returns true if worm state != 0x7C (rope-swinging)
304
+ pub is_not_on_rope: unsafe extern "thiscall" fn(*const CTaskWorm) -> bool,
305
+ /// [17]: Inherited (vtable44 from CGameTask — not overridden)
306
+ pub _inherited_17: *const (),
307
+ /// [18] 0x005168F0: GetTeamIndex — returns worm's team index (field +0xFC)
308
+ pub get_team_index: unsafe extern "thiscall" fn(*const CTaskWorm) -> u32,
309
+ /// [19]: Inherited (vtable4C from CGameTask — not overridden)
310
+ pub _inherited_19: *const (),
311
+ }
312
+
313
+ const _: () = assert!(core::mem::size_of::<CTaskWormVTable>() == 20 * 4);
314
+
315
+ /// Worm entity task — the primary playable character in WA.
316
+ ///
317
+ /// Extends CGameTask (0xFC bytes) with worm identity, physics overrides, and
318
+ /// per-worm state. Total size: 0x3FC bytes.
319
+ ///
320
+ /// Constructor: 0x50BFB0 (stdcall, 5 params):
321
+ /// this, parent_task, team_index, worm_index, init_data_ptr
322
+ ///
323
+ /// Vtable at 0x6644C8. Class type byte: 0x12.
324
+ ///
325
+ /// # Important fields in the CGameTask base
326
+ /// The worm state field lives at **offset +0x44** (inside `base._unknown_30`).
327
+ /// Use [`CTaskWorm::state`] to read it without pointer arithmetic.
328
+ ///
329
+ /// Source: Ghidra decompilation of 0x50BFB0, vtable analysis of 0x6644C8,
330
+ /// wkJellyWorm CTaskWorm.h
331
+ #[repr(C)]
332
+ pub struct CTaskWorm {
333
+ /// 0x00–0xFB: CGameTask base (position, velocity, sound emitter, etc.)
334
+ pub base: CGameTask,
335
+
336
+ /// 0xFC: Team index (0-based); 3rd constructor param
337
+ pub team_index: u32,
338
+ /// 0x100: Worm index within team (0-based); 4th constructor param
339
+ pub worm_index: u32,
340
+ /// 0x104: Unknown flag (checked in OnContactEntity)
341
+ pub _unknown_104: u32,
342
+ /// 0x108–0x10F: Unknown
343
+ pub _unknown_108: [u8; 8],
344
+ /// 0x110–0x137: Ten u32s copied from spawn init_data (5th constructor param)
345
+ pub spawn_params: [u32; 10],
346
+ /// 0x138–0x14F: Unknown
347
+ pub _unknown_138: [u8; 0x18],
348
+ /// 0x150: Unknown (slot 9 in GetEntityData query 0x7D4 output)
349
+ pub _unknown_150: u32,
350
+ /// 0x154: Unknown (rope-related; cleared in some SetState transitions)
351
+ pub _unknown_154: u32,
352
+ /// 0x158: Worm pool slot index in DDGame (assigned from pool at construction)
353
+ pub slot_id: u32,
354
+ /// 0x15C–0x247: Unknown
355
+ pub _unknown_15c: [u8; 0xEC],
356
+ /// 0x248–0x25B: Aiming / weapon data (slots 4–8 in GetEntityData query 0x7D4)
357
+ pub _unknown_248: [u32; 5],
358
+ /// 0x25C–0x283: Unknown
359
+ pub _unknown_25c: [u8; 0x28],
360
+ /// 0x284–0x28B: Aiming data (slots 5–6 in GetEntityData query 0x7D4)
361
+ pub _unknown_284: [u32; 2],
362
+ /// 0x28C–0x2EF: Unknown
363
+ pub _unknown_28c: [u8; 0x64],
364
+ /// 0x2F0: Worm name, null-terminated (max 17 chars, from spawn init_data+3)
365
+ pub worm_name: [u8; 0x11],
366
+ /// 0x301: Country / team name from scheme, null-terminated (max 17 chars)
367
+ pub country_name: [u8; 0x11],
368
+ /// 0x312–0x367: Unknown (rope string, state history, etc.)
369
+ pub _unknown_312: [u8; 0x56],
370
+ /// 0x368: Animator / controller object (dispatched via vtable for state animations)
371
+ pub animator: *mut u8,
372
+ /// 0x36C–0x3FB: Unknown (rope anchor, weapon-specific data, etc.)
373
+ pub _unknown_36c: [u8; 0x90],
374
+ }
375
+
376
+ const _: () = assert!(core::mem::size_of::<CTaskWorm>() == 0x3FC);
377
+
378
+ /// Message-subscription filter task — routes messages selectively to child tasks.
379
+ ///
380
+ /// CTaskFilter is a CTask subclass that overrides HandleMessage to only forward
381
+ /// messages whose type is marked in a 100-entry boolean subscription table. Each
382
+ /// CTaskFilter instance subscribes to a specific set of message IDs at construction
383
+ /// time; all other messages are silently dropped before reaching the subtree.
384
+ ///
385
+ /// **Role in the task tree**: CTaskTeam creates multiple CTaskFilter children per
386
+ /// team during construction. Each filter represents a different event-routing path
387
+ /// (e.g., movement, UI, game-flow, weather). Messages from CTaskTurnGame propagate
388
+ /// down through these filters, which gate access to their subtrees.
389
+ ///
390
+ /// **Allocation size**: 0xB4 bytes (via operator new in factory functions).
391
+ ///
392
+ /// **Constructor**: `CTaskFilter__Constructor` (0x54F3D0, thiscall):
393
+ /// - `init_val_1c`: stored at CTask+0x1C (role unknown)
394
+ /// - `parent_task`: parent in the task tree (determines shared_data)
395
+ ///
396
+ /// **Key vtable methods** (vtable at 0x669DAC):
397
+ /// - [2] `CTaskFilter__HandleMessage` (0x54F4A0): checks subscription table, forwards
398
+ /// only if `msg_type < 100 && subscription_table[msg_type] != 0`
399
+ /// - [7] `CTaskFilter__SubscribeAll` (0x54F390): sets all 100 entries to 1
400
+ /// - [8] `CTaskFilter__Subscribe` (0x54F370): sets `subscription_table[msg_id] = 1`
401
+ ///
402
+ /// **Four factory functions** (all called by `CTaskTeam__Constructor_Maybe` 0x550E70):
403
+ /// - `FUN_00552030`: subscribes to messages 0, 1, 3, 5
404
+ /// - `FUN_005520D0`: subscribes to messages 0, 1, 2, 3, 0x15, 0x18, 0x1C
405
+ /// - `FUN_00552190`: subscribes to messages 0, 1, 2, 3, 5, 0x15, 0x17, 0x1C, 0x2C–0x2E, 0x4B,
406
+ /// and optionally 0x0E (if `GameInfo+0xD778 < -1`)
407
+ /// - `CTaskTeam__CreateWeatherFilter` (0x552960): subscribes to 1, 2, 3, 0x54, then
408
+ /// spawns `CTaskCloud` children using randomised positions (only if `DDGame+0x777C == 0`)
409
+ #[repr(C)]
410
+ pub struct CTaskFilter {
411
+ /// 0x00–0x2F: CTask base.
412
+ ///
413
+ /// Notable base fields set by CTaskFilter__Constructor:
414
+ /// - CTask+0x18 (`_unknown_18`): set to 0
415
+ /// - CTask+0x1C (`_unknown_1c`): set to `init_val_1c` constructor param
416
+ /// - CTask+0x20 (`_unknown_20`): set to 7 (task type / mode constant)
417
+ pub base: CTask,
418
+ /// 0x30–0x93: Boolean subscription table, indexed by message type ID (0–99).
419
+ ///
420
+ /// `subscription_table[id] != 0` means this filter will forward messages of
421
+ /// that type. Cleared to 0 at construction, then populated by Subscribe/SubscribeAll
422
+ /// calls. Max 100 distinct message IDs (IDs >= 100 always pass through).
423
+ pub subscription_table: [u8; 100],
424
+ /// 0x94–0xB3: Unknown (present in 0xB4-byte allocation; not set by constructor).
425
+ pub _unknown_94: [u8; 0x20],
426
+ }
427
+
428
+ const _: () = assert!(core::mem::size_of::<CTaskFilter>() == 0xB4);
429
+
430
+ impl CTaskWorm {
431
+ /// Returns the worm's current state code (lives at offset +0x44, inside the
432
+ /// CGameTask base's `_unknown_30` padding region).
433
+ ///
434
+ /// Known states: `0x65`=idle, `0x67`=active turn, `0x7F`=drowning,
435
+ /// `0x80`=hurt, `0x81`/`0x86`=dead, `0x87`=dead variant, `0x8B`=unknown.
436
+ pub fn state(&self) -> u32 {
437
+ // SAFETY: offset 0x44 is within CGameTask._unknown_30 (0x30..0x84).
438
+ // Aligned to 4 bytes; repr(C) guarantees no reordering.
439
+ unsafe { *((self as *const CTaskWorm as *const u8).add(0x44) as *const u32) }
440
+ }
441
+
442
+ /// Returns a reference to the vtable.
443
+ ///
444
+ /// # Safety
445
+ /// The vtable pointer at offset 0 must be the genuine CTaskWorm vtable
446
+ /// (0x6644C8 in Ghidra, rebased at runtime).
447
+ pub unsafe fn vtable(&self) -> &'static CTaskWormVTable {
448
+ &*(self.base.base.vtable as *const CTaskWormVTable)
449
+ }
450
+ }
451
+
452
  // ============================================================
453
  // Derived task overlays — for accessing task-specific fields
454
  // beyond or within CGameTask that differ per task type.
crates/openwa-wormkit/src/validation/mod.rs CHANGED
@@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
11
 
12
  use openwa_core::rebase::rb;
13
  use openwa_core::address::va;
14
- use openwa_core::task::{CTask, CGameTask};
15
  use openwa_core::ddgame::DDGame;
16
  use openwa_core::ddgame_wrapper::DDGameWrapper;
17
 
@@ -711,6 +711,215 @@ fn log_hex_dump(data: &[u8]) {
711
  }
712
  }
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  // ---------------------------------------------------------------------------
715
  // Public entry point — called from wormkit's run()
716
  // ---------------------------------------------------------------------------
@@ -773,6 +982,10 @@ pub fn run() -> Result<(), String> {
773
  std::thread::sleep(std::time::Duration::from_secs(3));
774
  let _ = log_validation(" Running deferred global validation...");
775
  deferred_global_validation();
 
 
 
 
776
 
777
  // Wait briefly for replay to start, then dump game state
778
  // (fast-forward finishes the replay in ~10-15s total, so dump early)
@@ -827,18 +1040,18 @@ pub fn start_hotkeys() {
827
 
828
  std::thread::spawn(|| {
829
  use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
830
- const VK_F9: i32 = 0x78;
831
  const VK_F10: i32 = 0x79;
832
- let _ = log_validation(" Hotkey listener started (F9=team blocks, F10=landscape).");
 
 
833
  loop {
834
  std::thread::sleep(std::time::Duration::from_millis(100));
835
  unsafe {
836
- if GetAsyncKeyState(VK_F9) & 1 != 0 {
837
- dump_team_blocks();
838
- }
839
- if GetAsyncKeyState(VK_F10) & 1 != 0 {
840
- dump_landscape();
841
- }
842
  }
843
  }
844
  });
 
11
 
12
  use openwa_core::rebase::rb;
13
  use openwa_core::address::va;
14
+ use openwa_core::task::{CTask, CGameTask, SharedDataTable};
15
  use openwa_core::ddgame::DDGame;
16
  use openwa_core::ddgame_wrapper::DDGameWrapper;
17
 
 
711
  }
712
  }
713
 
714
+ // ---------------------------------------------------------------------------
715
+ // Entity census — enumerate all registered task types via shared_data table
716
+ // ---------------------------------------------------------------------------
717
+
718
+ fn dump_entity_census() {
719
+ let _ = log_validation("");
720
+ let _ = log_validation("--- Entity Census (SharedData hash table) ---");
721
+
722
+ unsafe {
723
+ let session_ptr = read_u32(rb(va::G_GAME_SESSION));
724
+ if session_ptr == 0 { let _ = log_validation(" No game session — skipping."); return; }
725
+ let wrapper_addr = read_u32(session_ptr + 0xA0);
726
+ if wrapper_addr == 0 { let _ = log_validation(" No DDGameWrapper."); return; }
727
+ let ddgame_ptr = read_u32(wrapper_addr + 0x488);
728
+ if ddgame_ptr == 0 { let _ = log_validation(" No DDGame."); return; }
729
+
730
+ let task_land_ptr = read_u32(ddgame_ptr + 0x54C);
731
+ if task_land_ptr == 0 {
732
+ let _ = log_validation(" CTaskLand NULL — game not loaded yet.");
733
+ return;
734
+ }
735
+
736
+ let task_land = task_land_ptr as *const CTask;
737
+ let shared_data_ptr = (*task_land).shared_data;
738
+ if shared_data_ptr.is_null() {
739
+ let _ = log_validation(" shared_data is NULL.");
740
+ return;
741
+ }
742
+
743
+ let table = SharedDataTable::from_ptr(shared_data_ptr);
744
+
745
+ // Name table: Ghidra VA → display name
746
+ let known: &[(u32, &str)] = &[
747
+ (va::CTASK_WORM_VTABLE, "CTaskWorm"),
748
+ (va::CTASK_LAND_VTABLE, "CTaskLand"),
749
+ (va::CTASK_TURN_GAME_VTABLE, "CTaskTurnGame"),
750
+ (va::CTASK_TEAM_VTABLE, "CTaskTeam"),
751
+ (va::CTASK_FILTER_VTABLE, "CTaskFilter"),
752
+ (va::CTASK_DIRT_VTABLE, "CTaskDirt"),
753
+ (va::CTASK_SPRITE_ANIM_VTABLE, "CTaskSpriteAnim"),
754
+ (va::CTASK_CPU_VTABLE, "CTaskCPU"),
755
+ (va::CTASK_VTABLE, "CTask"),
756
+ (va::CGAMETASK_VTABLE, "CGameTask"),
757
+ ];
758
+
759
+ // Collect (ghidra_va, entity_ptr) for every node.
760
+ let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
761
+ let mut entries: Vec<(u32, u32)> = Vec::new();
762
+ let mut total = 0u32;
763
+
764
+ for node in table.iter() {
765
+ let entity = (*node).entity;
766
+ if entity.is_null() { continue; }
767
+ let vtable_runtime = read_u32(entity as u32);
768
+ if !is_in_rdata(vtable_runtime) { continue; }
769
+ let vtable_ghidra = vtable_runtime.wrapping_sub(delta);
770
+ entries.push((vtable_ghidra, entity as u32));
771
+ total += 1;
772
+ }
773
+
774
+ let _ = log_validation(&format!(" Total entities: {}", total));
775
+
776
+ // Group by vtable, sort by count descending.
777
+ let mut groups: Vec<(u32, Vec<u32>)> = Vec::new();
778
+ for (vt, ptr) in &entries {
779
+ if let Some(g) = groups.iter_mut().find(|(v, _)| *v == *vt) {
780
+ g.1.push(*ptr);
781
+ } else {
782
+ groups.push((*vt, vec![*ptr]));
783
+ }
784
+ }
785
+ groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
786
+
787
+ for (vt_ghidra, ptrs) in &groups {
788
+ let name = known.iter().find(|(v, _)| *v == *vt_ghidra)
789
+ .map(|(_, n)| *n)
790
+ .unwrap_or("UNKNOWN");
791
+ let _ = log_validation(&format!(
792
+ " {:>3}x {:<20} (vtable 0x{:08X})",
793
+ ptrs.len(), name, vt_ghidra
794
+ ));
795
+ for &ptr in ptrs.iter().take(8) {
796
+ use openwa_core::task::CTaskWorm;
797
+ if *vt_ghidra == va::CTASK_WORM_VTABLE {
798
+ let w = &*(ptr as *const CTaskWorm);
799
+ let nlen = w.worm_name.iter().position(|&c| c == 0).unwrap_or(w.worm_name.len());
800
+ let wname = core::str::from_utf8(&w.worm_name[..nlen]).unwrap_or("?");
801
+ let _ = log_validation(&format!(
802
+ " @ 0x{:08X} team={} idx={} state=0x{:02X} name=\"{}\"",
803
+ ptr, w.team_index, w.worm_index, w.state(), wname
804
+ ));
805
+ } else {
806
+ let a = read_u32(ptr + 4);
807
+ let b = read_u32(ptr + 8);
808
+ let c = read_u32(ptr + 12);
809
+ let d = read_u32(ptr + 16);
810
+ let _ = log_validation(&format!(
811
+ " @ 0x{:08X} [+4:{:08X} +8:{:08X} +c:{:08X} +10:{:08X}]",
812
+ ptr, a, b, c, d
813
+ ));
814
+ }
815
+ }
816
+ }
817
+ }
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // CTaskWorm entity dump
822
+ // ---------------------------------------------------------------------------
823
+
824
+ fn dump_worm_tasks() {
825
+ use openwa_core::task::CTaskWorm;
826
+
827
+ let _ = log_validation("");
828
+ let _ = log_validation("--- CTaskWorm Entity Dump ---");
829
+
830
+ unsafe {
831
+ let session_ptr = read_u32(rb(va::G_GAME_SESSION));
832
+ if session_ptr == 0 { let _ = log_validation(" No game session — skipping."); return; }
833
+
834
+ let wrapper_addr = read_u32(session_ptr + 0xA0);
835
+ if wrapper_addr == 0 { let _ = log_validation(" No DDGameWrapper."); return; }
836
+
837
+ let ddgame_ptr = read_u32(wrapper_addr + 0x488);
838
+ if ddgame_ptr == 0 { let _ = log_validation(" No DDGame."); return; }
839
+
840
+ // DDGame+0x54C = CTaskLand* — always present once map is loaded.
841
+ let task_land_ptr = read_u32(ddgame_ptr + 0x54C);
842
+ if task_land_ptr == 0 {
843
+ let _ = log_validation(" CTaskLand is NULL — game not fully loaded yet.");
844
+ return;
845
+ }
846
+ let _ = log_validation(&format!(" DDGame @ 0x{:08X}", ddgame_ptr));
847
+ let _ = log_validation(&format!(" CTaskLand @ 0x{:08X}", task_land_ptr));
848
+
849
+ // CTask.shared_data at +0x24 — root tasks own 0x420 bytes with a
850
+ // 256-bucket hash table (FUN_005406a0). All tasks in the same game
851
+ // tree share this block via the inherited shared_data pointer.
852
+ let task_land = task_land_ptr as *const CTask;
853
+ let shared_data_ptr = (*task_land).shared_data;
854
+ if shared_data_ptr.is_null() {
855
+ let _ = log_validation(" shared_data is NULL.");
856
+ return;
857
+ }
858
+ let _ = log_validation(&format!(" shared_data @ 0x{:08X}", shared_data_ptr as u32));
859
+
860
+ let table = SharedDataTable::from_ptr(shared_data_ptr);
861
+
862
+ let expected_vtable = rb(va::CTASK_WORM_VTABLE) as *const u8;
863
+ let _ = log_validation(&format!(" Expected CTaskWorm vtable: 0x{:08X}", expected_vtable as u32));
864
+
865
+ let mut worm_count = 0u32;
866
+
867
+ for node in table.iter() {
868
+ let candidate = (*node).entity;
869
+ if candidate.is_null() { continue; }
870
+ // Vtable is the first pointer in the object; must be in .rdata.
871
+ let vtable = read_u32(candidate as u32) as *const u8;
872
+ if !is_in_rdata(vtable as u32) || vtable != expected_vtable { continue; }
873
+
874
+ worm_count += 1;
875
+ let worm = &*(candidate as *const CTaskWorm);
876
+
877
+ let state = worm.state();
878
+ let pos_x_f = worm.base.pos_x.0 as f32 / 65536.0;
879
+ let pos_y_f = worm.base.pos_y.0 as f32 / 65536.0;
880
+ let team_idx = worm.team_index;
881
+ let worm_idx = worm.worm_index;
882
+ let slot_id = worm.slot_id;
883
+
884
+ let name_len = worm.worm_name.iter().position(|&c| c == 0).unwrap_or(worm.worm_name.len());
885
+ let name = core::str::from_utf8(&worm.worm_name[..name_len]).unwrap_or("?");
886
+
887
+ let _ = log_validation(&format!(
888
+ " worm#{} @ 0x{:08X}: team={} worm_idx={} slot={} state=0x{:04X} pos=({:.1},{:.1}) name=\"{}\"",
889
+ worm_count, candidate as u32,
890
+ team_idx, worm_idx, slot_id,
891
+ state, pos_x_f, pos_y_f, name
892
+ ));
893
+
894
+ // Cross-validate against WormEntry in TeamArena.
895
+ // WormEntry = DDGame+0x4090 + team*0x51C + worm_idx*0x9C
896
+ // +0x00: state, +0x78: name (17 bytes)
897
+ if team_idx < 6 && worm_idx < 8 {
898
+ let entry_addr = ddgame_ptr + 0x4090 + team_idx * 0x51C + worm_idx * 0x9C;
899
+ let entry_state = read_u32(entry_addr);
900
+ let mut entry_name = [0u8; 17];
901
+ for i in 0..17usize {
902
+ entry_name[i] = read_u8(entry_addr + 0x78 + i as u32);
903
+ if entry_name[i] == 0 { break; }
904
+ }
905
+ let entry_name_len = entry_name.iter().position(|&c| c == 0).unwrap_or(17);
906
+ let entry_name_str = core::str::from_utf8(&entry_name[..entry_name_len]).unwrap_or("?");
907
+
908
+ let state_msg = if state == entry_state { "state:OK".to_string() }
909
+ else { format!("state:MISMATCH(entry=0x{:04X})", entry_state) };
910
+ let name_msg = if name == entry_name_str { "name:OK".to_string() }
911
+ else { format!("name:MISMATCH(entry=\"{}\")", entry_name_str) };
912
+ let _ = log_validation(&format!(
913
+ " xcheck WormEntry[{},{}] @ 0x{:08X}: {} {}",
914
+ team_idx, worm_idx, entry_addr, state_msg, name_msg
915
+ ));
916
+ }
917
+ }
918
+
919
+ let _ = log_validation(&format!(" Total CTaskWorm entities found: {}", worm_count));
920
+ }
921
+ }
922
+
923
  // ---------------------------------------------------------------------------
924
  // Public entry point — called from wormkit's run()
925
  // ---------------------------------------------------------------------------
 
982
  std::thread::sleep(std::time::Duration::from_secs(3));
983
  let _ = log_validation(" Running deferred global validation...");
984
  deferred_global_validation();
985
+ let _ = log_validation(" Running entity census (5s mark)...");
986
+ dump_entity_census();
987
+ let _ = log_validation(" Running worm entity dump (5s mark)...");
988
+ dump_worm_tasks();
989
 
990
  // Wait briefly for replay to start, then dump game state
991
  // (fast-forward finishes the replay in ~10-15s total, so dump early)
 
1040
 
1041
  std::thread::spawn(|| {
1042
  use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
1043
+ const VK_F9: i32 = 0x78;
1044
  const VK_F10: i32 = 0x79;
1045
+ const VK_F11: i32 = 0x7A;
1046
+ const VK_F12: i32 = 0x7B;
1047
+ let _ = log_validation(" Hotkeys: F9=team blocks, F10=landscape, F11=worm tasks, F12=entity census");
1048
  loop {
1049
  std::thread::sleep(std::time::Duration::from_millis(100));
1050
  unsafe {
1051
+ if GetAsyncKeyState(VK_F9) & 1 != 0 { dump_team_blocks(); }
1052
+ if GetAsyncKeyState(VK_F10) & 1 != 0 { dump_landscape(); }
1053
+ if GetAsyncKeyState(VK_F11) & 1 != 0 { dump_worm_tasks(); }
1054
+ if GetAsyncKeyState(VK_F12) & 1 != 0 { dump_entity_census(); }
 
 
1055
  }
1056
  }
1057
  });