feat: add game state snapshot system
Browse filesNew `snapshot` command captures canonicalized game state for diffing:
- DDGame: frame counter, RNG, game speed, camera, team arena, ammo
- Teams: name, alliance, worm count, per-worm state/hp/name
- Entities: BFS walk of task tree, rich dump for CTaskWorm/CTaskMissile,
raw hex with pointer canonicalization for unknown entity types
Snapshot trait implemented for DDGame, WormEntry, CTaskWorm, CTaskMissile.
Unknown regions dumped as raw hex with [ptr---] replacing heap pointers.
Usage: openwa-debug snapshot > /tmp/state.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- crates/openwa-core/src/engine/ddgame.rs +71 -0
- crates/openwa-core/src/task/missile.rs +48 -0
- crates/openwa-core/src/task/worm.rs +35 -0
- crates/openwa-debug-cli/src/main.rs +6 -0
- crates/openwa-debug-proto/src/lib.rs +4 -0
- crates/openwa-wormkit/src/debug_server.rs +5 -0
- crates/openwa-wormkit/src/lib.rs +1 -0
- crates/openwa-wormkit/src/snapshot.rs +149 -0
crates/openwa-core/src/engine/ddgame.rs
CHANGED
|
@@ -2039,3 +2039,74 @@ impl TeamArenaRef {
|
|
| 2039 |
(alliance_id, weapon_id as usize)
|
| 2040 |
}
|
| 2041 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2039 |
(alliance_id, weapon_id as usize)
|
| 2040 |
}
|
| 2041 |
}
|
| 2042 |
+
|
| 2043 |
+
// ββ Snapshot impls ββββββββββββββββββββββββββββββββββββββββββ
|
| 2044 |
+
|
| 2045 |
+
#[cfg(target_arch = "x86")]
|
| 2046 |
+
impl crate::snapshot::Snapshot for DDGame {
|
| 2047 |
+
unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
|
| 2048 |
+
use crate::fixed::Fixed;
|
| 2049 |
+
use crate::snapshot::{write_indent, fmt_ptr};
|
| 2050 |
+
let i = indent;
|
| 2051 |
+
|
| 2052 |
+
write_indent(w, i)?; writeln!(w, "frame_counter = {}", self.frame_counter)?;
|
| 2053 |
+
write_indent(w, i)?; writeln!(w, "game_speed = {}", Fixed(self.game_speed))?;
|
| 2054 |
+
write_indent(w, i)?; writeln!(w, "game_speed_target = {}", Fixed(self.game_speed_target))?;
|
| 2055 |
+
write_indent(w, i)?; writeln!(w, "rng_state = 0x{:08X} 0x{:08X}", self.rng_state_1, self.rng_state_2)?;
|
| 2056 |
+
write_indent(w, i)?; writeln!(w, "camera = ({}, {})", Fixed(self.camera_x), Fixed(self.camera_y))?;
|
| 2057 |
+
write_indent(w, i)?; writeln!(w, "camera_target = ({}, {})", Fixed(self.camera_target_x), Fixed(self.camera_target_y))?;
|
| 2058 |
+
write_indent(w, i)?; writeln!(w, "level_size = {}x{}", self.level_width, self.level_height)?;
|
| 2059 |
+
write_indent(w, i)?; writeln!(w, "level_size_raw = {}x{}", self.level_width_raw, self.level_height_raw)?;
|
| 2060 |
+
write_indent(w, i)?; writeln!(w, "landscape_property = {}", self.landscape_property)?;
|
| 2061 |
+
write_indent(w, i)?; writeln!(w, "gfx_color_table = {:?}", self.gfx_color_table)?;
|
| 2062 |
+
write_indent(w, i)?; writeln!(w, "fast_forward = req={} active={}", self.fast_forward_request, self.fast_forward_active)?;
|
| 2063 |
+
write_indent(w, i)?; writeln!(w, "keyboard = {}", fmt_ptr(self.keyboard as *const u8))?;
|
| 2064 |
+
write_indent(w, i)?; writeln!(w, "display = {}", fmt_ptr(self.display as *const u8))?;
|
| 2065 |
+
write_indent(w, i)?; writeln!(w, "sound = {}", fmt_ptr(self.sound as *const u8))?;
|
| 2066 |
+
write_indent(w, i)?; writeln!(w, "game_info = {}", fmt_ptr(self.game_info as *const u8))?;
|
| 2067 |
+
write_indent(w, i)?; writeln!(w, "landscape = {}", fmt_ptr(self.landscape as *const u8))?;
|
| 2068 |
+
write_indent(w, i)?; writeln!(w, "task_land = {}", fmt_ptr(self.task_land))?;
|
| 2069 |
+
|
| 2070 |
+
// TeamArenaState summary
|
| 2071 |
+
write_indent(w, i)?; writeln!(w, "team_arena.team_count = {}", self.team_arena.team_count)?;
|
| 2072 |
+
write_indent(w, i)?; writeln!(w, "team_arena.game_phase = {}", self.team_arena.game_phase)?;
|
| 2073 |
+
write_indent(w, i)?; writeln!(w, "team_arena.game_mode_flag = {}", self.team_arena.game_mode_flag)?;
|
| 2074 |
+
|
| 2075 |
+
// Dump weapon slots (ammo only, skip zeros)
|
| 2076 |
+
write_indent(w, i)?; writeln!(w, "weapon_slots:")?;
|
| 2077 |
+
for team in 0..6usize {
|
| 2078 |
+
let slots = &self.team_arena.weapon_slots.teams[team];
|
| 2079 |
+
let mut has_any = false;
|
| 2080 |
+
for wpn in 0..71usize {
|
| 2081 |
+
let ammo = slots.ammo[wpn];
|
| 2082 |
+
if ammo != 0 {
|
| 2083 |
+
if !has_any {
|
| 2084 |
+
write_indent(w, i + 1)?; write!(w, "team[{}] ammo:", team)?;
|
| 2085 |
+
has_any = true;
|
| 2086 |
+
}
|
| 2087 |
+
if ammo == -1 {
|
| 2088 |
+
write!(w, " {}=inf", wpn)?;
|
| 2089 |
+
} else {
|
| 2090 |
+
write!(w, " {}={}", wpn, ammo)?;
|
| 2091 |
+
}
|
| 2092 |
+
}
|
| 2093 |
+
}
|
| 2094 |
+
if has_any { writeln!(w)?; }
|
| 2095 |
+
}
|
| 2096 |
+
|
| 2097 |
+
Ok(())
|
| 2098 |
+
}
|
| 2099 |
+
}
|
| 2100 |
+
|
| 2101 |
+
impl crate::snapshot::Snapshot for WormEntry {
|
| 2102 |
+
unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
|
| 2103 |
+
use crate::snapshot::write_indent;
|
| 2104 |
+
let i = indent;
|
| 2105 |
+
let name = core::ffi::CStr::from_ptr(self.name.as_ptr() as *const core::ffi::c_char)
|
| 2106 |
+
.to_string_lossy();
|
| 2107 |
+
write_indent(w, i)?;
|
| 2108 |
+
writeln!(w, "state=0x{:02X} active={} hp={}/{} name=\"{}\"",
|
| 2109 |
+
self.state, self.active_flag, self.health, self.max_health, name)?;
|
| 2110 |
+
Ok(())
|
| 2111 |
+
}
|
| 2112 |
+
}
|
crates/openwa-core/src/task/missile.rs
CHANGED
|
@@ -182,3 +182,51 @@ pub enum MissileType {
|
|
| 182 |
/// Unknown type code (value 1 never observed; any other unrecognised value).
|
| 183 |
Unknown(u32),
|
| 184 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
/// Unknown type code (value 1 never observed; any other unrecognised value).
|
| 183 |
Unknown(u32),
|
| 184 |
}
|
| 185 |
+
|
| 186 |
+
// ββ Snapshot impl ββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
|
| 188 |
+
#[cfg(target_arch = "x86")]
|
| 189 |
+
impl crate::snapshot::Snapshot for CTaskMissile {
|
| 190 |
+
unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
|
| 191 |
+
use crate::snapshot::{write_indent, write_raw_region};
|
| 192 |
+
let i = indent;
|
| 193 |
+
let b = &self.base; // CGameTask
|
| 194 |
+
|
| 195 |
+
write_indent(w, i)?; writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
|
| 196 |
+
write_indent(w, i)?; writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
|
| 197 |
+
write_indent(w, i)?; writeln!(w, "launch_seed = 0x{:08X}", self.launch_seed)?;
|
| 198 |
+
write_indent(w, i)?; writeln!(w, "slot_id = {}", self.slot_id)?;
|
| 199 |
+
|
| 200 |
+
write_indent(w, i)?; write!(w, "spawn_params =")?;
|
| 201 |
+
for v in &self.spawn_params { write!(w, " {:08X}", v)?; }
|
| 202 |
+
writeln!(w)?;
|
| 203 |
+
|
| 204 |
+
write_indent(w, i)?; write!(w, "weapon_data =")?;
|
| 205 |
+
for (j, v) in self.weapon_data.iter().enumerate() {
|
| 206 |
+
if j % 16 == 0 { writeln!(w)?; write_indent(w, i + 1)?; }
|
| 207 |
+
write!(w, " {:08X}", v)?;
|
| 208 |
+
}
|
| 209 |
+
writeln!(w)?;
|
| 210 |
+
|
| 211 |
+
write_indent(w, i)?; write!(w, "render_data =")?;
|
| 212 |
+
for (j, v) in self.render_data.iter().enumerate() {
|
| 213 |
+
if j % 16 == 0 { writeln!(w)?; write_indent(w, i + 1)?; }
|
| 214 |
+
write!(w, " {:08X}", v)?;
|
| 215 |
+
}
|
| 216 |
+
writeln!(w)?;
|
| 217 |
+
|
| 218 |
+
write_indent(w, i)?; writeln!(w, "launch_speed_raw = {}", self.launch_speed_raw)?;
|
| 219 |
+
write_indent(w, i)?; writeln!(w, "homing_enabled = {}", self.homing_enabled)?;
|
| 220 |
+
write_indent(w, i)?; writeln!(w, "direction = {}", self.direction)?;
|
| 221 |
+
|
| 222 |
+
// Unknown regions
|
| 223 |
+
write_indent(w, i)?; writeln!(w, "_unknown_fc ({} bytes):", self._unknown_fc.len())?;
|
| 224 |
+
write_raw_region(w, self._unknown_fc.as_ptr(), self._unknown_fc.len(), i + 1)?;
|
| 225 |
+
write_indent(w, i)?; writeln!(w, "_unknown_37c ({} bytes):", self._unknown_37c.len())?;
|
| 226 |
+
write_raw_region(w, self._unknown_37c.as_ptr(), self._unknown_37c.len(), i + 1)?;
|
| 227 |
+
write_indent(w, i)?; writeln!(w, "_unknown_3cc ({} bytes):", self._unknown_3cc.len())?;
|
| 228 |
+
write_raw_region(w, self._unknown_3cc.as_ptr(), self._unknown_3cc.len(), i + 1)?;
|
| 229 |
+
|
| 230 |
+
Ok(())
|
| 231 |
+
}
|
| 232 |
+
}
|
crates/openwa-core/src/task/worm.rs
CHANGED
|
@@ -210,3 +210,38 @@ impl CTaskWorm {
|
|
| 210 |
&*(self.base.base.vtable as *const CTaskWormVTable)
|
| 211 |
}
|
| 212 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
&*(self.base.base.vtable as *const CTaskWormVTable)
|
| 211 |
}
|
| 212 |
}
|
| 213 |
+
|
| 214 |
+
// ββ Snapshot impl ββββββββββββββββββββββββββββββββββββββββββ
|
| 215 |
+
|
| 216 |
+
#[cfg(target_arch = "x86")]
|
| 217 |
+
impl crate::snapshot::Snapshot for CTaskWorm {
|
| 218 |
+
unsafe fn write_snapshot(&self, w: &mut dyn core::fmt::Write, indent: usize) -> core::fmt::Result {
|
| 219 |
+
use crate::snapshot::{write_indent, write_raw_region};
|
| 220 |
+
let i = indent;
|
| 221 |
+
let b = &self.base; // CGameTask
|
| 222 |
+
|
| 223 |
+
write_indent(w, i)?; writeln!(w, "pos = ({}, {})", b.pos_x, b.pos_y)?;
|
| 224 |
+
write_indent(w, i)?; writeln!(w, "speed = ({}, {})", b.speed_x, b.speed_y)?;
|
| 225 |
+
write_indent(w, i)?; writeln!(w, "angle = {}", b.angle)?;
|
| 226 |
+
write_indent(w, i)?; writeln!(w, "team_index = {}", self.team_index)?;
|
| 227 |
+
write_indent(w, i)?; writeln!(w, "worm_index = {}", self.worm_index)?;
|
| 228 |
+
write_indent(w, i)?; writeln!(w, "slot_id = {}", self.slot_id)?;
|
| 229 |
+
write_indent(w, i)?; writeln!(w, "selected_weapon = {}", self.selected_weapon)?;
|
| 230 |
+
write_indent(w, i)?; writeln!(w, "facing = {}", self.facing_direction)?;
|
| 231 |
+
write_indent(w, i)?; writeln!(w, "aim_angle = 0x{:08X}", self.aim_angle)?;
|
| 232 |
+
|
| 233 |
+
write_indent(w, i)?; write!(w, "spawn_params =")?;
|
| 234 |
+
for v in &self.spawn_params { write!(w, " {:08X}", v)?; }
|
| 235 |
+
writeln!(w)?;
|
| 236 |
+
|
| 237 |
+
// Raw dump of unknown regions
|
| 238 |
+
write_indent(w, i)?; writeln!(w, "_unknown_1f0 ({} bytes):", self._unknown_1f0.len())?;
|
| 239 |
+
write_raw_region(w, self._unknown_1f0.as_ptr(), self._unknown_1f0.len(), i + 1)?;
|
| 240 |
+
write_indent(w, i)?; writeln!(w, "_unknown_28c ({} bytes):", self._unknown_28c.len())?;
|
| 241 |
+
write_raw_region(w, self._unknown_28c.as_ptr(), self._unknown_28c.len(), i + 1)?;
|
| 242 |
+
write_indent(w, i)?; writeln!(w, "_unknown_370 ({} bytes):", self._unknown_370.len())?;
|
| 243 |
+
write_raw_region(w, self._unknown_370.as_ptr(), self._unknown_370.len(), i + 1)?;
|
| 244 |
+
|
| 245 |
+
Ok(())
|
| 246 |
+
}
|
| 247 |
+
}
|
crates/openwa-debug-cli/src/main.rs
CHANGED
|
@@ -86,6 +86,7 @@ fn main() {
|
|
| 86 |
.unwrap_or_else(|| { eprintln!("Usage: break <frame> | break clear"); process::exit(1); });
|
| 87 |
Request::Break { frame }
|
| 88 |
}
|
|
|
|
| 89 |
Some(cmd) => {
|
| 90 |
eprintln!("Unknown command: {cmd}");
|
| 91 |
print_usage();
|
|
@@ -177,6 +178,10 @@ fn print_response(response: &Response, format: Format) {
|
|
| 177 |
println!("Breakpoint cleared");
|
| 178 |
}
|
| 179 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
Response::Error { message } => {
|
| 181 |
eprintln!("Server error: {message}");
|
| 182 |
process::exit(1);
|
|
@@ -347,6 +352,7 @@ fn print_usage() {
|
|
| 347 |
eprintln!(" step [N] Advance N frames (default 1), then pause");
|
| 348 |
eprintln!(" frame Show current frame and pause state");
|
| 349 |
eprintln!(" break <N> Set frame breakpoint (break clear to remove)");
|
|
|
|
| 350 |
eprintln!();
|
| 351 |
eprintln!("Address syntax:");
|
| 352 |
eprintln!(" 0x669F8C Ghidra VA (rebased automatically)");
|
|
|
|
| 86 |
.unwrap_or_else(|| { eprintln!("Usage: break <frame> | break clear"); process::exit(1); });
|
| 87 |
Request::Break { frame }
|
| 88 |
}
|
| 89 |
+
Some("snapshot") => Request::Snapshot,
|
| 90 |
Some(cmd) => {
|
| 91 |
eprintln!("Unknown command: {cmd}");
|
| 92 |
print_usage();
|
|
|
|
| 178 |
println!("Breakpoint cleared");
|
| 179 |
}
|
| 180 |
}
|
| 181 |
+
Response::Snapshot { frame, text } => {
|
| 182 |
+
println!("=== Snapshot at frame {frame} ===\n");
|
| 183 |
+
print!("{text}");
|
| 184 |
+
}
|
| 185 |
Response::Error { message } => {
|
| 186 |
eprintln!("Server error: {message}");
|
| 187 |
process::exit(1);
|
|
|
|
| 352 |
eprintln!(" step [N] Advance N frames (default 1), then pause");
|
| 353 |
eprintln!(" frame Show current frame and pause state");
|
| 354 |
eprintln!(" break <N> Set frame breakpoint (break clear to remove)");
|
| 355 |
+
eprintln!(" snapshot Dump canonicalized game state (for diffing)");
|
| 356 |
eprintln!();
|
| 357 |
eprintln!("Address syntax:");
|
| 358 |
eprintln!(" 0x669F8C Ghidra VA (rebased automatically)");
|
crates/openwa-debug-proto/src/lib.rs
CHANGED
|
@@ -41,6 +41,8 @@ pub enum Request {
|
|
| 41 |
Frame,
|
| 42 |
/// Set a frame breakpoint (-1 to clear).
|
| 43 |
Break { frame: i32 },
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
/// One step in a resolved pointer chain, for display.
|
|
@@ -93,6 +95,8 @@ pub enum Response {
|
|
| 93 |
FrameInfo { frame: i32, paused: bool, breakpoint: i32 },
|
| 94 |
/// Breakpoint set/cleared.
|
| 95 |
BreakSet { frame: i32 },
|
|
|
|
|
|
|
| 96 |
Error { message: String },
|
| 97 |
}
|
| 98 |
|
|
|
|
| 41 |
Frame,
|
| 42 |
/// Set a frame breakpoint (-1 to clear).
|
| 43 |
Break { frame: i32 },
|
| 44 |
+
/// Capture a canonicalized game state snapshot.
|
| 45 |
+
Snapshot,
|
| 46 |
}
|
| 47 |
|
| 48 |
/// One step in a resolved pointer chain, for display.
|
|
|
|
| 95 |
FrameInfo { frame: i32, paused: bool, breakpoint: i32 },
|
| 96 |
/// Breakpoint set/cleared.
|
| 97 |
BreakSet { frame: i32 },
|
| 98 |
+
/// Game state snapshot.
|
| 99 |
+
Snapshot { frame: i32, text: String },
|
| 100 |
Error { message: String },
|
| 101 |
}
|
| 102 |
|
crates/openwa-wormkit/src/debug_server.rs
CHANGED
|
@@ -135,6 +135,11 @@ fn handle_request(request: Request) -> Response {
|
|
| 135 |
crate::debug_sync::set_breakpoint(frame);
|
| 136 |
Response::BreakSet { frame }
|
| 137 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
}
|
| 140 |
|
|
|
|
| 135 |
crate::debug_sync::set_breakpoint(frame);
|
| 136 |
Response::BreakSet { frame }
|
| 137 |
}
|
| 138 |
+
Request::Snapshot => {
|
| 139 |
+
let text = unsafe { crate::snapshot::capture() };
|
| 140 |
+
let frame = crate::debug_sync::current_frame();
|
| 141 |
+
Response::Snapshot { frame, text }
|
| 142 |
+
}
|
| 143 |
}
|
| 144 |
}
|
| 145 |
|
crates/openwa-wormkit/src/lib.rs
CHANGED
|
@@ -9,6 +9,7 @@ mod debug_ui;
|
|
| 9 |
mod debug_watchpoint;
|
| 10 |
pub mod hook;
|
| 11 |
mod replacements;
|
|
|
|
| 12 |
mod validation;
|
| 13 |
|
| 14 |
// ---------------------------------------------------------------------------
|
|
|
|
| 9 |
mod debug_watchpoint;
|
| 10 |
pub mod hook;
|
| 11 |
mod replacements;
|
| 12 |
+
mod snapshot;
|
| 13 |
mod validation;
|
| 14 |
|
| 15 |
// ---------------------------------------------------------------------------
|
crates/openwa-wormkit/src/snapshot.rs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Game state snapshot capture.
|
| 2 |
+
//!
|
| 3 |
+
//! Walks DDGame, the team arena, and the entity tree to produce a
|
| 4 |
+
//! canonicalized text dump suitable for cross-run diffing.
|
| 5 |
+
|
| 6 |
+
use core::fmt::Write;
|
| 7 |
+
|
| 8 |
+
use openwa_core::address::va;
|
| 9 |
+
use openwa_core::engine::GameSession;
|
| 10 |
+
use openwa_core::rebase::rb;
|
| 11 |
+
use openwa_core::snapshot::{write_raw_region, Snapshot};
|
| 12 |
+
use openwa_core::task::{CTask, CTaskBfsIter, CTaskMissile, CTaskWorm};
|
| 13 |
+
|
| 14 |
+
/// Capture a full game state snapshot as text.
|
| 15 |
+
///
|
| 16 |
+
/// # Safety
|
| 17 |
+
/// Must be called from the DLL while the game is paused (frame breakpoint).
|
| 18 |
+
#[cfg(target_arch = "x86")]
|
| 19 |
+
pub unsafe fn capture() -> String {
|
| 20 |
+
let mut out = String::with_capacity(128 * 1024);
|
| 21 |
+
|
| 22 |
+
let session_ptr = rb(va::G_GAME_SESSION) as *const *mut GameSession;
|
| 23 |
+
let session = *session_ptr;
|
| 24 |
+
if session.is_null() {
|
| 25 |
+
let _ = writeln!(out, "ERROR: g_GameSession is null");
|
| 26 |
+
return out;
|
| 27 |
+
}
|
| 28 |
+
let wrapper = (*session).ddgame_wrapper;
|
| 29 |
+
if wrapper.is_null() {
|
| 30 |
+
let _ = writeln!(out, "ERROR: DDGameWrapper is null");
|
| 31 |
+
return out;
|
| 32 |
+
}
|
| 33 |
+
let ddgame = (*wrapper).ddgame;
|
| 34 |
+
if ddgame.is_null() {
|
| 35 |
+
let _ = writeln!(out, "ERROR: DDGame is null");
|
| 36 |
+
return out;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
let _ = writeln!(out, "=== Frame {} ===\n", (*ddgame).frame_counter);
|
| 40 |
+
|
| 41 |
+
// ββ DDGame ββ
|
| 42 |
+
let _ = writeln!(out, "[DDGame]");
|
| 43 |
+
let _ = (*ddgame).write_snapshot(&mut out, 1);
|
| 44 |
+
let _ = writeln!(out);
|
| 45 |
+
|
| 46 |
+
// ββ Team blocks + worm entries ββ
|
| 47 |
+
let team_count = (*ddgame).team_arena.team_count as usize;
|
| 48 |
+
let _ = writeln!(out, "[Teams] count={}", team_count);
|
| 49 |
+
let arena = openwa_core::engine::ddgame::TeamArenaRef::from_ptr(
|
| 50 |
+
&raw mut (*ddgame).team_arena,
|
| 51 |
+
);
|
| 52 |
+
let blocks = arena.blocks();
|
| 53 |
+
for t in 0..team_count {
|
| 54 |
+
let header = arena.team_header(t);
|
| 55 |
+
let name = core::ffi::CStr::from_ptr(header.team_name.as_ptr() as *const _)
|
| 56 |
+
.to_string_lossy();
|
| 57 |
+
let _ = writeln!(out, "\n [Team {}] \"{}\"", t, name);
|
| 58 |
+
let _ = writeln!(out, " eliminated={} alliance={} worm_count={} active_worm={}",
|
| 59 |
+
header.eliminated, header.alliance, header.worm_count, header.active_worm);
|
| 60 |
+
let _ = writeln!(out, " weapon_alliance={} turn_action_flags=0x{:08X}",
|
| 61 |
+
header.weapon_alliance, header.turn_action_flags);
|
| 62 |
+
|
| 63 |
+
// Worms are in block[t+1].worms[0..worm_count] (1-indexed blocks)
|
| 64 |
+
let worm_count = header.worm_count.max(0) as usize;
|
| 65 |
+
let block = &*blocks.add(t + 1);
|
| 66 |
+
for wi in 0..worm_count.min(7) {
|
| 67 |
+
let worm = &block.worms[wi];
|
| 68 |
+
let _ = write!(out, " [Worm {}] ", wi);
|
| 69 |
+
let _ = worm.write_snapshot(&mut out, 0);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 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");
|
| 78 |
+
return out;
|
| 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
|
| 86 |
+
let entities: Vec<*const CTask> = iter.collect();
|
| 87 |
+
let _ = writeln!(out, "[Entities] {} total", entities.len());
|
| 88 |
+
|
| 89 |
+
// Census by type
|
| 90 |
+
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
|
| 91 |
+
for &task in &entities {
|
| 92 |
+
let vt = (*(task as *const u32)).wrapping_sub(delta);
|
| 93 |
+
let name = vtable_name(vt);
|
| 94 |
+
*counts.entry(name).or_default() += 1;
|
| 95 |
+
}
|
| 96 |
+
let _ = write!(out, " ");
|
| 97 |
+
for (name, count) in &counts {
|
| 98 |
+
let _ = write!(out, "{}x{} ", name, count);
|
| 99 |
+
}
|
| 100 |
+
let _ = writeln!(out, "\n");
|
| 101 |
+
|
| 102 |
+
// Detail per entity
|
| 103 |
+
for &task in &entities {
|
| 104 |
+
let vt = (*(task as *const u32)).wrapping_sub(delta);
|
| 105 |
+
let name = vtable_name(vt);
|
| 106 |
+
let class_type = (*task).class_type;
|
| 107 |
+
let _ = writeln!(out, " [{}] class_type={:?}", name, class_type);
|
| 108 |
+
|
| 109 |
+
match vt {
|
| 110 |
+
x if x == va::CTASK_WORM_VTABLE => {
|
| 111 |
+
let worm = task as *const CTaskWorm;
|
| 112 |
+
let _ = (*worm).write_snapshot(&mut out, 2);
|
| 113 |
+
}
|
| 114 |
+
x if x == va::CTASK_MISSILE_VTABLE => {
|
| 115 |
+
let missile = task as *const CTaskMissile;
|
| 116 |
+
let _ = (*missile).write_snapshot(&mut out, 2);
|
| 117 |
+
}
|
| 118 |
+
_ => {
|
| 119 |
+
// Unknown entity β raw dump of first 0x100 bytes
|
| 120 |
+
let size = 0x100usize;
|
| 121 |
+
let _ = write_raw_region(&mut out, task as *const u8, size, 2);
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
let _ = writeln!(out);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
out
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
fn vtable_name(ghidra_vt: u32) -> &'static str {
|
| 131 |
+
match ghidra_vt {
|
| 132 |
+
x if x == va::CTASK_WORM_VTABLE => "CTaskWorm",
|
| 133 |
+
x if x == va::CTASK_MISSILE_VTABLE => "CTaskMissile",
|
| 134 |
+
x if x == va::CTASK_TEAM_VTABLE => "CTaskTeam",
|
| 135 |
+
x if x == va::CTASK_LAND_VTABLE => "CTaskLand",
|
| 136 |
+
x if x == va::CTASK_TURN_GAME_VTABLE => "CTaskTurnGame",
|
| 137 |
+
x if x == va::CTASK_CLOUD_VTABLE => "CTaskCloud",
|
| 138 |
+
x if x == va::CTASK_DIRT_VTABLE => "CTaskDirt",
|
| 139 |
+
x if x == va::CTASK_FIRE_VTABLE => "CTaskFire",
|
| 140 |
+
x if x == va::CTASK_SEA_BUBBLE_VTABLE => "CTaskSeaBubble",
|
| 141 |
+
x if x == va::CTASK_MINE_VTABLE => "CTaskMine",
|
| 142 |
+
x if x == va::CTASK_OILDRUM_VTABLE => "CTaskOilDrum",
|
| 143 |
+
x if x == va::CTASK_CRATE_VTABLE => "CTaskCrate",
|
| 144 |
+
x if x == va::CTASK_CPU_VTABLE => "CTaskCPU",
|
| 145 |
+
x if x == va::CTASK_FILTER_VTABLE => "CTaskFilter",
|
| 146 |
+
x if x == va::CTASK_SPRITE_ANIM_VTABLE => "CTaskSpriteAnim",
|
| 147 |
+
_ => "Unknown",
|
| 148 |
+
}
|
| 149 |
+
}
|