File size: 23,640 Bytes
f3cbb8a
 
654bf33
d088c03
1d3de83
123d0d1
f3cbb8a
2a09823
 
 
 
e80ee6b
2a09823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5fbbeb
 
 
 
 
 
3b55aa0
 
 
 
 
 
f5fbbeb
 
3b55aa0
 
 
 
 
 
2a09823
 
 
 
 
3b55aa0
 
f5fbbeb
 
2a09823
 
 
 
 
 
f5fbbeb
 
 
 
2a09823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5fbbeb
 
 
 
2a09823
 
 
 
 
 
 
 
f5fbbeb
 
2a09823
 
 
 
 
f5fbbeb
e565bdc
 
 
a0cd512
 
e565bdc
 
 
 
 
 
c7778ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e565bdc
 
94ea94e
 
 
 
 
 
 
 
 
f3cbb8a
 
 
 
 
 
b532037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3cbb8a
1a163b2
f3cbb8a
1a163b2
 
 
 
 
 
 
 
1d3de83
 
1a163b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a09823
1a163b2
 
 
 
 
 
 
 
 
 
 
f3cbb8a
 
 
 
 
 
 
 
 
 
 
 
 
0706c93
f3cbb8a
 
 
 
123d0d1
f3cbb8a
 
 
c6d4e73
f3cbb8a
 
 
 
 
 
 
 
 
 
 
ed7ea8c
 
 
 
 
 
 
 
 
 
 
 
f3cbb8a
 
 
 
 
 
2835c9a
 
 
 
 
 
 
9d2738d
ed7ea8c
 
 
 
 
 
 
f5fbbeb
 
 
 
 
 
 
 
 
 
 
 
2835c9a
 
 
 
 
 
 
 
 
 
f5fbbeb
 
 
 
 
 
 
654bf33
f5fbbeb
654bf33
2835c9a
 
 
 
 
 
 
 
f5fbbeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3cbb8a
5f22f7f
f3cbb8a
 
5f22f7f
f3cbb8a
ed7ea8c
 
 
 
 
 
 
 
 
 
2835c9a
 
 
 
f3cbb8a
 
d088c03
 
fe03f4c
d088c03
f5fbbeb
 
 
 
 
aab6d6e
74fd7c8
 
aab6d6e
74fd7c8
 
2835c9a
 
 
 
 
 
 
 
 
 
 
 
 
 
f3cbb8a
 
 
 
c6d4e73
 
 
f3cbb8a
0706c93
2a09823
f3cbb8a
c7778ea
f3cbb8a
 
bb59078
 
 
 
5dd0ca9
 
 
 
 
 
 
 
 
 
 
 
 
 
0706c93
c6d4e73
f3cbb8a
2c6f096
 
 
 
 
1d3de83
 
 
 
 
2c6f096
 
 
 
1d3de83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c6f096
1d3de83
 
 
 
 
2c6f096
 
 
1d3de83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c6f096
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
use super::base::CTask;
use super::game_task::CGameTask;
use crate::fixed::Fixed;
use crate::game::weapon::WeaponEntry;
use crate::game::Weapon;
use crate::FieldRegistry;

/// Worm state machine states.
///
/// CTaskWorm's `SetState` (vtable slot 14) transitions between these.
/// The state byte lives at CTaskWorm+0x44 (inside `base.subclass_data`).
/// Also stored in WormEntry.state in the TeamArena.
///
/// States 0x68..=0x8A are the "weapon/action active" range β€” checked by
/// `(state - 0x68) < 0x23` in HandleMessage. States 0x80+ are dying/dead.
/// Names are best guesses from behavioral observation and disassembly.
///
/// Source: CTaskWorm::HandleMessage (0x510B40) decompilation + weapon fire dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
#[allow(non_camel_case_types)]
pub enum WormState {
    /// Transitional state checked by CheckWormState0x64 (0x5228D0).
    /// Appears briefly during turn transitions.
    Transitional = 0x64,
    /// Idle β€” not this worm's turn. Also used by MailMineMole to re-enter idle.
    Idle = 0x65,
    /// Idle variant β€” grouped with 0x65/0x67 in HandleMessage switch cases.
    IdleVariant_Maybe = 0x66,
    /// Active turn β€” this worm is currently being controlled.
    Active = 0x67,
    /// Active variant β€” observed in WormEntry. Start of weapon/action range.
    ActiveVariant_Maybe = 0x68,
    /// Unknown (within weapon/action range).
    Unknown_0x69 = 0x69,
    /// Unknown (within weapon/action range).
    Unknown_0x6A = 0x6A,
    /// Unknown (within weapon/action range).
    Unknown_0x6B = 0x6B,
    /// Fire Punch β€” performing fire punch attack (sub34=1).
    FirePunch = 0x6C,
    /// Kamikaze β€” performing kamikaze attack (sub34=4). Frequently checked in HandleMessage.
    Kamikaze = 0x6D,
    /// Pneumatic Drill β€” using the pneumatic drill (sub34=8).
    PneumaticDrill = 0x6E,
    /// Air Strike pending β€” set when air strike fires with `_unknown_208 == 0`.
    AirStrikePending_Maybe = 0x6F,
    /// Unknown β€” sub34=6 has no replay test log data.
    Unknown_0x70 = 0x70,
    /// Blowtorch β€” using the blowtorch weapon (sub34=11).
    Blowtorch = 0x71,
    /// Unknown β€” sub34=18 has no replay test log data.
    Unknown_0x72 = 0x72,
    /// Weapon charging β€” entered from aiming states (0x7B, 0x7C) before release.
    /// Also set by CheckPendingAction when field +0xBC is nonzero.
    WeaponCharging_Maybe = 0x73,
    /// Teleport cancelled β€” teleport failed or was denied.
    TeleportCancelled_Maybe = 0x74,
    /// Suicide Bomber β€” performing suicide bomber attack (sub34=5).
    SuicideBomber = 0x75,
    /// Unknown (within weapon/action range).
    Unknown_0x76 = 0x76,
    /// Weapon selected β€” entered via SelectCursor (msg 0x24) from idle/active states.
    /// HandleMessage sets param[0xa7]=-1 when already in this state.
    WeaponSelected_Maybe = 0x77,
    /// Weapon aimed β€” post-select state. Teleport check accepts this.
    /// Also used for Magic Bullet weapon fire.
    WeaponAimed_Maybe = 0x78,
    /// Unknown. Checked in WeaponRelease spawn offset (type 2, Y adjustment when state == 0x79).
    Unknown_0x79 = 0x79,
    /// Unknown.
    Unknown_0x7A = 0x7A,
    /// Aiming with angle β€” entered for aimed weapons. Sets angle params.
    /// Teleport check accepts this. Transitions to 0x73 on fire.
    AimingAngle_Maybe = 0x7B,
    /// Rope swinging β€” IsNotOnRope checks `state != 0x7C`.
    /// Transitions to 0x73 on fire.
    RopeSwinging = 0x7C,
    /// Pre-fire variant β€” MailMineMole version check uses this.
    /// Transitions to 0x7E or 0x65 depending on FUN_004fb580.
    PreFire_Maybe = 0x7D,
    /// Post-fire / special movement β€” entered from 0x78 and 0x7D
    /// when FUN_004fb580 returns nonzero.
    PostFire_Maybe = 0x7E,
    /// Drowning β€” worm fell in water.
    Drowning = 0x7F,
    /// Hurt β€” worm took damage.
    Hurt = 0x80,
    /// Dead variant 1.
    Dead1 = 0x81,
    /// Dying variant 1 β€” checked alongside 0x83 in HandleMessage.
    Dying1_Maybe = 0x82,
    /// Dying variant 2 β€” checked alongside 0x82 in HandleMessage.
    Dying2_Maybe = 0x83,
    /// Unknown.
    Unknown_0x84 = 0x84,
    /// Unknown.
    Unknown_0x85 = 0x85,
    /// Dead β€” set by Surrender (msg 0x29). Frequently checked in HandleMessage.
    Dead = 0x86,
    /// Dead variant 3.
    Dead3 = 0x87,
    /// Unknown β€” grouped with idle states (0x65/0x66/0x67/0x8B) in HandleMessage.
    Unknown_0x88 = 0x88,
    /// Dying/special animation (from WormEntry state documentation).
    DyingAnimation_Maybe = 0x89,
    /// Unknown. End of weapon/action range.
    Unknown_0x8A = 0x8A,
    /// Unknown state checked in CTaskTeam handlers.
    /// Grouped with idle states in HandleMessage switch cases.
    Unknown_0x8B = 0x8B,
}

impl WormState {}

/// Raw-pointer methods for CTaskWorm.
///
/// `set_state_raw` and other vtable methods are now auto-generated by the
/// `bind_CTaskWormVTable!` macro. Only non-vtable raw helpers remain here.
impl CTaskWorm {
    /// Set the weapon fire completion flag at +0x3C without creating `&mut self`.
    pub unsafe fn set_fire_complete_raw(this: *mut CTaskWorm, value: i32) {
        let base = this as *mut u8;
        core::ptr::write(base.add(0x3C) as *mut i32, value);
    }

    /// Fire subtype 1 at CGameTask+0x34 (subclass_data[4..8]).
    /// Air strike temporarily swaps this with `_unknown_190` during position update.
    pub unsafe fn fire_subtype_1(this: *const CTaskWorm) -> i32 {
        i32::from_ne_bytes((&(*this).base.subclass_data)[4..8].try_into().unwrap())
    }

    /// Set fire subtype 1 without creating `&mut self` (avoids LLVM noalias violations).
    pub unsafe fn set_fire_subtype_1_raw(this: *mut CTaskWorm, value: i32) {
        (&mut (*this).base.subclass_data)[4..8].copy_from_slice(&value.to_ne_bytes());
    }

    /// Set the action field at CGameTask+0x48 (subclass_data[0x18..0x1C]).
    /// Cleared after air strike completion alongside `_unknown_208` / `_unknown_19x`.
    pub unsafe fn set_action_field_raw(this: *mut CTaskWorm, value: i32) {
        (&mut (*this).base.subclass_data)[0x18..0x1C].copy_from_slice(&value.to_ne_bytes());
    }
}

crate::define_addresses! {
    class "CTaskWorm" {
        /// CTaskWorm vtable
        vtable CTASK_WORM_VTABLE = 0x0066_44C8;
        /// CTaskWorm constructor
        ctor CTASK_WORM_CONSTRUCTOR = 0x0050_BFB0;
    }
}

/// Virtual method table for CTaskWorm (vtable at 0x6644C8, 20 slots).
///
/// CTaskWorm overrides 14 of the 20 inherited CTask/CGameTask slots;
/// 6 slots pass through unchanged. Slot layout by vtable byte offset:
///
/// ```text
/// Slot  Offset  Name                 Base class
/// ----  ------  -------------------  ----------
///  0    0x00    WriteReplayState     CTask (overridden)
///  1    0x04    Free                 CTask (overridden)
///  2    0x08    HandleMessage        CTask (overridden)
///  3    0x0C    GetEntityData        CTask (overridden)
///  4    0x10    (stub, returns 0)    CTask β€” inherited
///  5    0x14    ProcessChildren      CTask β€” inherited
///  6    0x18    ProcessFrame         CTask β€” inherited
///  7    0x1C    OnContactEntity      CGameTask (overridden)
///  8    0x20    OnWormPush           CGameTask (overridden)
///  9    0x24    OnLandBounce         CGameTask (overridden)
/// 10    0x28    OnLandSlide          CGameTask? (new)
/// 11    0x2C    OnSink               CGameTask? (new)
/// 12    0x30    (inherited)          CGameTask
/// 13    0x34    OnKilled             CGameTask (overridden)
/// 14    0x38    SetState             CTaskWorm (new)
/// 15    0x3C    CheckPendingAction   CTaskWorm (new)
/// 16    0x40    IsNotOnRope          CTaskWorm (new)
/// 17    0x44    (inherited)          CGameTask
/// 18    0x48    GetTeamIndex         CTaskWorm (new)
/// 19    0x4C    (inherited)          CGameTask
/// ```
#[openwa_core::vtable(size = 20)]
pub struct CTaskWormVTable {
    /// WriteReplayState β€” serializes worm state to a replay stream
    #[slot(0)]
    pub write_replay_state: fn(this: *mut CTaskWorm, stream: *mut u8),
    /// Free β€” calls inner destructor, then `_free(this)` if flags & 1
    #[slot(1)]
    pub free: fn(this: *mut CTaskWorm, flags: u8) -> *mut CTaskWorm,
    /// HandleMessage β€” processes all TaskMessages sent to this worm
    #[slot(2)]
    pub handle_message:
        fn(this: *mut CTaskWorm, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
    /// GetEntityData β€” returns worm data by query code
    #[slot(3)]
    pub get_entity_data: fn(this: *mut CTaskWorm, query: u32, param: u32, out: *mut u32) -> u32,
    // Slots 4-6: Inherited CTask stubs (auto-filled as usize)
    /// OnContactEntity β€” handles physical contact with another entity
    #[slot(7)]
    pub on_contact_entity: fn(this: *mut CTaskWorm, other: *mut CGameTask, flags: u32) -> u32,
    /// OnWormPush β€” post-contact worm-worm push impulse
    #[slot(8)]
    pub on_worm_push: fn(this: *mut CTaskWorm, other: *mut CGameTask, flags: u32) -> u32,
    /// OnLandBounce β€” worm lands on terrain; plays thud sound, bounce physics
    #[slot(9)]
    pub on_land_bounce: fn(this: *mut CTaskWorm),
    /// OnLandSlide β€” secondary landing callback; sliding/friction physics
    #[slot(10)]
    pub on_land_slide: fn(this: *mut CTaskWorm),
    /// OnSink β€” worm sinks in water/acid; transitions to drowning state
    #[slot(11)]
    pub on_sink: fn(this: *mut CTaskWorm, dx: i32, dy: i32) -> u32,
    // Slot 12: Inherited (auto-filled)
    /// OnKilled β€” worm death; plays death sound, transitions to dead state
    #[slot(13)]
    pub on_killed: fn(this: *mut CTaskWorm),
    /// SetState β€” worm state machine; handles all state transitions
    #[slot(14)]
    pub set_state: fn(this: *mut CTaskWorm, state: WormState),
    /// CheckPendingAction β€” if field +0xBC is set, calls SetState(0x73)
    #[slot(15)]
    pub check_pending_action: fn(this: *mut CTaskWorm),
    /// IsNotOnRope β€” returns true if worm state != 0x7C (rope-swinging)
    #[slot(16)]
    pub is_not_on_rope: fn(this: *const CTaskWorm) -> bool,
    // Slot 17: Inherited (auto-filled)
    /// GetTeamIndex β€” returns worm's team index (field +0xFC)
    #[slot(18)]
    pub get_team_index: fn(this: *const CTaskWorm) -> u32,
    // Slot 19: Inherited (auto-filled)
}

/// Worm entity task β€” the primary playable character in WA.
///
/// Extends CGameTask (0xFC bytes) with worm identity, physics overrides, and
/// per-worm state. Total size: 0x3FC bytes.
///
/// Constructor: 0x50BFB0 (stdcall, 5 params):
///   this, parent_task, team_index, worm_index, init_data_ptr
///
/// Vtable at 0x6644C8. Class type byte: 0x12.
///
/// # Important fields in the CGameTask base
/// The worm state field lives at **offset +0x44** (inside `base.subclass_data`).
/// Use [`CTaskWorm::state`] to read it without pointer arithmetic.
///
/// Source: Ghidra decompilation of 0x50BFB0, vtable analysis of 0x6644C8,
///         wkJellyWorm CTaskWorm.h
#[derive(FieldRegistry)]
#[repr(C)]
pub struct CTaskWorm {
    /// 0x00–0xFB: CGameTask base (position, velocity, sound emitter, etc.)
    pub base: CGameTask<*const CTaskWormVTable>,

    /// 0xFC: Team index (0-based); 3rd constructor param
    pub team_index: u32,
    /// 0x100: Worm index within team (0-based); 4th constructor param
    pub worm_index: u32,
    /// 0x104: Unknown flag (checked in OnContactEntity)
    pub _unknown_104: u32,
    /// 0x108–0x10F: Unknown
    pub _unknown_108: [u8; 8],
    /// 0x110–0x137: Ten u32s copied from spawn init_data (5th constructor param)
    pub spawn_params: [u32; 10],
    /// 0x138–0x143: Unknown
    pub _unknown_138: [u8; 0x0C],
    /// 0x144: Poison source bitmask β€” tracks which alliances have poisoned this worm.
    /// PoisonWorm handler (msg 0x51): `poison_source_mask |= alliance_bit`.
    /// Prevents double-poisoning from the same source.
    pub poison_source_mask: u32,
    /// 0x148: Poison damage per turn. 0 = not poisoned.
    /// PoisonWorm handler: `poison_damage += msg_data[0]`.
    /// ApplyPoison handler (msg 0x3E): subtracts this from health each turn.
    pub poison_damage: i32,
    /// 0x14C–0x14F: Unknown
    pub _unknown_14c: [u8; 4],
    /// 0x150: Unknown (slot 9 in GetEntityData query 0x7D4 output)
    pub _unknown_150: u32,
    /// 0x154: Unknown (rope-related; cleared in some SetState transitions)
    pub _unknown_154: u32,
    /// 0x158: Worm pool slot index in DDGame (assigned from pool at construction)
    pub slot_id: u32,
    /// 0x15C–0x163: Unknown
    pub _unknown_15c: [u8; 0x164 - 0x15C],
    /// 0x164: Frames the worm has stayed stationary (no movement). Resets on movement.
    pub stationary_frames: u32,
    /// 0x168–0x16F: Unknown
    pub _unknown_168: [u8; 0x170 - 0x168],
    /// 0x170: Currently selected weapon ID.
    pub selected_weapon: Weapon,
    /// 0x174–0x177: Unknown
    pub _unknown_174: [u8; 4],
    /// 0x178: Display health (animated toward target). Used for health bar interpolation.
    /// Stored as `00 00 XX 00` where XX is health β€” actual layout is u16 at +0x17A.
    pub display_health_raw: u32,
    /// 0x17C: Target health (matches WormEntry.health). Same byte layout as display_health.
    pub target_health_raw: u32,
    /// 0x180–0x18F: Unknown
    pub _unknown_180: [u8; 0x190 - 0x180],
    /// 0x190: Secondary position/mode field. Air strike temporarily swaps this with +0x34.
    pub _unknown_190: i32,
    /// 0x194–0x197: Unknown
    pub _unknown_194: [u8; 4],
    /// 0x198: Cleared after air strike fire.
    pub _unknown_198: i32,
    /// 0x19C: Cleared after air strike fire.
    pub _unknown_19c: i32,
    /// 0x1A0–0x1A7: Unknown
    pub _unknown_1a0: [u8; 0x1A8 - 0x1A0],
    /// 0x1A8: Facing direction copy. -1 = left, +1 = right (same as +0x3DC).
    pub facing_direction_2: i32,
    /// 0x1AC: Inverted facing direction. +1 = left, -1 = right.
    pub facing_direction_inv: i32,
    /// 0x1B0–0x1EB: Unknown
    pub _unknown_1b0: [u8; 0x1EC - 0x1B0],
    /// 0x1EC: Movement streak counter. Increases ~once per second while moving
    /// in one direction. Resets to 0 when movement resumes after a stop.
    /// Set to -1 when the worm is blocked (e.g. hits a wall).
    pub movement_streak: i32,
    /// 0x1F0–0x207: Unknown
    pub _unknown_1f0: [u8; 0x208 - 0x1F0],
    /// 0x208: Action/mode flag. Checked in air strike (must be 0 to fire).
    /// Cleared after air strike completes.
    pub _unknown_208: i32,
    /// 0x20C–0x247: Unknown
    pub _unknown_20c: [u8; 0x248 - 0x20C],
    /// 0x248: Landscape scale factor for spawn offset calculation (Fixed 16.16).
    /// Used by WeaponRelease to convert aim params to world-space projectile offsets.
    pub landscape_scale: Fixed,
    /// 0x24C: Aim angle (fixed-point 16.16, range 0..0x10000 = 0..360 degrees).
    pub aim_angle: u32,
    /// 0x250–0x267: Unknown
    pub _unknown_250: [u8; 0x268 - 0x250],
    /// 0x268: Show aiming cursor flag (nonzero = cursor visible).
    pub show_cursor: u32,
    /// 0x26C–0x283: Unknown
    pub _unknown_26c: [u8; 0x284 - 0x26C],
    /// 0x284: Shot/aim data 1 (slot 5 in GetEntityData query 0x7D4).
    /// For weapons 0x22/0x24 (Teleport/Freeze), copied from shot_data_2 on first fire.
    /// Sent in HandleMessage 0x49 buffer during WeaponRelease.
    pub shot_data_1: u32,
    /// 0x288: Shot/aim data 2 (slot 6 in GetEntityData query 0x7D4).
    pub shot_data_2: u32,
    /// 0x28C–0x28F: Unknown
    pub _unknown_28c: [u8; 4],
    /// 0x290: Fire sync frame counter 1. Compared with fire_sync_frame_2
    /// in WeaponRelease; when equal, weapon slot table is reset.
    pub fire_sync_frame_1: i32,
    /// 0x294: Fire sync frame counter 2.
    pub fire_sync_frame_2: i32,
    /// 0x298–0x2BB: Unknown
    pub _unknown_298: [u8; 0x2BC - 0x298],
    /// 0x2BC: Network client index. Compared against max client count
    /// (5 or 10 depending on FE version) to compute network delay.
    pub network_client_index: i32,
    /// 0x2C0: Difficulty level (0 = easier/30-frame delay, 1 = harder/60-frame delay).
    pub difficulty_level: i32,
    /// 0x2C4–0x2C7: Unknown
    pub _unknown_2c4: [u8; 4],
    /// 0x2C8: Network/sound condition flag. In type 2 (rope) sound dispatch,
    /// sound plays only when this is 0 OR when it equals 1.
    pub _unknown_2c8: i32,
    /// 0x2CC: Network flag. Nonzero = network mode active.
    /// Used in sound dispatch conditions and HandleMessage 0x49 buffer.
    pub _unknown_2cc: i32,
    /// 0x2D0–0x2DF: Unknown
    pub _unknown_2d0: [u8; 0x2E0 - 0x2D0],
    /// 0x2E0: Weapon parameter 1. Polymorphic per weapon:
    /// - WeaponRelease: ammo_per_turn (copied to release context)
    /// - Air Strike: fire position X
    /// - Freeze: freeze effect X position
    pub weapon_param_1: i32,
    /// 0x2E4: Weapon parameter 2. Polymorphic per weapon:
    /// - WeaponRelease: ammo_per_slot (copied to release context)
    /// - Air Strike: fire position Y
    /// - Freeze: freeze effect Y position
    pub weapon_param_2: i32,
    /// 0x2E8–0x2EB: Unknown
    pub _unknown_2e8: [u8; 4],
    /// 0x2EC: Weapon parameter 3 / launch count.
    /// - Weapons 0x22/0x24: checked == 0 to copy shot_data_2 β†’ shot_data_1
    /// - Freeze: freeze target entity ID
    pub weapon_param_3: i32,
    /// 0x2F0: Worm name, null-terminated (max 17 chars, from spawn init_data+3)
    #[field(kind = "CString")]
    pub worm_name: [u8; 0x11],
    /// 0x301: Country / team name from scheme, null-terminated (max 17 chars)
    #[field(kind = "CString")]
    pub country_name: [u8; 0x11],
    /// 0x312: Health display string, null-terminated ASCII (e.g. "100", "88").
    /// Updated when health changes for the floating health number display.
    #[field(kind = "CString")]
    pub health_text: [u8; 0x09],
    /// 0x31B: Poison damage display string, null-terminated ASCII (e.g. "5", "").
    /// Shown as the green poison damage number above the worm.
    #[field(kind = "CString")]
    pub poison_text: [u8; 0x09],
    /// 0x324–0x333: Unknown
    pub _unknown_324: [u8; 0x334 - 0x324],
    /// 0x334: Facing direction copy. -1 = left, +1 = right (same as +0x3DC).
    pub facing_direction_3: i32,
    /// 0x338–0x367: Unknown
    pub _unknown_338: [u8; 0x368 - 0x338],
    /// 0x368: Animator / controller object (dispatched via vtable for state animations)
    pub animator: *mut u8,
    /// 0x36C: Active weapon entry pointer. Points to `&WeaponTable.entries[selected_weapon]`.
    /// Contains fire type (+0x30), subtypes (+0x34/+0x38), and completion flag (+0x3C).
    /// Used by WeaponRelease: `MOV EAX, [EDI+0x36C]` before calling FireWeapon.
    pub active_weapon_entry: *mut WeaponEntry,
    /// 0x370–0x3AF: Unknown (rope anchor, weapon-specific data, etc.)
    pub _unknown_370: [u8; 0x3B0 - 0x370],
    /// 0x3B0: Streaming sound handle. Nonzero when a worm sound effect
    /// (e.g., weapon charge-up) is actively playing. PlayWormSound stores the
    /// new handle here; StopWormSound clears it.
    pub sound_handle: i32,
    /// 0x3B4: Secondary sound handle, used by FUN_00515020 (teleport/weapon sounds).
    /// Same stop/play semantics as `sound_handle` but a separate channel.
    pub sound_handle_2: i32,
    /// 0x3B8–0x3DB: Unknown
    pub _unknown_3b8: [u8; 0x3DC - 0x3B8],
    /// 0x3DC: Facing direction. -1 = facing left, +1 = facing right.
    pub facing_direction: i32,
    /// 0x3E0–0x3E3: Unknown
    pub _unknown_3e0: u32,
    /// 0x3E4: Input: aim up key held (nonzero = adjusting aim upward).
    pub input_aim_up: u32,
    /// 0x3E8: Input: aim down key held (nonzero = adjusting aim downward).
    pub input_aim_down: u32,
    /// 0x3EC: Input: move left key held (nonzero = worm walking left).
    pub input_move_left: u32,
    /// 0x3F0: Input: move right key held (nonzero = worm walking right).
    pub input_move_right: u32,
    /// 0x3F4–0x3FB: Unknown
    pub _unknown_3f4: [u8; 0x3FC - 0x3F4],
}

const _: () = assert!(core::mem::size_of::<CTaskWorm>() == 0x3FC);

// Generate typed vtable method wrappers: handle_message(), on_contact_entity(), etc.
bind_CTaskWormVTable!(CTaskWorm, base.base.vtable);

impl CTaskWorm {
    /// Returns the worm's current state code (lives at offset +0x44, inside
    /// `base.subclass_data`). See [`WormState`] for known values.
    pub fn state(&self) -> u32 {
        u32::from_ne_bytes(self.base.subclass_data[0x14..0x18].try_into().unwrap())
    }

    pub fn is_in_state(&self, state: WormState) -> bool {
        self.state() == state as u32
    }

    // Weapon fire dispatch state:
    // - Fire type/subtypes live in the WeaponEntry (via active_weapon_entry at +0x36C)
    // - Completion flag lives in CGameTask.subclass_data[12] (this object, at +0x3C)

    /// Weapon fire completion flag at CGameTask+0x3C (subclass_data[12]).
    /// Set to 0 before FireWeapon dispatch, 1 after.
    pub fn fire_complete(&self) -> i32 {
        i32::from_ne_bytes(self.base.subclass_data[12..16].try_into().unwrap())
    }

    /// Set the weapon fire completion flag.
    pub fn set_fire_complete(&mut self, value: i32) {
        self.base.subclass_data[12..16].copy_from_slice(&value.to_ne_bytes());
    }

    // vtable() method is now provided by bind_CTaskWormVTable! macro above.
}

// ── Snapshot impl ──────────────────────────────────────────

#[cfg(target_arch = "x86")]
impl crate::snapshot::Snapshot for CTaskWorm {
    unsafe fn write_snapshot(
        &self,
        w: &mut dyn core::fmt::Write,
        indent: usize,
    ) -> core::fmt::Result {
        use crate::snapshot::{write_indent, write_raw_region};
        let i = indent;
        let b = &self.base; // CGameTask

        write_indent(w, i)?;
        writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
        write_indent(w, i)?;
        writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
        write_indent(w, i)?;
        writeln!(w, "angle = {}", b.angle)?;
        write_indent(w, i)?;
        writeln!(w, "team_index = {}", self.team_index)?;
        write_indent(w, i)?;
        writeln!(w, "worm_index = {}", self.worm_index)?;
        write_indent(w, i)?;
        writeln!(w, "slot_id = {}", self.slot_id)?;
        write_indent(w, i)?;
        writeln!(w, "selected_weapon = {:?}", self.selected_weapon)?;
        write_indent(w, i)?;
        writeln!(w, "facing = {}", self.facing_direction)?;
        write_indent(w, i)?;
        writeln!(w, "aim_angle = 0x{:08X}", self.aim_angle)?;

        write_indent(w, i)?;
        write!(w, "spawn_params =")?;
        for v in &self.spawn_params {
            write!(w, " {:08X}", v)?;
        }
        writeln!(w)?;

        // Raw dump of unknown regions
        write_indent(w, i)?;
        writeln!(w, "_unknown_1f0 ({} bytes):", self._unknown_1f0.len())?;
        write_raw_region(
            w,
            self._unknown_1f0.as_ptr(),
            self._unknown_1f0.len(),
            i + 1,
        )?;
        write_indent(w, i)?;
        writeln!(w, "_unknown_28c ({} bytes):", self._unknown_28c.len())?;
        write_raw_region(
            w,
            self._unknown_28c.as_ptr(),
            self._unknown_28c.len(),
            i + 1,
        )?;
        write_indent(w, i)?;
        writeln!(w, "_unknown_370 ({} bytes):", self._unknown_370.len())?;
        write_raw_region(
            w,
            self._unknown_370.as_ptr(),
            self._unknown_370.len(),
            i + 1,
        )?;

        Ok(())
    }
}