File size: 14,577 Bytes
8962003
a683d3d
 
 
8962003
a683d3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8962003
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a683d3d
 
 
 
 
8962003
 
 
 
a683d3d
8962003
a683d3d
 
 
8962003
 
 
 
 
 
 
 
 
a683d3d
8962003
a683d3d
8962003
a683d3d
8962003
a683d3d
8962003
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# Desync Investigation Journal: Seven Bugs, One Checksum Mismatch

## Summary

During the incremental Rust reimplementation of Worms Armageddon's DDGame constructor and gameplay hooks, a deterministic replay of a longbow match produced "Checksum Mismatch" errors. What appeared to be a single constructor bug turned out to be seven independent issues across three subsystems (constructor, replay loader, weapon dispatch), found over multiple debugging sessions using hardware watchpoints, sub-object hashing, binary search on hooks, and targeted test replays.

## Background

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.

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.

## The Symptom

- Rust constructor: **4 checksum mismatches** at frames 1350, 2186, 2249, 2775
- Original constructor: all checksums pass
- DDGame flat memory (39KB): **byte-for-byte identical** between both constructors
- Terrain collision bitmaps: identical
- Entity tree: same 68 entities in same BFS order

The game outcome was the same (same winner, same damage), but the checksums diverged.

## Dead Ends

Several hypotheses were investigated and ruled out:

1. **Landscape bitmap differences** β€” dumped 1.3MB terrain collision bitmaps from both runs: byte-identical at construction AND at the divergence frame.

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.**

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.

4. **PCLandscape constructor parameters** β€” verified via x86 disassembly that `param_5 = game_info + 0xDAAC` (not raw game_info). Our code was already correct.

## The Breakthrough: Sub-Object Diffing

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).

Dumping the display object (16KB) before and after construction revealed:

| Offset | Before | After (orig) | After (rust) |
|--------|--------|-------------|-------------|
| +0x09C0 | 0 | **3** | 0 |
| +0x353C | 0 | **1** | 0 |

The display started identical and diverged *during* the constructor.

## Hardware Watchpoints With Stack Traces

To find what wrote these display fields, we used x86 debug registers (DR0–DR3) via a Vectored Exception Handler:

1. Register VEH before the constructor
2. Set DR0/DR1 to watch `display + 0x09C0` and `display + 0x353C`
3. On `STATUS_SINGLE_STEP` (write trap), log the value, EIP, and walk the EBP chain for a stack trace

The watchpoint reported:
```
display+0x353C = 0x00000001  eip=0x005234C0  stack=[...]
display+0x09C0 = 0x00000003  eip=0x005234B8  stack=[...]
```

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**.

## The Root Cause

Tracing back to our Rust constructor:

```rust
if (*wrapper).gfx_mode != 0 {
    DDDisplay::load_sprite_by_layer(disp, 3, 0x26D, land_layer, "back.spr");
    DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, "debris.spr");
}
```

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.

`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:

- Original: 15,470 RNG writes (from frame 808 onward)
- Rust (broken): 17,106 RNG writes
- Delta: 1,636 extra RNG ticks β†’ cascading simulation divergence

## The Partial Fix (and a Lesson in Verifying Results)

```rust
// Load unconditionally β€” debris.spr affects game RNG via GenerateDebrisParticles
DDDisplay::load_sprite(disp, 3, 0x26E, 0, land_layer, c"debris.spr".as_ptr());
```

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.

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.

---

## Phase 2: RNG Tracing and the Debris Particle Divergence

### Per-frame RNG tracking

With the sprite fix applied but the desync persisting, the next step was tracking the game RNG (DDGame+0x45EC) at frame boundaries. A hook on `TurnManager_ProcessFrame` logged the RNG value before and after each frame:

```
game_f=817  rng=B714AF97  (identical in both runs)
game_f=818  rng=BE0D887F  (Rust) vs ADB76793 (orig)
```

The RNG was **identical through frame 817** and diverged during frame 818. But `AdvanceGameRNG` (0x53F320) turned out to be **mostly inlined** β€” hooking the function only caught rare non-inlined calls. The real tool was a hardware watchpoint on DDGame+0x45EC itself.

### Hardware watchpoint on the RNG field

Arming DR0 on DDGame+0x45EC at frame 817 captured every RNG write with its EIP. Comparing the two runs:

| Write # | Rust EIP | Orig EIP | Notes |
|---------|----------|----------|-------|
| 1–24 | 0x5470BB/DE | 0x5470BB/DE | Identical (GenerateDebrisParticles inlined) |
| 25 | **0x6529960D** (hook) | **0x5470BB** (more debris!) | Divergence point |

The original had **36 debris writes** (3 Γ— GenerateDebrisParticles calls) while Rust had only **24** (2 calls). One terrain collision event was missing. Yet total debris writes across the game were identical (72 each) β€” just distributed differently across frames.

### Caller frequency analysis

Counting all 17K+ RNG write EIPs revealed the dominant divergence source: `AdvanceGameRNG_Low16` (0x507CE0) in `CTaskFire__HandleMessage` β€” 14,085 calls (Rust) vs 12,517 (orig). The 1,568 extra calls represented projectiles processing more frames due to the cascading RNG shift.

## Phase 3: The Flat Memory Puzzle

At this point, DDGame flat memory was verified **byte-for-byte identical** between constructors. Display, landscape, and wrapper non-pointer values all matched at frame 0. The desync was deterministic. What could differ?

### Sub-object hashing: the breakthrough

The key insight: DDGame contains **pointers** to heap-allocated sub-objects. Flat memory comparison only shows the pointer values (which differ between runs due to heap layout), not the *content* of what they point to.

A new tool β€” `hash_pointer_targets()` β€” walks every DWORD in DDGame, follows each heap pointer, and hashes the first 256 bytes of the target with pointer canonicalization (replacing pointer-looking values with 0).

The first run produced hundreds of hash differences due to an overly aggressive pointer filter. After tuning to use the existing `is_likely_pointer` heuristic, a targeted raw dump of specific sub-objects revealed the smoking gun:

**Arrow collision region** (SpriteRegion at DDGame+0x48C):

| Offset | Rust | Original |
|--------|------|----------|
| +0x14 (this[5]) | **0** | **10** |
| +0x18 (this[6]) | **20** | **10** |

Different collision box dimensions. The Rust constructor was creating origin-based collision boxes instead of centered boxes with 10px margins.

### The root cause: wrong SpriteRegion parameters

Tracing the SpriteRegion constructor (0x57DB20) parameters via disassembly:

```
this[3] = p4 - p2    (width)
this[4] = EDX - p3   (height)
this[5] = ECX - p2   (x inset)
this[6] = p5 - p3    (y inset)
```

The original computes a **centered** collision box:
- ECX = `sprite_w / 2`, EDX = `sprite_h - margin_h`
- p2 = `margin_w`, p3 = `margin_h` (where margin = max(0, dim/2 βˆ’ 10))
- p4 = `sprite_w - margin_w`, p5 = `sprite_h / 2`
- p6 = **arrow sprite pointer**

Our code passed:
- ECX = **0**, EDX = `margin_h`
- p2 = **0**, p3 = **0**
- p4 = `margin_w`, p5 = `margin_h`
- p6 = **landscape gfx_resource** (wrong object entirely β€” from an outer scope)

Six of eight parameters were wrong. The collision boxes were origin-based instead of centered, AND referenced the wrong sprite, causing different terrain collision detection for arrow projectiles.

## Phase 4: It Wasn't Just the Constructor

### New test replays change everything

Recording two additional replays proved crucial:
- **longbow_water** β€” longbow fired at water (no terrain collision)
- **longbow_alt_theme** β€” longbow on a different WA theme

Expected logs were generated from **unmodded WA.exe** (critical β€” our DLL's hooks can affect the game log).

Results with the original constructor:

| Replay | Status |
|--------|--------|
| longbow_water | **FAIL** |
| longbow_alt_theme | **FAIL** (Frame 0!) |

Both failed with the *original* constructor! The desync wasn't constructor-specific β€” it was caused by **gameplay hooks**.

### Binary search on hooks

Disabling hooks in groups narrowed the search:

1. All hooks disabled except essentials β†’ PASS
2. Add weapon hooks β†’ FAIL
3. Only weapon hooks, disable FireWeapon β†’ PASS for longbow_water
4. Only replay hooks β†’ FAIL for alt_theme

Two independent hook bugs:

**ReplayLoader (replay.rs):**
- Player array strides: Ghidra showed element indices (`i*0x3C` for `u16*`, `i*0x1E` for `u32*`) but code used them as byte offsets. Correct byte stride: `i*0x78`.
- Non-zero `map_seed` path: per-team weapon config reads from stream were unimplemented. Fallback to original parser for `map_seed β‰  0`.

**FireWeapon (weapon.rs):**
- Binary search within the type-4 dispatch: type 4 β†’ subtypes 10+13 β†’ subtype 13 (Napalm Strike).
- `fire_send_team_message` wrote `team_index` to `buf[8..12]` but the original writes it to `buf[0..4]`. The HandleMessage handler reads `data[0]`, receiving 0 instead of the correct team index.
- Root cause confirmed by disassembly of the original Napalm handler at 0x51E5C0: `MOV [ESP+4], EAX` where EAX = `worm+0xFC` (team_index) and `ESP+4` = `buffer[0]`.

### Also fixed along the way

- **DDGame allocation size**: 0x98B8 β†’ 0x98D8 (original mallocs 0x98D8 but memsets only 0x98B8)
- **Wrong function after InitPaletteGradientSprites**: called `LoadingProgressTick` (0x5717A0) instead of `DisplayGfx__InitTeamPaletteDisplayObjects` (0x5703E0)

## Debugging Tools Built

The investigation produced several reusable tools:

- **Hardware watchpoint system** (`debug_watchpoint.rs`) β€” x86 DR0–DR3 with VEH handler and EBP stack trace walking
- **Sub-object hashing** (`snapshot.rs: hash_pointer_targets`) β€” follows heap pointers in a struct, hashes target content with pointer canonicalization. Integrated into the snapshot system.
- **Per-frame RNG logging** via TurnManager hook
- **`OPENWA_WATCH_DISPLAY=1`** / **`OPENWA_WATCH_FRAME=N`** β€” arm watchpoints on display or DDGame at specific frames
- **Binary sub-object dumping** for display, GfxHandler, PCLandscape comparison
- **A/B constructor toggle** (`OPENWA_USE_ORIG_CTOR=1`) β€” instant switching between Rust and original constructors

## Key Lessons

1. **Identical struct memory β‰  identical behaviour.** DDGame flat memory was byte-identical, but *sub-objects pointed to by DDGame* had different content. The `hash_pointer_targets` tool was purpose-built to catch this.

2. **Validate your diff tools.** Our snapshot comparison showed ~490 "differences" at known-good frames β€” pure noise from pointer canonicalization heuristics. And our dump code once read past a heap allocation into adjacent memory, producing false diffs. Always baseline.

3. **There is no "visual-only" code in a deterministic game engine.** `debris.spr` seems purely decorative, but Worms uses a **single shared RNG** for everything. Skipping one sprite load shifts the entire RNG sequence.

4. **Hardware watchpoints are underrated.** x86 DR0–DR3 with a VEH handler gives "what wrote this byte?" answers in seconds, without an external debugger.

5. **Binary search on frames, not code.** Per-frame RNG logging narrowed 542 candidate frames to the exact frame (818) where divergence began.

6. **Binary search on hooks.** Disabling half the hooks at a time isolated two independent bugs in minutes. Then within a single hook (FireWeapon), binary search on dispatch paths (type β†’ subtype β†’ specific handler) pinpointed the exact buggy function.

7. **Multiple test replays expose different bugs.** The original longbow replay only triggered the constructor bug. Adding longbow_water and longbow_alt_theme exposed hook bugs that were invisible with the original replay alone. Different weapons, themes, and targets exercise different code paths.

8. **Ghidra element indexing is a trap.** The decompiler shows `(&DAT[i*0x3C])` for a `u16*` array β€” the `0x3C` is an *element* index, not a byte offset. The actual byte offset is `i * 0x3C * sizeof(u16) = i * 0x78`. This caused player data corruption for index 1+.

9. **Check the buffer layout against disassembly.** The Napalm Strike bug was a single wrong offset in a message buffer: `buf[8]` vs `buf[0]`. The original's disassembly (`MOV [ESP+4], EAX` at 0x51E5D2) unambiguously shows the write destination. When in doubt, read the assembly.