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