paavohtl Claude Opus 4.6 (1M context) commited on
Commit
a683d3d
Β·
1 Parent(s): a5c1336

fix: load sprites unconditionally, improve desync debugging tools

Browse files

Constructor correctness (match original WA behavior):
- Load debris.spr and back.spr unconditionally, not gfx_mode-gated
- Initialize PaletteContext for masks.img (word=1, word=0xFE, Init)
- Document PCLandscape param_5 as game_info+0xDAAC

Debug tooling improvements:
- Hardware watchpoints: EBP-based stack trace on every hit
- Watchpoint base selection: OPENWA_WATCH_WRAPPER, OPENWA_WATCH_DISPLAY
- Display watchpoint arming during constructor for field tracing
- Snapshot: walk parent chain to CTaskTurnGame (true entity root)

Add docs/re-notes/desync-investigation.md β€” writeup of the longbow
desync investigation methodology and findings.

NOTE: Longbow desync (4 checksum mismatches) is NOT yet resolved.
These changes are correctness fixes and tooling for continued work.

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

CLAUDE.md CHANGED
@@ -85,6 +85,8 @@ Key env vars:
85
  - `OPENWA_BREAK_FRAME=N` β€” auto-pause at frame N
86
  - `OPENWA_USE_ORIG_CTOR=1` β€” use original WA DDGame constructor (for A/B testing)
87
  - `OPENWA_WATCH_FRAME=N` β€” arm hardware watchpoint on DDGame at frame N
 
 
88
 
89
  ## Crate Architecture
90
 
@@ -124,6 +126,18 @@ Hooks use the `minhook` crate. Two patterns:
124
 
125
  For `__usercall` functions, use a naked trampoline to capture register params before calling the Rust impl.
126
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  ## Hardware Watchpoint Debugger
128
 
129
  `crates/openwa-wormkit/src/debug_watchpoint.rs` β€” self-contained x86 debug register instrumentation. Sets DR0–DR3 write watchpoints on DDGame offsets via an INT3β†’VEH trick (no external debugger needed). Logs the exact Ghidra VA of every write.
 
85
  - `OPENWA_BREAK_FRAME=N` β€” auto-pause at frame N
86
  - `OPENWA_USE_ORIG_CTOR=1` β€” use original WA DDGame constructor (for A/B testing)
87
  - `OPENWA_WATCH_FRAME=N` β€” arm hardware watchpoint on DDGame at frame N
88
+ - `OPENWA_WATCH_WRAPPER=1` β€” watchpoint base = DDGameWrapper instead of DDGame
89
+ - `OPENWA_WATCH_DISPLAY=1` β€” watchpoint base = display object (DDGameWrapper+0x4D0), armed during constructor
90
 
91
  ## Crate Architecture
92
 
 
126
 
127
  For `__usercall` functions, use a naked trampoline to capture register params before calling the Rust impl.
128
 
129
+ ## Desync Debugging
130
+
131
+ Replay desyncs (checksum mismatches) can be caused by any code difference β€” constructor side effects, hooked function behaviour, missing state, wrong calling conventions, etc. Key methodology:
132
+
133
+ 1. **WA uses a single shared RNG** (DDGame+0x45EC, `AdvanceGameRNG` at 0x53F320) for both gameplay AND visual effects. There is no separate "visual RNG." Even purely decorative things like particle sprites affect the game RNG and will cause desyncs in headless mode if handled differently.
134
+ 2. **DDGame flat memory matching is NOT sufficient.** Constructors and hooks have side effects on sub-objects (display, GfxHandler, PCLandscape). Compare all objects pointed to by DDGame AND DDGameWrapper.
135
+ 3. **Use hardware watchpoints** (`debug_watchpoint.rs`) with stack traces to find what writes a specific field. DR0–DR3 + VEH handler gives "who wrote this byte?" answers without an external debugger.
136
+ 4. **Per-frame RNG logging** (DDGame+0x45EC) pinpoints the exact frame where simulation diverges. Binary search on frames, not code.
137
+ 5. **Always validate diff methodology** against a known-good frame first. The snapshot system's pointer canonicalization produces false positives.
138
+
139
+ See `docs/re-notes/desync-investigation.md` for a detailed case study.
140
+
141
  ## Hardware Watchpoint Debugger
142
 
143
  `crates/openwa-wormkit/src/debug_watchpoint.rs` β€” self-contained x86 debug register instrumentation. Sets DR0–DR3 write watchpoints on DDGame offsets via an INT3β†’VEH trick (no external debugger needed). Logs the exact Ghidra VA of every write.
crates/openwa-core/src/engine/ddgame.rs CHANGED
@@ -559,13 +559,17 @@ unsafe fn wa_load_effect_wavs(wrapper: *mut DDGameWrapper) {
559
  }
560
 
561
  /// PCLandscape__Constructor (0x57ACB0): construct landscape object (0xB44 bytes, 11 params).
 
 
 
 
562
  #[cfg(target_arch = "x86")]
563
  unsafe fn wa_pc_landscape_ctor(
564
  this: *mut u8,
565
  ddgame: *mut DDGame,
566
  gfx_resource: *mut u8,
567
  display: *mut DDDisplay,
568
- landscape_path: *const u8,
569
  byte_output: *mut u8,
570
  gfx_mode: u32,
571
  temp_buf: *mut u32,
@@ -591,7 +595,7 @@ unsafe fn wa_pc_landscape_ctor(
591
  ddgame,
592
  gfx_resource,
593
  display,
594
- landscape_path,
595
  byte_output,
596
  gfx_mode,
597
  temp_buf,
@@ -955,13 +959,31 @@ unsafe fn init_graphics_and_resources(
955
  }
956
  }
957
 
958
- // ── GfxResource: thiscall(ECX=gfx_dir) + EAX=name + 1 stack(output), RET 0x4 ──
 
 
 
 
959
  let gfx_resource: *mut u8;
960
  {
961
  let gfx_dir = (*wrapper).primary_gfx_dir;
962
- let out_buf = wa_malloc_zeroed(0x900);
 
 
 
 
 
963
  gfx_resource =
964
- gfx_resource_create(gfx_dir, rb(va::STR_MASKS_IMG) as *const c_char, out_buf);
 
 
 
 
 
 
 
 
 
965
  }
966
 
967
  // ── PCLandscape (alloc 0xB44, stdcall 11 params) ──
@@ -1244,16 +1266,22 @@ unsafe fn init_graphics_and_resources(
1244
  let disp = (*wrapper).display;
1245
  DDDisplay::set_active_layer(disp, 3);
1246
 
1247
- if (*wrapper).gfx_mode != 0 {
1248
- DDDisplay::load_sprite_by_layer(
1249
- disp,
1250
- 3,
1251
- 0x26D,
1252
- land_layer,
1253
- c"back.spr".as_ptr().cast(),
1254
- );
1255
- DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
1256
- }
 
 
 
 
 
 
1257
 
1258
  DDDisplay::load_sprite_by_layer(
1259
  disp,
 
559
  }
560
 
561
  /// PCLandscape__Constructor (0x57ACB0): construct landscape object (0xB44 bytes, 11 params).
562
+ ///
563
+ /// param_5 is `game_info + 0xDAAC` (landscape data path region within GameInfo).
564
+ /// The SoundEmitter sub-constructor reads paths, water settings, etc. from this
565
+ /// pointer with negative offsets back into GameInfo.
566
  #[cfg(target_arch = "x86")]
567
  unsafe fn wa_pc_landscape_ctor(
568
  this: *mut u8,
569
  ddgame: *mut DDGame,
570
  gfx_resource: *mut u8,
571
  display: *mut DDDisplay,
572
+ landscape_data: *const u8,
573
  byte_output: *mut u8,
574
  gfx_mode: u32,
575
  temp_buf: *mut u32,
 
595
  ddgame,
596
  gfx_resource,
597
  display,
598
+ landscape_data,
599
  byte_output,
600
  gfx_mode,
601
  temp_buf,
 
959
  }
960
  }
961
 
962
+ // ── GfxResource for masks.img ──
963
+ // The original constructor uses a stack-local PaletteContext buffer (ESP+0x1A4)
964
+ // initialized at the start with (word=1, word=0xFE, PaletteContext__Init).
965
+ // This same buffer is reused as the output param for GfxResource__Create(masks.img).
966
+ // We replicate this initialization here. Size 0x900 matches the stack allocation.
967
  let gfx_resource: *mut u8;
968
  {
969
  let gfx_dir = (*wrapper).primary_gfx_dir;
970
+ // Create PaletteContext the same way the original DDGame constructor does:
971
+ // word[0]=1, word[1]=0xFE, then PaletteContext__Init (0x5411A0)
972
+ let palette_ctx = wa_malloc_zeroed(0x900);
973
+ *(palette_ctx as *mut u16) = 1;
974
+ *(palette_ctx.add(2) as *mut u16) = 0xFE;
975
+ call_usercall_eax(palette_ctx as *mut DDGameWrapper, rb(va::PALETTE_CONTEXT_INIT));
976
  gfx_resource =
977
+ gfx_resource_create(gfx_dir, rb(va::STR_MASKS_IMG) as *const c_char, palette_ctx);
978
+ }
979
+
980
+ // ── Dump GfxResource for A/B comparison ──
981
+ if !gfx_resource.is_null() {
982
+ let use_orig = std::env::var("OPENWA_USE_ORIG_CTOR").is_ok();
983
+ let tag = if use_orig { "orig" } else { "rust" };
984
+ // Dump GfxResource object + first sub-object
985
+ let gr_data = core::slice::from_raw_parts(gfx_resource, 0x100);
986
+ let _ = std::fs::write(format!("gfx_resource_{}.bin", tag), gr_data);
987
  }
988
 
989
  // ── PCLandscape (alloc 0xB44, stdcall 11 params) ──
 
1266
  let disp = (*wrapper).display;
1267
  DDDisplay::set_active_layer(disp, 3);
1268
 
1269
+ // back.spr and debris.spr must be loaded unconditionally β€” they're used by
1270
+ // GenerateDebrisParticles (0x546F70) for particle effects, which affects
1271
+ // the game RNG (DDGame+0x45EC). The original constructor loads them even
1272
+ // in headless mode. Skipping them causes replay desync.
1273
+ DDDisplay::load_sprite_by_layer(
1274
+ disp,
1275
+ 3,
1276
+ 0x26D,
1277
+ land_layer,
1278
+ c"back.spr".as_ptr().cast(),
1279
+ );
1280
+ // debris.spr must be loaded unconditionally β€” it's used by
1281
+ // GenerateDebrisParticles (0x546F70) for particle effects, which
1282
+ // affects the game RNG (DDGame+0x45EC). Skipping it in headless
1283
+ // mode causes desync (longbow replay checksum mismatch at frame 1350).
1284
+ DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
1285
 
1286
  DDDisplay::load_sprite_by_layer(
1287
  disp,
crates/openwa-wormkit/src/debug_watchpoint.rs CHANGED
@@ -30,10 +30,12 @@ use windows_sys::Win32::System::Diagnostics::Debug::{
30
  AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, EXCEPTION_POINTERS,
31
  };
32
 
33
- /// DDGame offsets to watch. Hardware limit: 4 watchpoints (DR0–DR3).
34
  /// Change these to investigate different fields.
35
- const WATCH_OFFSETS: [(u32, &str); 1] = [
36
- (0x0050, "ddgame+0x50"),
 
 
37
  ];
38
 
39
  /// DDGame base address (set when allocation is reported).
@@ -115,28 +117,58 @@ unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
115
  (STATUS_SINGLE_STEP, Phase::Active) => {
116
  let dr6 = ctx.Dr6;
117
  let eip = ctx.Eip;
118
- // Compute Ghidra VA (only meaningful if EIP is inside WA.exe)
119
  let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
120
  let wa_base = rb(va::IMAGE_BASE);
121
- let ghidra_eip = eip.wrapping_sub(delta);
122
- // WA.exe is ~2MB; check if EIP is plausibly inside it
123
- let in_wa = eip >= wa_base && eip < wa_base + 0x300000;
124
 
125
  let base = DDGAME_BASE.load(Ordering::Relaxed);
126
  for (i, &(offset, name)) in WATCH_OFFSETS.iter().enumerate() {
127
  if dr6 & (1 << i) != 0 {
128
  let val = *((base + offset) as *const u32);
129
- if in_wa {
130
- let _ = log_line(&format!(
131
- "[Watchpoint] {} (+0x{:04X}) = 0x{:08X} writer before 0x{:08X} (WA.exe)",
132
- name, offset, val, ghidra_eip,
133
- ));
134
- } else {
135
- let _ = log_line(&format!(
136
- "[Watchpoint] {} (+0x{:04X}) = 0x{:08X} writer before 0x{:08X} (runtime, outside WA.exe)",
137
- name, offset, val, eip,
138
- ));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
 
 
 
 
 
140
  }
141
  }
142
 
 
30
  AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, EXCEPTION_POINTERS,
31
  };
32
 
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); 2] = [
37
+ (0x09C0, "display+0x09C0"),
38
+ (0x353C, "display+0x353C"),
39
  ];
40
 
41
  /// DDGame base address (set when allocation is reported).
 
117
  (STATUS_SINGLE_STEP, Phase::Active) => {
118
  let dr6 = ctx.Dr6;
119
  let eip = ctx.Eip;
 
120
  let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
121
  let wa_base = rb(va::IMAGE_BASE);
 
 
 
122
 
123
  let base = DDGAME_BASE.load(Ordering::Relaxed);
124
  for (i, &(offset, name)) in WATCH_OFFSETS.iter().enumerate() {
125
  if dr6 & (1 << i) != 0 {
126
  let val = *((base + offset) as *const u32);
127
+ let ghidra_eip = eip.wrapping_sub(delta);
128
+
129
+ // Walk EBP chain for stack trace
130
+ let mut trace = heapless::String::<512>::new();
131
+ let mut ebp = ctx.Ebp;
132
+ let esp = ctx.Esp;
133
+ for depth in 0..12 {
134
+ // Validate EBP: must be in plausible stack range, aligned,
135
+ // and above ESP (stack grows down)
136
+ if ebp < 0x10000 || ebp > 0x7FFE0000 || (ebp & 3) != 0 {
137
+ break;
138
+ }
139
+ // Safety: check both [ebp] and [ebp+4] are readable
140
+ if !openwa_core::mem::can_read(ebp, 8) {
141
+ break;
142
+ }
143
+ let ret_addr = *((ebp + 4) as *const u32);
144
+ let next_ebp = *(ebp as *const u32);
145
+ let ghidra_ret = ret_addr.wrapping_sub(delta);
146
+ let in_wa = ret_addr >= wa_base && ret_addr < wa_base + 0x300000;
147
+ if depth > 0 {
148
+ let _ = core::fmt::Write::write_str(&mut trace, "<-");
149
+ }
150
+ if in_wa {
151
+ let _ = core::fmt::Write::write_fmt(
152
+ &mut trace,
153
+ format_args!("{:08X}", ghidra_ret),
154
+ );
155
+ } else {
156
+ let _ = core::fmt::Write::write_fmt(
157
+ &mut trace,
158
+ format_args!("r:{:08X}", ret_addr),
159
+ );
160
+ }
161
+ // EBP must increase (frames go up the stack)
162
+ if next_ebp <= ebp {
163
+ break;
164
+ }
165
+ ebp = next_ebp;
166
  }
167
+
168
+ let _ = log_line(&format!(
169
+ "[Watchpoint] {} = 0x{:08X} eip=0x{:08X} stack=[{}]",
170
+ name, val, ghidra_eip, trace,
171
+ ));
172
  }
173
  }
174
 
crates/openwa-wormkit/src/replacements/game_session.rs CHANGED
@@ -102,6 +102,12 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
102
  display as u32, net_game as u32, timer_obj as u32, game_info as u32,
103
  ));
104
 
 
 
 
 
 
 
105
  // Use env var to switch between original and Rust constructor
106
  let use_original = std::env::var("OPENWA_USE_ORIG_CTOR").is_ok();
107
  if use_original {
@@ -125,6 +131,11 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
125
 
126
  }
127
 
 
 
 
 
 
128
  // Initialize DDGame's game-state fields.
129
  let init_state: unsafe extern "stdcall" fn(*mut DDGameWrapper) =
130
  core::mem::transmute(rb(va::DDGAME_INIT_GAME_STATE) as usize);
 
102
  display as u32, net_game as u32, timer_obj as u32, game_info as u32,
103
  ));
104
 
105
+ // Arm display watchpoint during construction if requested
106
+ if std::env::var("OPENWA_WATCH_DISPLAY").is_ok() {
107
+ crate::debug_watchpoint::prepare();
108
+ crate::debug_watchpoint::on_ddgame_alloc(display as *mut u8);
109
+ }
110
+
111
  // Use env var to switch between original and Rust constructor
112
  let use_original = std::env::var("OPENWA_USE_ORIG_CTOR").is_ok();
113
  if use_original {
 
131
 
132
  }
133
 
134
+ // Disarm display watchpoint
135
+ if std::env::var("OPENWA_WATCH_DISPLAY").is_ok() {
136
+ crate::debug_watchpoint::teardown();
137
+ }
138
+
139
  // Initialize DDGame's game-state fields.
140
  let init_state: unsafe extern "stdcall" fn(*mut DDGameWrapper) =
141
  core::mem::transmute(rb(va::DDGAME_INIT_GAME_STATE) as usize);
crates/openwa-wormkit/src/replacements/input.rs CHANGED
@@ -195,7 +195,18 @@ unsafe extern "stdcall" fn hook_turn_manager(turngame: u32) {
195
  if game_frame >= target {
196
  WATCH_ARMED.store(true, Ordering::Relaxed);
197
  crate::debug_watchpoint::prepare();
198
- crate::debug_watchpoint::on_ddgame_alloc(ddgame as *mut u8);
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
  }
201
  }
 
195
  if game_frame >= target {
196
  WATCH_ARMED.store(true, Ordering::Relaxed);
197
  crate::debug_watchpoint::prepare();
198
+ // Select watchpoint base: DDGame, DDGameWrapper, or Display
199
+ let watch_base = if std::env::var("OPENWA_WATCH_DISPLAY").is_ok() {
200
+ let session = *(rb(va::G_GAME_SESSION) as *const u32);
201
+ let wrapper = *((session + 0xA0) as *const *const u8);
202
+ *(wrapper.add(0x4D0) as *const *mut u8)
203
+ } else if std::env::var("OPENWA_WATCH_WRAPPER").is_ok() {
204
+ let session = *(rb(va::G_GAME_SESSION) as *const u32);
205
+ *((session + 0xA0) as *const *mut u8)
206
+ } else {
207
+ ddgame as *mut u8
208
+ };
209
+ crate::debug_watchpoint::on_ddgame_alloc(watch_base);
210
  }
211
  }
212
  }
crates/openwa-wormkit/src/snapshot.rs CHANGED
@@ -72,6 +72,9 @@ pub unsafe fn capture() -> String {
72
  let _ = writeln!(out);
73
 
74
  // ── Entity tree ──
 
 
 
75
  let task_land = (*ddgame).task_land;
76
  if task_land.is_null() {
77
  let _ = writeln!(out, "[Entities] task_land=null");
@@ -79,7 +82,20 @@ pub unsafe fn capture() -> String {
79
  }
80
 
81
  let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
82
- let root = task_land as *const CTask;
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  let iter = CTaskBfsIter::new(root);
84
 
85
  // Census + detailed dump
 
72
  let _ = writeln!(out);
73
 
74
  // ── Entity tree ──
75
+ // CTaskTurnGame is the root of the entity tree. Find it by checking
76
+ // CTaskLand's parent chain or the shared data table.
77
+ // CTaskTurnGame vtable = 0x669F70.
78
  let task_land = (*ddgame).task_land;
79
  if task_land.is_null() {
80
  let _ = writeln!(out, "[Entities] task_land=null");
 
82
  }
83
 
84
  let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
85
+
86
+ // Walk parent chain from CTaskLand to find CTaskTurnGame (root of entity tree).
87
+ // Safety limit to prevent infinite loops on corrupt parent chains.
88
+ let mut root = task_land as *const CTask;
89
+ for _ in 0..10 {
90
+ let parent = (*root).parent as *const CTask;
91
+ if parent.is_null() || parent == root {
92
+ break;
93
+ }
94
+ root = parent;
95
+ }
96
+ let _ = writeln!(out, "[Entities] root vt=0x{:08X}",
97
+ (*(root as *const u32)).wrapping_sub(delta));
98
+
99
  let iter = CTaskBfsIter::new(root);
100
 
101
  // Census + detailed dump
docs/re-notes/desync-investigation.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The debris.spr Desync: Debugging a Replay Mismatch in a Reimplemented Game Constructor
2
+
3
+ ## Summary
4
+
5
+ During the incremental Rust reimplementation of Worms Armageddon's DDGame constructor, a deterministic replay of a longbow match produced "Checksum Mismatch" errors starting at frame 1350. Other replays (bots 3v3, shotgun) passed. This documents the investigation that ultimately traced the root cause to a single missing sprite load (`debris.spr`) in headless mode, and the debugging methodology used to find it.
6
+
7
+ ## Background
8
+
9
+ OpenWA replaces WA functions with Rust reimplementations via DLL injection. The DDGame constructor (~900 lines of Rust replacing a ~19KB x86 function) initialises the entire game engine: display layers, terrain, sprites, sound, HUD, and entity systems. An A/B toggle (`OPENWA_USE_ORIG_CTOR=1`) switches between the Rust and original constructors for comparison.
10
+
11
+ Replay testing works by running WA.exe with a recorded `.WAgame` file in headless mode (no graphics window), capturing the game log, and comparing against a known-good baseline. WA's built-in checksum system sends periodic state checksums during gameplay; mismatches indicate simulation divergence.
12
+
13
+ ## The Symptom
14
+
15
+ - Rust constructor: **4 checksum mismatches** at frames 1350, 2186, 2249, 2775
16
+ - Original constructor: all checksums pass
17
+ - DDGame flat memory (39KB): **byte-for-byte identical** between both constructors
18
+ - Terrain collision bitmaps: identical
19
+ - Entity tree: same 68 entities in same BFS order
20
+
21
+ The game outcome was the same (same winner, same damage), but the checksums diverged.
22
+
23
+ ## Dead Ends
24
+
25
+ Several hypotheses were investigated and ruled out:
26
+
27
+ 1. **Landscape bitmap differences** β€” dumped 1.3MB terrain collision bitmaps from both runs: byte-identical at construction AND at the divergence frame.
28
+
29
+ 2. **Entity tree ordering** β€” full entity snapshot comparison showed ~490 diff lines. Seemed like proof of different entity ordering. But the same comparison at a *known-good* frame also showed ~490 diffs, and the bots replay (which passes) showed ~480 diffs. The snapshot tool's pointer canonicalization heuristic (`is_likely_pointer` using `can_read()`) was producing false positives. **Lesson: always validate your diff tool against a known baseline.**
30
+
31
+ 3. **PaletteContext buffer** β€” the original constructor creates a PaletteContext on the stack and reuses it; our code creates a fresh one. Tried matching the initialization: no effect. The cached GfxDir path was taken, bypassing the PaletteContext entirely.
32
+
33
+ 4. **PCLandscape constructor parameters** β€” verified via x86 disassembly that `param_5 = game_info + 0xDAAC` (not raw game_info). Our code was already correct.
34
+
35
+ ## The Breakthrough: Sub-Object Diffing
36
+
37
+ The key insight: DDGame's flat memory matching doesn't mean *everything* matches. The constructor calls WA functions that modify objects *outside* DDGame β€” particularly the **display object** (DDGameWrapper+0x4D0).
38
+
39
+ Dumping the display object (16KB) before and after construction revealed:
40
+
41
+ | Offset | Before | After (orig) | After (rust) |
42
+ |--------|--------|-------------|-------------|
43
+ | +0x09C0 | 0 | **3** | 0 |
44
+ | +0x353C | 0 | **1** | 0 |
45
+
46
+ The display started identical and diverged *during* the constructor.
47
+
48
+ ## Hardware Watchpoints With Stack Traces
49
+
50
+ To find what wrote these display fields, we used x86 debug registers (DR0–DR3) via a Vectored Exception Handler:
51
+
52
+ 1. Register VEH before the constructor
53
+ 2. Set DR0/DR1 to watch `display + 0x09C0` and `display + 0x353C`
54
+ 3. On `STATUS_SINGLE_STEP` (write trap), log the value, EIP, and walk the EBP chain for a stack trace
55
+
56
+ The watchpoint reported:
57
+ ```
58
+ display+0x353C = 0x00000001 eip=0x005234C0 stack=[...]
59
+ display+0x09C0 = 0x00000003 eip=0x005234B8 stack=[...]
60
+ ```
61
+
62
+ Both writes came from `LoadSprite` (0x523400) β€” a display vtable method (slot 31) that loads a sprite into the display's layer cache. The sprite being loaded was **index 0x26E** into **layer 3**.
63
+
64
+ ## The Root Cause
65
+
66
+ Tracing back to our Rust constructor:
67
+
68
+ ```rust
69
+ if (*wrapper).gfx_mode != 0 {
70
+ DDDisplay::load_sprite_by_layer(disp, 3, 0x26D, land_layer, "back.spr");
71
+ DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, "debris.spr");
72
+ }
73
+ ```
74
+
75
+ In headless mode (`gfx_mode = 0`), both sprites were skipped. But the original WA constructor loads them unconditionally β€” even without a graphics window. Sprite 0x26E is `debris.spr`, used by `GenerateDebrisParticles` (0x546F70) for particle effects.
76
+
77
+ `GenerateDebrisParticles` reads terrain pixels and, when a hit is detected, updates the **game RNG** (DDGame+0x45EC) and creates particle entities. Without `debris.spr` loaded in the display, the sprite lookup returns different data, causing different RNG update patterns:
78
+
79
+ - Original: 15,470 RNG writes (from frame 808 onward)
80
+ - Rust (broken): 17,106 RNG writes
81
+ - Delta: 1,636 extra RNG ticks β†’ cascading simulation divergence
82
+
83
+ ## The Partial Fix (and a Lesson in Verifying Results)
84
+
85
+ ```rust
86
+ // Load unconditionally β€” debris.spr affects game RNG via GenerateDebrisParticles
87
+ DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
88
+ ```
89
+
90
+ This change is correct β€” the original constructor loads `debris.spr` unconditionally, and our code should match. However, it did **not** resolve the desync. An initial test appeared to show 3 of 4 mismatches fixed, but this was a `tail -5` truncation error β€” only the last mismatch was visible, creating a false impression. All 4 mismatches persist.
91
+
92
+ The display state differences (sprite layer counters) we found were real and worth fixing for correctness, but they turned out not to be the root cause of the checksum mismatch. The investigation continues.
93
+
94
+ ## Debugging Tools Built
95
+
96
+ The investigation produced several reusable tools:
97
+
98
+ - **Hardware watchpoint system** with EBP-based stack trace walking (VEH + DR0–DR3)
99
+ - **Per-frame game state hashing** via forced `SerializeGameState` calls
100
+ - **Binary sub-object dumping** for display, GfxHandler, PCLandscape comparison
101
+ - **`OPENWA_WATCH_DISPLAY=1`** env var to arm watchpoints on the display object during construction
102
+ - Terrain collision bitmap dumping at arbitrary frames
103
+
104
+ ## Key Lessons
105
+
106
+ 1. **Identical struct memory β‰  identical behaviour.** The constructor's side effects on *other* objects matter as much as the struct it fills. Compare sub-objects systematically.
107
+
108
+ 2. **Validate your diff tools.** Our snapshot comparison showed ~490 "differences" at known-good frames β€” pure noise from pointer canonicalization heuristics. Always baseline.
109
+
110
+ 3. **There is no "visual-only" code in a deterministic game engine.** `debris.spr` seems purely decorative β€” it controls what debris particles look like when terrain is destroyed. But Worms uses a **single shared RNG** for everything: gameplay physics, AI decisions, particle effects, sound variation. There is no separate "visual RNG." Every RNG consumer must execute identically, or the streams diverge and every subsequent random outcome differs. Skipping a single sprite load changed how `GenerateDebrisParticles` consumed RNG ticks, shifting the entire RNG sequence for all downstream gameplay.
111
+
112
+ 4. **Hardware watchpoints are underrated.** x86 DR0–DR3 with a VEH handler gives "what wrote this byte?" answers in seconds, without an external debugger. Adding stack traces made them even more powerful.
113
+
114
+ 5. **Binary search on frames, not code.** Per-frame RNG logging narrowed 542 candidate frames to the exact frame (818) where divergence began, before any code analysis.