paavohtl Claude Opus 4.6 (1M context) commited on
Commit
1d3de83
Β·
1 Parent(s): 12635a0

style: cargo fmt across all crates

Browse files

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

Files changed (46) hide show
  1. crates/openwa-core/src/address.rs +25 -30
  2. crates/openwa-core/src/display/dd_display.rs +15 -2
  3. crates/openwa-core/src/engine/ddgame.rs +89 -32
  4. crates/openwa-core/src/engine/ddgame_constructor.rs +28 -25
  5. crates/openwa-core/src/engine/game_info.rs +0 -1
  6. crates/openwa-core/src/engine/game_state_init.rs +4 -6
  7. crates/openwa-core/src/engine/mod.rs +8 -8
  8. crates/openwa-core/src/engine/replay.rs +7 -7
  9. crates/openwa-core/src/engine/team_arena.rs +10 -3
  10. crates/openwa-core/src/fixed.rs +10 -2
  11. crates/openwa-core/src/game/scheme.rs +12 -13
  12. crates/openwa-core/src/lib.rs +3 -3
  13. crates/openwa-core/src/mem.rs +7 -9
  14. crates/openwa-core/src/registry.rs +39 -11
  15. crates/openwa-core/src/snapshot.rs +36 -10
  16. crates/openwa-core/src/task/arrow.rs +2 -1
  17. crates/openwa-core/src/task/base.rs +12 -13
  18. crates/openwa-core/src/task/cloud.rs +2 -1
  19. crates/openwa-core/src/task/filter.rs +2 -1
  20. crates/openwa-core/src/task/fire.rs +2 -1
  21. crates/openwa-core/src/task/game_task.rs +2 -1
  22. crates/openwa-core/src/task/mine_oil_drum.rs +4 -2
  23. crates/openwa-core/src/task/missile.rs +65 -23
  24. crates/openwa-core/src/task/mod.rs +3 -1
  25. crates/openwa-core/src/task/supply_crate.rs +2 -1
  26. crates/openwa-core/src/task/team.rs +14 -10
  27. crates/openwa-core/src/task/turn_game.rs +2 -1
  28. crates/openwa-core/src/task/worm.rs +55 -20
  29. crates/openwa-debug-cli/src/main.rs +174 -73
  30. crates/openwa-debug-proto/src/lib.rs +56 -16
  31. crates/openwa-debugui/src/app.rs +6 -17
  32. crates/openwa-derive/src/lib.rs +15 -11
  33. crates/openwa-test-runner/src/main.rs +32 -13
  34. crates/openwa-wormkit/src/debug_server.rs +35 -21
  35. crates/openwa-wormkit/src/debug_sync.rs +13 -7
  36. crates/openwa-wormkit/src/debug_watchpoint.rs +6 -6
  37. crates/openwa-wormkit/src/lib.rs +6 -3
  38. crates/openwa-wormkit/src/replacements/file_isolation.rs +2 -3
  39. crates/openwa-wormkit/src/replacements/game_session.rs +22 -8
  40. crates/openwa-wormkit/src/replacements/input.rs +14 -5
  41. crates/openwa-wormkit/src/replacements/render.rs +4 -5
  42. crates/openwa-wormkit/src/replacements/replay.rs +198 -82
  43. crates/openwa-wormkit/src/replacements/task/cloud.rs +2 -1
  44. crates/openwa-wormkit/src/replacements/weapon_release.rs +98 -54
  45. crates/openwa-wormkit/src/snapshot.rs +25 -13
  46. crates/openwa-wormkit/src/validation/mod.rs +13 -11
crates/openwa-core/src/address.rs CHANGED
@@ -29,46 +29,41 @@ pub mod va {
29
 
30
  // Re-exported from task modules
31
  pub use crate::task::base::{
32
- CTASK_VTABLE, CTASK_CONSTRUCTOR, CTASK_VT0_INIT, CTASK_VT1_FREE,
33
- CTASK_VT2_HANDLE_MESSAGE, CTASK_VT3, CTASK_VT5, CTASK_VT6,
34
- CTASK_VT7_PROCESS_FRAME,
35
- CTASK_LAND_VTABLE, CTASK_LAND_CTOR,
36
- CTASK_DIRT_VTABLE, CTASK_DIRT_CTOR,
37
- CTASK_SPRITE_ANIM_VTABLE, CTASK_SPRITE_ANIM_CTOR,
38
- CTASK_CPU_VTABLE, CTASK_CPU_CTOR,
39
- CTASK_SEA_BUBBLE_VTABLE, CTASK_SEABUBBLE_CTOR,
40
- CTASK_AIRSTRIKE_CTOR, CTASK_ARROW_CTOR, CTASK_CANISTER_CTOR,
41
- CTASK_CROSS_CTOR, CTASK_FIREBALL_CTOR, CTASK_FLAME_CTOR,
42
- CTASK_GAS_CTOR, CTASK_OLDWORM_CTOR, CTASK_SCOREBUBBLE_CTOR,
43
- CTASK_SMOKE_CTOR,
44
  };
 
 
 
45
  pub use crate::task::game_task::{
46
- CGAMETASK_VTABLE, CGAMETASK_SOUND_EMITTER_VT, CGAMETASK_CONSTRUCTOR,
47
- CGAMETASK_VT0, CGAMETASK_VT1_FREE, CGAMETASK_VT2_HANDLE_MESSAGE,
48
  };
49
- pub use crate::task::worm::{CTASK_WORM_VTABLE, CTASK_WORM_CONSTRUCTOR};
50
- pub use crate::task::turn_game::{
51
- CTASK_TURN_GAME_VTABLE, CTASK_TURNGAME_CTOR,
52
- TURNGAME_HANDLE_MESSAGE, TURNGAME_HURRY_HANDLER, TURNGAME_AUTO_SELECT_TEAMS,
53
- };
54
- pub use crate::task::team::{CTASK_TEAM_VTABLE, CTASK_TEAM_CTOR};
55
- pub use crate::task::missile::{CTASK_MISSILE_VTABLE, CTASK_MISSILE_CTOR};
56
  pub use crate::task::mine_oil_drum::{
57
- CTASK_MINE_VTABLE, CTASK_MINE_CTOR, CTASK_OILDRUM_VTABLE, CTASK_OILDRUM_CTOR,
 
 
 
 
 
 
 
58
  };
59
- pub use crate::task::supply_crate::{CTASK_CRATE_VTABLE, CTASK_CRATE_CTOR};
60
- pub use crate::task::cloud::{CTASK_CLOUD_VTABLE, CTASK_CLOUD_CTOR};
61
- pub use crate::task::filter::{CTASK_FILTER_VTABLE, CTASK_FILTER_CTOR};
62
- pub use crate::task::fire::{CTASK_FIRE_VTABLE, CTASK_FIRE_CTOR};
63
 
64
  // Re-exported from modules using #[vtable(...)] attribute
65
- pub use crate::display::palette::PALETTE_VTABLE;
 
66
  pub use crate::display::dd_display::DD_DISPLAY_VTABLE;
67
- pub use crate::input::controller::INPUT_CTRL_VTABLE;
68
  pub use crate::frontend::map_view::MAP_VIEW_VTABLE;
 
69
  pub use crate::task::game_task::SOUND_EMITTER_VTABLE;
70
- pub use crate::display::base::DISPLAY_BASE_VTABLE;
71
- pub use crate::audio::dssound::DS_SOUND_VTABLE;
72
 
73
  crate::define_addresses! {
74
  class "DDGameWrapper" {
 
29
 
30
  // Re-exported from task modules
31
  pub use crate::task::base::{
32
+ CTASK_AIRSTRIKE_CTOR, CTASK_ARROW_CTOR, CTASK_CANISTER_CTOR, CTASK_CONSTRUCTOR,
33
+ CTASK_CPU_CTOR, CTASK_CPU_VTABLE, CTASK_CROSS_CTOR, CTASK_DIRT_CTOR, CTASK_DIRT_VTABLE,
34
+ CTASK_FIREBALL_CTOR, CTASK_FLAME_CTOR, CTASK_GAS_CTOR, CTASK_LAND_CTOR, CTASK_LAND_VTABLE,
35
+ CTASK_OLDWORM_CTOR, CTASK_SCOREBUBBLE_CTOR, CTASK_SEABUBBLE_CTOR, CTASK_SEA_BUBBLE_VTABLE,
36
+ CTASK_SMOKE_CTOR, CTASK_SPRITE_ANIM_CTOR, CTASK_SPRITE_ANIM_VTABLE, CTASK_VT0_INIT,
37
+ CTASK_VT1_FREE, CTASK_VT2_HANDLE_MESSAGE, CTASK_VT3, CTASK_VT5, CTASK_VT6,
38
+ CTASK_VT7_PROCESS_FRAME, CTASK_VTABLE,
 
 
 
 
 
39
  };
40
+ pub use crate::task::cloud::{CTASK_CLOUD_CTOR, CTASK_CLOUD_VTABLE};
41
+ pub use crate::task::filter::{CTASK_FILTER_CTOR, CTASK_FILTER_VTABLE};
42
+ pub use crate::task::fire::{CTASK_FIRE_CTOR, CTASK_FIRE_VTABLE};
43
  pub use crate::task::game_task::{
44
+ CGAMETASK_CONSTRUCTOR, CGAMETASK_SOUND_EMITTER_VT, CGAMETASK_VT0, CGAMETASK_VT1_FREE,
45
+ CGAMETASK_VT2_HANDLE_MESSAGE, CGAMETASK_VTABLE,
46
  };
 
 
 
 
 
 
 
47
  pub use crate::task::mine_oil_drum::{
48
+ CTASK_MINE_CTOR, CTASK_MINE_VTABLE, CTASK_OILDRUM_CTOR, CTASK_OILDRUM_VTABLE,
49
+ };
50
+ pub use crate::task::missile::{CTASK_MISSILE_CTOR, CTASK_MISSILE_VTABLE};
51
+ pub use crate::task::supply_crate::{CTASK_CRATE_CTOR, CTASK_CRATE_VTABLE};
52
+ pub use crate::task::team::{CTASK_TEAM_CTOR, CTASK_TEAM_VTABLE};
53
+ pub use crate::task::turn_game::{
54
+ CTASK_TURNGAME_CTOR, CTASK_TURN_GAME_VTABLE, TURNGAME_AUTO_SELECT_TEAMS,
55
+ TURNGAME_HANDLE_MESSAGE, TURNGAME_HURRY_HANDLER,
56
  };
57
+ pub use crate::task::worm::{CTASK_WORM_CONSTRUCTOR, CTASK_WORM_VTABLE};
 
 
 
58
 
59
  // Re-exported from modules using #[vtable(...)] attribute
60
+ pub use crate::audio::dssound::DS_SOUND_VTABLE;
61
+ pub use crate::display::base::DISPLAY_BASE_VTABLE;
62
  pub use crate::display::dd_display::DD_DISPLAY_VTABLE;
63
+ pub use crate::display::palette::PALETTE_VTABLE;
64
  pub use crate::frontend::map_view::MAP_VIEW_VTABLE;
65
+ pub use crate::input::controller::INPUT_CTRL_VTABLE;
66
  pub use crate::task::game_task::SOUND_EMITTER_VTABLE;
 
 
67
 
68
  crate::define_addresses! {
69
  class "DDGameWrapper" {
crates/openwa-core/src/display/dd_display.rs CHANGED
@@ -31,10 +31,23 @@ pub struct DDDisplayVtable {
31
  pub set_layer_visibility: fn(this: *mut DDDisplay, layer: i32, value: i32),
32
  /// load sprite with flag (RET 0x14)
33
  #[slot(31)]
34
- pub load_sprite: fn(this: *mut DDDisplay, layer: u32, id: u32, flag: u32, gfx: *mut u8, name: *const core::ffi::c_char) -> i32,
 
 
 
 
 
 
 
35
  /// load sprite by layer (RET 0x10)
36
  #[slot(37)]
37
- pub load_sprite_by_layer: fn(this: *mut DDDisplay, layer: u32, id: u32, gfx: *mut u8, name: *const core::ffi::c_char) -> i32,
 
 
 
 
 
 
38
  }
39
 
40
  // Generate calling wrappers: DDDisplay::set_layer_color(), etc.
 
31
  pub set_layer_visibility: fn(this: *mut DDDisplay, layer: i32, value: i32),
32
  /// load sprite with flag (RET 0x14)
33
  #[slot(31)]
34
+ pub load_sprite: fn(
35
+ this: *mut DDDisplay,
36
+ layer: u32,
37
+ id: u32,
38
+ flag: u32,
39
+ gfx: *mut u8,
40
+ name: *const core::ffi::c_char,
41
+ ) -> i32,
42
  /// load sprite by layer (RET 0x10)
43
  #[slot(37)]
44
+ pub load_sprite_by_layer: fn(
45
+ this: *mut DDDisplay,
46
+ layer: u32,
47
+ id: u32,
48
+ gfx: *mut u8,
49
+ name: *const core::ffi::c_char,
50
+ ) -> i32,
51
  }
52
 
53
  // Generate calling wrappers: DDDisplay::set_layer_color(), etc.
crates/openwa-core/src/engine/ddgame.rs CHANGED
@@ -1,17 +1,17 @@
1
- use crate::FieldRegistry;
2
  use crate::audio::active_sound::ActiveSoundTable;
3
- use crate::fixed::Fixed;
4
  use crate::audio::dssound::DSSound;
5
  use crate::audio::music::Music;
6
  use crate::audio::speech::SpeechSlotTable;
7
  use crate::display::dd_display::DDDisplay;
8
  use crate::display::palette::Palette;
9
  use crate::engine::game_info::GameInfo;
 
10
  use crate::game::weapon::WeaponTable;
11
  use crate::input::keyboard::DDKeyboard;
12
  use crate::render::landscape::PCLandscape;
13
  use crate::render::queue::RenderQueue;
14
  use crate::render::turn_order::TurnOrderWidget;
 
15
 
16
  // Re-export constructor items so existing `engine::ddgame::*` imports keep working.
17
  pub use super::ddgame_constructor::{
@@ -24,9 +24,9 @@ pub use crate::render::gfx_dir::gfx_dir_load_dir;
24
 
25
  // Re-export all team_arena types so existing `engine::ddgame::*` imports keep working.
26
  pub use crate::engine::team_arena::{
27
- CoordEntry, CoordList, CoordListEntry, GAME_PHASE_NORMAL_MIN, GAME_PHASE_SUDDEN_DEATH,
28
- RenderEntry, SoundQueueEntry, TeamArenaRef, TeamArenaState, TeamBlock, TeamHeader,
29
- TeamIndexMap, TeamSlot0, TeamWeaponSlots, WeaponSlots, WormEntry, worm,
30
  };
31
 
32
  /// DDGame β€” the main game engine object.
@@ -483,8 +483,8 @@ impl DDGame {
483
  /// # Safety
484
  /// `self.game_info` must be valid.
485
  pub unsafe fn show_pool_overflow_warning(&mut self) {
486
- use crate::rebase::rb;
487
  use crate::address::va;
 
488
 
489
  let game_version = (*self.game_info).game_version;
490
  if game_version < 0x3C {
@@ -621,36 +621,91 @@ pub mod offsets {
621
 
622
  #[cfg(target_arch = "x86")]
623
  impl crate::snapshot::Snapshot for DDGame {
624
- unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
 
 
 
 
625
  use crate::fixed::Fixed;
626
- use crate::snapshot::{write_indent, fmt_ptr};
627
  let i = indent;
628
 
629
- write_indent(w, i)?; writeln!(w, "frame_counter = {}", self.frame_counter)?;
630
- write_indent(w, i)?; writeln!(w, "game_speed = {}", Fixed(self.game_speed))?;
631
- write_indent(w, i)?; writeln!(w, "game_speed_target = {}", Fixed(self.game_speed_target))?;
632
- write_indent(w, i)?; writeln!(w, "rng_state = 0x{:08X} 0x{:08X}", self.rng_state_1, self.rng_state_2)?;
633
- write_indent(w, i)?; writeln!(w, "camera = ({}, {})", Fixed(self.camera_x), Fixed(self.camera_y))?;
634
- write_indent(w, i)?; writeln!(w, "camera_target = ({}, {})", Fixed(self.camera_target_x), Fixed(self.camera_target_y))?;
635
- write_indent(w, i)?; writeln!(w, "level_size = {}x{}", self.level_width, self.level_height)?;
636
- write_indent(w, i)?; writeln!(w, "level_size_raw = {}x{}", self.level_width_raw, self.level_height_raw)?;
637
- write_indent(w, i)?; writeln!(w, "landscape_property = {}", fmt_ptr(self.landscape_property as *const u8))?;
638
- write_indent(w, i)?; writeln!(w, "gfx_color_table = {:?}", self.gfx_color_table)?;
639
- write_indent(w, i)?; writeln!(w, "fast_forward = req={} active={}", self.fast_forward_request, self.fast_forward_active)?;
640
- write_indent(w, i)?; writeln!(w, "keyboard = {}", fmt_ptr(self.keyboard as *const u8))?;
641
- write_indent(w, i)?; writeln!(w, "display = {}", fmt_ptr(self.display as *const u8))?;
642
- write_indent(w, i)?; writeln!(w, "sound = {}", fmt_ptr(self.sound as *const u8))?;
643
- write_indent(w, i)?; writeln!(w, "game_info = {}", fmt_ptr(self.game_info as *const u8))?;
644
- write_indent(w, i)?; writeln!(w, "landscape = {}", fmt_ptr(self.landscape as *const u8))?;
645
- write_indent(w, i)?; writeln!(w, "task_land = {}", fmt_ptr(self.task_land))?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
 
647
  // TeamArenaState summary
648
- write_indent(w, i)?; writeln!(w, "team_arena.team_count = {}", self.team_arena.team_count)?;
649
- write_indent(w, i)?; writeln!(w, "team_arena.game_phase = {}", self.team_arena.game_phase)?;
650
- write_indent(w, i)?; writeln!(w, "team_arena.game_mode_flag = {}", self.team_arena.game_mode_flag)?;
 
 
 
 
 
 
 
651
 
652
  // Dump weapon slots (ammo only, skip zeros)
653
- write_indent(w, i)?; writeln!(w, "weapon_slots:")?;
 
654
  for team in 0..6usize {
655
  let slots = &self.team_arena.weapon_slots.teams[team];
656
  let mut has_any = false;
@@ -658,7 +713,8 @@ impl crate::snapshot::Snapshot for DDGame {
658
  let ammo = slots.ammo[wpn];
659
  if ammo != 0 {
660
  if !has_any {
661
- write_indent(w, i + 1)?; write!(w, "team[{}] ammo:", team)?;
 
662
  has_any = true;
663
  }
664
  if ammo == -1 {
@@ -668,10 +724,11 @@ impl crate::snapshot::Snapshot for DDGame {
668
  }
669
  }
670
  }
671
- if has_any { writeln!(w)?; }
 
 
672
  }
673
 
674
  Ok(())
675
  }
676
  }
677
-
 
 
1
  use crate::audio::active_sound::ActiveSoundTable;
 
2
  use crate::audio::dssound::DSSound;
3
  use crate::audio::music::Music;
4
  use crate::audio::speech::SpeechSlotTable;
5
  use crate::display::dd_display::DDDisplay;
6
  use crate::display::palette::Palette;
7
  use crate::engine::game_info::GameInfo;
8
+ use crate::fixed::Fixed;
9
  use crate::game::weapon::WeaponTable;
10
  use crate::input::keyboard::DDKeyboard;
11
  use crate::render::landscape::PCLandscape;
12
  use crate::render::queue::RenderQueue;
13
  use crate::render::turn_order::TurnOrderWidget;
14
+ use crate::FieldRegistry;
15
 
16
  // Re-export constructor items so existing `engine::ddgame::*` imports keep working.
17
  pub use super::ddgame_constructor::{
 
24
 
25
  // Re-export all team_arena types so existing `engine::ddgame::*` imports keep working.
26
  pub use crate::engine::team_arena::{
27
+ worm, CoordEntry, CoordList, CoordListEntry, RenderEntry, SoundQueueEntry, TeamArenaRef,
28
+ TeamArenaState, TeamBlock, TeamHeader, TeamIndexMap, TeamSlot0, TeamWeaponSlots, WeaponSlots,
29
+ WormEntry, GAME_PHASE_NORMAL_MIN, GAME_PHASE_SUDDEN_DEATH,
30
  };
31
 
32
  /// DDGame β€” the main game engine object.
 
483
  /// # Safety
484
  /// `self.game_info` must be valid.
485
  pub unsafe fn show_pool_overflow_warning(&mut self) {
 
486
  use crate::address::va;
487
+ use crate::rebase::rb;
488
 
489
  let game_version = (*self.game_info).game_version;
490
  if game_version < 0x3C {
 
621
 
622
  #[cfg(target_arch = "x86")]
623
  impl crate::snapshot::Snapshot for DDGame {
624
+ unsafe fn write_snapshot(
625
+ &self,
626
+ w: &mut dyn core::fmt::Write,
627
+ indent: usize,
628
+ ) -> core::fmt::Result {
629
  use crate::fixed::Fixed;
630
+ use crate::snapshot::{fmt_ptr, write_indent};
631
  let i = indent;
632
 
633
+ write_indent(w, i)?;
634
+ writeln!(w, "frame_counter = {}", self.frame_counter)?;
635
+ write_indent(w, i)?;
636
+ writeln!(w, "game_speed = {}", Fixed(self.game_speed))?;
637
+ write_indent(w, i)?;
638
+ writeln!(w, "game_speed_target = {}", Fixed(self.game_speed_target))?;
639
+ write_indent(w, i)?;
640
+ writeln!(
641
+ w,
642
+ "rng_state = 0x{:08X} 0x{:08X}",
643
+ self.rng_state_1, self.rng_state_2
644
+ )?;
645
+ write_indent(w, i)?;
646
+ writeln!(
647
+ w,
648
+ "camera = ({}, {})",
649
+ Fixed(self.camera_x),
650
+ Fixed(self.camera_y)
651
+ )?;
652
+ write_indent(w, i)?;
653
+ writeln!(
654
+ w,
655
+ "camera_target = ({}, {})",
656
+ Fixed(self.camera_target_x),
657
+ Fixed(self.camera_target_y)
658
+ )?;
659
+ write_indent(w, i)?;
660
+ writeln!(w, "level_size = {}x{}", self.level_width, self.level_height)?;
661
+ write_indent(w, i)?;
662
+ writeln!(
663
+ w,
664
+ "level_size_raw = {}x{}",
665
+ self.level_width_raw, self.level_height_raw
666
+ )?;
667
+ write_indent(w, i)?;
668
+ writeln!(
669
+ w,
670
+ "landscape_property = {}",
671
+ fmt_ptr(self.landscape_property as *const u8)
672
+ )?;
673
+ write_indent(w, i)?;
674
+ writeln!(w, "gfx_color_table = {:?}", self.gfx_color_table)?;
675
+ write_indent(w, i)?;
676
+ writeln!(
677
+ w,
678
+ "fast_forward = req={} active={}",
679
+ self.fast_forward_request, self.fast_forward_active
680
+ )?;
681
+ write_indent(w, i)?;
682
+ writeln!(w, "keyboard = {}", fmt_ptr(self.keyboard as *const u8))?;
683
+ write_indent(w, i)?;
684
+ writeln!(w, "display = {}", fmt_ptr(self.display as *const u8))?;
685
+ write_indent(w, i)?;
686
+ writeln!(w, "sound = {}", fmt_ptr(self.sound as *const u8))?;
687
+ write_indent(w, i)?;
688
+ writeln!(w, "game_info = {}", fmt_ptr(self.game_info as *const u8))?;
689
+ write_indent(w, i)?;
690
+ writeln!(w, "landscape = {}", fmt_ptr(self.landscape as *const u8))?;
691
+ write_indent(w, i)?;
692
+ writeln!(w, "task_land = {}", fmt_ptr(self.task_land))?;
693
 
694
  // TeamArenaState summary
695
+ write_indent(w, i)?;
696
+ writeln!(w, "team_arena.team_count = {}", self.team_arena.team_count)?;
697
+ write_indent(w, i)?;
698
+ writeln!(w, "team_arena.game_phase = {}", self.team_arena.game_phase)?;
699
+ write_indent(w, i)?;
700
+ writeln!(
701
+ w,
702
+ "team_arena.game_mode_flag = {}",
703
+ self.team_arena.game_mode_flag
704
+ )?;
705
 
706
  // Dump weapon slots (ammo only, skip zeros)
707
+ write_indent(w, i)?;
708
+ writeln!(w, "weapon_slots:")?;
709
  for team in 0..6usize {
710
  let slots = &self.team_arena.weapon_slots.teams[team];
711
  let mut has_any = false;
 
713
  let ammo = slots.ammo[wpn];
714
  if ammo != 0 {
715
  if !has_any {
716
+ write_indent(w, i + 1)?;
717
+ write!(w, "team[{}] ammo:", team)?;
718
  has_any = true;
719
  }
720
  if ammo == -1 {
 
724
  }
725
  }
726
  }
727
+ if has_any {
728
+ writeln!(w)?;
729
+ }
730
  }
731
 
732
  Ok(())
733
  }
734
  }
 
crates/openwa-core/src/engine/ddgame_constructor.rs CHANGED
@@ -52,8 +52,16 @@ pub unsafe fn ddgame_init_fields(ddgame: *mut DDGame) {
52
  // These offsets are deep in the unknown 0x2E00-0x45EB region and don't
53
  // have named fields yet β€” keep as raw offsets for now.
54
  for &off in &[
55
- 0x379Cusize, 0x3930, 0x3AC4, 0x3C58, 0x3DEC,
56
- 0x3F80, 0x4114, 0x42A8, 0x443C, 0x45D0,
 
 
 
 
 
 
 
 
57
  ] {
58
  *(base.add(off) as *mut u32) = 0;
59
  }
@@ -482,13 +490,7 @@ unsafe fn init_graphics_and_resources(
482
  let disp = (*wrapper).display;
483
  let gfx_dir = (*wrapper).primary_gfx_dir;
484
  (*disp).set_layer_color(1, 0xFE);
485
- (*disp).load_sprite(
486
- 1,
487
- 1,
488
- 0,
489
- gfx_dir,
490
- rb(va::STR_CDROM_SPR) as *const c_char,
491
- );
492
  (*disp).set_layer_visibility(1, -100);
493
 
494
  // Palette slot range init (raw byte offsets into DisplayBase)
@@ -618,9 +620,15 @@ unsafe fn init_graphics_and_resources(
618
  let palette_ctx = wa_malloc_zeroed(0x900);
619
  *(palette_ctx as *mut u16) = 1;
620
  *(palette_ctx.add(2) as *mut u16) = 0xFE;
621
- call_usercall_eax(palette_ctx as *mut DDGameWrapper, rb(va::PALETTE_CONTEXT_INIT));
622
- gfx_resource =
623
- gfx_resource_create(gfx_dir, rb(va::STR_MASKS_IMG) as *const core::ffi::c_char, palette_ctx);
 
 
 
 
 
 
624
  }
625
 
626
  // ── Dump GfxResource for A/B comparison ──
@@ -771,13 +779,13 @@ unsafe fn init_graphics_and_resources(
771
  let region = if !alloc.is_null() {
772
  call_sprite_region_ctor(
773
  alloc,
774
- (sprite_w / 2) as u32, // ECX β†’ this[5] = ECX - p2
775
- (sprite_h - margin_h) as u32, // EDX β†’ this[4] = EDX - p3
776
- margin_w as u32, // p2 (left margin)
777
- margin_h as u32, // p3 (top margin)
778
- (sprite_w - margin_w) as u32, // p4 β†’ this[3] = p4 - p2
779
- (sprite_h / 2) as u32, // p5 β†’ this[6] = p5 - p3
780
- sprite as u32, // p6 = arrow sprite (NOT landscape gfx_resource)
781
  )
782
  } else {
783
  core::ptr::null_mut()
@@ -917,12 +925,7 @@ unsafe fn init_graphics_and_resources(
917
  // GenerateDebrisParticles (0x546F70) for particle effects, which affects
918
  // the game RNG (DDGame+0x45EC). The original constructor loads them even
919
  // in headless mode. Skipping them causes replay desync.
920
- (*disp).load_sprite_by_layer(
921
- 3,
922
- 0x26D,
923
- land_layer,
924
- c"back.spr".as_ptr().cast(),
925
- );
926
  // debris.spr must be loaded unconditionally β€” it's used by
927
  // GenerateDebrisParticles (0x546F70) for particle effects, which
928
  // affects the game RNG (DDGame+0x45EC). Skipping it in headless
 
52
  // These offsets are deep in the unknown 0x2E00-0x45EB region and don't
53
  // have named fields yet β€” keep as raw offsets for now.
54
  for &off in &[
55
+ 0x379Cusize,
56
+ 0x3930,
57
+ 0x3AC4,
58
+ 0x3C58,
59
+ 0x3DEC,
60
+ 0x3F80,
61
+ 0x4114,
62
+ 0x42A8,
63
+ 0x443C,
64
+ 0x45D0,
65
  ] {
66
  *(base.add(off) as *mut u32) = 0;
67
  }
 
490
  let disp = (*wrapper).display;
491
  let gfx_dir = (*wrapper).primary_gfx_dir;
492
  (*disp).set_layer_color(1, 0xFE);
493
+ (*disp).load_sprite(1, 1, 0, gfx_dir, rb(va::STR_CDROM_SPR) as *const c_char);
 
 
 
 
 
 
494
  (*disp).set_layer_visibility(1, -100);
495
 
496
  // Palette slot range init (raw byte offsets into DisplayBase)
 
620
  let palette_ctx = wa_malloc_zeroed(0x900);
621
  *(palette_ctx as *mut u16) = 1;
622
  *(palette_ctx.add(2) as *mut u16) = 0xFE;
623
+ call_usercall_eax(
624
+ palette_ctx as *mut DDGameWrapper,
625
+ rb(va::PALETTE_CONTEXT_INIT),
626
+ );
627
+ gfx_resource = gfx_resource_create(
628
+ gfx_dir,
629
+ rb(va::STR_MASKS_IMG) as *const core::ffi::c_char,
630
+ palette_ctx,
631
+ );
632
  }
633
 
634
  // ── Dump GfxResource for A/B comparison ──
 
779
  let region = if !alloc.is_null() {
780
  call_sprite_region_ctor(
781
  alloc,
782
+ (sprite_w / 2) as u32, // ECX β†’ this[5] = ECX - p2
783
+ (sprite_h - margin_h) as u32, // EDX β†’ this[4] = EDX - p3
784
+ margin_w as u32, // p2 (left margin)
785
+ margin_h as u32, // p3 (top margin)
786
+ (sprite_w - margin_w) as u32, // p4 β†’ this[3] = p4 - p2
787
+ (sprite_h / 2) as u32, // p5 β†’ this[6] = p5 - p3
788
+ sprite as u32, // p6 = arrow sprite (NOT landscape gfx_resource)
789
  )
790
  } else {
791
  core::ptr::null_mut()
 
925
  // GenerateDebrisParticles (0x546F70) for particle effects, which affects
926
  // the game RNG (DDGame+0x45EC). The original constructor loads them even
927
  // in headless mode. Skipping them causes replay desync.
928
+ (*disp).load_sprite_by_layer(3, 0x26D, land_layer, c"back.spr".as_ptr().cast());
 
 
 
 
 
929
  // debris.spr must be loaded unconditionally β€” it's used by
930
  // GenerateDebrisParticles (0x546F70) for particle effects, which
931
  // affects the game RNG (DDGame+0x45EC). Skipping it in headless
crates/openwa-core/src/engine/game_info.rs CHANGED
@@ -94,7 +94,6 @@ pub struct GameInfo {
94
  pub land_dat_path: [u8; 14],
95
 
96
  // --- Replay configuration (populated by ReplayLoader) ---
97
-
98
  /// 0xDAFA-0xDB07: Unknown
99
  pub _unknown_dafa: [u8; 0xDB08 - 0xDAFA],
100
  /// 0xDB08: Invisibility (weapon 0x42) mode flag (u32). Controls team-count
 
94
  pub land_dat_path: [u8; 14],
95
 
96
  // --- Replay configuration (populated by ReplayLoader) ---
 
97
  /// 0xDAFA-0xDB07: Unknown
98
  pub _unknown_dafa: [u8; 0xDB08 - 0xDAFA],
99
  /// 0xDB08: Invisibility (weapon 0x42) mode flag (u32). Controls team-count
crates/openwa-core/src/engine/game_state_init.rs CHANGED
@@ -376,10 +376,9 @@ pub unsafe fn check_weapon_avail(ddgame: *mut DDGame, weapon_index: u32) -> i32
376
  }
377
  }
378
  w if w == Weapon::DoubleTurnTime as u32 => {
379
- if game_version > 0xD1
380
- && (*gi).double_turn_time_threshold > 0x7FFF {
381
- return 0;
382
- }
383
  }
384
  _ => {}
385
  }
@@ -401,8 +400,7 @@ pub unsafe fn check_weapon_avail(ddgame: *mut DDGame, weapon_index: u32) -> i32
401
  }
402
 
403
  // AquaSheep (25) or SuperSheep (24) depending on weapon_index_offset
404
- let restricted_id = Weapon::AquaSheep as u32
405
- - ((*gi).aquasheep_is_supersheep != 0) as u32;
406
  if weapon_index != restricted_id {
407
  return 1;
408
  }
 
376
  }
377
  }
378
  w if w == Weapon::DoubleTurnTime as u32 => {
379
+ if game_version > 0xD1 && (*gi).double_turn_time_threshold > 0x7FFF {
380
+ return 0;
381
+ }
 
382
  }
383
  _ => {}
384
  }
 
400
  }
401
 
402
  // AquaSheep (25) or SuperSheep (24) depending on weapon_index_offset
403
+ let restricted_id = Weapon::AquaSheep as u32 - ((*gi).aquasheep_is_supersheep != 0) as u32;
 
404
  if weapon_index != restricted_id {
405
  return 1;
406
  }
crates/openwa-core/src/engine/mod.rs CHANGED
@@ -1,6 +1,5 @@
1
  pub mod ddgame;
2
  pub mod ddgame_constructor;
3
- pub mod replay;
4
  pub mod ddgame_wrapper;
5
  pub mod game_info;
6
  pub mod game_session;
@@ -8,20 +7,21 @@ pub mod game_state_init;
8
  pub mod game_timer;
9
  pub mod net_bridge;
10
  pub mod net_wrapper;
 
11
  pub mod team_arena;
12
 
13
  pub use ddgame::DDGame;
14
  pub use ddgame_constructor::{
15
- create_ddgame, ddgame_init_fields, ddgame_init_render_indices,
16
- display_layer_color_init, init_constructor_addrs, ON_DDGAME_ALLOC,
17
- };
18
- pub use team_arena::{
19
- CoordEntry, CoordList, CoordListEntry, GAME_PHASE_NORMAL_MIN, GAME_PHASE_SUDDEN_DEATH,
20
- RenderEntry, SoundQueueEntry, TeamArenaRef, TeamArenaState, TeamBlock, TeamHeader,
21
- TeamIndexMap, TeamSlot0, TeamWeaponSlots, WeaponSlots, WormEntry,
22
  };
23
  pub use ddgame_wrapper::DDGameWrapper;
24
  pub use game_info::GameInfo;
25
  pub use game_session::GameSession;
26
  pub use game_timer::GameTimer;
27
  pub use net_wrapper::DDNetGameWrapper;
 
 
 
 
 
 
1
  pub mod ddgame;
2
  pub mod ddgame_constructor;
 
3
  pub mod ddgame_wrapper;
4
  pub mod game_info;
5
  pub mod game_session;
 
7
  pub mod game_timer;
8
  pub mod net_bridge;
9
  pub mod net_wrapper;
10
+ pub mod replay;
11
  pub mod team_arena;
12
 
13
  pub use ddgame::DDGame;
14
  pub use ddgame_constructor::{
15
+ create_ddgame, ddgame_init_fields, ddgame_init_render_indices, display_layer_color_init,
16
+ init_constructor_addrs, ON_DDGAME_ALLOC,
 
 
 
 
 
17
  };
18
  pub use ddgame_wrapper::DDGameWrapper;
19
  pub use game_info::GameInfo;
20
  pub use game_session::GameSession;
21
  pub use game_timer::GameTimer;
22
  pub use net_wrapper::DDNetGameWrapper;
23
+ pub use team_arena::{
24
+ CoordEntry, CoordList, CoordListEntry, RenderEntry, SoundQueueEntry, TeamArenaRef,
25
+ TeamArenaState, TeamBlock, TeamHeader, TeamIndexMap, TeamSlot0, TeamWeaponSlots, WeaponSlots,
26
+ WormEntry, GAME_PHASE_NORMAL_MIN, GAME_PHASE_SUDDEN_DEATH,
27
+ };
crates/openwa-core/src/engine/replay.rs CHANGED
@@ -7,13 +7,13 @@
7
  #[repr(i32)]
8
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
  pub enum ReplayError {
10
- FileNotFound = -1, // 0xFFFFFFFF
11
- InvalidFormat = -2, // 0xFFFFFFFE
12
- VersionTooNew = -3, // 0xFFFFFFFD
13
- MallocFailure = -4, // 0xFFFFFFFC
14
- MapLoadFailure = -5, // 0xFFFFFFFB
15
- ArtClassLimit = -6, // 0xFFFFFFFA
16
- RepairWithTeams = -7, // 0xFFFFFFF9
17
  // -8 is unused (gap in error codes)
18
  NoScheme = -9, // 0xFFFFFFF7
19
  SchemeSaveFailure = -10, // 0xFFFFFFF6
 
7
  #[repr(i32)]
8
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
  pub enum ReplayError {
10
+ FileNotFound = -1, // 0xFFFFFFFF
11
+ InvalidFormat = -2, // 0xFFFFFFFE
12
+ VersionTooNew = -3, // 0xFFFFFFFD
13
+ MallocFailure = -4, // 0xFFFFFFFC
14
+ MapLoadFailure = -5, // 0xFFFFFFFB
15
+ ArtClassLimit = -6, // 0xFFFFFFFA
16
+ RepairWithTeams = -7, // 0xFFFFFFF9
17
  // -8 is unused (gap in error codes)
18
  NoScheme = -9, // 0xFFFFFFF7
19
  SchemeSaveFailure = -10, // 0xFFFFFFF6
crates/openwa-core/src/engine/team_arena.rs CHANGED
@@ -531,14 +531,21 @@ impl TeamArenaRef {
531
  // ── Snapshot impl ──────────────────────────────────────────
532
 
533
  impl crate::snapshot::Snapshot for WormEntry {
534
- unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
 
 
 
 
535
  use crate::snapshot::write_indent;
536
  let i = indent;
537
  let name = core::ffi::CStr::from_ptr(self.name.as_ptr() as *const core::ffi::c_char)
538
  .to_string_lossy();
539
  write_indent(w, i)?;
540
- writeln!(w, "state=0x{:02X} active={} hp={}/{} name=\"{}\"",
541
- self.state, self.active_flag, self.health, self.max_health, name)?;
 
 
 
542
  Ok(())
543
  }
544
  }
 
531
  // ── Snapshot impl ──────────────────────────────────────────
532
 
533
  impl crate::snapshot::Snapshot for WormEntry {
534
+ unsafe fn write_snapshot(
535
+ &self,
536
+ w: &mut dyn core::fmt::Write,
537
+ indent: usize,
538
+ ) -> core::fmt::Result {
539
  use crate::snapshot::write_indent;
540
  let i = indent;
541
  let name = core::ffi::CStr::from_ptr(self.name.as_ptr() as *const core::ffi::c_char)
542
  .to_string_lossy();
543
  write_indent(w, i)?;
544
+ writeln!(
545
+ w,
546
+ "state=0x{:02X} active={} hp={}/{} name=\"{}\"",
547
+ self.state, self.active_flag, self.health, self.max_health, name
548
+ )?;
549
  Ok(())
550
  }
551
  }
crates/openwa-core/src/fixed.rs CHANGED
@@ -48,12 +48,20 @@ impl Fixed {
48
 
49
  #[inline]
50
  pub fn max(self, other: Self) -> Self {
51
- if self > other { self } else { other }
 
 
 
 
52
  }
53
 
54
  #[inline]
55
  pub fn min(self, other: Self) -> Self {
56
- if self < other { self } else { other }
 
 
 
 
57
  }
58
 
59
  #[inline]
 
48
 
49
  #[inline]
50
  pub fn max(self, other: Self) -> Self {
51
+ if self > other {
52
+ self
53
+ } else {
54
+ other
55
+ }
56
  }
57
 
58
  #[inline]
59
  pub fn min(self, other: Self) -> Self {
60
+ if self < other {
61
+ self
62
+ } else {
63
+ other
64
+ }
65
  }
66
 
67
  #[inline]
crates/openwa-core/src/game/scheme.rs CHANGED
@@ -184,19 +184,18 @@ impl SchemeFile {
184
  /// For V3 schemes, trailing extended options bytes that match the defaults
185
  /// are trimmed, matching WA's minimal file format.
186
  pub fn to_bytes(&self) -> Vec<u8> {
187
- let payload = if self.version == SchemeVersion::V3
188
- && self.payload.len() == SCHEME_PAYLOAD_V3
189
- {
190
- // Trim trailing extended options bytes that match defaults
191
- let ext = &self.payload[EXTENDED_OPTIONS_OFFSET..];
192
- let mut trim_len = EXTENDED_OPTIONS_SIZE;
193
- while trim_len > 0 && ext[trim_len - 1] == EXTENDED_OPTIONS_DEFAULTS[trim_len - 1] {
194
- trim_len -= 1;
195
- }
196
- &self.payload[..EXTENDED_OPTIONS_OFFSET + trim_len]
197
- } else {
198
- &self.payload
199
- };
200
  let mut buf = Vec::with_capacity(SCHEME_HEADER_SIZE + payload.len());
201
  buf.extend_from_slice(&SCHEME_MAGIC);
202
  buf.push(self.version.to_byte());
 
184
  /// For V3 schemes, trailing extended options bytes that match the defaults
185
  /// are trimmed, matching WA's minimal file format.
186
  pub fn to_bytes(&self) -> Vec<u8> {
187
+ let payload =
188
+ if self.version == SchemeVersion::V3 && self.payload.len() == SCHEME_PAYLOAD_V3 {
189
+ // Trim trailing extended options bytes that match defaults
190
+ let ext = &self.payload[EXTENDED_OPTIONS_OFFSET..];
191
+ let mut trim_len = EXTENDED_OPTIONS_SIZE;
192
+ while trim_len > 0 && ext[trim_len - 1] == EXTENDED_OPTIONS_DEFAULTS[trim_len - 1] {
193
+ trim_len -= 1;
194
+ }
195
+ &self.payload[..EXTENDED_OPTIONS_OFFSET + trim_len]
196
+ } else {
197
+ &self.payload
198
+ };
 
199
  let mut buf = Vec::with_capacity(SCHEME_HEADER_SIZE + payload.len());
200
  buf.extend_from_slice(&SCHEME_MAGIC);
201
  buf.push(self.version.to_byte());
crates/openwa-core/src/lib.rs CHANGED
@@ -22,8 +22,8 @@ extern crate self as openwa_core;
22
  pub use inventory;
23
 
24
  // Re-export derive macros so users write `use openwa_core::FieldRegistry;`
25
- pub use openwa_derive::FieldRegistry;
26
  pub use openwa_derive::vtable;
 
27
 
28
  #[macro_use]
29
  pub mod macros;
@@ -37,13 +37,13 @@ pub mod input;
37
  pub mod render;
38
 
39
  pub mod address;
 
40
  pub mod fixed;
41
- pub mod registry;
42
  pub mod log;
43
  pub mod mem;
44
  pub mod rebase;
 
45
  pub mod snapshot;
46
- pub mod field_format;
47
  pub mod task;
48
  pub mod vtable;
49
  pub mod wa;
 
22
  pub use inventory;
23
 
24
  // Re-export derive macros so users write `use openwa_core::FieldRegistry;`
 
25
  pub use openwa_derive::vtable;
26
+ pub use openwa_derive::FieldRegistry;
27
 
28
  #[macro_use]
29
  pub mod macros;
 
37
  pub mod render;
38
 
39
  pub mod address;
40
+ pub mod field_format;
41
  pub mod fixed;
 
42
  pub mod log;
43
  pub mod mem;
44
  pub mod rebase;
45
+ pub mod registry;
46
  pub mod snapshot;
 
47
  pub mod task;
48
  pub mod vtable;
49
  pub mod wa;
crates/openwa-core/src/mem.rs CHANGED
@@ -41,7 +41,11 @@ pub unsafe fn classify_pointer(value: u32, delta: u32) -> Option<PointerInfo> {
41
  let name = registry::format_va(ghidra_val);
42
  let detail = if can_read(value, 4) {
43
  let vt0 = *(value as *const u32);
44
- Some(format!("{} vt[0]=ghidra:0x{:08X}", name, vt0.wrapping_sub(delta)))
 
 
 
 
45
  } else {
46
  Some(format!("{} (unreadable)", name))
47
  };
@@ -164,9 +168,7 @@ pub unsafe fn identify_pointer(value: u32, delta: u32) -> Option<PointerIdentity
164
  registry::AddrKind::Vtable | registry::AddrKind::VtableMethod => {
165
  PointerKind::Vtable
166
  }
167
- registry::AddrKind::Function | registry::AddrKind::Constructor => {
168
- PointerKind::Code
169
- }
170
  _ => PointerKind::Data,
171
  };
172
  let name = if resolved.offset == 0 {
@@ -241,11 +243,7 @@ pub unsafe fn identify_pointer(value: u32, delta: u32) -> Option<PointerIdentity
241
  ///
242
  /// Returns a vec of `PointerInfo` with offsets filled in relative to `base_offset`.
243
  #[cfg(target_os = "windows")]
244
- pub unsafe fn classify_region(
245
- data: &[u8],
246
- base_offset: u32,
247
- delta: u32,
248
- ) -> Vec<PointerInfo> {
249
  let mut pointers = Vec::new();
250
  let dword_count = data.len() / 4;
251
  for i in 0..dword_count {
 
41
  let name = registry::format_va(ghidra_val);
42
  let detail = if can_read(value, 4) {
43
  let vt0 = *(value as *const u32);
44
+ Some(format!(
45
+ "{} vt[0]=ghidra:0x{:08X}",
46
+ name,
47
+ vt0.wrapping_sub(delta)
48
+ ))
49
  } else {
50
  Some(format!("{} (unreadable)", name))
51
  };
 
168
  registry::AddrKind::Vtable | registry::AddrKind::VtableMethod => {
169
  PointerKind::Vtable
170
  }
171
+ registry::AddrKind::Function | registry::AddrKind::Constructor => PointerKind::Code,
 
 
172
  _ => PointerKind::Data,
173
  };
174
  let name = if resolved.offset == 0 {
 
243
  ///
244
  /// Returns a vec of `PointerInfo` with offsets filled in relative to `base_offset`.
245
  #[cfg(target_os = "windows")]
246
+ pub unsafe fn classify_region(data: &[u8], base_offset: u32, delta: u32) -> Vec<PointerInfo> {
 
 
 
 
247
  let mut pointers = Vec::new();
248
  let dword_count = data.len() / 4;
249
  for i in 0..dword_count {
crates/openwa-core/src/registry.rs CHANGED
@@ -153,10 +153,7 @@ pub fn format_va(ghidra_va: u32) -> String {
153
  format!("{} (0x{:X})", r.entry.name, ghidra_va)
154
  }
155
  Some(r) => {
156
- format!(
157
- "{}+0x{:X} (0x{:X})",
158
- r.entry.name, r.offset, ghidra_va
159
- )
160
  }
161
  None => format!("0x{:08X}", ghidra_va),
162
  }
@@ -164,7 +161,10 @@ pub fn format_va(ghidra_va: u32) -> String {
164
 
165
  /// Iterate all entries of a given kind.
166
  pub fn entries_by_kind(kind: AddrKind) -> impl Iterator<Item = &'static AddrEntry> {
167
- sorted_entries().iter().copied().filter(move |e| e.kind == kind)
 
 
 
168
  }
169
 
170
  /// Iterate all registered entries (sorted by VA).
@@ -588,9 +588,27 @@ mod tests {
588
  static FIELDS: StructFields = StructFields {
589
  struct_name: "TestStruct",
590
  fields: &[
591
- FieldEntry { offset: 0x00, name: "vtable", size: 4, kind: ValueKind::Pointer, doc: "" },
592
- FieldEntry { offset: 0x10, name: "health", size: 4, kind: ValueKind::U32, doc: "" },
593
- FieldEntry { offset: 0x20, name: "name", size: 16, kind: ValueKind::Raw, doc: "" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  ],
595
  };
596
 
@@ -674,7 +692,11 @@ mod tests {
674
  let reg = CTask::field_registry();
675
  let vtable = reg.field_at(0x00).unwrap();
676
  // Doc comment should be non-empty (we have "0x00: Pointer to virtual method table")
677
- assert!(!vtable.doc.is_empty(), "doc should be extracted: {:?}", vtable.doc);
 
 
 
 
678
  }
679
 
680
  #[test]
@@ -688,7 +710,10 @@ mod tests {
688
  assert!(ctask.is_some(), "CTask not found in struct registry");
689
 
690
  let session = struct_fields_for("GameSession");
691
- assert!(session.is_some(), "GameSession not found in struct registry");
 
 
 
692
 
693
  // Unknown struct returns None
694
  assert!(struct_fields_for("FooBarBaz").is_none());
@@ -700,7 +725,10 @@ mod tests {
700
 
701
  // DDGameWrapper vtable β†’ "DDGameWrapper" β†’ DDGameWrapper fields
702
  let fields = struct_fields_for_vtable(va::DDGAME_WRAPPER_VTABLE);
703
- assert!(fields.is_some(), "DDGameWrapper fields not found via vtable");
 
 
 
704
  assert_eq!(fields.unwrap().struct_name, "DDGameWrapper");
705
 
706
  // CTask vtable β†’ "CTask" β†’ CTask fields
 
153
  format!("{} (0x{:X})", r.entry.name, ghidra_va)
154
  }
155
  Some(r) => {
156
+ format!("{}+0x{:X} (0x{:X})", r.entry.name, r.offset, ghidra_va)
 
 
 
157
  }
158
  None => format!("0x{:08X}", ghidra_va),
159
  }
 
161
 
162
  /// Iterate all entries of a given kind.
163
  pub fn entries_by_kind(kind: AddrKind) -> impl Iterator<Item = &'static AddrEntry> {
164
+ sorted_entries()
165
+ .iter()
166
+ .copied()
167
+ .filter(move |e| e.kind == kind)
168
  }
169
 
170
  /// Iterate all registered entries (sorted by VA).
 
588
  static FIELDS: StructFields = StructFields {
589
  struct_name: "TestStruct",
590
  fields: &[
591
+ FieldEntry {
592
+ offset: 0x00,
593
+ name: "vtable",
594
+ size: 4,
595
+ kind: ValueKind::Pointer,
596
+ doc: "",
597
+ },
598
+ FieldEntry {
599
+ offset: 0x10,
600
+ name: "health",
601
+ size: 4,
602
+ kind: ValueKind::U32,
603
+ doc: "",
604
+ },
605
+ FieldEntry {
606
+ offset: 0x20,
607
+ name: "name",
608
+ size: 16,
609
+ kind: ValueKind::Raw,
610
+ doc: "",
611
+ },
612
  ],
613
  };
614
 
 
692
  let reg = CTask::field_registry();
693
  let vtable = reg.field_at(0x00).unwrap();
694
  // Doc comment should be non-empty (we have "0x00: Pointer to virtual method table")
695
+ assert!(
696
+ !vtable.doc.is_empty(),
697
+ "doc should be extracted: {:?}",
698
+ vtable.doc
699
+ );
700
  }
701
 
702
  #[test]
 
710
  assert!(ctask.is_some(), "CTask not found in struct registry");
711
 
712
  let session = struct_fields_for("GameSession");
713
+ assert!(
714
+ session.is_some(),
715
+ "GameSession not found in struct registry"
716
+ );
717
 
718
  // Unknown struct returns None
719
  assert!(struct_fields_for("FooBarBaz").is_none());
 
725
 
726
  // DDGameWrapper vtable β†’ "DDGameWrapper" β†’ DDGameWrapper fields
727
  let fields = struct_fields_for_vtable(va::DDGAME_WRAPPER_VTABLE);
728
+ assert!(
729
+ fields.is_some(),
730
+ "DDGameWrapper fields not found via vtable"
731
+ );
732
  assert_eq!(fields.unwrap().struct_name, "DDGameWrapper");
733
 
734
  // CTask vtable β†’ "CTask" β†’ CTask fields
crates/openwa-core/src/snapshot.rs CHANGED
@@ -6,9 +6,9 @@
6
 
7
  use core::fmt;
8
 
 
9
  use crate::mem;
10
  use crate::rebase::rb;
11
- use crate::address::va;
12
 
13
  /// Trait for types that can write a canonicalized snapshot.
14
  ///
@@ -35,12 +35,20 @@ pub fn write_indent(w: &mut dyn fmt::Write, indent: usize) -> fmt::Result {
35
 
36
  /// Format a pointer field: `<ptr>` if non-null, `null` if null.
37
  pub fn fmt_ptr(ptr: *const u8) -> &'static str {
38
- if ptr.is_null() { "null" } else { "<ptr>" }
 
 
 
 
39
  }
40
 
41
  /// Format a pointer field for a raw u32 that might be a pointer.
42
  pub fn fmt_ptr32(val: u32) -> &'static str {
43
- if val == 0 { "null" } else { "<ptr>" }
 
 
 
 
44
  }
45
 
46
  /// Dump a raw memory region as canonicalized hex lines.
@@ -121,12 +129,22 @@ pub unsafe fn hash_region_canonical(ptr: *const u8, len: usize) -> u32 {
121
  for i in (0..len).step_by(4) {
122
  let val = if i + 4 <= len {
123
  *(ptr.add(i) as *const u32)
124
- } else { 0 };
125
- let hash_val = if is_likely_pointer(val, delta) { 0u32 } else { val };
 
 
 
 
 
 
126
  for byte in hash_val.to_le_bytes() {
127
  h ^= byte as u32;
128
  for _ in 0..8 {
129
- h = if h & 1 != 0 { (h >> 1) ^ 0xEDB88320 } else { h >> 1 };
 
 
 
 
130
  }
131
  }
132
  }
@@ -155,13 +173,21 @@ pub unsafe fn hash_pointer_targets(
155
 
156
  for off in (0..struct_size).step_by(4) {
157
  let val = *(base.add(off) as *const u32);
158
- if val == 0 { continue; }
159
- if !is_likely_pointer(val, delta) { continue; }
 
 
 
 
160
  // Skip WA code/data pointers β€” only follow heap pointers
161
  let ghidra = val.wrapping_sub(delta);
162
- if (va::TEXT_START..va::DATA_END).contains(&ghidra) { continue; }
 
 
163
  // Verify target is readable
164
- if !mem::can_read(val, target_window as u32) { continue; }
 
 
165
 
166
  let h = hash_region_canonical(val as *const u8, target_window);
167
  writeln!(w, "[HASH] {}+0x{:04X} hash={:08X}", name, off, h)?;
 
6
 
7
  use core::fmt;
8
 
9
+ use crate::address::va;
10
  use crate::mem;
11
  use crate::rebase::rb;
 
12
 
13
  /// Trait for types that can write a canonicalized snapshot.
14
  ///
 
35
 
36
  /// Format a pointer field: `<ptr>` if non-null, `null` if null.
37
  pub fn fmt_ptr(ptr: *const u8) -> &'static str {
38
+ if ptr.is_null() {
39
+ "null"
40
+ } else {
41
+ "<ptr>"
42
+ }
43
  }
44
 
45
  /// Format a pointer field for a raw u32 that might be a pointer.
46
  pub fn fmt_ptr32(val: u32) -> &'static str {
47
+ if val == 0 {
48
+ "null"
49
+ } else {
50
+ "<ptr>"
51
+ }
52
  }
53
 
54
  /// Dump a raw memory region as canonicalized hex lines.
 
129
  for i in (0..len).step_by(4) {
130
  let val = if i + 4 <= len {
131
  *(ptr.add(i) as *const u32)
132
+ } else {
133
+ 0
134
+ };
135
+ let hash_val = if is_likely_pointer(val, delta) {
136
+ 0u32
137
+ } else {
138
+ val
139
+ };
140
  for byte in hash_val.to_le_bytes() {
141
  h ^= byte as u32;
142
  for _ in 0..8 {
143
+ h = if h & 1 != 0 {
144
+ (h >> 1) ^ 0xEDB88320
145
+ } else {
146
+ h >> 1
147
+ };
148
  }
149
  }
150
  }
 
173
 
174
  for off in (0..struct_size).step_by(4) {
175
  let val = *(base.add(off) as *const u32);
176
+ if val == 0 {
177
+ continue;
178
+ }
179
+ if !is_likely_pointer(val, delta) {
180
+ continue;
181
+ }
182
  // Skip WA code/data pointers β€” only follow heap pointers
183
  let ghidra = val.wrapping_sub(delta);
184
+ if (va::TEXT_START..va::DATA_END).contains(&ghidra) {
185
+ continue;
186
+ }
187
  // Verify target is readable
188
+ if !mem::can_read(val, target_window as u32) {
189
+ continue;
190
+ }
191
 
192
  let h = hash_region_canonical(val as *const u8, target_window);
193
  writeln!(w, "[HASH] {}+0x{:04X} hash={:08X}", name, off, h)?;
crates/openwa-core/src/task/arrow.rs CHANGED
@@ -17,7 +17,8 @@ pub struct CTaskArrowVTable {
17
  /// HandleMessage β€” processes arrow messages.
18
  /// thiscall + 4 stack params, RET 0x10.
19
  #[slot(2)]
20
- pub handle_message: fn(this: *mut CTaskArrow, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
21
  /// ProcessFrame β€” per-frame arrow update.
22
  /// thiscall + 1 stack param (flags), RET 0x4.
23
  #[slot(7)]
 
17
  /// HandleMessage β€” processes arrow messages.
18
  /// thiscall + 4 stack params, RET 0x10.
19
  #[slot(2)]
20
+ pub handle_message:
21
+ fn(this: *mut CTaskArrow, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
22
  /// ProcessFrame β€” per-frame arrow update.
23
  /// thiscall + 1 stack param (flags), RET 0x4.
24
  #[slot(7)]
crates/openwa-core/src/task/base.rs CHANGED
@@ -116,7 +116,8 @@ pub struct CTaskVtable {
116
  pub free: fn(this: *mut CTask, flags: u8) -> *mut CTask,
117
  /// HandleMessage β€” broadcasts message to all children (base implementation).
118
  #[slot(2)]
119
- pub handle_message: fn(this: *mut CTask, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
120
  /// ProcessChildren β€” iterates children with flags. Base at 0x562FA0.
121
  #[slot(5)]
122
  pub process_children: fn(this: *mut CTask, flags: u32),
@@ -246,15 +247,14 @@ pub unsafe trait Task {
246
  loop {
247
  // Scan for next non-null child
248
  let child = loop {
249
- let watermark = core::ptr::read_volatile(
250
- core::ptr::addr_of!((*task_ptr).children_watermark),
251
- ) as usize;
252
  if i >= watermark {
253
  return;
254
  }
255
- let children = core::ptr::read_volatile(
256
- core::ptr::addr_of!((*task_ptr).children_data),
257
- );
258
  let c = *children.add(i);
259
  i += 1;
260
  if !c.is_null() {
@@ -305,15 +305,14 @@ impl CTask {
305
  let mut i: usize = 0;
306
  loop {
307
  let child = loop {
308
- let watermark = core::ptr::read_volatile(
309
- core::ptr::addr_of!((*task_ptr).children_watermark),
310
- ) as usize;
311
  if i >= watermark {
312
  return;
313
  }
314
- let children = core::ptr::read_volatile(
315
- core::ptr::addr_of!((*task_ptr).children_data),
316
- );
317
  let c = *children.add(i);
318
  i += 1;
319
  if !c.is_null() {
 
116
  pub free: fn(this: *mut CTask, flags: u8) -> *mut CTask,
117
  /// HandleMessage β€” broadcasts message to all children (base implementation).
118
  #[slot(2)]
119
+ pub handle_message:
120
+ fn(this: *mut CTask, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
121
  /// ProcessChildren β€” iterates children with flags. Base at 0x562FA0.
122
  #[slot(5)]
123
  pub process_children: fn(this: *mut CTask, flags: u32),
 
247
  loop {
248
  // Scan for next non-null child
249
  let child = loop {
250
+ let watermark =
251
+ core::ptr::read_volatile(core::ptr::addr_of!((*task_ptr).children_watermark))
252
+ as usize;
253
  if i >= watermark {
254
  return;
255
  }
256
+ let children =
257
+ core::ptr::read_volatile(core::ptr::addr_of!((*task_ptr).children_data));
 
258
  let c = *children.add(i);
259
  i += 1;
260
  if !c.is_null() {
 
305
  let mut i: usize = 0;
306
  loop {
307
  let child = loop {
308
+ let watermark =
309
+ core::ptr::read_volatile(core::ptr::addr_of!((*task_ptr).children_watermark))
310
+ as usize;
311
  if i >= watermark {
312
  return;
313
  }
314
+ let children =
315
+ core::ptr::read_volatile(core::ptr::addr_of!((*task_ptr).children_data));
 
316
  let c = *children.add(i);
317
  i += 1;
318
  if !c.is_null() {
crates/openwa-core/src/task/cloud.rs CHANGED
@@ -18,7 +18,8 @@ pub struct CTaskCloudVTable {
18
  /// HandleMessage β€” processes cloud messages (wind updates, render).
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
- pub handle_message: fn(this: *mut CTaskCloud, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
22
  /// ProcessFrame β€” per-frame cloud update.
23
  /// thiscall + 1 stack param (flags), RET 0x4.
24
  #[slot(7)]
 
18
  /// HandleMessage β€” processes cloud messages (wind updates, render).
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
+ pub handle_message:
22
+ fn(this: *mut CTaskCloud, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
23
  /// ProcessFrame β€” per-frame cloud update.
24
  /// thiscall + 1 stack param (flags), RET 0x4.
25
  #[slot(7)]
crates/openwa-core/src/task/filter.rs CHANGED
@@ -18,7 +18,8 @@ pub struct CTaskFilterVTable {
18
  /// HandleMessage β€” checks subscription table, forwards matching messages.
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
- pub handle_message: fn(this: *mut CTaskFilter, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
22
  /// ProcessFrame β€” per-frame filter update.
23
  /// thiscall + 1 stack param (flags), RET 0x4.
24
  #[slot(7)]
 
18
  /// HandleMessage β€” checks subscription table, forwards matching messages.
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
+ pub handle_message:
22
+ fn(this: *mut CTaskFilter, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
23
  /// ProcessFrame β€” per-frame filter update.
24
  /// thiscall + 1 stack param (flags), RET 0x4.
25
  #[slot(7)]
crates/openwa-core/src/task/fire.rs CHANGED
@@ -17,7 +17,8 @@ pub struct CTaskFireVTable {
17
  /// HandleMessage β€” processes fire messages.
18
  /// thiscall + 4 stack params, RET 0x10.
19
  #[slot(2)]
20
- pub handle_message: fn(this: *mut CTaskFire, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
21
  /// ProcessFrame β€” per-frame fire update (countdown, spread, damage).
22
  /// thiscall + 1 stack param (flags), RET 0x4.
23
  #[slot(7)]
 
17
  /// HandleMessage β€” processes fire messages.
18
  /// thiscall + 4 stack params, RET 0x10.
19
  #[slot(2)]
20
+ pub handle_message:
21
+ fn(this: *mut CTaskFire, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
22
  /// ProcessFrame β€” per-frame fire update (countdown, spread, damage).
23
  /// thiscall + 1 stack param (flags), RET 0x4.
24
  #[slot(7)]
crates/openwa-core/src/task/game_task.rs CHANGED
@@ -96,5 +96,6 @@ pub struct SoundEmitterVTable {
96
  pub destructor: fn(this: *mut SoundEmitter, flags: u32) -> *mut SoundEmitter,
97
  /// HandleMessage β€” sound queue manager
98
  #[slot(4)]
99
- pub handle_message: fn(this: *mut SoundEmitter, sender: u32, msg_type: u32, size: u32, data: u32),
 
100
  }
 
96
  pub destructor: fn(this: *mut SoundEmitter, flags: u32) -> *mut SoundEmitter,
97
  /// HandleMessage β€” sound queue manager
98
  #[slot(4)]
99
+ pub handle_message:
100
+ fn(this: *mut SoundEmitter, sender: u32, msg_type: u32, size: u32, data: u32),
101
  }
crates/openwa-core/src/task/mine_oil_drum.rs CHANGED
@@ -24,7 +24,8 @@ pub struct CTaskMineVTable {
24
  /// HandleMessage β€” processes mine messages (arm, trigger, detonate).
25
  /// thiscall + 4 stack params, RET 0x10.
26
  #[slot(2)]
27
- pub handle_message: fn(this: *mut CTaskMine, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
28
  /// ProcessFrame β€” per-frame mine update.
29
  /// thiscall + 1 stack param (flags), RET 0x4.
30
  #[slot(7)]
@@ -39,7 +40,8 @@ pub struct CTaskOilDrumVTable {
39
  /// HandleMessage β€” processes oil drum messages.
40
  /// thiscall + 4 stack params, RET 0x10.
41
  #[slot(2)]
42
- pub handle_message: fn(this: *mut CTaskOilDrum, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
43
  /// ProcessFrame β€” per-frame oil drum update.
44
  /// thiscall + 1 stack param (flags), RET 0x4.
45
  #[slot(7)]
 
24
  /// HandleMessage β€” processes mine messages (arm, trigger, detonate).
25
  /// thiscall + 4 stack params, RET 0x10.
26
  #[slot(2)]
27
+ pub handle_message:
28
+ fn(this: *mut CTaskMine, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
29
  /// ProcessFrame β€” per-frame mine update.
30
  /// thiscall + 1 stack param (flags), RET 0x4.
31
  #[slot(7)]
 
40
  /// HandleMessage β€” processes oil drum messages.
41
  /// thiscall + 4 stack params, RET 0x10.
42
  #[slot(2)]
43
+ pub handle_message:
44
+ fn(this: *mut CTaskOilDrum, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
45
  /// ProcessFrame β€” per-frame oil drum update.
46
  /// thiscall + 1 stack param (flags), RET 0x4.
47
  #[slot(7)]
crates/openwa-core/src/task/missile.rs CHANGED
@@ -20,7 +20,8 @@ pub struct CTaskMissileVTable {
20
  /// HandleMessage β€” processes missile messages.
21
  /// thiscall + 4 stack params, RET 0x10.
22
  #[slot(2)]
23
- pub handle_message: fn(this: *mut CTaskMissile, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
24
  /// ProcessFrame β€” per-frame missile update (physics, homing, detonation).
25
  /// thiscall + 1 stack param (flags), RET 0x4.
26
  #[slot(7)]
@@ -206,48 +207,89 @@ pub enum MissileType {
206
 
207
  #[cfg(target_arch = "x86")]
208
  impl crate::snapshot::Snapshot for CTaskMissile {
209
- unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
 
 
 
 
210
  use crate::snapshot::{write_indent, write_raw_region};
211
  let i = indent;
212
  let b = &self.base; // CGameTask
213
 
214
- write_indent(w, i)?; writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
215
- write_indent(w, i)?; writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
216
- write_indent(w, i)?; writeln!(w, "launch_seed = 0x{:08X}", self.launch_seed)?;
217
- write_indent(w, i)?; writeln!(w, "slot_id = {}", self.slot_id)?;
 
 
 
 
218
 
219
  let sp = &self.spawn_params;
220
- write_indent(w, i)?; writeln!(w, "spawn_params:")?;
221
- write_indent(w, i + 1)?; writeln!(w, "owner={} spawn=({}, {}) speed=({}, {})",
222
- sp.owner_id, sp.spawn_x, sp.spawn_y, sp.initial_speed_x, sp.initial_speed_y)?;
223
- write_indent(w, i + 1)?; writeln!(w, "cursor=({}, {}) pellet={} fallback=({}, {})",
224
- sp.cursor_x, sp.cursor_y, sp.pellet_index, sp.fallback_timer, sp.fallback_param)?;
 
 
 
 
 
 
 
 
 
225
 
226
- write_indent(w, i)?; write!(w, "weapon_data =")?;
 
227
  for (j, v) in self.weapon_data.iter().enumerate() {
228
- if j % 16 == 0 { writeln!(w)?; write_indent(w, i + 1)?; }
 
 
 
229
  write!(w, " {:08X}", v)?;
230
  }
231
  writeln!(w)?;
232
 
233
- write_indent(w, i)?; write!(w, "render_data =")?;
 
234
  for (j, v) in self.render_data.iter().enumerate() {
235
- if j % 16 == 0 { writeln!(w)?; write_indent(w, i + 1)?; }
 
 
 
236
  write!(w, " {:08X}", v)?;
237
  }
238
  writeln!(w)?;
239
 
240
- write_indent(w, i)?; writeln!(w, "launch_speed_raw = {}", self.launch_speed_raw)?;
241
- write_indent(w, i)?; writeln!(w, "homing_enabled = {}", self.homing_enabled)?;
242
- write_indent(w, i)?; writeln!(w, "direction = {}", self.direction)?;
 
 
 
243
 
244
  // Unknown regions
245
- write_indent(w, i)?; writeln!(w, "_unknown_fc ({} bytes):", self._unknown_fc.len())?;
 
246
  write_raw_region(w, self._unknown_fc.as_ptr(), self._unknown_fc.len(), i + 1)?;
247
- write_indent(w, i)?; writeln!(w, "_unknown_37c ({} bytes):", self._unknown_37c.len())?;
248
- write_raw_region(w, self._unknown_37c.as_ptr(), self._unknown_37c.len(), i + 1)?;
249
- write_indent(w, i)?; writeln!(w, "_unknown_3cc ({} bytes):", self._unknown_3cc.len())?;
250
- write_raw_region(w, self._unknown_3cc.as_ptr(), self._unknown_3cc.len(), i + 1)?;
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
  Ok(())
253
  }
 
20
  /// HandleMessage β€” processes missile messages.
21
  /// thiscall + 4 stack params, RET 0x10.
22
  #[slot(2)]
23
+ pub handle_message:
24
+ fn(this: *mut CTaskMissile, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
25
  /// ProcessFrame β€” per-frame missile update (physics, homing, detonation).
26
  /// thiscall + 1 stack param (flags), RET 0x4.
27
  #[slot(7)]
 
207
 
208
  #[cfg(target_arch = "x86")]
209
  impl crate::snapshot::Snapshot for CTaskMissile {
210
+ unsafe fn write_snapshot(
211
+ &self,
212
+ w: &mut dyn core::fmt::Write,
213
+ indent: usize,
214
+ ) -> core::fmt::Result {
215
  use crate::snapshot::{write_indent, write_raw_region};
216
  let i = indent;
217
  let b = &self.base; // CGameTask
218
 
219
+ write_indent(w, i)?;
220
+ writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
221
+ write_indent(w, i)?;
222
+ writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
223
+ write_indent(w, i)?;
224
+ writeln!(w, "launch_seed = 0x{:08X}", self.launch_seed)?;
225
+ write_indent(w, i)?;
226
+ writeln!(w, "slot_id = {}", self.slot_id)?;
227
 
228
  let sp = &self.spawn_params;
229
+ write_indent(w, i)?;
230
+ writeln!(w, "spawn_params:")?;
231
+ write_indent(w, i + 1)?;
232
+ writeln!(
233
+ w,
234
+ "owner={} spawn=({}, {}) speed=({}, {})",
235
+ sp.owner_id, sp.spawn_x, sp.spawn_y, sp.initial_speed_x, sp.initial_speed_y
236
+ )?;
237
+ write_indent(w, i + 1)?;
238
+ writeln!(
239
+ w,
240
+ "cursor=({}, {}) pellet={} fallback=({}, {})",
241
+ sp.cursor_x, sp.cursor_y, sp.pellet_index, sp.fallback_timer, sp.fallback_param
242
+ )?;
243
 
244
+ write_indent(w, i)?;
245
+ write!(w, "weapon_data =")?;
246
  for (j, v) in self.weapon_data.iter().enumerate() {
247
+ if j % 16 == 0 {
248
+ writeln!(w)?;
249
+ write_indent(w, i + 1)?;
250
+ }
251
  write!(w, " {:08X}", v)?;
252
  }
253
  writeln!(w)?;
254
 
255
+ write_indent(w, i)?;
256
+ write!(w, "render_data =")?;
257
  for (j, v) in self.render_data.iter().enumerate() {
258
+ if j % 16 == 0 {
259
+ writeln!(w)?;
260
+ write_indent(w, i + 1)?;
261
+ }
262
  write!(w, " {:08X}", v)?;
263
  }
264
  writeln!(w)?;
265
 
266
+ write_indent(w, i)?;
267
+ writeln!(w, "launch_speed_raw = {}", self.launch_speed_raw)?;
268
+ write_indent(w, i)?;
269
+ writeln!(w, "homing_enabled = {}", self.homing_enabled)?;
270
+ write_indent(w, i)?;
271
+ writeln!(w, "direction = {}", self.direction)?;
272
 
273
  // Unknown regions
274
+ write_indent(w, i)?;
275
+ writeln!(w, "_unknown_fc ({} bytes):", self._unknown_fc.len())?;
276
  write_raw_region(w, self._unknown_fc.as_ptr(), self._unknown_fc.len(), i + 1)?;
277
+ write_indent(w, i)?;
278
+ writeln!(w, "_unknown_37c ({} bytes):", self._unknown_37c.len())?;
279
+ write_raw_region(
280
+ w,
281
+ self._unknown_37c.as_ptr(),
282
+ self._unknown_37c.len(),
283
+ i + 1,
284
+ )?;
285
+ write_indent(w, i)?;
286
+ writeln!(w, "_unknown_3cc ({} bytes):", self._unknown_3cc.len())?;
287
+ write_raw_region(
288
+ w,
289
+ self._unknown_3cc.as_ptr(),
290
+ self._unknown_3cc.len(),
291
+ i + 1,
292
+ )?;
293
 
294
  Ok(())
295
  }
crates/openwa-core/src/task/mod.rs CHANGED
@@ -15,7 +15,9 @@ pub mod turn_game;
15
  pub mod worm;
16
 
17
  pub use arrow::{CTaskArrow, CTaskArrowVTable};
18
- pub use base::{CTask, CTaskBfsIter, CTaskVtable, SharedDataIter, SharedDataNode, SharedDataTable, Task, Vtable};
 
 
19
  pub use cloud::{CTaskCloud, CTaskCloudVTable};
20
  pub use filter::{CTaskFilter, CTaskFilterVTable};
21
  pub use fire::{CTaskFire, CTaskFireVTable};
 
15
  pub mod worm;
16
 
17
  pub use arrow::{CTaskArrow, CTaskArrowVTable};
18
+ pub use base::{
19
+ CTask, CTaskBfsIter, CTaskVtable, SharedDataIter, SharedDataNode, SharedDataTable, Task, Vtable,
20
+ };
21
  pub use cloud::{CTaskCloud, CTaskCloudVTable};
22
  pub use filter::{CTaskFilter, CTaskFilterVTable};
23
  pub use fire::{CTaskFire, CTaskFireVTable};
crates/openwa-core/src/task/supply_crate.rs CHANGED
@@ -18,7 +18,8 @@ pub struct CTaskCrateVTable {
18
  /// HandleMessage β€” processes crate messages (collection, parachute, etc.).
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
- pub handle_message: fn(this: *mut CTaskCrate, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
22
  /// ProcessFrame β€” per-frame crate update.
23
  /// thiscall + 1 stack param (flags), RET 0x4.
24
  #[slot(7)]
 
18
  /// HandleMessage β€” processes crate messages (collection, parachute, etc.).
19
  /// thiscall + 4 stack params, RET 0x10.
20
  #[slot(2)]
21
+ pub handle_message:
22
+ fn(this: *mut CTaskCrate, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
23
  /// ProcessFrame β€” per-frame crate update.
24
  /// thiscall + 1 stack param (flags), RET 0x4.
25
  #[slot(7)]
crates/openwa-core/src/task/team.rs CHANGED
@@ -27,7 +27,8 @@ pub struct CTaskTeamVTable {
27
  /// surrender, worm selection, napalm strike, etc.)
28
  /// thiscall + 4 stack params, RET 0x10.
29
  #[slot(2)]
30
- pub handle_message: fn(this: *mut CTaskTeam, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
31
  /// GetEntityData β€” returns team data by query code.
32
  /// thiscall + 3 stack params, RET 0xC.
33
  #[slot(3)]
@@ -133,11 +134,7 @@ impl CTaskTeam {
133
  ///
134
  /// # Safety
135
  /// `this` must be a valid CTaskTeam pointer with valid ddgame.
136
- pub unsafe fn on_surrender_fire(
137
- this: *mut Self,
138
- sender: *mut CTask,
139
- msg_team_index: u32,
140
- ) {
141
  use crate::game::TaskMessage;
142
 
143
  let team_index = (*this).team_index;
@@ -157,19 +154,26 @@ impl CTaskTeam {
157
  let game_version = (*(*ddgame).game_info).game_version;
158
  if game_version > 0xF4 {
159
  CTask::broadcast_message_raw(
160
- task_ptr, sender, TaskMessage::DetonateWeapon as u32, 4, buf.as_ptr(),
 
 
 
 
161
  );
162
  }
163
 
164
  // 3. Set per-team napalm flag
165
  // Original: *(ddgame + team_index * 0x51C + 0x4618) = 1
166
- let flag_ptr = (ddgame as *mut u8)
167
- .add(team_index as usize * 0x51C + 0x4618) as *mut u32;
168
  *flag_ptr = 1;
169
 
170
  // 4. Broadcast original message (0x2B) to children
171
  CTask::broadcast_message_raw(
172
- task_ptr, sender, TaskMessage::Surrender as u32, 4, buf.as_ptr(),
 
 
 
 
173
  );
174
  }
175
  }
 
27
  /// surrender, worm selection, napalm strike, etc.)
28
  /// thiscall + 4 stack params, RET 0x10.
29
  #[slot(2)]
30
+ pub handle_message:
31
+ fn(this: *mut CTaskTeam, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
32
  /// GetEntityData β€” returns team data by query code.
33
  /// thiscall + 3 stack params, RET 0xC.
34
  #[slot(3)]
 
134
  ///
135
  /// # Safety
136
  /// `this` must be a valid CTaskTeam pointer with valid ddgame.
137
+ pub unsafe fn on_surrender_fire(this: *mut Self, sender: *mut CTask, msg_team_index: u32) {
 
 
 
 
138
  use crate::game::TaskMessage;
139
 
140
  let team_index = (*this).team_index;
 
154
  let game_version = (*(*ddgame).game_info).game_version;
155
  if game_version > 0xF4 {
156
  CTask::broadcast_message_raw(
157
+ task_ptr,
158
+ sender,
159
+ TaskMessage::DetonateWeapon as u32,
160
+ 4,
161
+ buf.as_ptr(),
162
  );
163
  }
164
 
165
  // 3. Set per-team napalm flag
166
  // Original: *(ddgame + team_index * 0x51C + 0x4618) = 1
167
+ let flag_ptr = (ddgame as *mut u8).add(team_index as usize * 0x51C + 0x4618) as *mut u32;
 
168
  *flag_ptr = 1;
169
 
170
  // 4. Broadcast original message (0x2B) to children
171
  CTask::broadcast_message_raw(
172
+ task_ptr,
173
+ sender,
174
+ TaskMessage::Surrender as u32,
175
+ 4,
176
+ buf.as_ptr(),
177
  );
178
  }
179
  }
crates/openwa-core/src/task/turn_game.rs CHANGED
@@ -98,7 +98,8 @@ pub struct CTaskTurnGameVTable {
98
  /// HandleMessage β€” processes messages for turn flow control
99
  /// thiscall + 4 stack params, RET 0x10.
100
  #[slot(2)]
101
- pub handle_message: fn(this: *mut CTaskTurnGame, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
102
  /// ProcessFrame β€” per-frame turn update.
103
  /// thiscall + 1 stack param (flags), RET 0x4.
104
  #[slot(7)]
 
98
  /// HandleMessage β€” processes messages for turn flow control
99
  /// thiscall + 4 stack params, RET 0x10.
100
  #[slot(2)]
101
+ pub handle_message:
102
+ fn(this: *mut CTaskTurnGame, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
103
  /// ProcessFrame β€” per-frame turn update.
104
  /// thiscall + 1 stack param (flags), RET 0x4.
105
  #[slot(7)]
crates/openwa-core/src/task/worm.rs CHANGED
@@ -1,8 +1,8 @@
1
  use super::base::CTask;
2
  use super::game_task::CGameTask;
3
  use crate::fixed::Fixed;
4
- use crate::game::Weapon;
5
  use crate::game::weapon::WeaponEntry;
 
6
  use crate::FieldRegistry;
7
 
8
  /// Worm state machine states.
@@ -173,7 +173,8 @@ pub struct CTaskWormVTable {
173
  pub free: fn(this: *mut CTaskWorm, flags: u8) -> *mut CTaskWorm,
174
  /// HandleMessage β€” processes all TaskMessages sent to this worm
175
  #[slot(2)]
176
- pub handle_message: fn(this: *mut CTaskWorm, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
 
177
  /// GetEntityData β€” returns worm data by query code
178
  #[slot(3)]
179
  pub get_entity_data: fn(this: *mut CTaskWorm, query: u32, param: u32, out: *mut u32) -> u32,
@@ -451,32 +452,66 @@ impl CTaskWorm {
451
 
452
  #[cfg(target_arch = "x86")]
453
  impl crate::snapshot::Snapshot for CTaskWorm {
454
- unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
 
 
 
 
455
  use crate::snapshot::{write_indent, write_raw_region};
456
  let i = indent;
457
  let b = &self.base; // CGameTask
458
 
459
- write_indent(w, i)?; writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
460
- write_indent(w, i)?; writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
461
- write_indent(w, i)?; writeln!(w, "angle = {}", b.angle)?;
462
- write_indent(w, i)?; writeln!(w, "team_index = {}", self.team_index)?;
463
- write_indent(w, i)?; writeln!(w, "worm_index = {}", self.worm_index)?;
464
- write_indent(w, i)?; writeln!(w, "slot_id = {}", self.slot_id)?;
465
- write_indent(w, i)?; writeln!(w, "selected_weapon = {:?}", self.selected_weapon)?;
466
- write_indent(w, i)?; writeln!(w, "facing = {}", self.facing_direction)?;
467
- write_indent(w, i)?; writeln!(w, "aim_angle = 0x{:08X}", self.aim_angle)?;
 
 
 
 
 
 
 
 
 
468
 
469
- write_indent(w, i)?; write!(w, "spawn_params =")?;
470
- for v in &self.spawn_params { write!(w, " {:08X}", v)?; }
 
 
 
471
  writeln!(w)?;
472
 
473
  // Raw dump of unknown regions
474
- write_indent(w, i)?; writeln!(w, "_unknown_1f0 ({} bytes):", self._unknown_1f0.len())?;
475
- write_raw_region(w, self._unknown_1f0.as_ptr(), self._unknown_1f0.len(), i + 1)?;
476
- write_indent(w, i)?; writeln!(w, "_unknown_28c ({} bytes):", self._unknown_28c.len())?;
477
- write_raw_region(w, self._unknown_28c.as_ptr(), self._unknown_28c.len(), i + 1)?;
478
- write_indent(w, i)?; writeln!(w, "_unknown_370 ({} bytes):", self._unknown_370.len())?;
479
- write_raw_region(w, self._unknown_370.as_ptr(), self._unknown_370.len(), i + 1)?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
 
481
  Ok(())
482
  }
 
1
  use super::base::CTask;
2
  use super::game_task::CGameTask;
3
  use crate::fixed::Fixed;
 
4
  use crate::game::weapon::WeaponEntry;
5
+ use crate::game::Weapon;
6
  use crate::FieldRegistry;
7
 
8
  /// Worm state machine states.
 
173
  pub free: fn(this: *mut CTaskWorm, flags: u8) -> *mut CTaskWorm,
174
  /// HandleMessage β€” processes all TaskMessages sent to this worm
175
  #[slot(2)]
176
+ pub handle_message:
177
+ fn(this: *mut CTaskWorm, sender: *mut CTask, msg_type: u32, size: u32, data: *const u8),
178
  /// GetEntityData β€” returns worm data by query code
179
  #[slot(3)]
180
  pub get_entity_data: fn(this: *mut CTaskWorm, query: u32, param: u32, out: *mut u32) -> u32,
 
452
 
453
  #[cfg(target_arch = "x86")]
454
  impl crate::snapshot::Snapshot for CTaskWorm {
455
+ unsafe fn write_snapshot(
456
+ &self,
457
+ w: &mut dyn core::fmt::Write,
458
+ indent: usize,
459
+ ) -> core::fmt::Result {
460
  use crate::snapshot::{write_indent, write_raw_region};
461
  let i = indent;
462
  let b = &self.base; // CGameTask
463
 
464
+ write_indent(w, i)?;
465
+ writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
466
+ write_indent(w, i)?;
467
+ writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
468
+ write_indent(w, i)?;
469
+ writeln!(w, "angle = {}", b.angle)?;
470
+ write_indent(w, i)?;
471
+ writeln!(w, "team_index = {}", self.team_index)?;
472
+ write_indent(w, i)?;
473
+ writeln!(w, "worm_index = {}", self.worm_index)?;
474
+ write_indent(w, i)?;
475
+ writeln!(w, "slot_id = {}", self.slot_id)?;
476
+ write_indent(w, i)?;
477
+ writeln!(w, "selected_weapon = {:?}", self.selected_weapon)?;
478
+ write_indent(w, i)?;
479
+ writeln!(w, "facing = {}", self.facing_direction)?;
480
+ write_indent(w, i)?;
481
+ writeln!(w, "aim_angle = 0x{:08X}", self.aim_angle)?;
482
 
483
+ write_indent(w, i)?;
484
+ write!(w, "spawn_params =")?;
485
+ for v in &self.spawn_params {
486
+ write!(w, " {:08X}", v)?;
487
+ }
488
  writeln!(w)?;
489
 
490
  // Raw dump of unknown regions
491
+ write_indent(w, i)?;
492
+ writeln!(w, "_unknown_1f0 ({} bytes):", self._unknown_1f0.len())?;
493
+ write_raw_region(
494
+ w,
495
+ self._unknown_1f0.as_ptr(),
496
+ self._unknown_1f0.len(),
497
+ i + 1,
498
+ )?;
499
+ write_indent(w, i)?;
500
+ writeln!(w, "_unknown_28c ({} bytes):", self._unknown_28c.len())?;
501
+ write_raw_region(
502
+ w,
503
+ self._unknown_28c.as_ptr(),
504
+ self._unknown_28c.len(),
505
+ i + 1,
506
+ )?;
507
+ write_indent(w, i)?;
508
+ writeln!(w, "_unknown_370 ({} bytes):", self._unknown_370.len())?;
509
+ write_raw_region(
510
+ w,
511
+ self._unknown_370.as_ptr(),
512
+ self._unknown_370.len(),
513
+ i + 1,
514
+ )?;
515
 
516
  Ok(())
517
  }
crates/openwa-debug-cli/src/main.rs CHANGED
@@ -31,16 +31,20 @@ fn main() {
31
  match args[i].as_str() {
32
  "--port" => {
33
  i += 1;
34
- port = args.get(i)
35
- .and_then(|s| s.parse().ok())
36
- .unwrap_or_else(|| { eprintln!("Error: --port requires a number"); process::exit(1); });
 
37
  }
38
  "--format" => {
39
  i += 1;
40
  format = match args.get(i).map(|s| s.as_str()) {
41
  Some("hex") => Format::Hex,
42
  Some("raw") => Format::Raw,
43
- _ => { eprintln!("Error: --format must be 'hex' or 'raw'"); process::exit(1); }
 
 
 
44
  };
45
  }
46
  other => positional.push(other.to_string()),
@@ -58,36 +62,63 @@ fn main() {
58
  });
59
  // Detect shell eating '->' as redirect: address ends with '-'
60
  if addr_str.ends_with('-') {
61
- eprintln!("Error: address '{}' looks truncated β€” did the shell eat '->'?", addr_str);
 
 
 
62
  eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\"");
63
  process::exit(1);
64
  }
65
  let expr = resolve_address_expr(addr_str, port);
66
- let len = positional.get(2)
67
- .map(|s| parse_u32(s))
68
- .unwrap_or(256);
69
  match expr {
70
- AddressExpr::Simple { addr, absolute } =>
71
- Request::Read { addr, len, absolute },
72
- AddressExpr::Chain { addr, chain, absolute } =>
73
- Request::ReadChain { addr, chain, len, absolute },
 
 
 
 
 
 
 
 
 
 
 
74
  }
75
  }
76
  Some("inspect") => {
77
- let class_name = positional.get(1).unwrap_or_else(|| {
78
- eprintln!("Usage: inspect <class_name> <addr>");
79
- process::exit(1);
80
- }).clone();
 
 
 
81
  let addr_str = positional.get(2).unwrap_or_else(|| {
82
  eprintln!("Usage: inspect <class_name> <addr>");
83
  process::exit(1);
84
  });
85
  let expr = resolve_address_expr(addr_str, port);
86
  match expr {
87
- AddressExpr::Simple { addr, absolute } =>
88
- Request::Inspect { class_name, addr, chain: vec![], absolute },
89
- AddressExpr::Chain { addr, chain, absolute } =>
90
- Request::Inspect { class_name, addr, chain, absolute },
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
  }
93
  Some("objects") => Request::ListObjects,
@@ -99,9 +130,19 @@ fn main() {
99
  }
100
  Some("frame") => Request::Frame,
101
  Some("break") => {
102
- let frame = positional.get(1)
103
- .map(|s| if s == "clear" || s == "off" { -1 } else { parse_u32(s) as i32 })
104
- .unwrap_or_else(|| { eprintln!("Usage: break <frame> | break clear"); process::exit(1); });
 
 
 
 
 
 
 
 
 
 
105
  Request::Break { frame }
106
  }
107
  Some("snapshot") => Request::Snapshot,
@@ -149,39 +190,50 @@ fn print_response(response: &Response, format: Format) {
149
  println!(" {} β€” {}", cmd.usage, cmd.description);
150
  }
151
  }
152
- Response::ReadResult { ghidra_addr, runtime_addr, data, pointers } => {
153
- match format {
154
- Format::Hex => print_hex_read(*ghidra_addr, *runtime_addr, data, pointers),
155
- Format::Raw => {
156
- let stdout = io::stdout();
157
- let mut out = stdout.lock();
158
- let _ = out.write_all(data);
159
- }
 
 
 
160
  }
161
- }
162
- Response::ReadChainResult { steps, ghidra_addr, runtime_addr, data, pointers } => {
163
- match format {
164
- Format::Hex => {
165
- println!("Pointer chain ({} steps):", steps.len());
166
- for (i, step) in steps.iter().enumerate() {
167
- println!(
168
- " [{}] *(ghidra:0x{:08X}) = 0x{:08X} + 0x{:X} = 0x{:08X}",
169
- i, step.deref_addr, step.value, step.offset, step.result_addr
170
- );
171
- }
172
- println!();
173
- print_hex_read(*ghidra_addr, *runtime_addr, data, pointers);
174
- }
175
- Format::Raw => {
176
- let stdout = io::stdout();
177
- let mut out = stdout.lock();
178
- let _ = out.write_all(data);
179
  }
 
 
180
  }
181
- }
 
 
 
 
 
182
  Response::Suspended { frame } => println!("Suspended at frame {frame}"),
183
  Response::Resumed => println!("Resumed"),
184
- Response::FrameInfo { frame, paused, breakpoint } => {
 
 
 
 
185
  let state = if *paused { "PAUSED" } else { "running" };
186
  print!("Frame {frame} [{state}]");
187
  if *breakpoint >= 0 {
@@ -200,7 +252,12 @@ fn print_response(response: &Response, format: Format) {
200
  println!("=== Snapshot at frame {frame} ===\n");
201
  print!("{text}");
202
  }
203
- Response::InspectResult { class_name, ghidra_addr, runtime_addr, fields } => {
 
 
 
 
 
204
  println!(
205
  "{} at ghidra:0x{:08X} (runtime:0x{:08X})\n",
206
  class_name, ghidra_addr, runtime_addr
@@ -226,7 +283,10 @@ fn print_response(response: &Response, format: Format) {
226
  }
227
  }
228
  Response::AliasResult(alias) => {
229
- println!("{} at runtime:0x{:08X}", alias.class_name, alias.runtime_addr);
 
 
 
230
  }
231
  Response::FieldResult(field) => {
232
  println!("offset:0x{:X} size:{}", field.offset, field.size);
@@ -241,7 +301,9 @@ fn print_response(response: &Response, format: Format) {
241
  fn print_hex_read(ghidra_addr: u32, runtime_addr: u32, data: &[u8], pointers: &[PointerInfo]) {
242
  println!(
243
  "Reading 0x{:X} bytes at ghidra:0x{:08X} (runtime:0x{:08X})\n",
244
- data.len(), ghidra_addr, runtime_addr
 
 
245
  );
246
 
247
  // Hex dump with ASCII sidebar
@@ -252,13 +314,17 @@ fn print_hex_read(ghidra_addr: u32, runtime_addr: u32, data: &[u8], pointers: &[
252
  // Hex bytes
253
  for (i, byte) in chunk.iter().enumerate() {
254
  print!("{:02X} ", byte);
255
- if i == 7 { print!(" "); }
 
 
256
  }
257
  // Padding for short last line
258
  if chunk.len() < 16 {
259
  for i in chunk.len()..16 {
260
  print!(" ");
261
- if i == 7 { print!(" "); }
 
 
262
  }
263
  }
264
  // ASCII sidebar
@@ -284,7 +350,11 @@ fn print_hex_read(ghidra_addr: u32, runtime_addr: u32, data: &[u8], pointers: &[
284
  PointerKind::Object => "OBJECT",
285
  PointerKind::Heap => "HEAP",
286
  };
287
- let detail_str = p.detail.as_deref().map(|d| format!(" {d}")).unwrap_or_default();
 
 
 
 
288
  println!(
289
  " +0x{:03X} 0x{:08X} {:<7} ghidra:0x{:08X}{}",
290
  p.offset, p.raw_value, kind_str, p.ghidra_value, detail_str
@@ -304,7 +374,11 @@ enum AddressExpr {
304
  /// Simple address (with optional offset already applied)
305
  Simple { addr: u32, absolute: bool },
306
  /// Pointer chain: start address + list of deref offsets
307
- Chain { addr: u32, chain: Vec<u32>, absolute: bool },
 
 
 
 
308
  }
309
 
310
  /// Split a string on '+' or '[' into (base, offset_str). Returns (s, None) if no compound.
@@ -330,10 +404,13 @@ fn resolve_offset(s: &str, class: &Option<String>, port: u16) -> u32 {
330
 
331
  // Try as field name
332
  if let Some(cls) = class {
333
- match send_request(port, &Request::ResolveField {
334
- class_name: cls.clone(),
335
- field_name: s.to_string(),
336
- }) {
 
 
 
337
  Ok(Response::FieldResult(f)) => return f.offset,
338
  _ => {}
339
  }
@@ -344,7 +421,10 @@ fn resolve_offset(s: &str, class: &Option<String>, port: u16) -> u32 {
344
  }
345
 
346
  fn parse_hex(s: &str) -> u32 {
347
- let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
 
 
 
348
  u32::from_str_radix(s, 16).unwrap_or_else(|_| {
349
  eprintln!("Error: invalid hex address '{s}'");
350
  process::exit(1);
@@ -408,7 +488,10 @@ fn resolve_address_expr(s: &str, port: u16) -> AddressExpr {
408
  let (base_addr, base_absolute, mut current_class) = resolve_base(base_part, absolute, port);
409
 
410
  if parts.len() == 1 {
411
- return AddressExpr::Simple { addr: base_addr, absolute: base_absolute };
 
 
 
412
  }
413
 
414
  // Resolve each chain segment, tracking whether it's a field name or hex.
@@ -443,7 +526,11 @@ fn resolve_address_expr(s: &str, port: u16) -> AddressExpr {
443
  }
444
  }
445
 
446
- AddressExpr::Chain { addr: final_base, chain, absolute: base_absolute }
 
 
 
 
447
  }
448
 
449
  /// Resolve the base address part β€” could be a hex literal, a named alias, or alias+offset.
@@ -457,11 +544,18 @@ fn resolve_base(s: &str, absolute: bool, port: u16) -> (u32, bool, Option<String
457
  }
458
 
459
  // Try resolving as a named alias
460
- match send_request(port, &Request::ResolveAlias { name: base_str.to_string() }) {
 
 
 
 
 
461
  Ok(Response::AliasResult(alias)) => {
462
  // Resolved! Use runtime address (absolute)
463
  let class = Some(alias.class_name);
464
- let extra = offset_str.map(|o| resolve_offset(o, &class, port)).unwrap_or(0);
 
 
465
  (alias.runtime_addr.wrapping_add(extra), true, class)
466
  }
467
  _ => {
@@ -475,7 +569,9 @@ fn resolve_base(s: &str, absolute: bool, port: u16) -> (u32, bool, Option<String
475
  /// Resolve a chain segment, returning whether it was a field name or hex offset.
476
  fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u16) -> ChainSegment {
477
  let (base_str, offset_str) = split_compound(s);
478
- let extra = offset_str.map(|o| resolve_offset(o, current_class, port)).unwrap_or(0);
 
 
479
 
480
  // If it looks like hex, parse directly β€” standard chain semantics
481
  if looks_like_hex(base_str) {
@@ -484,10 +580,13 @@ fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u1
484
 
485
  // Try resolving as a field name if we know the current class
486
  if let Some(class) = current_class {
487
- match send_request(port, &Request::ResolveField {
488
- class_name: class.clone(),
489
- field_name: base_str.to_string(),
490
- }) {
 
 
 
491
  Ok(Response::FieldResult(f)) => {
492
  return ChainSegment::FieldOffset(f.offset.wrapping_add(extra));
493
  }
@@ -501,7 +600,9 @@ fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u1
501
 
502
  /// Check if a string looks like a hex number (starts with 0x or a digit).
503
  fn looks_like_hex(s: &str) -> bool {
504
- s.starts_with("0x") || s.starts_with("0X") || s.chars().next().map_or(false, |c| c.is_ascii_digit())
 
 
505
  }
506
 
507
  fn print_usage() {
 
31
  match args[i].as_str() {
32
  "--port" => {
33
  i += 1;
34
+ port = args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else(|| {
35
+ eprintln!("Error: --port requires a number");
36
+ process::exit(1);
37
+ });
38
  }
39
  "--format" => {
40
  i += 1;
41
  format = match args.get(i).map(|s| s.as_str()) {
42
  Some("hex") => Format::Hex,
43
  Some("raw") => Format::Raw,
44
+ _ => {
45
+ eprintln!("Error: --format must be 'hex' or 'raw'");
46
+ process::exit(1);
47
+ }
48
  };
49
  }
50
  other => positional.push(other.to_string()),
 
62
  });
63
  // Detect shell eating '->' as redirect: address ends with '-'
64
  if addr_str.ends_with('-') {
65
+ eprintln!(
66
+ "Error: address '{}' looks truncated β€” did the shell eat '->'?",
67
+ addr_str
68
+ );
69
  eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\"");
70
  process::exit(1);
71
  }
72
  let expr = resolve_address_expr(addr_str, port);
73
+ let len = positional.get(2).map(|s| parse_u32(s)).unwrap_or(256);
 
 
74
  match expr {
75
+ AddressExpr::Simple { addr, absolute } => Request::Read {
76
+ addr,
77
+ len,
78
+ absolute,
79
+ },
80
+ AddressExpr::Chain {
81
+ addr,
82
+ chain,
83
+ absolute,
84
+ } => Request::ReadChain {
85
+ addr,
86
+ chain,
87
+ len,
88
+ absolute,
89
+ },
90
  }
91
  }
92
  Some("inspect") => {
93
+ let class_name = positional
94
+ .get(1)
95
+ .unwrap_or_else(|| {
96
+ eprintln!("Usage: inspect <class_name> <addr>");
97
+ process::exit(1);
98
+ })
99
+ .clone();
100
  let addr_str = positional.get(2).unwrap_or_else(|| {
101
  eprintln!("Usage: inspect <class_name> <addr>");
102
  process::exit(1);
103
  });
104
  let expr = resolve_address_expr(addr_str, port);
105
  match expr {
106
+ AddressExpr::Simple { addr, absolute } => Request::Inspect {
107
+ class_name,
108
+ addr,
109
+ chain: vec![],
110
+ absolute,
111
+ },
112
+ AddressExpr::Chain {
113
+ addr,
114
+ chain,
115
+ absolute,
116
+ } => Request::Inspect {
117
+ class_name,
118
+ addr,
119
+ chain,
120
+ absolute,
121
+ },
122
  }
123
  }
124
  Some("objects") => Request::ListObjects,
 
130
  }
131
  Some("frame") => Request::Frame,
132
  Some("break") => {
133
+ let frame = positional
134
+ .get(1)
135
+ .map(|s| {
136
+ if s == "clear" || s == "off" {
137
+ -1
138
+ } else {
139
+ parse_u32(s) as i32
140
+ }
141
+ })
142
+ .unwrap_or_else(|| {
143
+ eprintln!("Usage: break <frame> | break clear");
144
+ process::exit(1);
145
+ });
146
  Request::Break { frame }
147
  }
148
  Some("snapshot") => Request::Snapshot,
 
190
  println!(" {} β€” {}", cmd.usage, cmd.description);
191
  }
192
  }
193
+ Response::ReadResult {
194
+ ghidra_addr,
195
+ runtime_addr,
196
+ data,
197
+ pointers,
198
+ } => match format {
199
+ Format::Hex => print_hex_read(*ghidra_addr, *runtime_addr, data, pointers),
200
+ Format::Raw => {
201
+ let stdout = io::stdout();
202
+ let mut out = stdout.lock();
203
+ let _ = out.write_all(data);
204
  }
205
+ },
206
+ Response::ReadChainResult {
207
+ steps,
208
+ ghidra_addr,
209
+ runtime_addr,
210
+ data,
211
+ pointers,
212
+ } => match format {
213
+ Format::Hex => {
214
+ println!("Pointer chain ({} steps):", steps.len());
215
+ for (i, step) in steps.iter().enumerate() {
216
+ println!(
217
+ " [{}] *(ghidra:0x{:08X}) = 0x{:08X} + 0x{:X} = 0x{:08X}",
218
+ i, step.deref_addr, step.value, step.offset, step.result_addr
219
+ );
 
 
 
220
  }
221
+ println!();
222
+ print_hex_read(*ghidra_addr, *runtime_addr, data, pointers);
223
  }
224
+ Format::Raw => {
225
+ let stdout = io::stdout();
226
+ let mut out = stdout.lock();
227
+ let _ = out.write_all(data);
228
+ }
229
+ },
230
  Response::Suspended { frame } => println!("Suspended at frame {frame}"),
231
  Response::Resumed => println!("Resumed"),
232
+ Response::FrameInfo {
233
+ frame,
234
+ paused,
235
+ breakpoint,
236
+ } => {
237
  let state = if *paused { "PAUSED" } else { "running" };
238
  print!("Frame {frame} [{state}]");
239
  if *breakpoint >= 0 {
 
252
  println!("=== Snapshot at frame {frame} ===\n");
253
  print!("{text}");
254
  }
255
+ Response::InspectResult {
256
+ class_name,
257
+ ghidra_addr,
258
+ runtime_addr,
259
+ fields,
260
+ } => {
261
  println!(
262
  "{} at ghidra:0x{:08X} (runtime:0x{:08X})\n",
263
  class_name, ghidra_addr, runtime_addr
 
283
  }
284
  }
285
  Response::AliasResult(alias) => {
286
+ println!(
287
+ "{} at runtime:0x{:08X}",
288
+ alias.class_name, alias.runtime_addr
289
+ );
290
  }
291
  Response::FieldResult(field) => {
292
  println!("offset:0x{:X} size:{}", field.offset, field.size);
 
301
  fn print_hex_read(ghidra_addr: u32, runtime_addr: u32, data: &[u8], pointers: &[PointerInfo]) {
302
  println!(
303
  "Reading 0x{:X} bytes at ghidra:0x{:08X} (runtime:0x{:08X})\n",
304
+ data.len(),
305
+ ghidra_addr,
306
+ runtime_addr
307
  );
308
 
309
  // Hex dump with ASCII sidebar
 
314
  // Hex bytes
315
  for (i, byte) in chunk.iter().enumerate() {
316
  print!("{:02X} ", byte);
317
+ if i == 7 {
318
+ print!(" ");
319
+ }
320
  }
321
  // Padding for short last line
322
  if chunk.len() < 16 {
323
  for i in chunk.len()..16 {
324
  print!(" ");
325
+ if i == 7 {
326
+ print!(" ");
327
+ }
328
  }
329
  }
330
  // ASCII sidebar
 
350
  PointerKind::Object => "OBJECT",
351
  PointerKind::Heap => "HEAP",
352
  };
353
+ let detail_str = p
354
+ .detail
355
+ .as_deref()
356
+ .map(|d| format!(" {d}"))
357
+ .unwrap_or_default();
358
  println!(
359
  " +0x{:03X} 0x{:08X} {:<7} ghidra:0x{:08X}{}",
360
  p.offset, p.raw_value, kind_str, p.ghidra_value, detail_str
 
374
  /// Simple address (with optional offset already applied)
375
  Simple { addr: u32, absolute: bool },
376
  /// Pointer chain: start address + list of deref offsets
377
+ Chain {
378
+ addr: u32,
379
+ chain: Vec<u32>,
380
+ absolute: bool,
381
+ },
382
  }
383
 
384
  /// Split a string on '+' or '[' into (base, offset_str). Returns (s, None) if no compound.
 
404
 
405
  // Try as field name
406
  if let Some(cls) = class {
407
+ match send_request(
408
+ port,
409
+ &Request::ResolveField {
410
+ class_name: cls.clone(),
411
+ field_name: s.to_string(),
412
+ },
413
+ ) {
414
  Ok(Response::FieldResult(f)) => return f.offset,
415
  _ => {}
416
  }
 
421
  }
422
 
423
  fn parse_hex(s: &str) -> u32 {
424
+ let s = s
425
+ .strip_prefix("0x")
426
+ .or_else(|| s.strip_prefix("0X"))
427
+ .unwrap_or(s);
428
  u32::from_str_radix(s, 16).unwrap_or_else(|_| {
429
  eprintln!("Error: invalid hex address '{s}'");
430
  process::exit(1);
 
488
  let (base_addr, base_absolute, mut current_class) = resolve_base(base_part, absolute, port);
489
 
490
  if parts.len() == 1 {
491
+ return AddressExpr::Simple {
492
+ addr: base_addr,
493
+ absolute: base_absolute,
494
+ };
495
  }
496
 
497
  // Resolve each chain segment, tracking whether it's a field name or hex.
 
526
  }
527
  }
528
 
529
+ AddressExpr::Chain {
530
+ addr: final_base,
531
+ chain,
532
+ absolute: base_absolute,
533
+ }
534
  }
535
 
536
  /// Resolve the base address part β€” could be a hex literal, a named alias, or alias+offset.
 
544
  }
545
 
546
  // Try resolving as a named alias
547
+ match send_request(
548
+ port,
549
+ &Request::ResolveAlias {
550
+ name: base_str.to_string(),
551
+ },
552
+ ) {
553
  Ok(Response::AliasResult(alias)) => {
554
  // Resolved! Use runtime address (absolute)
555
  let class = Some(alias.class_name);
556
+ let extra = offset_str
557
+ .map(|o| resolve_offset(o, &class, port))
558
+ .unwrap_or(0);
559
  (alias.runtime_addr.wrapping_add(extra), true, class)
560
  }
561
  _ => {
 
569
  /// Resolve a chain segment, returning whether it was a field name or hex offset.
570
  fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u16) -> ChainSegment {
571
  let (base_str, offset_str) = split_compound(s);
572
+ let extra = offset_str
573
+ .map(|o| resolve_offset(o, current_class, port))
574
+ .unwrap_or(0);
575
 
576
  // If it looks like hex, parse directly β€” standard chain semantics
577
  if looks_like_hex(base_str) {
 
580
 
581
  // Try resolving as a field name if we know the current class
582
  if let Some(class) = current_class {
583
+ match send_request(
584
+ port,
585
+ &Request::ResolveField {
586
+ class_name: class.clone(),
587
+ field_name: base_str.to_string(),
588
+ },
589
+ ) {
590
  Ok(Response::FieldResult(f)) => {
591
  return ChainSegment::FieldOffset(f.offset.wrapping_add(extra));
592
  }
 
600
 
601
  /// Check if a string looks like a hex number (starts with 0x or a digit).
602
  fn looks_like_hex(s: &str) -> bool {
603
+ s.starts_with("0x")
604
+ || s.starts_with("0X")
605
+ || s.chars().next().map_or(false, |c| c.is_ascii_digit())
606
  }
607
 
608
  fn print_usage() {
crates/openwa-debug-proto/src/lib.rs CHANGED
@@ -65,30 +65,54 @@ pub struct ResolvedField {
65
  pub enum Request {
66
  Ping,
67
  Help,
68
- Read { addr: u32, len: u32, #[serde(default)] absolute: bool },
 
 
 
 
 
69
  /// Walk a pointer chain: start at `addr`, then for each offset in `chain`,
70
  /// deref the current DWORD and add the offset. Read `len` bytes at the end.
71
- ReadChain { addr: u32, chain: Vec<u32>, len: u32, absolute: bool },
 
 
 
 
 
72
  /// Pause the game at the next frame boundary.
73
  Suspend,
74
  /// Resume the game.
75
  Resume,
76
  /// Advance `count` frames, then pause.
77
- Step { count: i32 },
 
 
78
  /// Query current frame number and pause state.
79
  Frame,
80
  /// Set a frame breakpoint (-1 to clear).
81
- Break { frame: i32 },
 
 
82
  /// Capture a canonicalized game state snapshot.
83
  Snapshot,
84
  /// Typed struct inspection: read all named fields at an address.
85
- Inspect { class_name: String, addr: u32, chain: Vec<u32>, absolute: bool },
 
 
 
 
 
86
  /// List all tracked live objects.
87
  ListObjects,
88
  /// Resolve a named alias (e.g., "ddgame") to a runtime address.
89
- ResolveAlias { name: String },
 
 
90
  /// Resolve a field name to its offset within a struct.
91
- ResolveField { class_name: String, field_name: String },
 
 
 
92
  }
93
 
94
  /// One step in a resolved pointer chain, for display.
@@ -114,7 +138,9 @@ pub struct CommandHelp {
114
  #[derive(Debug, Clone, Serialize, Deserialize)]
115
  pub enum Response {
116
  Pong,
117
- Help { commands: Vec<CommandHelp> },
 
 
118
  ReadResult {
119
  ghidra_addr: u32,
120
  runtime_addr: u32,
@@ -134,15 +160,26 @@ pub enum Response {
134
  pointers: Vec<PointerInfo>,
135
  },
136
  /// Game is now suspended at this frame.
137
- Suspended { frame: i32 },
 
 
138
  /// Game resumed.
139
  Resumed,
140
  /// Current frame info.
141
- FrameInfo { frame: i32, paused: bool, breakpoint: i32 },
 
 
 
 
142
  /// Breakpoint set/cleared.
143
- BreakSet { frame: i32 },
 
 
144
  /// Game state snapshot.
145
- Snapshot { frame: i32, text: String },
 
 
 
146
  /// Typed struct inspection result.
147
  InspectResult {
148
  class_name: String,
@@ -151,12 +188,16 @@ pub enum Response {
151
  fields: Vec<FieldValue>,
152
  },
153
  /// List of tracked live objects.
154
- ObjectList { objects: Vec<LiveObjectInfo> },
 
 
155
  /// Resolved named alias.
156
  AliasResult(ResolvedAlias),
157
  /// Resolved field offset.
158
  FieldResult(ResolvedField),
159
- Error { message: String },
 
 
160
  }
161
 
162
  // --- Length-prefixed framing ---
@@ -188,6 +229,5 @@ pub fn read_frame<R: Read, T: for<'de> Deserialize<'de>>(reader: &mut R) -> io::
188
  }
189
  let mut payload = vec![0u8; len];
190
  reader.read_exact(&mut payload)?;
191
- rmp_serde::from_slice(&payload)
192
- .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
193
  }
 
65
  pub enum Request {
66
  Ping,
67
  Help,
68
+ Read {
69
+ addr: u32,
70
+ len: u32,
71
+ #[serde(default)]
72
+ absolute: bool,
73
+ },
74
  /// Walk a pointer chain: start at `addr`, then for each offset in `chain`,
75
  /// deref the current DWORD and add the offset. Read `len` bytes at the end.
76
+ ReadChain {
77
+ addr: u32,
78
+ chain: Vec<u32>,
79
+ len: u32,
80
+ absolute: bool,
81
+ },
82
  /// Pause the game at the next frame boundary.
83
  Suspend,
84
  /// Resume the game.
85
  Resume,
86
  /// Advance `count` frames, then pause.
87
+ Step {
88
+ count: i32,
89
+ },
90
  /// Query current frame number and pause state.
91
  Frame,
92
  /// Set a frame breakpoint (-1 to clear).
93
+ Break {
94
+ frame: i32,
95
+ },
96
  /// Capture a canonicalized game state snapshot.
97
  Snapshot,
98
  /// Typed struct inspection: read all named fields at an address.
99
+ Inspect {
100
+ class_name: String,
101
+ addr: u32,
102
+ chain: Vec<u32>,
103
+ absolute: bool,
104
+ },
105
  /// List all tracked live objects.
106
  ListObjects,
107
  /// Resolve a named alias (e.g., "ddgame") to a runtime address.
108
+ ResolveAlias {
109
+ name: String,
110
+ },
111
  /// Resolve a field name to its offset within a struct.
112
+ ResolveField {
113
+ class_name: String,
114
+ field_name: String,
115
+ },
116
  }
117
 
118
  /// One step in a resolved pointer chain, for display.
 
138
  #[derive(Debug, Clone, Serialize, Deserialize)]
139
  pub enum Response {
140
  Pong,
141
+ Help {
142
+ commands: Vec<CommandHelp>,
143
+ },
144
  ReadResult {
145
  ghidra_addr: u32,
146
  runtime_addr: u32,
 
160
  pointers: Vec<PointerInfo>,
161
  },
162
  /// Game is now suspended at this frame.
163
+ Suspended {
164
+ frame: i32,
165
+ },
166
  /// Game resumed.
167
  Resumed,
168
  /// Current frame info.
169
+ FrameInfo {
170
+ frame: i32,
171
+ paused: bool,
172
+ breakpoint: i32,
173
+ },
174
  /// Breakpoint set/cleared.
175
+ BreakSet {
176
+ frame: i32,
177
+ },
178
  /// Game state snapshot.
179
+ Snapshot {
180
+ frame: i32,
181
+ text: String,
182
+ },
183
  /// Typed struct inspection result.
184
  InspectResult {
185
  class_name: String,
 
188
  fields: Vec<FieldValue>,
189
  },
190
  /// List of tracked live objects.
191
+ ObjectList {
192
+ objects: Vec<LiveObjectInfo>,
193
+ },
194
  /// Resolved named alias.
195
  AliasResult(ResolvedAlias),
196
  /// Resolved field offset.
197
  FieldResult(ResolvedField),
198
+ Error {
199
+ message: String,
200
+ },
201
  }
202
 
203
  // --- Length-prefixed framing ---
 
229
  }
230
  let mut payload = vec![0u8; len];
231
  reader.read_exact(&mut payload)?;
232
+ rmp_serde::from_slice(&payload).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
 
233
  }
crates/openwa-debugui/src/app.rs CHANGED
@@ -408,8 +408,7 @@ unsafe fn show_game_task_raw_fields(
408
  // Pointer identification via registry
409
  use openwa_core::mem;
410
  let ptr_label = if val >= 0x10000 {
411
- mem::identify_pointer(val, delta)
412
- .and_then(|id| id.name)
413
  } else {
414
  None
415
  };
@@ -569,23 +568,17 @@ impl DebugApp {
569
 
570
  // --- CTaskMine-specific fields ---
571
  if name == "CTaskMine" {
572
- show_game_task_raw_fields(
573
- ui, addr, "CTaskMine", 0x128,
574
- );
575
  }
576
 
577
  // --- CTaskOilDrum-specific fields ---
578
  if name == "CTaskOilDrum" {
579
- show_game_task_raw_fields(
580
- ui, addr, "CTaskOilDrum", 0x110,
581
- );
582
  }
583
 
584
  // --- CTaskCrate-specific fields ---
585
  if name == "CTaskCrate" {
586
- show_game_task_raw_fields(
587
- ui, addr, "CTaskCrate", 0x4B0,
588
- );
589
  }
590
 
591
  // --- CTaskCloud-specific fields ---
@@ -803,9 +796,7 @@ impl DebugApp {
803
  ));
804
  ui.separator();
805
 
806
- show_game_task_raw_fields(
807
- ui, addr, "CTaskWorm", 0x3FC,
808
- );
809
  }
810
 
811
  // --- CTaskMissile-specific fields ---
@@ -821,9 +812,7 @@ impl DebugApp {
821
  ));
822
  ui.separator();
823
 
824
- show_game_task_raw_fields(
825
- ui, addr, "CTaskMissile", 0x41C,
826
- );
827
  }
828
  }
829
  });
 
408
  // Pointer identification via registry
409
  use openwa_core::mem;
410
  let ptr_label = if val >= 0x10000 {
411
+ mem::identify_pointer(val, delta).and_then(|id| id.name)
 
412
  } else {
413
  None
414
  };
 
568
 
569
  // --- CTaskMine-specific fields ---
570
  if name == "CTaskMine" {
571
+ show_game_task_raw_fields(ui, addr, "CTaskMine", 0x128);
 
 
572
  }
573
 
574
  // --- CTaskOilDrum-specific fields ---
575
  if name == "CTaskOilDrum" {
576
+ show_game_task_raw_fields(ui, addr, "CTaskOilDrum", 0x110);
 
 
577
  }
578
 
579
  // --- CTaskCrate-specific fields ---
580
  if name == "CTaskCrate" {
581
+ show_game_task_raw_fields(ui, addr, "CTaskCrate", 0x4B0);
 
 
582
  }
583
 
584
  // --- CTaskCloud-specific fields ---
 
796
  ));
797
  ui.separator();
798
 
799
+ show_game_task_raw_fields(ui, addr, "CTaskWorm", 0x3FC);
 
 
800
  }
801
 
802
  // --- CTaskMissile-specific fields ---
 
812
  ));
813
  ui.separator();
814
 
815
+ show_game_task_raw_fields(ui, addr, "CTaskMissile", 0x41C);
 
 
816
  }
817
  }
818
  });
crates/openwa-derive/src/lib.rs CHANGED
@@ -110,9 +110,10 @@ fn vtable_impl(
110
  let mut has_implicit_slot = false;
111
 
112
  for (field_idx, field) in fields.iter().enumerate() {
113
- let ident = field.ident.as_ref().ok_or_else(|| {
114
- syn::Error::new_spanned(field, "Vtable fields must be named")
115
- })?;
 
116
 
117
  let explicit_index = parse_slot_attr(&field.attrs, ident)?;
118
 
@@ -513,9 +514,9 @@ fn parse_vtable_attr_args(
513
 
514
  syn::parse::Parser::parse2(parser, attr)?;
515
 
516
- let size = args.size.ok_or_else(|| {
517
- syn::Error::new_spanned(span, "vtable attribute requires `size = N`")
518
- })?;
519
 
520
  Ok((size, args.va, args.class))
521
  }
@@ -586,9 +587,12 @@ pub fn derive_field_registry(input: TokenStream) -> TokenStream {
586
  }
587
  },
588
  _ => {
589
- return syn::Error::new_spanned(struct_name, "FieldRegistry can only be used on structs")
590
- .to_compile_error()
591
- .into();
 
 
 
592
  }
593
  };
594
 
@@ -616,8 +620,8 @@ pub fn derive_field_registry(input: TokenStream) -> TokenStream {
616
  let size_ty = substitute_generics(field_ty, &generic_defaults);
617
 
618
  // Determine ValueKind: check for #[field(kind = "...")] override, else infer
619
- let kind = parse_field_kind_attr(&field.attrs)
620
- .unwrap_or_else(|| infer_value_kind(field_ty));
621
 
622
  entries.push(quote! {
623
  openwa_core::registry::FieldEntry {
 
110
  let mut has_implicit_slot = false;
111
 
112
  for (field_idx, field) in fields.iter().enumerate() {
113
+ let ident = field
114
+ .ident
115
+ .as_ref()
116
+ .ok_or_else(|| syn::Error::new_spanned(field, "Vtable fields must be named"))?;
117
 
118
  let explicit_index = parse_slot_attr(&field.attrs, ident)?;
119
 
 
514
 
515
  syn::parse::Parser::parse2(parser, attr)?;
516
 
517
+ let size = args
518
+ .size
519
+ .ok_or_else(|| syn::Error::new_spanned(span, "vtable attribute requires `size = N`"))?;
520
 
521
  Ok((size, args.va, args.class))
522
  }
 
587
  }
588
  },
589
  _ => {
590
+ return syn::Error::new_spanned(
591
+ struct_name,
592
+ "FieldRegistry can only be used on structs",
593
+ )
594
+ .to_compile_error()
595
+ .into();
596
  }
597
  };
598
 
 
620
  let size_ty = substitute_generics(field_ty, &generic_defaults);
621
 
622
  // Determine ValueKind: check for #[field(kind = "...")] override, else infer
623
+ let kind =
624
+ parse_field_kind_attr(&field.attrs).unwrap_or_else(|| infer_value_kind(field_ty));
625
 
626
  entries.push(quote! {
627
  openwa_core::registry::FieldEntry {
crates/openwa-test-runner/src/main.rs CHANGED
@@ -194,7 +194,14 @@ fn find_launcher() -> Option<PathBuf> {
194
  fn build() -> Result<Duration, String> {
195
  let start = Instant::now();
196
  let status = Command::new("cargo")
197
- .args(["build", "--release", "-p", "openwa-wormkit", "-p", "openwa-launcher"])
 
 
 
 
 
 
 
198
  .status()
199
  .map_err(|e| format!("Failed to run cargo: {e}"))?;
200
 
@@ -227,7 +234,6 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
227
  .creation_flags(CREATE_NO_WINDOW)
228
  .status();
229
 
230
-
231
  let duration = start.elapsed();
232
 
233
  match result {
@@ -262,8 +268,10 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
262
  passed: false,
263
  duration,
264
  diff_lines: Vec::new(),
265
- error: Some(format!("No output log generated (exit code: {})",
266
- status.code().unwrap_or(-1))),
 
 
267
  };
268
  }
269
 
@@ -286,7 +294,8 @@ fn run_test(test: &TestCase, launcher: &Path, wa_exe: &Path, run_dir: &Path) ->
286
  if diff.is_empty() {
287
  diff.push(format!(
288
  " (byte-level difference: expected {} bytes, actual {} bytes)",
289
- expected.len(), actual.len()
 
290
  ));
291
  }
292
  let _ = fs::remove_file(&test.output_log);
@@ -407,7 +416,11 @@ fn run_tests_parallel(
407
  // ─── Output ─────────────────────────────────────────────────────────────────
408
 
409
  fn print_result(result: &TestResult) {
410
- let status = if result.passed { "\x1b[32m PASS\x1b[0m" } else { "\x1b[31m FAIL\x1b[0m" };
 
 
 
 
411
  let secs = result.duration.as_secs_f64();
412
  println!("{status} {:<28} ({secs:.1}s)", result.name);
413
 
@@ -443,7 +456,12 @@ fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
443
  let mut s = String::new();
444
  for r in results {
445
  let status = if r.passed { "PASS" } else { "FAIL" };
446
- let _ = writeln!(s, "{status} {:<28} ({:.1}s)", r.name, r.duration.as_secs_f64());
 
 
 
 
 
447
  if let Some(err) = &r.error {
448
  let _ = writeln!(s, " {err}");
449
  }
@@ -453,8 +471,12 @@ fn write_summary(results: &[TestResult], wall_time: Duration, path: &Path) {
453
  }
454
  let passed = results.iter().filter(|r| r.passed).count();
455
  let failed = results.len() - passed;
456
- let _ = writeln!(s, "\n{} tests: {passed} passed, {failed} failed (wall {:.1}s)",
457
- results.len(), wall_time.as_secs_f64());
 
 
 
 
458
 
459
  let _ = fs::write(path, s);
460
  }
@@ -469,10 +491,7 @@ fn cleanup_temp_files(wa_exe: &Path) {
469
  None => return,
470
  };
471
 
472
- let patterns = [
473
- (game_dir, "mono_", ".tmp"),
474
- (game_dir, "cur_", ".thm"),
475
- ];
476
  let data_dir = game_dir.join("DATA");
477
  let data_patterns = [
478
  ("land_", ".dat"),
 
194
  fn build() -> Result<Duration, String> {
195
  let start = Instant::now();
196
  let status = Command::new("cargo")
197
+ .args([
198
+ "build",
199
+ "--release",
200
+ "-p",
201
+ "openwa-wormkit",
202
+ "-p",
203
+ "openwa-launcher",
204
+ ])
205
  .status()
206
  .map_err(|e| format!("Failed to run cargo: {e}"))?;
207
 
 
234
  .creation_flags(CREATE_NO_WINDOW)
235
  .status();
236
 
 
237
  let duration = start.elapsed();
238
 
239
  match result {
 
268
  passed: false,
269
  duration,
270
  diff_lines: Vec::new(),
271
+ error: Some(format!(
272
+ "No output log generated (exit code: {})",
273
+ status.code().unwrap_or(-1)
274
+ )),
275
  };
276
  }
277
 
 
294
  if diff.is_empty() {
295
  diff.push(format!(
296
  " (byte-level difference: expected {} bytes, actual {} bytes)",
297
+ expected.len(),
298
+ actual.len()
299
  ));
300
  }
301
  let _ = fs::remove_file(&test.output_log);
 
416
  // ─── Output ─────────────────────────────────────────────────────────────────
417
 
418
  fn print_result(result: &TestResult) {
419
+ let status = if result.passed {
420
+ "\x1b[32m PASS\x1b[0m"
421
+ } else {
422
+ "\x1b[31m FAIL\x1b[0m"
423
+ };
424
  let secs = result.duration.as_secs_f64();
425
  println!("{status} {:<28} ({secs:.1}s)", result.name);
426
 
 
456
  let mut s = String::new();
457
  for r in results {
458
  let status = if r.passed { "PASS" } else { "FAIL" };
459
+ let _ = writeln!(
460
+ s,
461
+ "{status} {:<28} ({:.1}s)",
462
+ r.name,
463
+ r.duration.as_secs_f64()
464
+ );
465
  if let Some(err) = &r.error {
466
  let _ = writeln!(s, " {err}");
467
  }
 
471
  }
472
  let passed = results.iter().filter(|r| r.passed).count();
473
  let failed = results.len() - passed;
474
+ let _ = writeln!(
475
+ s,
476
+ "\n{} tests: {passed} passed, {failed} failed (wall {:.1}s)",
477
+ results.len(),
478
+ wall_time.as_secs_f64()
479
+ );
480
 
481
  let _ = fs::write(path, s);
482
  }
 
491
  None => return,
492
  };
493
 
494
+ let patterns = [(game_dir, "mono_", ".tmp"), (game_dir, "cur_", ".thm")];
 
 
 
495
  let data_dir = game_dir.join("DATA");
496
  let data_patterns = [
497
  ("land_", ".dat"),
crates/openwa-wormkit/src/debug_server.rs CHANGED
@@ -110,11 +110,22 @@ fn handle_request(request: Request) -> Response {
110
  },
111
  ],
112
  },
113
- Request::Read { addr, len, absolute } => handle_read(addr, len, absolute),
114
- Request::ReadChain { addr, chain, len, absolute } => handle_read_chain(addr, &chain, len, absolute),
 
 
 
 
 
 
 
 
 
115
  Request::Suspend => {
116
  crate::debug_sync::suspend();
117
- Response::Suspended { frame: crate::debug_sync::current_frame() }
 
 
118
  }
119
  Request::Resume => {
120
  crate::debug_sync::resume();
@@ -124,7 +135,9 @@ fn handle_request(request: Request) -> Response {
124
  crate::debug_sync::step(count);
125
  // Wait briefly for the step to complete, then report
126
  std::thread::sleep(Duration::from_millis(100));
127
- Response::Suspended { frame: crate::debug_sync::current_frame() }
 
 
128
  }
129
  Request::Frame => Response::FrameInfo {
130
  frame: crate::debug_sync::current_frame(),
@@ -140,14 +153,18 @@ fn handle_request(request: Request) -> Response {
140
  let frame = crate::debug_sync::current_frame();
141
  Response::Snapshot { frame, text }
142
  }
143
- Request::Inspect { class_name, addr, chain, absolute } => {
144
- handle_inspect(&class_name, addr, &chain, absolute)
145
- }
 
 
 
146
  Request::ListObjects => handle_list_objects(),
147
  Request::ResolveAlias { name } => handle_resolve_alias(&name),
148
- Request::ResolveField { class_name, field_name } => {
149
- handle_resolve_field(&class_name, &field_name)
150
- }
 
151
  }
152
  }
153
 
@@ -189,11 +206,7 @@ fn handle_read(addr: u32, len: u32, absolute: bool) -> Response {
189
 
190
  // Copy the memory region
191
  let mut data = vec![0u8; len as usize];
192
- std::ptr::copy_nonoverlapping(
193
- runtime_addr as *const u8,
194
- data.as_mut_ptr(),
195
- len as usize,
196
- );
197
 
198
  // Classify pointers in the copied data
199
  let pointers = mem::classify_region(&data, 0, delta);
@@ -230,10 +243,7 @@ fn handle_inspect(class_name: &str, addr: u32, chain: &[u32], absolute: bool) ->
230
  unsafe {
231
  if !mem::can_read(current, 4) {
232
  return Response::Error {
233
- message: format!(
234
- "Chain broke: cannot read at runtime:0x{:08X}",
235
- current
236
- ),
237
  };
238
  }
239
  let value = *(current as *const u32);
@@ -370,8 +380,12 @@ fn handle_resolve_field(class_name: &str, field_name: &str) -> Response {
370
  }
371
 
372
  Response::Error {
373
- message: format!("No field '{}' in '{}' (searched: {})", field_name, class_name,
374
- search_chain.join(" -> ")),
 
 
 
 
375
  }
376
  }
377
 
 
110
  },
111
  ],
112
  },
113
+ Request::Read {
114
+ addr,
115
+ len,
116
+ absolute,
117
+ } => handle_read(addr, len, absolute),
118
+ Request::ReadChain {
119
+ addr,
120
+ chain,
121
+ len,
122
+ absolute,
123
+ } => handle_read_chain(addr, &chain, len, absolute),
124
  Request::Suspend => {
125
  crate::debug_sync::suspend();
126
+ Response::Suspended {
127
+ frame: crate::debug_sync::current_frame(),
128
+ }
129
  }
130
  Request::Resume => {
131
  crate::debug_sync::resume();
 
135
  crate::debug_sync::step(count);
136
  // Wait briefly for the step to complete, then report
137
  std::thread::sleep(Duration::from_millis(100));
138
+ Response::Suspended {
139
+ frame: crate::debug_sync::current_frame(),
140
+ }
141
  }
142
  Request::Frame => Response::FrameInfo {
143
  frame: crate::debug_sync::current_frame(),
 
153
  let frame = crate::debug_sync::current_frame();
154
  Response::Snapshot { frame, text }
155
  }
156
+ Request::Inspect {
157
+ class_name,
158
+ addr,
159
+ chain,
160
+ absolute,
161
+ } => handle_inspect(&class_name, addr, &chain, absolute),
162
  Request::ListObjects => handle_list_objects(),
163
  Request::ResolveAlias { name } => handle_resolve_alias(&name),
164
+ Request::ResolveField {
165
+ class_name,
166
+ field_name,
167
+ } => handle_resolve_field(&class_name, &field_name),
168
  }
169
  }
170
 
 
206
 
207
  // Copy the memory region
208
  let mut data = vec![0u8; len as usize];
209
+ std::ptr::copy_nonoverlapping(runtime_addr as *const u8, data.as_mut_ptr(), len as usize);
 
 
 
 
210
 
211
  // Classify pointers in the copied data
212
  let pointers = mem::classify_region(&data, 0, delta);
 
243
  unsafe {
244
  if !mem::can_read(current, 4) {
245
  return Response::Error {
246
+ message: format!("Chain broke: cannot read at runtime:0x{:08X}", current),
 
 
 
247
  };
248
  }
249
  let value = *(current as *const u32);
 
380
  }
381
 
382
  Response::Error {
383
+ message: format!(
384
+ "No field '{}' in '{}' (searched: {})",
385
+ field_name,
386
+ class_name,
387
+ search_chain.join(" -> ")
388
+ ),
389
  }
390
  }
391
 
crates/openwa-wormkit/src/debug_sync.rs CHANGED
@@ -36,9 +36,7 @@ fn event() -> HANDLE {
36
 
37
  /// Initialize the sync primitives. Call once from DLL init.
38
  pub fn init() {
39
- let handle = unsafe {
40
- CreateEventA(core::ptr::null(), 1, 1, core::ptr::null())
41
- };
42
  assert!(!handle.is_null(), "debug_sync: CreateEventA failed");
43
  RESUME_EVENT.store(handle as usize, Ordering::Relaxed);
44
 
@@ -77,7 +75,9 @@ pub fn on_frame_start(frame: i32) {
77
 
78
  // Block if paused
79
  if PAUSED.load(Ordering::Acquire) {
80
- unsafe { WaitForSingleObject(event(), 0xFFFFFFFF); }
 
 
81
  }
82
  }
83
 
@@ -94,7 +94,9 @@ pub fn suspend() {
94
  pub fn resume() {
95
  PAUSED.store(false, Ordering::Release);
96
  STEP_REMAINING.store(-1, Ordering::Relaxed);
97
- unsafe { SetEvent(event()); }
 
 
98
  let _ = log_line("[DebugSync] Resumed");
99
  }
100
 
@@ -102,7 +104,9 @@ pub fn resume() {
102
  pub fn step(count: i32) {
103
  STEP_REMAINING.store(count.max(1), Ordering::Relaxed);
104
  PAUSED.store(false, Ordering::Release);
105
- unsafe { SetEvent(event()); }
 
 
106
  }
107
 
108
  /// Set a frame breakpoint. -1 to clear.
@@ -129,5 +133,7 @@ pub fn breakpoint() -> i32 {
129
 
130
  fn do_suspend() {
131
  PAUSED.store(true, Ordering::Release);
132
- unsafe { ResetEvent(event()); }
 
 
133
  }
 
36
 
37
  /// Initialize the sync primitives. Call once from DLL init.
38
  pub fn init() {
39
+ let handle = unsafe { CreateEventA(core::ptr::null(), 1, 1, core::ptr::null()) };
 
 
40
  assert!(!handle.is_null(), "debug_sync: CreateEventA failed");
41
  RESUME_EVENT.store(handle as usize, Ordering::Relaxed);
42
 
 
75
 
76
  // Block if paused
77
  if PAUSED.load(Ordering::Acquire) {
78
+ unsafe {
79
+ WaitForSingleObject(event(), 0xFFFFFFFF);
80
+ }
81
  }
82
  }
83
 
 
94
  pub fn resume() {
95
  PAUSED.store(false, Ordering::Release);
96
  STEP_REMAINING.store(-1, Ordering::Relaxed);
97
+ unsafe {
98
+ SetEvent(event());
99
+ }
100
  let _ = log_line("[DebugSync] Resumed");
101
  }
102
 
 
104
  pub fn step(count: i32) {
105
  STEP_REMAINING.store(count.max(1), Ordering::Relaxed);
106
  PAUSED.store(false, Ordering::Release);
107
+ unsafe {
108
+ SetEvent(event());
109
+ }
110
  }
111
 
112
  /// Set a frame breakpoint. -1 to clear.
 
133
 
134
  fn do_suspend() {
135
  PAUSED.store(true, Ordering::Release);
136
+ unsafe {
137
+ ResetEvent(event());
138
+ }
139
  }
crates/openwa-wormkit/src/debug_watchpoint.rs CHANGED
@@ -33,9 +33,7 @@ use windows_sys::Win32::System::Diagnostics::Debug::{
33
  /// Offsets to watch. Hardware limit: 4 watchpoints (DR0–DR3).
34
  /// Change these to investigate different fields.
35
  /// NOTE: The base pointer is set by `on_ddgame_alloc()` β€” can be DDGame or DDGameWrapper.
36
- const WATCH_OFFSETS: [(u32, &str); 1] = [
37
- (0x3A40, "display+0x3A40"),
38
- ];
39
 
40
  /// DDGame base address (set when allocation is reported).
41
  static DDGAME_BASE: AtomicU32 = AtomicU32::new(0);
@@ -160,7 +158,10 @@ unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
160
 
161
  let _ = log_line(&format!(
162
  "[Watchpoint] {} = 0x{:08X} eip={} stack=[{}]",
163
- name, val, openwa_core::registry::format_va(ghidra_eip), trace,
 
 
 
164
  ));
165
  }
166
  }
@@ -208,8 +209,7 @@ unsafe extern "cdecl" fn malloc_hook(size: u32) -> *mut u8 {
208
  if size == DDGAME_ALLOC_SIZE && !result.is_null() {
209
  let _ = log_line(&format!(
210
  "[Watchpoint] Intercepted wa_malloc(0x{:X}) = 0x{:08X}",
211
- size,
212
- result as u32,
213
  ));
214
  on_ddgame_alloc(result);
215
  }
 
33
  /// Offsets to watch. Hardware limit: 4 watchpoints (DR0–DR3).
34
  /// Change these to investigate different fields.
35
  /// NOTE: The base pointer is set by `on_ddgame_alloc()` β€” can be DDGame or DDGameWrapper.
36
+ const WATCH_OFFSETS: [(u32, &str); 1] = [(0x3A40, "display+0x3A40")];
 
 
37
 
38
  /// DDGame base address (set when allocation is reported).
39
  static DDGAME_BASE: AtomicU32 = AtomicU32::new(0);
 
158
 
159
  let _ = log_line(&format!(
160
  "[Watchpoint] {} = 0x{:08X} eip={} stack=[{}]",
161
+ name,
162
+ val,
163
+ openwa_core::registry::format_va(ghidra_eip),
164
+ trace,
165
  ));
166
  }
167
  }
 
209
  if size == DDGAME_ALLOC_SIZE && !result.is_null() {
210
  let _ = log_line(&format!(
211
  "[Watchpoint] Intercepted wa_malloc(0x{:X}) = 0x{:08X}",
212
+ size, result as u32,
 
213
  ));
214
  on_ddgame_alloc(result);
215
  }
crates/openwa-wormkit/src/lib.rs CHANGED
@@ -66,8 +66,8 @@ fn clear_log() -> std::io::Result<()> {
66
 
67
  fn run() -> Result<(), String> {
68
  let _ = clear_log();
69
- let validation_path = std::env::var_os("OPENWA_VALIDATION_LOG_PATH")
70
- .unwrap_or("OpenWA_validation.log".into());
71
  let _ = std::fs::write(validation_path, "");
72
 
73
  // Install panic hook that writes to our log file
@@ -132,7 +132,10 @@ fn signal_hooks_ready() {
132
  if !handle.is_null() {
133
  SetEvent(handle);
134
  CloseHandle(handle);
135
- let _ = log_line(&format!("=== Signalled {}{} ===", HOOKS_READY_EVENT_PREFIX, pid));
 
 
 
136
  }
137
  // If handle is null, we weren't launched by our launcher β€” that's fine.
138
  }
 
66
 
67
  fn run() -> Result<(), String> {
68
  let _ = clear_log();
69
+ let validation_path =
70
+ std::env::var_os("OPENWA_VALIDATION_LOG_PATH").unwrap_or("OpenWA_validation.log".into());
71
  let _ = std::fs::write(validation_path, "");
72
 
73
  // Install panic hook that writes to our log file
 
132
  if !handle.is_null() {
133
  SetEvent(handle);
134
  CloseHandle(handle);
135
+ let _ = log_line(&format!(
136
+ "=== Signalled {}{} ===",
137
+ HOOKS_READY_EVENT_PREFIX, pid
138
+ ));
139
  }
140
  // If handle is null, we weren't launched by our launcher β€” that's fine.
141
  }
crates/openwa-wormkit/src/replacements/file_isolation.rs CHANGED
@@ -96,9 +96,8 @@ pub fn install() -> Result<(), String> {
96
  }
97
 
98
  unsafe {
99
- let module = windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(
100
- b"kernel32.dll\0".as_ptr(),
101
- );
102
  if module.is_null() {
103
  return Err("kernel32.dll not loaded".to_string());
104
  }
 
96
  }
97
 
98
  unsafe {
99
+ let module =
100
+ windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(b"kernel32.dll\0".as_ptr());
 
101
  if module.is_null() {
102
  return Err("kernel32.dll not loaded".to_string());
103
  }
crates/openwa-wormkit/src/replacements/game_session.rs CHANGED
@@ -47,9 +47,16 @@ unsafe extern "stdcall" fn call_init_replay(_game_info: *mut GameInfo, _this: *m
47
  /// Temp: bridge to original DDGame__Constructor for comparison.
48
  #[unsafe(naked)]
49
  unsafe extern "C" fn call_original_ddgame_ctor(
50
- _wrapper: *mut DDGameWrapper, _display: *mut DDDisplay, _sound: *mut DSSound,
51
- _keyboard: *mut u8, _palette: *mut Palette, _music: *mut u8,
52
- _timer: *mut u8, _net_game: *mut u8, _game_info: *mut GameInfo, _input_ctrl: *mut u8,
 
 
 
 
 
 
 
53
  ) {
54
  core::arch::naked_asm!(
55
  "mov ecx, [esp+40]",
@@ -123,8 +130,16 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
123
  let use_original = std::env::var("OPENWA_USE_ORIG_CTOR").is_ok();
124
  if use_original {
125
  call_original_ddgame_ctor(
126
- this, display, sound, keyboard, palette, streaming_audio,
127
- timer_obj, net_game, game_info, input_ctrl,
 
 
 
 
 
 
 
 
128
  );
129
  } else {
130
  create_ddgame(
@@ -139,7 +154,6 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
139
  game_info,
140
  input_ctrl as u32,
141
  );
142
-
143
  }
144
 
145
  // Disarm display watchpoint
@@ -152,10 +166,10 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
152
  core::mem::transmute(rb(va::DDGAME_INIT_GAME_STATE) as usize);
153
  init_state(this);
154
 
155
-
156
  let _ = log_line(&format!(
157
  "[GameSession] DDGameWrapper::Constructor done: wrapper=0x{:08X} ddgame=0x{:08X}",
158
- this as u32, (*this).ddgame as u32,
 
159
  ));
160
 
161
  // Register live objects for pointer identification in debug tools.
 
47
  /// Temp: bridge to original DDGame__Constructor for comparison.
48
  #[unsafe(naked)]
49
  unsafe extern "C" fn call_original_ddgame_ctor(
50
+ _wrapper: *mut DDGameWrapper,
51
+ _display: *mut DDDisplay,
52
+ _sound: *mut DSSound,
53
+ _keyboard: *mut u8,
54
+ _palette: *mut Palette,
55
+ _music: *mut u8,
56
+ _timer: *mut u8,
57
+ _net_game: *mut u8,
58
+ _game_info: *mut GameInfo,
59
+ _input_ctrl: *mut u8,
60
  ) {
61
  core::arch::naked_asm!(
62
  "mov ecx, [esp+40]",
 
130
  let use_original = std::env::var("OPENWA_USE_ORIG_CTOR").is_ok();
131
  if use_original {
132
  call_original_ddgame_ctor(
133
+ this,
134
+ display,
135
+ sound,
136
+ keyboard,
137
+ palette,
138
+ streaming_audio,
139
+ timer_obj,
140
+ net_game,
141
+ game_info,
142
+ input_ctrl,
143
  );
144
  } else {
145
  create_ddgame(
 
154
  game_info,
155
  input_ctrl as u32,
156
  );
 
157
  }
158
 
159
  // Disarm display watchpoint
 
166
  core::mem::transmute(rb(va::DDGAME_INIT_GAME_STATE) as usize);
167
  init_state(this);
168
 
 
169
  let _ = log_line(&format!(
170
  "[GameSession] DDGameWrapper::Constructor done: wrapper=0x{:08X} ddgame=0x{:08X}",
171
+ this as u32,
172
+ (*this).ddgame as u32,
173
  ));
174
 
175
  // Register live objects for pointer identification in debug tools.
crates/openwa-wormkit/src/replacements/input.rs CHANGED
@@ -188,7 +188,8 @@ unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
188
  crate::debug_sync::on_frame_start(game_frame);
189
 
190
  // Hardware watchpoint: arm once at the watch frame
191
- static WATCH_ARMED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
 
192
  if !WATCH_ARMED.load(Ordering::Relaxed) {
193
  if let Ok(val) = std::env::var("OPENWA_WATCH_FRAME") {
194
  let target: i32 = val.parse().unwrap_or(0);
@@ -280,7 +281,9 @@ pub fn write_gameplay_report() {
280
  frames
281
  ));
282
  } else {
283
- let _ = log_validation("[GAMEPLAY FAIL] Game initialized - no frames processed (game may not have started)");
 
 
284
  }
285
 
286
  // Milestone 2: Match started (multiple teams with alive worms)
@@ -291,7 +294,8 @@ pub fn write_gameplay_report() {
291
  teams
292
  ));
293
  } else if frames > 0 {
294
- let _ = log_validation("[GAMEPLAY FAIL] Match started - never detected multiple alive teams");
 
295
  } else {
296
  let _ = log_validation("[GAMEPLAY FAIL] Match started - game never initialized");
297
  }
@@ -300,13 +304,18 @@ pub fn write_gameplay_report() {
300
  if completed {
301
  let end_frame = COMPLETION_FRAME.load(Ordering::Relaxed);
302
  let alive = ALIVE_AT_END.load(Ordering::Relaxed);
303
- let outcome = if alive == 1 { "winner decided" } else { "draw (all eliminated)" };
 
 
 
 
304
  let _ = log_validation(&format!(
305
  "[GAMEPLAY PASS] Match completed - {} at frame {}",
306
  outcome, end_frame
307
  ));
308
  } else if started {
309
- let _ = log_validation("[GAMEPLAY FAIL] Match completed - match started but never finished");
 
310
  } else {
311
  let _ = log_validation("[GAMEPLAY FAIL] Match completed - match never started");
312
  }
 
188
  crate::debug_sync::on_frame_start(game_frame);
189
 
190
  // Hardware watchpoint: arm once at the watch frame
191
+ static WATCH_ARMED: core::sync::atomic::AtomicBool =
192
+ core::sync::atomic::AtomicBool::new(false);
193
  if !WATCH_ARMED.load(Ordering::Relaxed) {
194
  if let Ok(val) = std::env::var("OPENWA_WATCH_FRAME") {
195
  let target: i32 = val.parse().unwrap_or(0);
 
281
  frames
282
  ));
283
  } else {
284
+ let _ = log_validation(
285
+ "[GAMEPLAY FAIL] Game initialized - no frames processed (game may not have started)",
286
+ );
287
  }
288
 
289
  // Milestone 2: Match started (multiple teams with alive worms)
 
294
  teams
295
  ));
296
  } else if frames > 0 {
297
+ let _ =
298
+ log_validation("[GAMEPLAY FAIL] Match started - never detected multiple alive teams");
299
  } else {
300
  let _ = log_validation("[GAMEPLAY FAIL] Match started - game never initialized");
301
  }
 
304
  if completed {
305
  let end_frame = COMPLETION_FRAME.load(Ordering::Relaxed);
306
  let alive = ALIVE_AT_END.load(Ordering::Relaxed);
307
+ let outcome = if alive == 1 {
308
+ "winner decided"
309
+ } else {
310
+ "draw (all eliminated)"
311
+ };
312
  let _ = log_validation(&format!(
313
  "[GAMEPLAY PASS] Match completed - {} at frame {}",
314
  outcome, end_frame
315
  ));
316
  } else if started {
317
+ let _ =
318
+ log_validation("[GAMEPLAY FAIL] Match completed - match started but never finished");
319
  } else {
320
  let _ = log_validation("[GAMEPLAY FAIL] Match completed - match never started");
321
  }
crates/openwa-wormkit/src/replacements/render.rs CHANGED
@@ -413,11 +413,10 @@ unsafe extern "stdcall" fn draw_bungee_trail_impl(task_ptr: u32, style: u32, fil
413
  let seg_angle = *(seg_data.add(4 + i as usize * 8) as *const i32);
414
 
415
  // Include vertex if: first segment, or segment has nonzero angle, or fill mode
416
- if (i == 0 || seg_angle != 0 || fill != 0)
417
- && vert_count < MAX_VERTICES {
418
- verts[vert_count] = [x, y, 0];
419
- vert_count += 1;
420
- }
421
 
422
  accumulated_angle = accumulated_angle.wrapping_add(seg_angle as u32);
423
 
 
413
  let seg_angle = *(seg_data.add(4 + i as usize * 8) as *const i32);
414
 
415
  // Include vertex if: first segment, or segment has nonzero angle, or fill mode
416
+ if (i == 0 || seg_angle != 0 || fill != 0) && vert_count < MAX_VERTICES {
417
+ verts[vert_count] = [x, y, 0];
418
+ vert_count += 1;
419
+ }
 
420
 
421
  accumulated_angle = accumulated_angle.wrapping_add(seg_angle as u32);
422
 
crates/openwa-wormkit/src/replacements/replay.rs CHANGED
@@ -7,8 +7,8 @@ use crate::hook;
7
  use crate::log_line;
8
  use openwa_core::address::va;
9
  use openwa_core::engine::game_info::GameInfo;
10
- use openwa_core::frontend::MapView;
11
  use openwa_core::engine::replay::{self, ReplayError, ReplayStream, ReplayTeamEntry};
 
12
  use openwa_core::rebase::rb;
13
  use openwa_core::wa_alloc::{wa_free, wa_malloc};
14
 
@@ -48,10 +48,16 @@ unsafe fn wa_file_to_rust(file: *mut FILE) -> Option<ManuallyDrop<File>> {
48
  core::mem::transmute(rb(va::WA_GET_OSFHANDLE));
49
 
50
  let fd = fileno(file);
51
- if fd < 0 { return None; }
 
 
52
  let handle = get_osfhandle(fd);
53
- if handle == -1 || handle == -2 { return None; }
54
- Some(ManuallyDrop::new(File::from_raw_handle(handle as *mut core::ffi::c_void)))
 
 
 
 
55
  }
56
 
57
  /// Load a WA string resource by ID (stdcall). Returns null-terminated C string.
@@ -71,10 +77,16 @@ static mut REPLAY_TIMESTAMP: u32 = 0;
71
 
72
  // ─── RAII cleanup guard (for wa_malloc'd payload) ───────────────────────────
73
 
74
- struct PayloadGuard { payload: *mut u8 }
 
 
75
  impl Drop for PayloadGuard {
76
  fn drop(&mut self) {
77
- unsafe { if !self.payload.is_null() { wa_free(self.payload); } }
 
 
 
 
78
  }
79
  }
80
 
@@ -96,7 +108,9 @@ unsafe extern "stdcall" fn hook_replay_loader(state: *mut GameInfo, mode: i32) -
96
 
97
  unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
98
  let artclass_counter = *(rb(va::G_ARTCLASS_COUNTER) as *const i32);
99
- if artclass_counter >= 0x34 { return Err(ReplayError::ArtClassLimit); }
 
 
100
  (*gi).replay_active = 1;
101
  (*gi).replay_field_ef60 = 0;
102
 
@@ -113,22 +127,29 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
113
  }
114
  };
115
 
116
- let mut file = File::open(&replay_path)
117
- .map_err(|_| ReplayError::FileNotFound)?;
118
- let file_size = file.metadata().map_err(|_| ReplayError::FileNotFound)?.len();
 
 
119
 
120
  // Helper: read exact bytes from file into a typed value
121
  fn read_u32(f: &mut File) -> Result<u32, ReplayError> {
122
  let mut buf = [0u8; 4];
123
- f.read_exact(&mut buf).map_err(|_| ReplayError::InvalidFormat)?;
 
124
  Ok(u32::from_le_bytes(buf))
125
  }
126
 
127
  // Header
128
  let header = read_u32(&mut file)?;
129
- if (header & 0xFFFF) != replay::REPLAY_MAGIC as u32 { return Err(ReplayError::InvalidFormat); }
 
 
130
  let version = header >> 16;
131
- if version == 0 || version > 20 { return Err(ReplayError::VersionTooNew); }
 
 
132
 
133
  (*gi).replay_format_version = version;
134
  (*gi).replay_format_version_2 = version;
@@ -136,12 +157,17 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
136
 
137
  // First payload
138
  let payload_size = read_u32(&mut file)?;
139
- if (payload_size as u64 + 8) > file_size { return Err(ReplayError::InvalidFormat); }
 
 
140
  let payload = wa_malloc(payload_size);
141
- if payload.is_null() { return Err(ReplayError::MallocFailure); }
 
 
142
  let mut guard = PayloadGuard { payload };
143
  let payload_slice = core::slice::from_raw_parts_mut(payload, payload_size as usize);
144
- file.read_exact(payload_slice).map_err(|_| ReplayError::FileNotFound)?;
 
145
 
146
  let first_dword = *(payload as *const i32);
147
  (*gi).replay_map_type = first_dword;
@@ -156,24 +182,25 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
156
  *(payload.add(8) as *const i32);
157
  } else if first_dword == -2 {
158
  let n = payload_size.saturating_sub(8) as usize;
159
- if n > 0x20 { return Err(ReplayError::InvalidFormat); }
160
- ptr::copy_nonoverlapping(
161
- payload.add(8),
162
- (*gi).replay_payload_extra.as_mut_ptr(),
163
- n,
164
- );
165
  // Null-terminate: write 0 at replay_map_type + payload_size offset
166
  // (the original uses base+0xDB1C+payload_size which spans into replay_payload_extra)
167
  *((gi as *mut u8).add(0xDB1C + payload_size as usize)) = 0;
168
  }
169
  }
170
- wa_free(payload); guard.payload = ptr::null_mut();
 
171
 
172
  // Clear global buffers
173
  ptr::write_bytes(rb(va::G_TEAM_HEADER_DATA) as *mut u8, 0, 0x5728);
174
  ptr::write_bytes(rb(va::G_TEAM_SECONDARY_DATA) as *mut u8, 0, 0xD9DC);
175
 
176
- let _ = log_line(&format!("[Replay] Header: ver={version} payload={payload_size} sub={first_dword}"));
 
 
177
 
178
  if version == 1 {
179
  drop(file);
@@ -183,18 +210,24 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
183
  // ─── Version 2+: read second payload ─────────────────────────────────
184
 
185
  let second_size = read_u32(&mut file)?;
186
- if (4u64 + 4 + payload_size as u64 + 4 + second_size as u64) > file_size { return Err(ReplayError::InvalidFormat); }
 
 
187
  let p2 = wa_malloc(second_size);
188
- if p2.is_null() { return Err(ReplayError::MallocFailure); }
 
 
189
  guard.payload = p2;
190
  let p2_slice = core::slice::from_raw_parts_mut(p2, second_size as usize);
191
- file.read_exact(p2_slice).map_err(|_| ReplayError::InvalidFormat)?;
 
192
  drop(file); // Done reading β€” close replay file
193
 
194
  // Parse and write to globals
195
  let data = core::slice::from_raw_parts(p2, second_size as usize);
196
  let result = parse_and_write_v2plus(gi, data, version);
197
- wa_free(p2); guard.payload = ptr::null_mut();
 
198
 
199
  match result {
200
  Ok(()) => {
@@ -209,7 +242,9 @@ unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
209
  Ok(())
210
  }
211
  Err(e) => {
212
- let _ = log_line(&format!("[Replay] Parse failed ({e:?}), falling back to original"));
 
 
213
  delegate_to_original(gi)
214
  }
215
  }
@@ -220,22 +255,33 @@ unsafe fn parse_version1(gi: *mut GameInfo) -> Result<(), ReplayError> {
220
  }
221
 
222
  unsafe fn delegate_to_original(gi: *mut GameInfo) -> Result<(), ReplayError> {
223
- let orig: unsafe extern "stdcall" fn(*mut GameInfo, i32) -> u32 = core::mem::transmute(REPLAY_LOADER_ORIG);
 
224
  let result = orig(gi, 1);
225
- if result == 0 { Ok(()) } else { Err(ReplayError::FileNotFound) }
 
 
 
 
226
  }
227
 
228
  // ─── Version 2+ parser that writes to globals ───────────────────────────────
229
 
230
  /// Helper: write byte to rebased global address.
231
  #[inline]
232
- unsafe fn wb(addr: u32, val: u8) { *(rb(addr) as *mut u8) = val; }
 
 
233
  /// Helper: write u32 to rebased global address.
234
  #[inline]
235
- unsafe fn wd(addr: u32, val: u32) { *(rb(addr) as *mut u32) = val; }
 
 
236
 
237
  unsafe fn parse_and_write_v2plus(
238
- gi: *mut GameInfo, data: &[u8], version: u32,
 
 
239
  ) -> Result<(), ReplayError> {
240
  let mut s = ReplayStream::new(data);
241
 
@@ -250,7 +296,9 @@ unsafe fn parse_and_write_v2plus(
250
  if version >= 10 {
251
  let sub_format = s.read_u16()?;
252
  wd(va::G_REPLAY_SUB_FORMAT, sub_format as u32);
253
- if sub_format != 0 { return Err(ReplayError::VersionTooNew); }
 
 
254
 
255
  if version >= 12 {
256
  if version < 18 {
@@ -260,7 +308,8 @@ unsafe fn parse_and_write_v2plus(
260
  let raw = s.read_u8_validated(0, 3)?;
261
  if (2..=3).contains(&raw) {
262
  wb(va::G_REPLAY_GAME_MODE, raw - 1);
263
- wb(va::G_REPLAY_VER_FLAG_A, 1); wb(va::G_REPLAY_VER_FLAG_B, 1);
 
264
  } else {
265
  wb(va::G_REPLAY_VER_FLAG_A, (raw != 0) as u8);
266
  wb(va::G_REPLAY_GAME_MODE, 0);
@@ -284,7 +333,9 @@ unsafe fn parse_and_write_v2plus(
284
  let obs_data: [u32; 4] = [team_id, 0, obs_type as u32, 0];
285
  // RegisterObserver: usercall(ESI=0x88C35C) + stdcall(1 param = &data). RET 0x4.
286
  call_register_observer(
287
- rb(va::G_OBSERVER_ARRAY), obs_data.as_ptr() as u32, rb(va::REPLAY_REGISTER_OBSERVER),
 
 
288
  );
289
  if obs_type == 0 {
290
  // The terminating entry's team_id is the replay timestamp (time_t)
@@ -296,7 +347,9 @@ unsafe fn parse_and_write_v2plus(
296
 
297
  let game_version_id = s.read_i32()?;
298
  wd(va::G_REPLAY_VERSION_ID, game_version_id as u32);
299
- if (game_version_id.wrapping_add(4) as u32) > 0x1F8 { return Err(ReplayError::VersionTooNew); }
 
 
300
  let use_fixed_names = game_version_id < 10;
301
 
302
  // ── Scheme presence ──────────────────────────────────────────────────
@@ -331,7 +384,9 @@ unsafe fn parse_and_write_v2plus(
331
  // SCHM magic + version
332
  let magic = s.read_u32()?;
333
  let scheme_version = s.read_u8()?;
334
- if magic != 0x4D484353 { return Err(ReplayError::InvalidFormat); }
 
 
335
 
336
  let scheme_data_size = match scheme_version {
337
  1 => 0xD8_usize,
@@ -343,7 +398,8 @@ unsafe fn parse_and_write_v2plus(
343
  // Copy defaults first for v3
344
  ptr::copy_nonoverlapping(
345
  rb(va::SCHEME_V3_DEFAULTS) as *const u8,
346
- rb(va::G_SCHEME_V3_DATA) as *mut u8, 0x6E,
 
347
  );
348
  (scheme_size_indicator as usize) - 5
349
  }
@@ -352,7 +408,11 @@ unsafe fn parse_and_write_v2plus(
352
 
353
  // Copy scheme data from stream to global 0x88DAE0
354
  let scheme_slice = s.advance_raw(scheme_data_size)?;
355
- ptr::copy_nonoverlapping(scheme_slice.as_ptr(), rb(va::G_SCHEME_DATA) as *mut u8, scheme_data_size);
 
 
 
 
356
 
357
  let header_val = *(rb(va::G_SCHEME_HEADER) as *const i8);
358
 
@@ -366,7 +426,8 @@ unsafe fn parse_and_write_v2plus(
366
  if scheme_version <= 2 {
367
  ptr::copy_nonoverlapping(
368
  rb(va::SCHEME_V3_DEFAULTS) as *const u8,
369
- rb(va::G_SCHEME_V3_DATA) as *mut u8, 0x6E,
 
370
  );
371
  }
372
  }
@@ -376,7 +437,9 @@ unsafe fn parse_and_write_v2plus(
376
  let validate: unsafe extern "cdecl" fn() -> i32 =
377
  core::mem::transmute(rb(va::SCHEME_VALIDATE_EXTENDED));
378
  let r = validate();
379
- if r != 0 { return Err(ReplayError::InvalidFormat); }
 
 
380
  }
381
 
382
  // Random seed: the stream value REPLACES the current seed.
@@ -415,22 +478,36 @@ unsafe fn parse_and_write_v2plus(
415
  let base = va::G_PLAYER_ARRAY + 0x74 + i * 0x78; // player[i].flag
416
  let flag = s.read_u8()?;
417
  wb(base, flag);
418
- if flag == 0 { continue; }
 
 
419
 
420
  // NOTE: original checks if i == host_player_index here (sets a local flag, unused)
421
  player_count += 1;
422
 
423
  let mut name = [0u8; 0x11];
424
  s.read_prefixed_string(&mut name)?;
425
- ptr::copy_nonoverlapping(name.as_ptr(), rb(va::G_PLAYER_ARRAY + i * 0x78) as *mut u8, 0x11);
 
 
 
 
426
 
427
  let mut display = [0u8; 0x31];
428
  s.read_prefixed_string(&mut display)?;
429
- ptr::copy_nonoverlapping(display.as_ptr(), rb(va::G_PLAYER_ARRAY + 0x11 + i * 0x78) as *mut u8, 0x31);
 
 
 
 
430
 
431
  let mut config = [0u8; 0x29];
432
  s.read_prefixed_string(&mut config)?;
433
- ptr::copy_nonoverlapping(config.as_ptr(), rb(va::G_PLAYER_ARRAY + 0x42 + i * 0x78) as *mut u8, 0x29);
 
 
 
 
434
 
435
  let u16_val = s.read_u16()?;
436
  // Ghidra shows (&DAT[i*0x3C]) with u16 elements β†’ byte offset = i*0x3C*2 = i*0x78
@@ -460,11 +537,15 @@ unsafe fn parse_and_write_v2plus(
460
  let team = &mut *teams.add(team_idx);
461
  let team_flag = s.read_u8()?;
462
  team.flag = team_flag;
463
- if team_flag == 0 { continue; }
 
 
464
  team_count += 1;
465
-
466
  let team_type = s.read_u8()? as i8;
467
- if !replay::validate_team_type(team_type) { return Err(ReplayError::InvalidFormat); }
 
 
468
  team.team_type = team_type as u8;
469
  team.alliance = s.read_u8_validated(0, 5)?;
470
  team.unknown_02 = s.read_u8()?;
@@ -496,7 +577,9 @@ unsafe fn parse_and_write_v2plus(
496
  s.read_prefixed_string(&mut team.config_name)?;
497
 
498
  let worm_count = s.read_u8()?;
499
- if worm_count == 0 || worm_count > 8 { return Err(ReplayError::InvalidFormat); }
 
 
500
  team.worm_count = worm_count;
501
  team.color = s.read_u8()?;
502
  team.flag2 = s.read_u8()?;
@@ -515,7 +598,9 @@ unsafe fn parse_and_write_v2plus(
515
  ptr::copy_nonoverlapping(w4.as_ptr(), team.weapons.as_mut_ptr().add(0x954), 0x300);
516
  }
517
 
518
- if team_count == 0 { return Err(ReplayError::InvalidFormat); }
 
 
519
 
520
  wb(va::G_TEAM_COUNT, team_count);
521
 
@@ -538,7 +623,10 @@ unsafe fn parse_and_write_v2plus(
538
  if map_seed != 0 {
539
  // Per-team weapon config reads from stream β€” not yet implemented in Rust.
540
  // Fall back to the original parser which handles this from scratch.
541
- let _ = log_line(&format!("[Replay] Non-zero map_seed (0x{:04X}), delegating to original", map_seed));
 
 
 
542
  return Err(ReplayError::InvalidFormat); // triggers delegate_to_original
543
  }
544
 
@@ -554,8 +642,8 @@ unsafe fn parse_and_write_v2plus(
554
  srand(current_seed);
555
  let r1 = rand_fn() as u32;
556
  let r2 = rand_fn() as u32;
557
- wd(va::G_RANDOM_SEED, r2 + (r1 << 16)); // new seed
558
- wd(va::G_SAVED_RANDOM_SEED, current_seed); // save old seed
559
 
560
  let ver = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
561
  if ver != 0x22 && !(0x29..=0x2A).contains(&ver) && ver < 0x2D {
@@ -610,8 +698,6 @@ unsafe fn parse_and_write_v2plus(
610
 
611
  // ─── Log output formatting ──────────────────────────────────────────────────
612
 
613
-
614
-
615
  /// Write the /getlog replay header to the log file.
616
  unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(), ReplayError> {
617
  use std::io::Write as IoWrite;
@@ -635,9 +721,15 @@ unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(
635
  if !tm.is_null() {
636
  let tm = &*tm;
637
  let mut s = heapless::String::<128>::new();
638
- let _ = writeln!(s,
 
639
  "Game Started at {:04}-{:02}-{:02} {:02}:{:02}:{:02} GMT",
640
- tm[5] + 1900, tm[4] + 1, tm[3], tm[2], tm[1], tm[0]
 
 
 
 
 
641
  );
642
  let _ = log_file.write_all(s.as_bytes());
643
  }
@@ -657,7 +749,7 @@ unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(
657
  -4 => rb(0x650EAC) as *const u8, // "1.0"
658
  -2 => rb(0x650EB0) as *const u8, // "3.0"
659
  -1 => rb(0x650EB4) as *const u8, // "3.5 Beta 1"
660
- _ => wa_load_string(0x0F), // "Unknown"
661
  }
662
  } else {
663
  let table = rb(va::VERSION_STRING_TABLE) as *const u32;
@@ -684,7 +776,7 @@ unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(
684
  // ── "Exported with Version" line ─────────────────────────────────────
685
  {
686
  let label_exported = wa_load_string(0x6E9); // "Exported with Version"
687
- // Current WA version from version table: 0x699814[DAT_00697702]
688
  let ver_byte = *(rb(va::G_VERSION_BYTE) as *const u8) as u32;
689
  // "3.8.1" literal at 0x641C60 + suffix from version table
690
  let ver_literal = rb(va::STR_VERSION_381) as *const u8; // "3.8.1"
@@ -699,7 +791,6 @@ unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(
699
  let _ = log_file.write_all(s.as_bytes());
700
  }
701
 
702
-
703
  // ── Team listing ─────────────────────────────────────────────────────
704
  // For each team: "Color: "TeamName" [CPU X.XX]\n" or similar
705
 
@@ -718,24 +809,36 @@ unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(
718
  let mut max_name_len = 0usize;
719
  for slot in 0..6usize {
720
  let team = &*teams.add(slot);
721
- if team.flag == 0 { continue; }
 
 
722
  let ci = team.alliance as usize;
723
  if ci < 6 {
724
  let len = c_strlen(color_names[ci]);
725
- if len > max_color_len { max_color_len = len; }
 
 
726
  }
727
  let name_len = c_strlen(team.config_abbrev.as_ptr());
728
- if name_len > max_name_len { max_name_len = name_len; }
 
 
729
  }
730
 
731
  for slot in 0..6usize {
732
  let team = &*teams.add(slot);
733
- if team.flag == 0 { continue; }
 
 
734
 
735
  let team_type = team.team_type as i8;
736
  let color_idx = team.alliance as usize;
737
 
738
- let color = if color_idx < 6 { color_names[color_idx] } else { color_names[0] };
 
 
 
 
739
  let clen = c_strlen(color);
740
 
741
  // The displayed team name comes from the config_abbrev field
@@ -798,7 +901,9 @@ unsafe fn push_cstr<const N: usize>(s: &mut heapless::String<N>, cstr: *const u8
798
  /// Length of a null-terminated C string.
799
  unsafe fn c_strlen(s: *const u8) -> usize {
800
  let mut len = 0;
801
- while *s.add(len) != 0 { len += 1; }
 
 
802
  len
803
  }
804
 
@@ -808,8 +913,8 @@ unsafe fn c_strlen(s: *const u8) -> usize {
808
  #[unsafe(naked)]
809
  unsafe extern "cdecl" fn call_usercall_eax(_eax_val: *mut GameInfo, _func: u32) {
810
  core::arch::naked_asm!(
811
- "mov eax, [esp+4]", // eax_val
812
- "mov ecx, [esp+8]", // func_addr
813
  "call ecx",
814
  "ret",
815
  );
@@ -821,8 +926,8 @@ unsafe extern "cdecl" fn call_usercall_esi(_esi_val: u32, _func: u32) {
821
  core::arch::naked_asm!(
822
  "push esi",
823
  "push edi",
824
- "mov esi, [esp+12]", // esi_val
825
- "mov eax, [esp+16]", // func_addr
826
  "call eax",
827
  "pop edi",
828
  "pop esi",
@@ -833,16 +938,14 @@ unsafe extern "cdecl" fn call_usercall_esi(_esi_val: u32, _func: u32) {
833
  /// Bridge for RegisterObserver: usercall(ESI=array) + stdcall(1 param=data_ptr).
834
  /// cdecl(esi_val, data_ptr, func_addr).
835
  #[unsafe(naked)]
836
- unsafe extern "cdecl" fn call_register_observer(
837
- _esi_val: u32, _data_ptr: u32, _func: u32,
838
- ) {
839
  core::arch::naked_asm!(
840
  "push esi",
841
  "push edi",
842
- "mov esi, [esp+12]", // esi_val (0x88C35C rebased)
843
- "push [esp+16]", // data_ptr β†’ stdcall param
844
- "mov eax, [esp+24]", // func_addr (shifted by push)
845
- "call eax", // stdcall cleans 1 param (4 bytes)
846
  "pop edi",
847
  "pop esi",
848
  "ret",
@@ -860,7 +963,12 @@ unsafe fn call_process_scheme_defaults(gi: *mut GameInfo, func: u32) {
860
 
861
  unsafe extern "stdcall" fn hook_parse_replay_position(input: *const u8) -> i32 {
862
  let mut len = 0usize;
863
- while *input.add(len) != 0 { len += 1; if len > 256 { break; } }
 
 
 
 
 
864
  let slice = core::slice::from_raw_parts(input, len + 1);
865
  replay::parse_replay_position(slice)
866
  }
@@ -869,8 +977,16 @@ unsafe extern "stdcall" fn hook_parse_replay_position(input: *const u8) -> i32 {
869
 
870
  pub fn install() -> Result<(), String> {
871
  unsafe {
872
- REPLAY_LOADER_ORIG = hook::install("ReplayLoader", va::REPLAY_LOADER, hook_replay_loader as *const ())? as *const ();
873
- PARSE_POSITION_ORIG = hook::install("ParseReplayPosition", va::PARSE_REPLAY_POSITION, hook_parse_replay_position as *const ())? as *const ();
 
 
 
 
 
 
 
 
874
  }
875
  Ok(())
876
  }
 
7
  use crate::log_line;
8
  use openwa_core::address::va;
9
  use openwa_core::engine::game_info::GameInfo;
 
10
  use openwa_core::engine::replay::{self, ReplayError, ReplayStream, ReplayTeamEntry};
11
+ use openwa_core::frontend::MapView;
12
  use openwa_core::rebase::rb;
13
  use openwa_core::wa_alloc::{wa_free, wa_malloc};
14
 
 
48
  core::mem::transmute(rb(va::WA_GET_OSFHANDLE));
49
 
50
  let fd = fileno(file);
51
+ if fd < 0 {
52
+ return None;
53
+ }
54
  let handle = get_osfhandle(fd);
55
+ if handle == -1 || handle == -2 {
56
+ return None;
57
+ }
58
+ Some(ManuallyDrop::new(File::from_raw_handle(
59
+ handle as *mut core::ffi::c_void,
60
+ )))
61
  }
62
 
63
  /// Load a WA string resource by ID (stdcall). Returns null-terminated C string.
 
77
 
78
  // ─── RAII cleanup guard (for wa_malloc'd payload) ───────────────────────────
79
 
80
+ struct PayloadGuard {
81
+ payload: *mut u8,
82
+ }
83
  impl Drop for PayloadGuard {
84
  fn drop(&mut self) {
85
+ unsafe {
86
+ if !self.payload.is_null() {
87
+ wa_free(self.payload);
88
+ }
89
+ }
90
  }
91
  }
92
 
 
108
 
109
  unsafe fn replay_loader_play(gi: *mut GameInfo) -> Result<(), ReplayError> {
110
  let artclass_counter = *(rb(va::G_ARTCLASS_COUNTER) as *const i32);
111
+ if artclass_counter >= 0x34 {
112
+ return Err(ReplayError::ArtClassLimit);
113
+ }
114
  (*gi).replay_active = 1;
115
  (*gi).replay_field_ef60 = 0;
116
 
 
127
  }
128
  };
129
 
130
+ let mut file = File::open(&replay_path).map_err(|_| ReplayError::FileNotFound)?;
131
+ let file_size = file
132
+ .metadata()
133
+ .map_err(|_| ReplayError::FileNotFound)?
134
+ .len();
135
 
136
  // Helper: read exact bytes from file into a typed value
137
  fn read_u32(f: &mut File) -> Result<u32, ReplayError> {
138
  let mut buf = [0u8; 4];
139
+ f.read_exact(&mut buf)
140
+ .map_err(|_| ReplayError::InvalidFormat)?;
141
  Ok(u32::from_le_bytes(buf))
142
  }
143
 
144
  // Header
145
  let header = read_u32(&mut file)?;
146
+ if (header & 0xFFFF) != replay::REPLAY_MAGIC as u32 {
147
+ return Err(ReplayError::InvalidFormat);
148
+ }
149
  let version = header >> 16;
150
+ if version == 0 || version > 20 {
151
+ return Err(ReplayError::VersionTooNew);
152
+ }
153
 
154
  (*gi).replay_format_version = version;
155
  (*gi).replay_format_version_2 = version;
 
157
 
158
  // First payload
159
  let payload_size = read_u32(&mut file)?;
160
+ if (payload_size as u64 + 8) > file_size {
161
+ return Err(ReplayError::InvalidFormat);
162
+ }
163
  let payload = wa_malloc(payload_size);
164
+ if payload.is_null() {
165
+ return Err(ReplayError::MallocFailure);
166
+ }
167
  let mut guard = PayloadGuard { payload };
168
  let payload_slice = core::slice::from_raw_parts_mut(payload, payload_size as usize);
169
+ file.read_exact(payload_slice)
170
+ .map_err(|_| ReplayError::FileNotFound)?;
171
 
172
  let first_dword = *(payload as *const i32);
173
  (*gi).replay_map_type = first_dword;
 
182
  *(payload.add(8) as *const i32);
183
  } else if first_dword == -2 {
184
  let n = payload_size.saturating_sub(8) as usize;
185
+ if n > 0x20 {
186
+ return Err(ReplayError::InvalidFormat);
187
+ }
188
+ ptr::copy_nonoverlapping(payload.add(8), (*gi).replay_payload_extra.as_mut_ptr(), n);
 
 
189
  // Null-terminate: write 0 at replay_map_type + payload_size offset
190
  // (the original uses base+0xDB1C+payload_size which spans into replay_payload_extra)
191
  *((gi as *mut u8).add(0xDB1C + payload_size as usize)) = 0;
192
  }
193
  }
194
+ wa_free(payload);
195
+ guard.payload = ptr::null_mut();
196
 
197
  // Clear global buffers
198
  ptr::write_bytes(rb(va::G_TEAM_HEADER_DATA) as *mut u8, 0, 0x5728);
199
  ptr::write_bytes(rb(va::G_TEAM_SECONDARY_DATA) as *mut u8, 0, 0xD9DC);
200
 
201
+ let _ = log_line(&format!(
202
+ "[Replay] Header: ver={version} payload={payload_size} sub={first_dword}"
203
+ ));
204
 
205
  if version == 1 {
206
  drop(file);
 
210
  // ─── Version 2+: read second payload ─────────────────────────────────
211
 
212
  let second_size = read_u32(&mut file)?;
213
+ if (4u64 + 4 + payload_size as u64 + 4 + second_size as u64) > file_size {
214
+ return Err(ReplayError::InvalidFormat);
215
+ }
216
  let p2 = wa_malloc(second_size);
217
+ if p2.is_null() {
218
+ return Err(ReplayError::MallocFailure);
219
+ }
220
  guard.payload = p2;
221
  let p2_slice = core::slice::from_raw_parts_mut(p2, second_size as usize);
222
+ file.read_exact(p2_slice)
223
+ .map_err(|_| ReplayError::InvalidFormat)?;
224
  drop(file); // Done reading β€” close replay file
225
 
226
  // Parse and write to globals
227
  let data = core::slice::from_raw_parts(p2, second_size as usize);
228
  let result = parse_and_write_v2plus(gi, data, version);
229
+ wa_free(p2);
230
+ guard.payload = ptr::null_mut();
231
 
232
  match result {
233
  Ok(()) => {
 
242
  Ok(())
243
  }
244
  Err(e) => {
245
+ let _ = log_line(&format!(
246
+ "[Replay] Parse failed ({e:?}), falling back to original"
247
+ ));
248
  delegate_to_original(gi)
249
  }
250
  }
 
255
  }
256
 
257
  unsafe fn delegate_to_original(gi: *mut GameInfo) -> Result<(), ReplayError> {
258
+ let orig: unsafe extern "stdcall" fn(*mut GameInfo, i32) -> u32 =
259
+ core::mem::transmute(REPLAY_LOADER_ORIG);
260
  let result = orig(gi, 1);
261
+ if result == 0 {
262
+ Ok(())
263
+ } else {
264
+ Err(ReplayError::FileNotFound)
265
+ }
266
  }
267
 
268
  // ─── Version 2+ parser that writes to globals ───────────────────────────────
269
 
270
  /// Helper: write byte to rebased global address.
271
  #[inline]
272
+ unsafe fn wb(addr: u32, val: u8) {
273
+ *(rb(addr) as *mut u8) = val;
274
+ }
275
  /// Helper: write u32 to rebased global address.
276
  #[inline]
277
+ unsafe fn wd(addr: u32, val: u32) {
278
+ *(rb(addr) as *mut u32) = val;
279
+ }
280
 
281
  unsafe fn parse_and_write_v2plus(
282
+ gi: *mut GameInfo,
283
+ data: &[u8],
284
+ version: u32,
285
  ) -> Result<(), ReplayError> {
286
  let mut s = ReplayStream::new(data);
287
 
 
296
  if version >= 10 {
297
  let sub_format = s.read_u16()?;
298
  wd(va::G_REPLAY_SUB_FORMAT, sub_format as u32);
299
+ if sub_format != 0 {
300
+ return Err(ReplayError::VersionTooNew);
301
+ }
302
 
303
  if version >= 12 {
304
  if version < 18 {
 
308
  let raw = s.read_u8_validated(0, 3)?;
309
  if (2..=3).contains(&raw) {
310
  wb(va::G_REPLAY_GAME_MODE, raw - 1);
311
+ wb(va::G_REPLAY_VER_FLAG_A, 1);
312
+ wb(va::G_REPLAY_VER_FLAG_B, 1);
313
  } else {
314
  wb(va::G_REPLAY_VER_FLAG_A, (raw != 0) as u8);
315
  wb(va::G_REPLAY_GAME_MODE, 0);
 
333
  let obs_data: [u32; 4] = [team_id, 0, obs_type as u32, 0];
334
  // RegisterObserver: usercall(ESI=0x88C35C) + stdcall(1 param = &data). RET 0x4.
335
  call_register_observer(
336
+ rb(va::G_OBSERVER_ARRAY),
337
+ obs_data.as_ptr() as u32,
338
+ rb(va::REPLAY_REGISTER_OBSERVER),
339
  );
340
  if obs_type == 0 {
341
  // The terminating entry's team_id is the replay timestamp (time_t)
 
347
 
348
  let game_version_id = s.read_i32()?;
349
  wd(va::G_REPLAY_VERSION_ID, game_version_id as u32);
350
+ if (game_version_id.wrapping_add(4) as u32) > 0x1F8 {
351
+ return Err(ReplayError::VersionTooNew);
352
+ }
353
  let use_fixed_names = game_version_id < 10;
354
 
355
  // ── Scheme presence ──────────────────────────────────────────────────
 
384
  // SCHM magic + version
385
  let magic = s.read_u32()?;
386
  let scheme_version = s.read_u8()?;
387
+ if magic != 0x4D484353 {
388
+ return Err(ReplayError::InvalidFormat);
389
+ }
390
 
391
  let scheme_data_size = match scheme_version {
392
  1 => 0xD8_usize,
 
398
  // Copy defaults first for v3
399
  ptr::copy_nonoverlapping(
400
  rb(va::SCHEME_V3_DEFAULTS) as *const u8,
401
+ rb(va::G_SCHEME_V3_DATA) as *mut u8,
402
+ 0x6E,
403
  );
404
  (scheme_size_indicator as usize) - 5
405
  }
 
408
 
409
  // Copy scheme data from stream to global 0x88DAE0
410
  let scheme_slice = s.advance_raw(scheme_data_size)?;
411
+ ptr::copy_nonoverlapping(
412
+ scheme_slice.as_ptr(),
413
+ rb(va::G_SCHEME_DATA) as *mut u8,
414
+ scheme_data_size,
415
+ );
416
 
417
  let header_val = *(rb(va::G_SCHEME_HEADER) as *const i8);
418
 
 
426
  if scheme_version <= 2 {
427
  ptr::copy_nonoverlapping(
428
  rb(va::SCHEME_V3_DEFAULTS) as *const u8,
429
+ rb(va::G_SCHEME_V3_DATA) as *mut u8,
430
+ 0x6E,
431
  );
432
  }
433
  }
 
437
  let validate: unsafe extern "cdecl" fn() -> i32 =
438
  core::mem::transmute(rb(va::SCHEME_VALIDATE_EXTENDED));
439
  let r = validate();
440
+ if r != 0 {
441
+ return Err(ReplayError::InvalidFormat);
442
+ }
443
  }
444
 
445
  // Random seed: the stream value REPLACES the current seed.
 
478
  let base = va::G_PLAYER_ARRAY + 0x74 + i * 0x78; // player[i].flag
479
  let flag = s.read_u8()?;
480
  wb(base, flag);
481
+ if flag == 0 {
482
+ continue;
483
+ }
484
 
485
  // NOTE: original checks if i == host_player_index here (sets a local flag, unused)
486
  player_count += 1;
487
 
488
  let mut name = [0u8; 0x11];
489
  s.read_prefixed_string(&mut name)?;
490
+ ptr::copy_nonoverlapping(
491
+ name.as_ptr(),
492
+ rb(va::G_PLAYER_ARRAY + i * 0x78) as *mut u8,
493
+ 0x11,
494
+ );
495
 
496
  let mut display = [0u8; 0x31];
497
  s.read_prefixed_string(&mut display)?;
498
+ ptr::copy_nonoverlapping(
499
+ display.as_ptr(),
500
+ rb(va::G_PLAYER_ARRAY + 0x11 + i * 0x78) as *mut u8,
501
+ 0x31,
502
+ );
503
 
504
  let mut config = [0u8; 0x29];
505
  s.read_prefixed_string(&mut config)?;
506
+ ptr::copy_nonoverlapping(
507
+ config.as_ptr(),
508
+ rb(va::G_PLAYER_ARRAY + 0x42 + i * 0x78) as *mut u8,
509
+ 0x29,
510
+ );
511
 
512
  let u16_val = s.read_u16()?;
513
  // Ghidra shows (&DAT[i*0x3C]) with u16 elements β†’ byte offset = i*0x3C*2 = i*0x78
 
537
  let team = &mut *teams.add(team_idx);
538
  let team_flag = s.read_u8()?;
539
  team.flag = team_flag;
540
+ if team_flag == 0 {
541
+ continue;
542
+ }
543
  team_count += 1;
544
+
545
  let team_type = s.read_u8()? as i8;
546
+ if !replay::validate_team_type(team_type) {
547
+ return Err(ReplayError::InvalidFormat);
548
+ }
549
  team.team_type = team_type as u8;
550
  team.alliance = s.read_u8_validated(0, 5)?;
551
  team.unknown_02 = s.read_u8()?;
 
577
  s.read_prefixed_string(&mut team.config_name)?;
578
 
579
  let worm_count = s.read_u8()?;
580
+ if worm_count == 0 || worm_count > 8 {
581
+ return Err(ReplayError::InvalidFormat);
582
+ }
583
  team.worm_count = worm_count;
584
  team.color = s.read_u8()?;
585
  team.flag2 = s.read_u8()?;
 
598
  ptr::copy_nonoverlapping(w4.as_ptr(), team.weapons.as_mut_ptr().add(0x954), 0x300);
599
  }
600
 
601
+ if team_count == 0 {
602
+ return Err(ReplayError::InvalidFormat);
603
+ }
604
 
605
  wb(va::G_TEAM_COUNT, team_count);
606
 
 
623
  if map_seed != 0 {
624
  // Per-team weapon config reads from stream β€” not yet implemented in Rust.
625
  // Fall back to the original parser which handles this from scratch.
626
+ let _ = log_line(&format!(
627
+ "[Replay] Non-zero map_seed (0x{:04X}), delegating to original",
628
+ map_seed
629
+ ));
630
  return Err(ReplayError::InvalidFormat); // triggers delegate_to_original
631
  }
632
 
 
642
  srand(current_seed);
643
  let r1 = rand_fn() as u32;
644
  let r2 = rand_fn() as u32;
645
+ wd(va::G_RANDOM_SEED, r2 + (r1 << 16)); // new seed
646
+ wd(va::G_SAVED_RANDOM_SEED, current_seed); // save old seed
647
 
648
  let ver = *(rb(va::G_REPLAY_VERSION_ID) as *const i32);
649
  if ver != 0x22 && !(0x29..=0x2A).contains(&ver) && ver < 0x2D {
 
698
 
699
  // ─── Log output formatting ──────────────────────────────────────────────────
700
 
 
 
701
  /// Write the /getlog replay header to the log file.
702
  unsafe fn write_replay_log(gi: *const GameInfo, log_file: &mut File) -> Result<(), ReplayError> {
703
  use std::io::Write as IoWrite;
 
721
  if !tm.is_null() {
722
  let tm = &*tm;
723
  let mut s = heapless::String::<128>::new();
724
+ let _ = writeln!(
725
+ s,
726
  "Game Started at {:04}-{:02}-{:02} {:02}:{:02}:{:02} GMT",
727
+ tm[5] + 1900,
728
+ tm[4] + 1,
729
+ tm[3],
730
+ tm[2],
731
+ tm[1],
732
+ tm[0]
733
  );
734
  let _ = log_file.write_all(s.as_bytes());
735
  }
 
749
  -4 => rb(0x650EAC) as *const u8, // "1.0"
750
  -2 => rb(0x650EB0) as *const u8, // "3.0"
751
  -1 => rb(0x650EB4) as *const u8, // "3.5 Beta 1"
752
+ _ => wa_load_string(0x0F), // "Unknown"
753
  }
754
  } else {
755
  let table = rb(va::VERSION_STRING_TABLE) as *const u32;
 
776
  // ── "Exported with Version" line ─────────────────────────────────────
777
  {
778
  let label_exported = wa_load_string(0x6E9); // "Exported with Version"
779
+ // Current WA version from version table: 0x699814[DAT_00697702]
780
  let ver_byte = *(rb(va::G_VERSION_BYTE) as *const u8) as u32;
781
  // "3.8.1" literal at 0x641C60 + suffix from version table
782
  let ver_literal = rb(va::STR_VERSION_381) as *const u8; // "3.8.1"
 
791
  let _ = log_file.write_all(s.as_bytes());
792
  }
793
 
 
794
  // ── Team listing ─────────────────────────────────────────────────────
795
  // For each team: "Color: "TeamName" [CPU X.XX]\n" or similar
796
 
 
809
  let mut max_name_len = 0usize;
810
  for slot in 0..6usize {
811
  let team = &*teams.add(slot);
812
+ if team.flag == 0 {
813
+ continue;
814
+ }
815
  let ci = team.alliance as usize;
816
  if ci < 6 {
817
  let len = c_strlen(color_names[ci]);
818
+ if len > max_color_len {
819
+ max_color_len = len;
820
+ }
821
  }
822
  let name_len = c_strlen(team.config_abbrev.as_ptr());
823
+ if name_len > max_name_len {
824
+ max_name_len = name_len;
825
+ }
826
  }
827
 
828
  for slot in 0..6usize {
829
  let team = &*teams.add(slot);
830
+ if team.flag == 0 {
831
+ continue;
832
+ }
833
 
834
  let team_type = team.team_type as i8;
835
  let color_idx = team.alliance as usize;
836
 
837
+ let color = if color_idx < 6 {
838
+ color_names[color_idx]
839
+ } else {
840
+ color_names[0]
841
+ };
842
  let clen = c_strlen(color);
843
 
844
  // The displayed team name comes from the config_abbrev field
 
901
  /// Length of a null-terminated C string.
902
  unsafe fn c_strlen(s: *const u8) -> usize {
903
  let mut len = 0;
904
+ while *s.add(len) != 0 {
905
+ len += 1;
906
+ }
907
  len
908
  }
909
 
 
913
  #[unsafe(naked)]
914
  unsafe extern "cdecl" fn call_usercall_eax(_eax_val: *mut GameInfo, _func: u32) {
915
  core::arch::naked_asm!(
916
+ "mov eax, [esp+4]", // eax_val
917
+ "mov ecx, [esp+8]", // func_addr
918
  "call ecx",
919
  "ret",
920
  );
 
926
  core::arch::naked_asm!(
927
  "push esi",
928
  "push edi",
929
+ "mov esi, [esp+12]", // esi_val
930
+ "mov eax, [esp+16]", // func_addr
931
  "call eax",
932
  "pop edi",
933
  "pop esi",
 
938
  /// Bridge for RegisterObserver: usercall(ESI=array) + stdcall(1 param=data_ptr).
939
  /// cdecl(esi_val, data_ptr, func_addr).
940
  #[unsafe(naked)]
941
+ unsafe extern "cdecl" fn call_register_observer(_esi_val: u32, _data_ptr: u32, _func: u32) {
 
 
942
  core::arch::naked_asm!(
943
  "push esi",
944
  "push edi",
945
+ "mov esi, [esp+12]", // esi_val (0x88C35C rebased)
946
+ "push [esp+16]", // data_ptr β†’ stdcall param
947
+ "mov eax, [esp+24]", // func_addr (shifted by push)
948
+ "call eax", // stdcall cleans 1 param (4 bytes)
949
  "pop edi",
950
  "pop esi",
951
  "ret",
 
963
 
964
  unsafe extern "stdcall" fn hook_parse_replay_position(input: *const u8) -> i32 {
965
  let mut len = 0usize;
966
+ while *input.add(len) != 0 {
967
+ len += 1;
968
+ if len > 256 {
969
+ break;
970
+ }
971
+ }
972
  let slice = core::slice::from_raw_parts(input, len + 1);
973
  replay::parse_replay_position(slice)
974
  }
 
977
 
978
  pub fn install() -> Result<(), String> {
979
  unsafe {
980
+ REPLAY_LOADER_ORIG = hook::install(
981
+ "ReplayLoader",
982
+ va::REPLAY_LOADER,
983
+ hook_replay_loader as *const (),
984
+ )? as *const ();
985
+ PARSE_POSITION_ORIG = hook::install(
986
+ "ParseReplayPosition",
987
+ va::PARSE_REPLAY_POSITION,
988
+ hook_parse_replay_position as *const (),
989
+ )? as *const ();
990
  }
991
  Ok(())
992
  }
crates/openwa-wormkit/src/replacements/task/cloud.rs CHANGED
@@ -69,7 +69,8 @@ unsafe extern "thiscall" fn cloud_handle_message(
69
  if (*ddgame).render_phase == 5 {
70
  // Compute parallax X offset: (vel_x + wind * 10) * parallax_scale
71
  let scroll_speed = (*this).vel_x.0 + (*this).wind_accel.0 * 10;
72
- let parallax_x = ((scroll_speed as i64 * (*ddgame).parallax_scale as i64) >> 16) as i32;
 
73
  let x = parallax_x + (*this).pos_x.0;
74
 
75
  let rq = &mut *(*ddgame).render_queue;
 
69
  if (*ddgame).render_phase == 5 {
70
  // Compute parallax X offset: (vel_x + wind * 10) * parallax_scale
71
  let scroll_speed = (*this).vel_x.0 + (*this).wind_accel.0 * 10;
72
+ let parallax_x =
73
+ ((scroll_speed as i64 * (*ddgame).parallax_scale as i64) >> 16) as i32;
74
  let x = parallax_x + (*this).pos_x.0;
75
 
76
  let rq = &mut *(*ddgame).render_queue;
crates/openwa-wormkit/src/replacements/weapon_release.rs CHANGED
@@ -98,7 +98,10 @@ fn is_weapon_category_a(weapon: Weapon) -> bool {
98
  /// FUN_00565920: weapon category B β€” fire/napalm weapons.
99
  fn is_weapon_category_b(weapon: Weapon) -> bool {
100
  use Weapon::*;
101
- matches!(weapon, NapalmStrike | FlameThrower | PetrolBomb | SheepStrike)
 
 
 
102
  }
103
 
104
  // ── Main implementation ─────────────────────────────────────
@@ -152,7 +155,7 @@ unsafe extern "cdecl" fn weapon_release_impl(
152
  let special_subtype = (*entry).special_subtype;
153
  let fire_method = (*entry).fire_method;
154
 
155
- use openwa_core::game::weapon::{FireType, FireMethod};
156
 
157
  // ── 3. Spawn offset calculation ─────────────────────────
158
  let scale = w.landscape_scale;
@@ -243,11 +246,7 @@ unsafe extern "cdecl" fn weapon_release_impl(
243
  write_u32(&mut msg_buf, 0x0C, w.shot_data_2);
244
  write_u32(&mut msg_buf, 0x10, w.fire_sync_frame_1 as u32);
245
  write_u32(&mut msg_buf, 0x14, w.fire_sync_frame_2 as u32);
246
- write_u32(
247
- &mut msg_buf,
248
- 0x18,
249
- if w._unknown_2cc != 0 { 1 } else { 0 },
250
- );
251
  write_u32(&mut msg_buf, 0x1C, weapon as u32);
252
 
253
  let team = weapon::lookup_turn_game(worm);
@@ -292,47 +291,63 @@ unsafe extern "cdecl" fn weapon_release_impl(
292
  let entry = w.active_weapon_entry;
293
 
294
  match FireType::try_from((*entry).fire_type) {
295
- Ok(FireType::Projectile) => {
296
- match (*entry).special_subtype {
297
- 1 => {
298
- if w.sound_handle == 0 {
299
- sound::play_worm_sound(worm, SoundId(0x1004E), Fixed::ONE);
300
- }
301
- do_effect = true;
302
- effect_state = 0x73;
303
- }
304
- 2 => {
305
- sound::play_sound_local(task, KnownSoundId::ThrowRelease, 3, Fixed::ONE, Fixed::ONE);
306
- sound::stop_worm_sound(worm);
307
  }
308
- 3 | 7 | 0xB | 0xC => {
309
- sound::play_sound_local(task, KnownSoundId::RocketRelease, 3, Fixed::ONE, Fixed::ONE);
310
- sound::stop_worm_sound(worm);
311
- }
312
- 4 => {
313
- if w.sound_handle == 0 {
314
- sound::play_worm_sound(worm, SoundId(0x1004F), Fixed::ONE);
315
- }
316
- do_effect = true;
317
- effect_state = 0x73;
318
- }
319
- 5 => {
320
- sound::play_sound_local(task, KnownSoundId::ShotgunFire, 3, Fixed::ONE, Fixed::ONE);
321
- do_effect = true;
322
- effect_state = 0x75;
323
- }
324
- 6 => {
325
- sound::play_sound_local(task, KnownSoundId::HandgunFire, 3, Fixed::ONE, Fixed::ONE);
326
- do_effect = true;
327
- effect_state = 0x73;
328
- }
329
- 10 => {
330
- sound::play_sound_local(task, KnownSoundId::LongbowRelease, 3, Fixed::ONE, Fixed::ONE);
331
- sound::stop_worm_sound(worm);
 
 
332
  }
333
- _ => {}
 
334
  }
335
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  Ok(FireType::Rope) => {
337
  if w._unknown_2cc == 0 || w._unknown_2c8 == 1 {
338
  let team_sound_raw = (*w.ddgame()).team_sound_id(team_id);
@@ -345,19 +360,41 @@ unsafe extern "cdecl" fn weapon_release_impl(
345
  match S::try_from((*entry).special_subtype) {
346
  Ok(S::BaseballBat) => {
347
  sound::play_sound_local(
348
- task, KnownSoundId::BaseballBatRelease, 3, Fixed::ONE, Fixed::ONE,
 
 
 
 
349
  );
350
  }
351
  Ok(S::DragonBall) => {
352
- sound::play_sound_local(task, SoundId((*entry).fire_method as u32), 3, Fixed::ONE, Fixed::ONE);
 
 
 
 
 
 
353
  }
354
  Ok(S::Kamikaze) => {
355
  // Sound ID from fire_params.spread (polymorphic use of field)
356
- sound::play_sound_local(task, SoundId((*entry).fire_params.spread as u32), 3, Fixed::ONE, Fixed::ONE);
 
 
 
 
 
 
357
  }
358
  Ok(S::Teleport) => {
359
  if w._unknown_208 == 0 {
360
- sound::play_sound_local(task, KnownSoundId::Teleport, 3, Fixed::ONE, Fixed::ONE);
 
 
 
 
 
 
361
  }
362
  }
363
  Ok(S::Blowtorch) => {
@@ -390,8 +427,16 @@ unsafe extern "cdecl" fn weapon_release_impl(
390
  let state_flag = facing_flag + effect_state;
391
 
392
  spawn_effect(
393
- worm, 0x80000, speed_x, speed_y, rng_scaled, rng1_offset, palette, state_flag,
394
- Fixed(0xA0000), Fixed(0x1999),
 
 
 
 
 
 
 
 
395
  );
396
  }
397
 
@@ -454,9 +499,8 @@ pub(crate) unsafe fn spawn_effect(
454
  let entity = table.lookup(0, 0x1A);
455
  if !entity.is_null() {
456
  let vtable = *(entity as *const *const usize);
457
- let handle_msg: unsafe extern "thiscall" fn(
458
- *mut u8, *mut u8, u32, u32, *const u8,
459
- ) = core::mem::transmute(*vtable.add(2));
460
  handle_msg(
461
  entity as *mut u8,
462
  worm as *mut u8,
 
98
  /// FUN_00565920: weapon category B β€” fire/napalm weapons.
99
  fn is_weapon_category_b(weapon: Weapon) -> bool {
100
  use Weapon::*;
101
+ matches!(
102
+ weapon,
103
+ NapalmStrike | FlameThrower | PetrolBomb | SheepStrike
104
+ )
105
  }
106
 
107
  // ── Main implementation ─────────────────────────────────────
 
155
  let special_subtype = (*entry).special_subtype;
156
  let fire_method = (*entry).fire_method;
157
 
158
+ use openwa_core::game::weapon::{FireMethod, FireType};
159
 
160
  // ── 3. Spawn offset calculation ─────────────────────────
161
  let scale = w.landscape_scale;
 
246
  write_u32(&mut msg_buf, 0x0C, w.shot_data_2);
247
  write_u32(&mut msg_buf, 0x10, w.fire_sync_frame_1 as u32);
248
  write_u32(&mut msg_buf, 0x14, w.fire_sync_frame_2 as u32);
249
+ write_u32(&mut msg_buf, 0x18, if w._unknown_2cc != 0 { 1 } else { 0 });
 
 
 
 
250
  write_u32(&mut msg_buf, 0x1C, weapon as u32);
251
 
252
  let team = weapon::lookup_turn_game(worm);
 
291
  let entry = w.active_weapon_entry;
292
 
293
  match FireType::try_from((*entry).fire_type) {
294
+ Ok(FireType::Projectile) => match (*entry).special_subtype {
295
+ 1 => {
296
+ if w.sound_handle == 0 {
297
+ sound::play_worm_sound(worm, SoundId(0x1004E), Fixed::ONE);
 
 
 
 
 
 
 
 
298
  }
299
+ do_effect = true;
300
+ effect_state = 0x73;
301
+ }
302
+ 2 => {
303
+ sound::play_sound_local(
304
+ task,
305
+ KnownSoundId::ThrowRelease,
306
+ 3,
307
+ Fixed::ONE,
308
+ Fixed::ONE,
309
+ );
310
+ sound::stop_worm_sound(worm);
311
+ }
312
+ 3 | 7 | 0xB | 0xC => {
313
+ sound::play_sound_local(
314
+ task,
315
+ KnownSoundId::RocketRelease,
316
+ 3,
317
+ Fixed::ONE,
318
+ Fixed::ONE,
319
+ );
320
+ sound::stop_worm_sound(worm);
321
+ }
322
+ 4 => {
323
+ if w.sound_handle == 0 {
324
+ sound::play_worm_sound(worm, SoundId(0x1004F), Fixed::ONE);
325
  }
326
+ do_effect = true;
327
+ effect_state = 0x73;
328
  }
329
+ 5 => {
330
+ sound::play_sound_local(task, KnownSoundId::ShotgunFire, 3, Fixed::ONE, Fixed::ONE);
331
+ do_effect = true;
332
+ effect_state = 0x75;
333
+ }
334
+ 6 => {
335
+ sound::play_sound_local(task, KnownSoundId::HandgunFire, 3, Fixed::ONE, Fixed::ONE);
336
+ do_effect = true;
337
+ effect_state = 0x73;
338
+ }
339
+ 10 => {
340
+ sound::play_sound_local(
341
+ task,
342
+ KnownSoundId::LongbowRelease,
343
+ 3,
344
+ Fixed::ONE,
345
+ Fixed::ONE,
346
+ );
347
+ sound::stop_worm_sound(worm);
348
+ }
349
+ _ => {}
350
+ },
351
  Ok(FireType::Rope) => {
352
  if w._unknown_2cc == 0 || w._unknown_2c8 == 1 {
353
  let team_sound_raw = (*w.ddgame()).team_sound_id(team_id);
 
360
  match S::try_from((*entry).special_subtype) {
361
  Ok(S::BaseballBat) => {
362
  sound::play_sound_local(
363
+ task,
364
+ KnownSoundId::BaseballBatRelease,
365
+ 3,
366
+ Fixed::ONE,
367
+ Fixed::ONE,
368
  );
369
  }
370
  Ok(S::DragonBall) => {
371
+ sound::play_sound_local(
372
+ task,
373
+ SoundId((*entry).fire_method as u32),
374
+ 3,
375
+ Fixed::ONE,
376
+ Fixed::ONE,
377
+ );
378
  }
379
  Ok(S::Kamikaze) => {
380
  // Sound ID from fire_params.spread (polymorphic use of field)
381
+ sound::play_sound_local(
382
+ task,
383
+ SoundId((*entry).fire_params.spread as u32),
384
+ 3,
385
+ Fixed::ONE,
386
+ Fixed::ONE,
387
+ );
388
  }
389
  Ok(S::Teleport) => {
390
  if w._unknown_208 == 0 {
391
+ sound::play_sound_local(
392
+ task,
393
+ KnownSoundId::Teleport,
394
+ 3,
395
+ Fixed::ONE,
396
+ Fixed::ONE,
397
+ );
398
  }
399
  }
400
  Ok(S::Blowtorch) => {
 
427
  let state_flag = facing_flag + effect_state;
428
 
429
  spawn_effect(
430
+ worm,
431
+ 0x80000,
432
+ speed_x,
433
+ speed_y,
434
+ rng_scaled,
435
+ rng1_offset,
436
+ palette,
437
+ state_flag,
438
+ Fixed(0xA0000),
439
+ Fixed(0x1999),
440
  );
441
  }
442
 
 
499
  let entity = table.lookup(0, 0x1A);
500
  if !entity.is_null() {
501
  let vtable = *(entity as *const *const usize);
502
+ let handle_msg: unsafe extern "thiscall" fn(*mut u8, *mut u8, u32, u32, *const u8) =
503
+ core::mem::transmute(*vtable.add(2));
 
504
  handle_msg(
505
  entity as *mut u8,
506
  worm as *mut u8,
crates/openwa-wormkit/src/snapshot.rs CHANGED
@@ -60,19 +60,23 @@ pub unsafe fn capture() -> String {
60
  // ── Team blocks + worm entries ──
61
  let team_count = (*ddgame).team_arena.team_count as usize;
62
  let _ = writeln!(out, "[Teams] count={}", team_count);
63
- let arena = openwa_core::engine::ddgame::TeamArenaRef::from_ptr(
64
- &raw mut (*ddgame).team_arena,
65
- );
66
  let blocks = arena.blocks();
67
  for t in 0..team_count {
68
  let header = arena.team_header(t);
69
- let name = core::ffi::CStr::from_ptr(header.team_name.as_ptr() as *const _)
70
- .to_string_lossy();
71
  let _ = writeln!(out, "\n [Team {}] \"{}\"", t, name);
72
- let _ = writeln!(out, " eliminated={} alliance={} worm_count={} active_worm={}",
73
- header.eliminated, header.alliance, header.worm_count, header.active_worm);
74
- let _ = writeln!(out, " weapon_alliance={} turn_action_flags=0x{:08X}",
75
- header.weapon_alliance, header.turn_action_flags);
 
 
 
 
 
 
76
 
77
  // Worms are in block[t+1].worms[0..worm_count] (1-indexed blocks)
78
  let worm_count = header.worm_count.max(0) as usize;
@@ -107,8 +111,11 @@ pub unsafe fn capture() -> String {
107
  }
108
  root = parent;
109
  }
110
- let _ = writeln!(out, "[Entities] root vt=0x{:08X}",
111
- (*(root as *const u32)).wrapping_sub(delta));
 
 
 
112
 
113
  let iter = CTaskBfsIter::new(root);
114
 
@@ -150,7 +157,8 @@ pub unsafe fn capture() -> String {
150
  let class = vtable_name(vt);
151
  if class != "Unknown" {
152
  if let Some(fields) = openwa_core::registry::struct_fields_for(class) {
153
- let _ = write_registry_fields(&mut out, task as *const u8, fields, delta, 2);
 
154
  } else {
155
  let _ = write_raw_region(&mut out, task as *const u8, 0x100, 2);
156
  }
@@ -187,7 +195,11 @@ unsafe fn write_registry_fields(
187
  let pad = " ".repeat(indent);
188
  for field in fields.fields {
189
  let addr = base as u32 + field.offset;
190
- write!(w, "{}+0x{:04X} {:<16} [{:>2}] ", pad, field.offset, field.name, field.size)?;
 
 
 
 
191
  if openwa_core::mem::can_read(addr, field.size) {
192
  let ptr = base.add(field.offset as usize);
193
  let data = core::slice::from_raw_parts(ptr, field.size as usize);
 
60
  // ── Team blocks + worm entries ──
61
  let team_count = (*ddgame).team_arena.team_count as usize;
62
  let _ = writeln!(out, "[Teams] count={}", team_count);
63
+ let arena = openwa_core::engine::ddgame::TeamArenaRef::from_ptr(&raw mut (*ddgame).team_arena);
 
 
64
  let blocks = arena.blocks();
65
  for t in 0..team_count {
66
  let header = arena.team_header(t);
67
+ let name =
68
+ core::ffi::CStr::from_ptr(header.team_name.as_ptr() as *const _).to_string_lossy();
69
  let _ = writeln!(out, "\n [Team {}] \"{}\"", t, name);
70
+ let _ = writeln!(
71
+ out,
72
+ " eliminated={} alliance={} worm_count={} active_worm={}",
73
+ header.eliminated, header.alliance, header.worm_count, header.active_worm
74
+ );
75
+ let _ = writeln!(
76
+ out,
77
+ " weapon_alliance={} turn_action_flags=0x{:08X}",
78
+ header.weapon_alliance, header.turn_action_flags
79
+ );
80
 
81
  // Worms are in block[t+1].worms[0..worm_count] (1-indexed blocks)
82
  let worm_count = header.worm_count.max(0) as usize;
 
111
  }
112
  root = parent;
113
  }
114
+ let _ = writeln!(
115
+ out,
116
+ "[Entities] root vt=0x{:08X}",
117
+ (*(root as *const u32)).wrapping_sub(delta)
118
+ );
119
 
120
  let iter = CTaskBfsIter::new(root);
121
 
 
157
  let class = vtable_name(vt);
158
  if class != "Unknown" {
159
  if let Some(fields) = openwa_core::registry::struct_fields_for(class) {
160
+ let _ =
161
+ write_registry_fields(&mut out, task as *const u8, fields, delta, 2);
162
  } else {
163
  let _ = write_raw_region(&mut out, task as *const u8, 0x100, 2);
164
  }
 
195
  let pad = " ".repeat(indent);
196
  for field in fields.fields {
197
  let addr = base as u32 + field.offset;
198
+ write!(
199
+ w,
200
+ "{}+0x{:04X} {:<16} [{:>2}] ",
201
+ pad, field.offset, field.name, field.size
202
+ )?;
203
  if openwa_core::mem::can_read(addr, field.size) {
204
  let ptr = base.add(field.offset as usize);
205
  let data = core::slice::from_raw_parts(ptr, field.size as usize);
crates/openwa-wormkit/src/validation/mod.rs CHANGED
@@ -109,14 +109,13 @@ fn validate_addresses(result: &mut ValidationResult) {
109
  let _ = log_validation("--- Address Validation ---");
110
 
111
  // Auto-discover all registered vtables from the address registry
112
- let vtables: Vec<(&str, u32)> = openwa_core::registry::entries_by_kind(
113
- openwa_core::registry::AddrKind::Vtable,
114
- )
115
- .map(|e| {
116
- let name = e.class_name.map_or(e.name, |c| c);
117
- (name, e.va)
118
- })
119
- .collect();
120
 
121
  let _ = log_validation("");
122
  let _ = log_validation(&format!(
@@ -287,7 +286,11 @@ fn validate_typed_vtable_slots(result: &mut ValidationResult) {
287
  &format!(
288
  "0x{:08X} {}",
289
  actual,
290
- if in_text { "in .text" } else { "NOT in .text β€” hooked or corrupt?" }
 
 
 
 
291
  ),
292
  );
293
  }
@@ -1010,8 +1013,7 @@ fn dump_entity_census() {
1010
  groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
1011
 
1012
  for (vt_ghidra, ptrs) in &groups {
1013
- let name = openwa_core::registry::vtable_class_name(*vt_ghidra)
1014
- .unwrap_or("UNKNOWN");
1015
  let _ = log_validation(&format!(
1016
  " {:>3}x {:<20} (vtable 0x{:08X})",
1017
  ptrs.len(),
 
109
  let _ = log_validation("--- Address Validation ---");
110
 
111
  // Auto-discover all registered vtables from the address registry
112
+ let vtables: Vec<(&str, u32)> =
113
+ openwa_core::registry::entries_by_kind(openwa_core::registry::AddrKind::Vtable)
114
+ .map(|e| {
115
+ let name = e.class_name.map_or(e.name, |c| c);
116
+ (name, e.va)
117
+ })
118
+ .collect();
 
119
 
120
  let _ = log_validation("");
121
  let _ = log_validation(&format!(
 
286
  &format!(
287
  "0x{:08X} {}",
288
  actual,
289
+ if in_text {
290
+ "in .text"
291
+ } else {
292
+ "NOT in .text β€” hooked or corrupt?"
293
+ }
294
  ),
295
  );
296
  }
 
1013
  groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
1014
 
1015
  for (vt_ghidra, ptrs) in &groups {
1016
+ let name = openwa_core::registry::vtable_class_name(*vt_ghidra).unwrap_or("UNKNOWN");
 
1017
  let _ = log_validation(&format!(
1018
  " {:>3}x {:<20} (vtable 0x{:08X})",
1019
  ptrs.len(),