fix: load sprites unconditionally, improve desync debugging tools
Browse filesConstructor 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 +14 -0
- crates/openwa-core/src/engine/ddgame.rs +43 -15
- crates/openwa-wormkit/src/debug_watchpoint.rs +49 -17
- crates/openwa-wormkit/src/replacements/game_session.rs +11 -0
- crates/openwa-wormkit/src/replacements/input.rs +12 -1
- crates/openwa-wormkit/src/snapshot.rs +17 -1
- docs/re-notes/desync-investigation.md +114 -0
|
@@ -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.
|
|
@@ -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 |
-
|
| 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 |
-
|
| 595 |
byte_output,
|
| 596 |
gfx_mode,
|
| 597 |
temp_buf,
|
|
@@ -955,13 +959,31 @@ unsafe fn init_graphics_and_resources(
|
|
| 955 |
}
|
| 956 |
}
|
| 957 |
|
| 958 |
-
// ββ GfxResource
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
let gfx_resource: *mut u8;
|
| 960 |
{
|
| 961 |
let gfx_dir = (*wrapper).primary_gfx_dir;
|
| 962 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
gfx_resource =
|
| 964 |
-
gfx_resource_create(gfx_dir, rb(va::STR_MASKS_IMG) as *const c_char,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 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,
|
|
@@ -30,10 +30,12 @@ use windows_sys::Win32::System::Diagnostics::Debug::{
|
|
| 30 |
AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, EXCEPTION_POINTERS,
|
| 31 |
};
|
| 32 |
|
| 33 |
-
///
|
| 34 |
/// Change these to investigate different fields.
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 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 |
|
|
@@ -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);
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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.
|