paavohtl Claude Opus 4.6 (1M context) commited on
Commit
5f22f7f
·
1 Parent(s): 44ed7d6

feat: upgrade debug tools with registry, typed reflection, and symbolic addresses

Browse files

Wire the address registry, field registries, typed vtables, and live
object tracker into all debug tools:

- Watchpoint stack traces now show symbolicated function names via
format_va() instead of raw hex addresses
- Validation auto-discovers vtables from the registry and validates
all named slots from #[vtable(...)] structs
- New ValueKind enum + FieldFormatter trait for typed field formatting:
scalars as decimal, Fixed as float, pointers resolved via registry,
CString as quoted strings. Extensible via inventory::submit!
- Snapshot fallback uses FieldRegistry for entity types without
hand-coded Snapshot impls (CTaskTeam, CTaskMine, etc.)
- New debug CLI commands: inspect (typed struct read), objects (list
tracked live objects)
- Symbolic address expressions: ddgame, ddgame+field_name,
ddgame->field_name work in all CLI commands
- New protocol messages: Inspect, ListObjects, ResolveAlias, ResolveField
- #[field(kind = "CString")] attribute for string fields in FieldRegistry

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

crates/openwa-core/src/field_format.rs ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Extensible field value formatting for debug tools.
2
+ //!
3
+ //! Provides a [`FieldFormatter`] trait and a top-level [`format_field`] function
4
+ //! that writes human-readable field values to any `fmt::Write` target.
5
+ //!
6
+ //! Default formatters handle scalars, Fixed, pointers, and booleans.
7
+ //! Custom formatters (e.g., for game-specific enums) can be registered via
8
+ //! `inventory::submit!` from any crate.
9
+
10
+ use core::fmt;
11
+
12
+ use crate::registry::{FieldEntry, ValueKind};
13
+
14
+ /// Context passed to formatters (avoids bloating the trait signature).
15
+ pub struct FormatContext {
16
+ /// ASLR delta for pointer resolution (runtime_base - 0x400000).
17
+ pub delta: u32,
18
+ }
19
+
20
+ /// Trait for formatting field values.
21
+ ///
22
+ /// Implementations are registered via `inventory::submit!` and dispatched
23
+ /// by [`format_field`] based on `ValueKind`.
24
+ ///
25
+ /// Writes to `&mut dyn fmt::Write` — no allocations required.
26
+ pub trait FieldFormatter: Send + Sync + 'static {
27
+ /// Which `ValueKind`(s) this formatter handles.
28
+ fn handles(&self) -> &[ValueKind];
29
+
30
+ /// Format `data` (the raw bytes of the field) into `w`.
31
+ fn format_field(
32
+ &self,
33
+ w: &mut dyn fmt::Write,
34
+ data: &[u8],
35
+ field: &FieldEntry,
36
+ ctx: &FormatContext,
37
+ ) -> fmt::Result;
38
+ }
39
+
40
+ // Inventory collection for custom formatters.
41
+ inventory::collect!(Box<dyn FieldFormatter>);
42
+
43
+ /// Write a formatted field value to any `fmt::Write` target.
44
+ ///
45
+ /// Dispatches to registered custom formatters first, then falls back to
46
+ /// built-in defaults based on `field.kind`.
47
+ pub fn format_field(
48
+ w: &mut dyn fmt::Write,
49
+ data: &[u8],
50
+ field: &FieldEntry,
51
+ ctx: &FormatContext,
52
+ ) -> fmt::Result {
53
+ // Check custom formatters first
54
+ for formatter in inventory::iter::<Box<dyn FieldFormatter>> {
55
+ if formatter.handles().contains(&field.kind) {
56
+ return formatter.format_field(w, data, field, ctx);
57
+ }
58
+ }
59
+
60
+ // Built-in defaults
61
+ match field.kind {
62
+ ValueKind::U8 if data.len() >= 1 => {
63
+ write!(w, "{}", data[0])
64
+ }
65
+ ValueKind::I8 if data.len() >= 1 => {
66
+ write!(w, "{}", data[0] as i8)
67
+ }
68
+ ValueKind::U16 if data.len() >= 2 => {
69
+ let v = u16::from_le_bytes([data[0], data[1]]);
70
+ write!(w, "{}", v)
71
+ }
72
+ ValueKind::I16 if data.len() >= 2 => {
73
+ let v = i16::from_le_bytes([data[0], data[1]]);
74
+ write!(w, "{}", v)
75
+ }
76
+ ValueKind::U32 if data.len() >= 4 => {
77
+ let v = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
78
+ if v > 0xFFFF {
79
+ write!(w, "0x{:08X}", v)
80
+ } else {
81
+ write!(w, "{}", v)
82
+ }
83
+ }
84
+ ValueKind::I32 if data.len() >= 4 => {
85
+ let v = i32::from_le_bytes([data[0], data[1], data[2], data[3]]);
86
+ write!(w, "{}", v)
87
+ }
88
+ ValueKind::Bool if data.len() >= 1 => match data[0] {
89
+ 0 => write!(w, "false"),
90
+ 1 => write!(w, "true"),
91
+ v => write!(w, "??(0x{:02X})", v),
92
+ },
93
+ ValueKind::Fixed if data.len() >= 4 => {
94
+ let raw = i32::from_le_bytes([data[0], data[1], data[2], data[3]]);
95
+ let f = crate::fixed::Fixed(raw);
96
+ write!(w, "{:.4} (0x{:08X})", f.to_f32(), raw as u32)
97
+ }
98
+ ValueKind::Pointer if data.len() >= 4 => {
99
+ let v = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
100
+ if v == 0 {
101
+ write!(w, "null")
102
+ } else {
103
+ format_pointer(w, v, ctx.delta)
104
+ }
105
+ }
106
+ ValueKind::Enum if data.len() >= 4 => {
107
+ // Default enum formatting — just show the numeric value.
108
+ // Custom formatters can override for specific enums.
109
+ let v = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
110
+ write!(w, "enum({})", v)
111
+ }
112
+ ValueKind::CString => {
113
+ let nul = data.iter().position(|&b| b == 0).unwrap_or(data.len());
114
+ let s = core::str::from_utf8(&data[..nul]).unwrap_or("<invalid utf8>");
115
+ write!(w, "\"{}\"", s)
116
+ }
117
+ ValueKind::Struct => {
118
+ write!(w, "<struct {} bytes>", data.len())
119
+ }
120
+ // Raw fallback: hex dump
121
+ _ => format_raw_hex(w, data),
122
+ }
123
+ }
124
+
125
+ /// Format a pointer value using the registry for symbolication.
126
+ fn format_pointer(w: &mut dyn fmt::Write, value: u32, delta: u32) -> fmt::Result {
127
+ let ghidra = value.wrapping_sub(delta);
128
+
129
+ // Try static registry lookup
130
+ if let Some(resolved) = crate::registry::lookup_va(ghidra) {
131
+ if resolved.offset == 0 {
132
+ return write!(w, "0x{:08X} ({})", value, resolved.entry.name);
133
+ } else {
134
+ return write!(
135
+ w,
136
+ "0x{:08X} ({}+0x{:X})",
137
+ value, resolved.entry.name, resolved.offset
138
+ );
139
+ }
140
+ }
141
+
142
+ // Try live object identification
143
+ #[cfg(target_os = "windows")]
144
+ if let Some(live) = crate::registry::identify_live_pointer(value) {
145
+ if let Some(field) = live.field {
146
+ return write!(
147
+ w,
148
+ "0x{:08X} ({}+0x{:X} .{})",
149
+ value, live.object.class_name, live.offset, field.name
150
+ );
151
+ } else {
152
+ return write!(
153
+ w,
154
+ "0x{:08X} ({}+0x{:X})",
155
+ value, live.object.class_name, live.offset
156
+ );
157
+ }
158
+ }
159
+
160
+ // Try vtable-based class detection
161
+ #[cfg(target_os = "windows")]
162
+ if unsafe { crate::mem::can_read(value, 4) } {
163
+ let first_dword = unsafe { *(value as *const u32) };
164
+ let vtable_ghidra = first_dword.wrapping_sub(delta);
165
+ if let Some(class) = crate::registry::vtable_class_name(vtable_ghidra) {
166
+ return write!(w, "0x{:08X} ({}*)", value, class);
167
+ }
168
+ }
169
+
170
+ // Bare hex
171
+ write!(w, "0x{:08X}", value)
172
+ }
173
+
174
+ /// Format raw bytes as hex.
175
+ fn format_raw_hex(w: &mut dyn fmt::Write, data: &[u8]) -> fmt::Result {
176
+ for (i, byte) in data.iter().enumerate() {
177
+ if i > 0 {
178
+ write!(w, " ")?;
179
+ }
180
+ write!(w, "{:02X}", byte)?;
181
+ // Truncate long arrays
182
+ if i >= 15 && data.len() > 16 {
183
+ write!(w, " ... ({} bytes)", data.len())?;
184
+ break;
185
+ }
186
+ }
187
+ Ok(())
188
+ }
crates/openwa-core/src/lib.rs CHANGED
@@ -43,6 +43,7 @@ pub mod log;
43
  pub mod mem;
44
  pub mod rebase;
45
  pub mod snapshot;
 
46
  pub mod task;
47
  pub mod vtable;
48
  pub mod wa;
 
43
  pub mod mem;
44
  pub mod rebase;
45
  pub mod snapshot;
46
+ pub mod field_format;
47
  pub mod task;
48
  pub mod vtable;
49
  pub mod wa;
crates/openwa-core/src/registry.rs CHANGED
@@ -179,6 +179,34 @@ pub fn entry_count() -> usize {
179
 
180
  // --- Struct field registry types (for Phase 4 #[derive(FieldRegistry)]) ---
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  /// A known field within a struct.
183
  #[derive(Debug, Clone, Copy)]
184
  pub struct FieldEntry {
@@ -188,6 +216,8 @@ pub struct FieldEntry {
188
  pub name: &'static str,
189
  /// Field size in bytes.
190
  pub size: u32,
 
 
191
  /// Brief description.
192
  pub doc: &'static str,
193
  }
@@ -558,9 +588,9 @@ mod tests {
558
  static FIELDS: StructFields = StructFields {
559
  struct_name: "TestStruct",
560
  fields: &[
561
- FieldEntry { offset: 0x00, name: "vtable", size: 4, doc: "" },
562
- FieldEntry { offset: 0x10, name: "health", size: 4, doc: "" },
563
- FieldEntry { offset: 0x20, name: "name", size: 16, doc: "" },
564
  ],
565
  };
566
 
 
179
 
180
  // --- Struct field registry types (for Phase 4 #[derive(FieldRegistry)]) ---
181
 
182
+ /// Describes the semantic type of a struct field for display formatting.
183
+ ///
184
+ /// Inferred automatically by `#[derive(FieldRegistry)]` from the Rust type.
185
+ /// Use `#[field(kind = "Fixed")]` to override when auto-inference is wrong.
186
+ /// Falls back to `Raw` for unrecognized types.
187
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
188
+ pub enum ValueKind {
189
+ U8,
190
+ U16,
191
+ U32,
192
+ I8,
193
+ I16,
194
+ I32,
195
+ Bool,
196
+ /// 16.16 fixed-point (`Fixed` newtype).
197
+ Fixed,
198
+ /// Any pointer (`*mut T`, `*const T`).
199
+ Pointer,
200
+ /// Enum type (e.g., `ClassType`).
201
+ Enum,
202
+ /// Embedded struct with its own FieldRegistry.
203
+ Struct,
204
+ /// Null-terminated C string in a fixed-size byte array.
205
+ CString,
206
+ /// Fallback for arrays, unknown types, etc.
207
+ Raw,
208
+ }
209
+
210
  /// A known field within a struct.
211
  #[derive(Debug, Clone, Copy)]
212
  pub struct FieldEntry {
 
216
  pub name: &'static str,
217
  /// Field size in bytes.
218
  pub size: u32,
219
+ /// Semantic type for formatting.
220
+ pub kind: ValueKind,
221
  /// Brief description.
222
  pub doc: &'static str,
223
  }
 
588
  static FIELDS: StructFields = StructFields {
589
  struct_name: "TestStruct",
590
  fields: &[
591
+ FieldEntry { offset: 0x00, name: "vtable", size: 4, kind: ValueKind::Pointer, doc: "" },
592
+ FieldEntry { offset: 0x10, name: "health", size: 4, kind: ValueKind::U32, doc: "" },
593
+ FieldEntry { offset: 0x20, name: "name", size: 16, kind: ValueKind::Raw, doc: "" },
594
  ],
595
  };
596
 
crates/openwa-core/src/task/worm.rs CHANGED
@@ -152,8 +152,10 @@ pub struct CTaskWorm {
152
  /// 0x28C–0x2EF: Unknown
153
  pub _unknown_28c: [u8; 0x64],
154
  /// 0x2F0: Worm name, null-terminated (max 17 chars, from spawn init_data+3)
 
155
  pub worm_name: [u8; 0x11],
156
  /// 0x301: Country / team name from scheme, null-terminated (max 17 chars)
 
157
  pub country_name: [u8; 0x11],
158
  /// 0x312–0x333: Unknown (rope string, state history, etc.)
159
  pub _unknown_312: [u8; 0x334 - 0x312],
 
152
  /// 0x28C–0x2EF: Unknown
153
  pub _unknown_28c: [u8; 0x64],
154
  /// 0x2F0: Worm name, null-terminated (max 17 chars, from spawn init_data+3)
155
+ #[field(kind = "CString")]
156
  pub worm_name: [u8; 0x11],
157
  /// 0x301: Country / team name from scheme, null-terminated (max 17 chars)
158
+ #[field(kind = "CString")]
159
  pub country_name: [u8; 0x11],
160
  /// 0x312–0x333: Unknown (rope string, state history, etc.)
161
  pub _unknown_312: [u8; 0x334 - 0x312],
crates/openwa-debug-cli/src/main.rs CHANGED
@@ -62,7 +62,7 @@ fn main() {
62
  eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\"");
63
  process::exit(1);
64
  }
65
- let expr = parse_address_expr(addr_str);
66
  let len = positional.get(2)
67
  .map(|s| parse_u32(s))
68
  .unwrap_or(256);
@@ -73,6 +73,24 @@ fn main() {
73
  Request::ReadChain { addr, chain, len, absolute },
74
  }
75
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  Some("suspend") => Request::Suspend,
77
  Some("resume") => Request::Resume,
78
  Some("step") => {
@@ -182,6 +200,37 @@ fn print_response(response: &Response, format: Format) {
182
  println!("=== Snapshot at frame {frame} ===\n");
183
  print!("{text}");
184
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  Response::Error { message } => {
186
  eprintln!("Server error: {message}");
187
  process::exit(1);
@@ -258,64 +307,42 @@ enum AddressExpr {
258
  Chain { addr: u32, chain: Vec<u32>, absolute: bool },
259
  }
260
 
261
- /// Parse an address expression.
262
- ///
263
- /// Syntax:
264
- /// 0x669F8C — Ghidra VA
265
- /// abs:0x7FFF0000 — absolute runtime address
266
- /// 0x669F8C+0x10 — Ghidra VA + hex offset
267
- /// 0x669F8C[0x10] — same as +0x10 (bracket notation)
268
- /// 0x7A0884->0xA0->0x2C — pointer chain (deref at each ->)
269
- /// abs:0x7FFF0000->4->0 — absolute + chain
270
- ///
271
- /// Chain semantics: read DWORD at addr, add first offset, read DWORD, add
272
- /// second offset, ... display memory at final address.
273
- fn parse_address_expr(s: &str) -> AddressExpr {
274
- let (s, absolute) = if let Some(rest) = s.strip_prefix("abs:") {
275
- (rest, true)
276
- } else {
277
- (s.as_ref(), false)
278
- };
279
-
280
- // Check for chain syntax: contains "->"
281
- if s.contains("->") {
282
- let parts: Vec<&str> = s.split("->").collect();
283
- let base = parse_addr_with_offset(parts[0]);
284
- let chain: Vec<u32> = parts[1..].iter().map(|p| parse_compound_offset(p)).collect();
285
- return AddressExpr::Chain { addr: base, chain, absolute };
286
- }
287
-
288
- // Simple address with optional offset
289
- AddressExpr::Simple { addr: parse_addr_with_offset(s), absolute }
290
- }
291
-
292
- /// Parse a single address token, possibly with +offset or [offset].
293
- fn parse_addr_with_offset(s: &str) -> u32 {
294
- let (base_str, offset) = split_compound(s);
295
- parse_hex(base_str).wrapping_add(offset)
296
- }
297
-
298
- /// Parse a chain offset segment, possibly with +offset or [offset].
299
- /// E.g. "0x488+0x10" → 0x498, "0xA0[4]" → 0xA4, "0x510" → 0x510.
300
- fn parse_compound_offset(s: &str) -> u32 {
301
- let (base_str, offset) = split_compound(s);
302
- parse_u32(base_str).wrapping_add(offset)
303
- }
304
-
305
- /// Split a string on '+' or '[' into (base, offset). Returns (s, 0) if no compound.
306
- fn split_compound(s: &str) -> (&str, u32) {
307
  if let Some(pos) = s.find('+') {
308
- let (base, off) = s.split_at(pos);
309
- (base, parse_u32(&off[1..]))
 
310
  } else if let Some(pos) = s.find('[') {
311
  let base = &s[..pos];
312
  let off_str = s[pos + 1..].trim_end_matches(']');
313
- (base, parse_u32(off_str))
314
  } else {
315
- (s, 0u32)
316
  }
317
  }
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  fn parse_hex(s: &str) -> u32 {
320
  let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
321
  u32::from_str_radix(s, 16).unwrap_or_else(|_| {
@@ -340,6 +367,143 @@ fn parse_u32(s: &str) -> u32 {
340
  }
341
  }
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  fn print_usage() {
344
  eprintln!("Usage: openwa-debug <command> [args...] [--port N] [--format hex|raw]");
345
  eprintln!();
@@ -347,6 +511,8 @@ fn print_usage() {
347
  eprintln!(" ping Check if server is running");
348
  eprintln!(" help List server commands");
349
  eprintln!(" read <addr> [len] Read memory at address (default len=256)");
 
 
350
  eprintln!(" suspend Pause game at next frame boundary");
351
  eprintln!(" resume Resume game");
352
  eprintln!(" step [N] Advance N frames (default 1), then pause");
@@ -360,6 +526,9 @@ fn print_usage() {
360
  eprintln!(" 0x669F8C+0x10 Address + hex offset");
361
  eprintln!(" 0x669F8C[16] Address + decimal offset (bracket notation)");
362
  eprintln!(" 0x7A0884->0xA0->0x2C Pointer chain (deref at each ->)");
 
 
 
363
  eprintln!();
364
  eprintln!("Options:");
365
  eprintln!(" --port <N> Server port (default: 19840)");
 
62
  eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\"");
63
  process::exit(1);
64
  }
65
+ let expr = resolve_address_expr(addr_str, port);
66
  let len = positional.get(2)
67
  .map(|s| parse_u32(s))
68
  .unwrap_or(256);
 
73
  Request::ReadChain { addr, chain, len, absolute },
74
  }
75
  }
76
+ Some("inspect") => {
77
+ let class_name = positional.get(1).unwrap_or_else(|| {
78
+ eprintln!("Usage: inspect <class_name> <addr>");
79
+ process::exit(1);
80
+ }).clone();
81
+ let addr_str = positional.get(2).unwrap_or_else(|| {
82
+ eprintln!("Usage: inspect <class_name> <addr>");
83
+ process::exit(1);
84
+ });
85
+ let expr = resolve_address_expr(addr_str, port);
86
+ match expr {
87
+ AddressExpr::Simple { addr, absolute } =>
88
+ Request::Inspect { class_name, addr, chain: vec![], absolute },
89
+ AddressExpr::Chain { addr, chain, absolute } =>
90
+ Request::Inspect { class_name, addr, chain, absolute },
91
+ }
92
+ }
93
+ Some("objects") => Request::ListObjects,
94
  Some("suspend") => Request::Suspend,
95
  Some("resume") => Request::Resume,
96
  Some("step") => {
 
200
  println!("=== Snapshot at frame {frame} ===\n");
201
  print!("{text}");
202
  }
203
+ Response::InspectResult { class_name, ghidra_addr, runtime_addr, fields } => {
204
+ println!(
205
+ "{} at ghidra:0x{:08X} (runtime:0x{:08X})\n",
206
+ class_name, ghidra_addr, runtime_addr
207
+ );
208
+ for f in fields {
209
+ println!(
210
+ " +0x{:04X} {:<20} [{:>2}] {}",
211
+ f.offset, f.name, f.size, f.display
212
+ );
213
+ }
214
+ }
215
+ Response::ObjectList { objects } => {
216
+ if objects.is_empty() {
217
+ println!("No tracked objects");
218
+ } else {
219
+ println!("Tracked objects ({}):\n", objects.len());
220
+ for obj in objects {
221
+ println!(
222
+ " {:<20} runtime:0x{:08X} size:0x{:X} fields:{}",
223
+ obj.class_name, obj.runtime_addr, obj.size, obj.field_count
224
+ );
225
+ }
226
+ }
227
+ }
228
+ Response::AliasResult(alias) => {
229
+ println!("{} at runtime:0x{:08X}", alias.class_name, alias.runtime_addr);
230
+ }
231
+ Response::FieldResult(field) => {
232
+ println!("offset:0x{:X} size:{}", field.offset, field.size);
233
+ }
234
  Response::Error { message } => {
235
  eprintln!("Server error: {message}");
236
  process::exit(1);
 
307
  Chain { addr: u32, chain: Vec<u32>, absolute: bool },
308
  }
309
 
310
+ /// Split a string on '+' or '[' into (base, offset_str). Returns (s, None) if no compound.
311
+ fn split_compound(s: &str) -> (&str, Option<&str>) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  if let Some(pos) = s.find('+') {
313
+ let base = &s[..pos];
314
+ let off = &s[pos + 1..];
315
+ (base, Some(off))
316
  } else if let Some(pos) = s.find('[') {
317
  let base = &s[..pos];
318
  let off_str = s[pos + 1..].trim_end_matches(']');
319
+ (base, Some(off_str))
320
  } else {
321
+ (s, None)
322
  }
323
  }
324
 
325
+ /// Resolve an offset string — could be numeric or a field name.
326
+ fn resolve_offset(s: &str, class: &Option<String>, port: u16) -> u32 {
327
+ if looks_like_hex(s) {
328
+ return parse_u32(s);
329
+ }
330
+
331
+ // Try as field name
332
+ if let Some(cls) = class {
333
+ match send_request(port, &Request::ResolveField {
334
+ class_name: cls.clone(),
335
+ field_name: s.to_string(),
336
+ }) {
337
+ Ok(Response::FieldResult(f)) => return f.offset,
338
+ _ => {}
339
+ }
340
+ }
341
+
342
+ // Fall back to numeric parse (will exit on error)
343
+ parse_u32(s)
344
+ }
345
+
346
  fn parse_hex(s: &str) -> u32 {
347
  let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
348
  u32::from_str_radix(s, 16).unwrap_or_else(|_| {
 
367
  }
368
  }
369
 
370
+ /// A resolved chain segment — either a normal "deref then offset" step,
371
+ /// or a field-name step where the offset should be applied BEFORE the deref.
372
+ enum ChainSegment {
373
+ /// Normal hex offset: protocol uses it directly (deref current, add offset).
374
+ HexOffset(u32),
375
+ /// Field offset: needs to be folded into the previous address/step
376
+ /// (add offset to current, THEN deref).
377
+ FieldOffset(u32),
378
+ }
379
+
380
+ /// Resolve a symbolic address expression via the debug server.
381
+ ///
382
+ /// The underlying chain protocol semantics: each step is "deref current addr,
383
+ /// then add offset." For hex chains like `0x7A0884->0xA0->0x2C`, this works
384
+ /// directly — the offset is added after the deref.
385
+ ///
386
+ /// For field-name chains like `ddgame->task_land`, the user intent is "go to
387
+ /// the task_land field (offset 0x54C) and follow that pointer." We translate
388
+ /// this by folding the field offset into the PREVIOUS address/step:
389
+ ///
390
+ /// `ddgame->task_land` → base=ddgame+0x54C, chain=[0]
391
+ /// `wrapper->ddgame->rng_state` → base=wrapper+ddgame_off, chain=[rng_state_off]
392
+ fn resolve_address_expr(s: &str, port: u16) -> AddressExpr {
393
+ let (s, absolute) = if let Some(rest) = s.strip_prefix("abs:") {
394
+ (rest, true)
395
+ } else {
396
+ (s.as_ref(), false)
397
+ };
398
+
399
+ // Split on "->" for chain syntax
400
+ let parts: Vec<&str> = if s.contains("->") {
401
+ s.split("->").collect()
402
+ } else {
403
+ vec![s]
404
+ };
405
+
406
+ // Resolve the base (first part)
407
+ let base_part = parts[0];
408
+ let (base_addr, base_absolute, mut current_class) = resolve_base(base_part, absolute, port);
409
+
410
+ if parts.len() == 1 {
411
+ return AddressExpr::Simple { addr: base_addr, absolute: base_absolute };
412
+ }
413
+
414
+ // Resolve each chain segment, tracking whether it's a field name or hex.
415
+ let mut segments: Vec<ChainSegment> = Vec::new();
416
+ for part in &parts[1..] {
417
+ segments.push(resolve_chain_segment_typed(part, &current_class, port));
418
+ current_class = None;
419
+ }
420
+
421
+ // Convert to protocol chain format.
422
+ // HexOffset segments pass through directly (deref then add offset).
423
+ // FieldOffset segments get folded into the previous position (add offset,
424
+ // then deref via a [0] chain entry).
425
+ let mut final_base = base_addr;
426
+ let mut chain: Vec<u32> = Vec::new();
427
+
428
+ for seg in &segments {
429
+ match seg {
430
+ ChainSegment::HexOffset(offset) => {
431
+ chain.push(*offset);
432
+ }
433
+ ChainSegment::FieldOffset(offset) => {
434
+ // Fold into previous: add to base or previous chain entry
435
+ if chain.is_empty() {
436
+ final_base = final_base.wrapping_add(*offset);
437
+ } else {
438
+ let last = chain.last_mut().unwrap();
439
+ *last = last.wrapping_add(*offset);
440
+ }
441
+ chain.push(0); // deref with no extra offset
442
+ }
443
+ }
444
+ }
445
+
446
+ AddressExpr::Chain { addr: final_base, chain, absolute: base_absolute }
447
+ }
448
+
449
+ /// Resolve the base address part — could be a hex literal, a named alias, or alias+offset.
450
+ fn resolve_base(s: &str, absolute: bool, port: u16) -> (u32, bool, Option<String>) {
451
+ let (base_str, offset_str) = split_compound(s);
452
+
453
+ // If it looks like hex (starts with digit or 0x), parse directly
454
+ if looks_like_hex(base_str) {
455
+ let extra = offset_str.map(|o| parse_u32(o)).unwrap_or(0);
456
+ return (parse_hex(base_str).wrapping_add(extra), absolute, None);
457
+ }
458
+
459
+ // Try resolving as a named alias
460
+ match send_request(port, &Request::ResolveAlias { name: base_str.to_string() }) {
461
+ Ok(Response::AliasResult(alias)) => {
462
+ // Resolved! Use runtime address (absolute)
463
+ let class = Some(alias.class_name);
464
+ let extra = offset_str.map(|o| resolve_offset(o, &class, port)).unwrap_or(0);
465
+ (alias.runtime_addr.wrapping_add(extra), true, class)
466
+ }
467
+ _ => {
468
+ // Fall back to hex parse (will error if not valid hex)
469
+ let extra = offset_str.map(|o| parse_u32(o)).unwrap_or(0);
470
+ (parse_hex(base_str).wrapping_add(extra), absolute, None)
471
+ }
472
+ }
473
+ }
474
+
475
+ /// Resolve a chain segment, returning whether it was a field name or hex offset.
476
+ fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u16) -> ChainSegment {
477
+ let (base_str, offset_str) = split_compound(s);
478
+ let extra = offset_str.map(|o| resolve_offset(o, current_class, port)).unwrap_or(0);
479
+
480
+ // If it looks like hex, parse directly — standard chain semantics
481
+ if looks_like_hex(base_str) {
482
+ return ChainSegment::HexOffset(parse_u32(base_str).wrapping_add(extra));
483
+ }
484
+
485
+ // Try resolving as a field name if we know the current class
486
+ if let Some(class) = current_class {
487
+ match send_request(port, &Request::ResolveField {
488
+ class_name: class.clone(),
489
+ field_name: base_str.to_string(),
490
+ }) {
491
+ Ok(Response::FieldResult(f)) => {
492
+ return ChainSegment::FieldOffset(f.offset.wrapping_add(extra));
493
+ }
494
+ _ => {}
495
+ }
496
+ }
497
+
498
+ // Fall back to numeric parse — treat as hex semantics
499
+ ChainSegment::HexOffset(parse_u32(base_str).wrapping_add(extra))
500
+ }
501
+
502
+ /// Check if a string looks like a hex number (starts with 0x or a digit).
503
+ fn looks_like_hex(s: &str) -> bool {
504
+ s.starts_with("0x") || s.starts_with("0X") || s.chars().next().map_or(false, |c| c.is_ascii_digit())
505
+ }
506
+
507
  fn print_usage() {
508
  eprintln!("Usage: openwa-debug <command> [args...] [--port N] [--format hex|raw]");
509
  eprintln!();
 
511
  eprintln!(" ping Check if server is running");
512
  eprintln!(" help List server commands");
513
  eprintln!(" read <addr> [len] Read memory at address (default len=256)");
514
+ eprintln!(" inspect <class> <addr> Typed struct inspection (named fields)");
515
+ eprintln!(" objects List tracked live objects");
516
  eprintln!(" suspend Pause game at next frame boundary");
517
  eprintln!(" resume Resume game");
518
  eprintln!(" step [N] Advance N frames (default 1), then pause");
 
526
  eprintln!(" 0x669F8C+0x10 Address + hex offset");
527
  eprintln!(" 0x669F8C[16] Address + decimal offset (bracket notation)");
528
  eprintln!(" 0x7A0884->0xA0->0x2C Pointer chain (deref at each ->)");
529
+ eprintln!(" ddgame Named alias (resolved via server)");
530
+ eprintln!(" ddgame->rng_state Field name chain (deref + field lookup)");
531
+ eprintln!(" ddgame+rng_state Named offset (no deref, just offset)");
532
  eprintln!();
533
  eprintln!("Options:");
534
  eprintln!(" --port <N> Server port (default: 19840)");
crates/openwa-debug-proto/src/lib.rs CHANGED
@@ -21,6 +21,44 @@ pub struct PointerInfo {
21
  pub detail: Option<String>,
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  // --- Protocol messages ---
25
 
26
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,6 +81,14 @@ pub enum Request {
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.
@@ -97,6 +143,19 @@ pub enum Response {
97
  BreakSet { frame: i32 },
98
  /// Game state snapshot.
99
  Snapshot { frame: i32, text: String },
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  Error { message: String },
101
  }
102
 
 
21
  pub detail: Option<String>,
22
  }
23
 
24
+ // --- Typed inspection types ---
25
+
26
+ /// A formatted field value from a struct inspection.
27
+ #[derive(Debug, Clone, Serialize, Deserialize)]
28
+ pub struct FieldValue {
29
+ pub offset: u32,
30
+ pub name: String,
31
+ pub size: u32,
32
+ /// Raw hex representation.
33
+ pub hex: String,
34
+ /// Human-readable formatted value.
35
+ pub display: String,
36
+ }
37
+
38
+ /// A tracked live object in the DLL.
39
+ #[derive(Debug, Clone, Serialize, Deserialize)]
40
+ pub struct LiveObjectInfo {
41
+ pub runtime_addr: u32,
42
+ pub ghidra_addr: u32,
43
+ pub size: u32,
44
+ pub class_name: String,
45
+ pub field_count: u32,
46
+ }
47
+
48
+ /// Result of resolving a named alias (e.g., "ddgame") to an address.
49
+ #[derive(Debug, Clone, Serialize, Deserialize)]
50
+ pub struct ResolvedAlias {
51
+ pub runtime_addr: u32,
52
+ pub class_name: String,
53
+ }
54
+
55
+ /// Result of resolving a field name to its offset.
56
+ #[derive(Debug, Clone, Serialize, Deserialize)]
57
+ pub struct ResolvedField {
58
+ pub offset: u32,
59
+ pub size: u32,
60
+ }
61
+
62
  // --- Protocol messages ---
63
 
64
  #[derive(Debug, Clone, Serialize, Deserialize)]
 
81
  Break { frame: i32 },
82
  /// Capture a canonicalized game state snapshot.
83
  Snapshot,
84
+ /// Typed struct inspection: read all named fields at an address.
85
+ Inspect { class_name: String, addr: u32, chain: Vec<u32>, absolute: bool },
86
+ /// List all tracked live objects.
87
+ ListObjects,
88
+ /// Resolve a named alias (e.g., "ddgame") to a runtime address.
89
+ ResolveAlias { name: String },
90
+ /// Resolve a field name to its offset within a struct.
91
+ ResolveField { class_name: String, field_name: String },
92
  }
93
 
94
  /// One step in a resolved pointer chain, for display.
 
143
  BreakSet { frame: i32 },
144
  /// Game state snapshot.
145
  Snapshot { frame: i32, text: String },
146
+ /// Typed struct inspection result.
147
+ InspectResult {
148
+ class_name: String,
149
+ ghidra_addr: u32,
150
+ runtime_addr: u32,
151
+ fields: Vec<FieldValue>,
152
+ },
153
+ /// List of tracked live objects.
154
+ ObjectList { objects: Vec<LiveObjectInfo> },
155
+ /// Resolved named alias.
156
+ AliasResult(ResolvedAlias),
157
+ /// Resolved field offset.
158
+ FieldResult(ResolvedField),
159
  Error { message: String },
160
  }
161
 
crates/openwa-derive/src/lib.rs CHANGED
@@ -533,7 +533,7 @@ fn to_screaming_snake(name: &str) -> String {
533
  /// field (excluding `_unknown*` and `_pad*` prefixes). Offsets and sizes
534
  /// are computed at compile time via `core::mem::offset_of!()` and
535
  /// `core::mem::size_of::<Type>()`.
536
- #[proc_macro_derive(FieldRegistry)]
537
  pub fn derive_field_registry(input: TokenStream) -> TokenStream {
538
  let input = parse_macro_input!(input as DeriveInput);
539
  let struct_name = &input.ident;
@@ -573,11 +573,16 @@ pub fn derive_field_registry(input: TokenStream) -> TokenStream {
573
  let doc = extract_doc_comment(&field.attrs);
574
  let field_ty = &field.ty;
575
 
 
 
 
 
576
  entries.push(quote! {
577
  openwa_core::registry::FieldEntry {
578
  offset: core::mem::offset_of!(#struct_name, #field_ident) as u32,
579
  name: #field_name,
580
  size: core::mem::size_of::<#field_ty>() as u32,
 
581
  doc: #doc,
582
  }
583
  });
@@ -643,3 +648,57 @@ fn extract_doc_comment(attrs: &[syn::Attribute]) -> String {
643
  }
644
  doc
645
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  /// field (excluding `_unknown*` and `_pad*` prefixes). Offsets and sizes
534
  /// are computed at compile time via `core::mem::offset_of!()` and
535
  /// `core::mem::size_of::<Type>()`.
536
+ #[proc_macro_derive(FieldRegistry, attributes(field))]
537
  pub fn derive_field_registry(input: TokenStream) -> TokenStream {
538
  let input = parse_macro_input!(input as DeriveInput);
539
  let struct_name = &input.ident;
 
573
  let doc = extract_doc_comment(&field.attrs);
574
  let field_ty = &field.ty;
575
 
576
+ // Determine ValueKind: check for #[field(kind = "...")] override, else infer
577
+ let kind = parse_field_kind_attr(&field.attrs)
578
+ .unwrap_or_else(|| infer_value_kind(field_ty));
579
+
580
  entries.push(quote! {
581
  openwa_core::registry::FieldEntry {
582
  offset: core::mem::offset_of!(#struct_name, #field_ident) as u32,
583
  name: #field_name,
584
  size: core::mem::size_of::<#field_ty>() as u32,
585
+ kind: #kind,
586
  doc: #doc,
587
  }
588
  });
 
648
  }
649
  doc
650
  }
651
+
652
+ /// Parse an explicit `#[field(kind = "Fixed")]` attribute override.
653
+ fn parse_field_kind_attr(attrs: &[syn::Attribute]) -> Option<proc_macro2::TokenStream> {
654
+ for attr in attrs {
655
+ if !attr.path().is_ident("field") {
656
+ continue;
657
+ }
658
+ let mut kind_str = None;
659
+ let _ = attr.parse_nested_meta(|meta| {
660
+ if meta.path.is_ident("kind") {
661
+ let value = meta.value()?;
662
+ let lit: Lit = value.parse()?;
663
+ if let Lit::Str(s) = lit {
664
+ kind_str = Some(s.value());
665
+ }
666
+ }
667
+ Ok(())
668
+ });
669
+ if let Some(k) = kind_str {
670
+ return Some(value_kind_token(&k));
671
+ }
672
+ }
673
+ None
674
+ }
675
+
676
+ /// Infer `ValueKind` from a field's Rust type.
677
+ fn infer_value_kind(ty: &syn::Type) -> proc_macro2::TokenStream {
678
+ match ty {
679
+ syn::Type::Ptr(_) => value_kind_token("Pointer"),
680
+ syn::Type::Array(_) => value_kind_token("Raw"),
681
+ syn::Type::Path(tp) => {
682
+ let last = tp.path.segments.last().map(|s| s.ident.to_string());
683
+ match last.as_deref() {
684
+ Some("u8") => value_kind_token("U8"),
685
+ Some("u16") => value_kind_token("U16"),
686
+ Some("u32") => value_kind_token("U32"),
687
+ Some("i8") => value_kind_token("I8"),
688
+ Some("i16") => value_kind_token("I16"),
689
+ Some("i32") => value_kind_token("I32"),
690
+ Some("bool") => value_kind_token("Bool"),
691
+ Some("Fixed") => value_kind_token("Fixed"),
692
+ Some("ClassType") => value_kind_token("Enum"),
693
+ _ => value_kind_token("Raw"),
694
+ }
695
+ }
696
+ _ => value_kind_token("Raw"),
697
+ }
698
+ }
699
+
700
+ /// Produce a `openwa_core::registry::ValueKind::Variant` token stream.
701
+ fn value_kind_token(variant: &str) -> proc_macro2::TokenStream {
702
+ let ident = quote::format_ident!("{}", variant);
703
+ quote! { openwa_core::registry::ValueKind::#ident }
704
+ }
crates/openwa-wormkit/src/debug_server.rs CHANGED
@@ -140,6 +140,14 @@ fn handle_request(request: Request) -> Response {
140
  let frame = crate::debug_sync::current_frame();
141
  Response::Snapshot { frame, text }
142
  }
 
 
 
 
 
 
 
 
143
  }
144
  }
145
 
@@ -199,6 +207,174 @@ fn handle_read(addr: u32, len: u32, absolute: bool) -> Response {
199
  }
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  fn handle_read_chain(addr: u32, chain: &[u32], len: u32, absolute: bool) -> Response {
203
  if len == 0 || len > MAX_READ_SIZE {
204
  return Response::Error {
 
140
  let frame = crate::debug_sync::current_frame();
141
  Response::Snapshot { frame, text }
142
  }
143
+ Request::Inspect { class_name, addr, chain, absolute } => {
144
+ handle_inspect(&class_name, addr, &chain, absolute)
145
+ }
146
+ Request::ListObjects => handle_list_objects(),
147
+ Request::ResolveAlias { name } => handle_resolve_alias(&name),
148
+ Request::ResolveField { class_name, field_name } => {
149
+ handle_resolve_field(&class_name, &field_name)
150
+ }
151
  }
152
  }
153
 
 
207
  }
208
  }
209
 
210
+ fn handle_inspect(class_name: &str, addr: u32, chain: &[u32], absolute: bool) -> Response {
211
+ use openwa_core::field_format::{self, FormatContext};
212
+ use openwa_core::registry;
213
+
214
+ let fields = match registry::struct_fields_for(class_name) {
215
+ Some(f) => f,
216
+ None => {
217
+ return Response::Error {
218
+ message: format!("No FieldRegistry for '{}'", class_name),
219
+ };
220
+ }
221
+ };
222
+
223
+ // Resolve address (possibly through a chain)
224
+ let (_, runtime_addr, delta) = if chain.is_empty() {
225
+ resolve_addr(addr, absolute)
226
+ } else {
227
+ // Walk chain first
228
+ let (_, mut current, delta) = resolve_addr(addr, absolute);
229
+ for &offset in chain {
230
+ unsafe {
231
+ if !mem::can_read(current, 4) {
232
+ return Response::Error {
233
+ message: format!(
234
+ "Chain broke: cannot read at runtime:0x{:08X}",
235
+ current
236
+ ),
237
+ };
238
+ }
239
+ let value = *(current as *const u32);
240
+ if value == 0 {
241
+ return Response::Error {
242
+ message: format!("Chain broke: NULL at runtime:0x{:08X}", current),
243
+ };
244
+ }
245
+ current = value.wrapping_add(offset);
246
+ }
247
+ }
248
+ (current.wrapping_sub(delta), current, delta)
249
+ };
250
+
251
+ let ghidra_addr = runtime_addr.wrapping_sub(delta);
252
+ let ctx = FormatContext { delta };
253
+
254
+ let mut result_fields = Vec::new();
255
+ for field in fields.fields {
256
+ unsafe {
257
+ let field_ptr = (runtime_addr + field.offset) as *const u8;
258
+ let size = field.size as usize;
259
+
260
+ if !mem::can_read(runtime_addr + field.offset, field.size) {
261
+ result_fields.push(FieldValue {
262
+ offset: field.offset,
263
+ name: field.name.to_string(),
264
+ size: field.size,
265
+ hex: "<unreadable>".into(),
266
+ display: "<unreadable>".into(),
267
+ });
268
+ continue;
269
+ }
270
+
271
+ let data = core::slice::from_raw_parts(field_ptr, size);
272
+
273
+ // Hex representation
274
+ let hex = data
275
+ .iter()
276
+ .map(|b| format!("{:02X}", b))
277
+ .collect::<Vec<_>>()
278
+ .join(" ");
279
+
280
+ // Formatted display
281
+ let mut display = String::new();
282
+ let _ = field_format::format_field(&mut display, data, field, &ctx);
283
+
284
+ result_fields.push(FieldValue {
285
+ offset: field.offset,
286
+ name: field.name.to_string(),
287
+ size: field.size,
288
+ hex,
289
+ display,
290
+ });
291
+ }
292
+ }
293
+
294
+ Response::InspectResult {
295
+ class_name: class_name.to_string(),
296
+ ghidra_addr,
297
+ runtime_addr,
298
+ fields: result_fields,
299
+ }
300
+ }
301
+
302
+ fn handle_list_objects() -> Response {
303
+ use openwa_core::registry;
304
+
305
+ let wa_base = rb(va::IMAGE_BASE);
306
+ let wa_end = wa_base + 0x300000; // approximate WA image end
307
+ let delta = wa_base.wrapping_sub(va::IMAGE_BASE);
308
+ let objects = registry::live_objects()
309
+ .into_iter()
310
+ .map(|obj| {
311
+ // ghidra_addr is only meaningful for image-mapped objects, not heap
312
+ let ghidra = if obj.ptr >= wa_base && obj.ptr < wa_end {
313
+ obj.ptr.wrapping_sub(delta)
314
+ } else {
315
+ 0
316
+ };
317
+ LiveObjectInfo {
318
+ runtime_addr: obj.ptr,
319
+ ghidra_addr: ghidra,
320
+ size: obj.size,
321
+ class_name: obj.class_name.to_string(),
322
+ field_count: obj.fields.map_or(0, |f| f.fields.len() as u32),
323
+ }
324
+ })
325
+ .collect();
326
+
327
+ Response::ObjectList { objects }
328
+ }
329
+
330
+ fn handle_resolve_alias(name: &str) -> Response {
331
+ use openwa_core::registry;
332
+
333
+ let name_lower = name.to_lowercase();
334
+ for obj in registry::live_objects() {
335
+ if obj.class_name.to_lowercase() == name_lower {
336
+ return Response::AliasResult(ResolvedAlias {
337
+ runtime_addr: obj.ptr,
338
+ class_name: obj.class_name.to_string(),
339
+ });
340
+ }
341
+ }
342
+
343
+ Response::Error {
344
+ message: format!("No tracked object matching '{}'", name),
345
+ }
346
+ }
347
+
348
+ fn handle_resolve_field(class_name: &str, field_name: &str) -> Response {
349
+ use openwa_core::registry;
350
+
351
+ // Search the class and its CTask inheritance chain for a field by name.
352
+ // Mirrors the inheritance chain in registry::field_at_inherited.
353
+ const CTASK_CHAIN: &[&str] = &["CGameTask", "CTask"];
354
+
355
+ let search_chain: Vec<&str> = std::iter::once(class_name)
356
+ .chain(CTASK_CHAIN.iter().copied())
357
+ .collect();
358
+
359
+ for &name in &search_chain {
360
+ if let Some(fields) = registry::struct_fields_for(name) {
361
+ for field in fields.fields {
362
+ if field.name == field_name {
363
+ return Response::FieldResult(ResolvedField {
364
+ offset: field.offset,
365
+ size: field.size,
366
+ });
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ Response::Error {
373
+ message: format!("No field '{}' in '{}' (searched: {})", field_name, class_name,
374
+ search_chain.join(" -> ")),
375
+ }
376
+ }
377
+
378
  fn handle_read_chain(addr: u32, chain: &[u32], len: u32, absolute: bool) -> Response {
379
  if len == 0 || len > MAX_READ_SIZE {
380
  return Response::Error {
crates/openwa-wormkit/src/debug_watchpoint.rs CHANGED
@@ -126,9 +126,8 @@ unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
126
  let ghidra_eip = eip.wrapping_sub(delta);
127
 
128
  // Walk EBP chain for stack trace
129
- let mut trace = heapless::String::<512>::new();
130
  let mut ebp = ctx.Ebp;
131
- let _esp = ctx.Esp;
132
  for depth in 0..12 {
133
  // Validate EBP: must be in plausible stack range, aligned,
134
  // and above ESP (stack grows down)
@@ -144,18 +143,13 @@ unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
144
  let ghidra_ret = ret_addr.wrapping_sub(delta);
145
  let in_wa = ret_addr >= wa_base && ret_addr < wa_base + 0x300000;
146
  if depth > 0 {
147
- let _ = core::fmt::Write::write_str(&mut trace, "<-");
148
  }
149
  if in_wa {
150
- let _ = core::fmt::Write::write_fmt(
151
- &mut trace,
152
- format_args!("{:08X}", ghidra_ret),
153
- );
154
  } else {
155
- let _ = core::fmt::Write::write_fmt(
156
- &mut trace,
157
- format_args!("r:{:08X}", ret_addr),
158
- );
159
  }
160
  // EBP must increase (frames go up the stack)
161
  if next_ebp <= ebp {
@@ -165,8 +159,8 @@ unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
165
  }
166
 
167
  let _ = log_line(&format!(
168
- "[Watchpoint] {} = 0x{:08X} eip=0x{:08X} stack=[{}]",
169
- name, val, ghidra_eip, trace,
170
  ));
171
  }
172
  }
 
126
  let ghidra_eip = eip.wrapping_sub(delta);
127
 
128
  // Walk EBP chain for stack trace
129
+ let mut trace = String::new();
130
  let mut ebp = ctx.Ebp;
 
131
  for depth in 0..12 {
132
  // Validate EBP: must be in plausible stack range, aligned,
133
  // and above ESP (stack grows down)
 
143
  let ghidra_ret = ret_addr.wrapping_sub(delta);
144
  let in_wa = ret_addr >= wa_base && ret_addr < wa_base + 0x300000;
145
  if depth > 0 {
146
+ trace.push_str("<-");
147
  }
148
  if in_wa {
149
+ trace.push_str(&openwa_core::registry::format_va(ghidra_ret));
 
 
 
150
  } else {
151
+ use core::fmt::Write;
152
+ let _ = write!(trace, "r:{:08X}", ret_addr);
 
 
153
  }
154
  // EBP must increase (frames go up the stack)
155
  if next_ebp <= ebp {
 
159
  }
160
 
161
  let _ = log_line(&format!(
162
+ "[Watchpoint] {} = 0x{:08X} eip={} stack=[{}]",
163
+ name, val, openwa_core::registry::format_va(ghidra_eip), trace,
164
  ));
165
  }
166
  }
crates/openwa-wormkit/src/snapshot.rs CHANGED
@@ -7,7 +7,9 @@ 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::{hash_pointer_targets, write_raw_region, Snapshot};
12
  use openwa_core::task::{CTask, CTaskBfsIter, CTaskMissile, CTaskWorm};
13
 
@@ -144,9 +146,17 @@ pub unsafe fn capture() -> String {
144
  let _ = (*missile).write_snapshot(&mut out, 2);
145
  }
146
  _ => {
147
- // Unknown entity — raw dump of first 0x100 bytes
148
- let size = 0x100usize;
149
- let _ = write_raw_region(&mut out, task as *const u8, size, 2);
 
 
 
 
 
 
 
 
150
  }
151
  }
152
  let _ = writeln!(out);
@@ -158,3 +168,34 @@ pub unsafe fn capture() -> String {
158
  fn vtable_name(ghidra_vt: u32) -> &'static str {
159
  openwa_core::registry::vtable_class_name(ghidra_vt).unwrap_or("Unknown")
160
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  use openwa_core::address::va;
9
  use openwa_core::engine::GameSession;
10
+ use openwa_core::field_format::{self, FormatContext};
11
  use openwa_core::rebase::rb;
12
+ use openwa_core::registry::StructFields;
13
  use openwa_core::snapshot::{hash_pointer_targets, write_raw_region, Snapshot};
14
  use openwa_core::task::{CTask, CTaskBfsIter, CTaskMissile, CTaskWorm};
15
 
 
146
  let _ = (*missile).write_snapshot(&mut out, 2);
147
  }
148
  _ => {
149
+ // Try FieldRegistry-based dump for known types
150
+ let class = vtable_name(vt);
151
+ if class != "Unknown" {
152
+ if let Some(fields) = openwa_core::registry::struct_fields_for(class) {
153
+ let _ = write_registry_fields(&mut out, task as *const u8, fields, delta, 2);
154
+ } else {
155
+ let _ = write_raw_region(&mut out, task as *const u8, 0x100, 2);
156
+ }
157
+ } else {
158
+ let _ = write_raw_region(&mut out, task as *const u8, 0x100, 2);
159
+ }
160
  }
161
  }
162
  let _ = writeln!(out);
 
168
  fn vtable_name(ghidra_vt: u32) -> &'static str {
169
  openwa_core::registry::vtable_class_name(ghidra_vt).unwrap_or("Unknown")
170
  }
171
+
172
+ /// Write struct fields using FieldRegistry metadata and the format_field system.
173
+ ///
174
+ /// Produces output like:
175
+ /// ```text
176
+ /// +0x0000 vtable [4] 0x0066A30C (DDGameWrapper vtable)
177
+ /// +0x0004 parent [4] null
178
+ /// ```
179
+ unsafe fn write_registry_fields(
180
+ w: &mut dyn Write,
181
+ base: *const u8,
182
+ fields: &StructFields,
183
+ delta: u32,
184
+ indent: usize,
185
+ ) -> core::fmt::Result {
186
+ let ctx = FormatContext { delta };
187
+ let pad = " ".repeat(indent);
188
+ for field in fields.fields {
189
+ let addr = base as u32 + field.offset;
190
+ write!(w, "{}+0x{:04X} {:<16} [{:>2}] ", pad, field.offset, field.name, field.size)?;
191
+ if openwa_core::mem::can_read(addr, field.size) {
192
+ let ptr = base.add(field.offset as usize);
193
+ let data = core::slice::from_raw_parts(ptr, field.size as usize);
194
+ field_format::format_field(w, data, field, &ctx)?;
195
+ } else {
196
+ write!(w, "<unreadable>")?;
197
+ }
198
+ writeln!(w)?;
199
+ }
200
+ Ok(())
201
+ }
crates/openwa-wormkit/src/validation/mod.rs CHANGED
@@ -108,28 +108,23 @@ fn validate_addresses(result: &mut ValidationResult) {
108
  let _ = log_validation("");
109
  let _ = log_validation("--- Address Validation ---");
110
 
111
- let vtables: &[(&str, u32)] = &[
112
- ("CTask vtable", va::CTASK_VTABLE),
113
- ("CGameTask vtable", va::CGAMETASK_VTABLE),
114
- (
115
- "CGameTask SoundEmitter vtable",
116
- va::CGAMETASK_SOUND_EMITTER_VT,
117
- ),
118
- ("DDGameWrapper vtable", va::DDGAME_WRAPPER_VTABLE),
119
- ("GfxHandler vtable", va::GFX_DIR_VTABLE),
120
- ("DisplayGfx vtable", va::DISPLAY_GFX_VTABLE),
121
- ("PCLandscape vtable", va::PC_LANDSCAPE_VTABLE),
122
- ("LandscapeShader vtable", va::LANDSCAPE_SHADER_VTABLE),
123
- // DSSound vtable excluded: all 24 slots patched to Rust function pointers
124
- ("BitGrid vtable", va::TASK_STATE_MACHINE_VTABLE),
125
- ("OpenGLCPU vtable", va::OPENGL_CPU_VTABLE),
126
- ("WaterEffect vtable", va::WATER_EFFECT_VTABLE),
127
- ];
128
 
129
  let _ = log_validation("");
130
- let _ = log_validation(" Vtable location checks (.rdata range):");
131
- for (name, ghidra_addr) in vtables {
132
- let addr = rb(*ghidra_addr);
 
 
 
133
  let in_rdata = is_in_rdata(addr);
134
  result.check(
135
  &format!("{} location", name),
@@ -149,8 +144,8 @@ fn validate_addresses(result: &mut ValidationResult) {
149
 
150
  let _ = log_validation("");
151
  let _ = log_validation(" Vtable first-entry checks (should point to .text):");
152
- for (name, ghidra_addr) in vtables {
153
- let addr = rb(*ghidra_addr);
154
  unsafe {
155
  let first_entry = read_u32(addr);
156
  let in_text = is_in_text(first_entry);
@@ -262,6 +257,49 @@ fn validate_addresses(result: &mut ValidationResult) {
262
  }
263
  }
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  // ---------------------------------------------------------------------------
266
  // Struct offset validation
267
  // ---------------------------------------------------------------------------
@@ -1686,6 +1724,7 @@ pub fn run() -> Result<(), String> {
1686
  let mut result = ValidationResult::new();
1687
 
1688
  validate_addresses(&mut result);
 
1689
  validate_struct_offsets(&mut result);
1690
 
1691
  let _ = log_validation("");
 
108
  let _ = log_validation("");
109
  let _ = log_validation("--- Address Validation ---");
110
 
111
+ // Auto-discover all registered vtables from the address registry
112
+ let vtables: Vec<(&str, u32)> = openwa_core::registry::entries_by_kind(
113
+ openwa_core::registry::AddrKind::Vtable,
114
+ )
115
+ .map(|e| {
116
+ let name = e.class_name.map_or(e.name, |c| c);
117
+ (name, e.va)
118
+ })
119
+ .collect();
 
 
 
 
 
 
 
 
120
 
121
  let _ = log_validation("");
122
+ let _ = log_validation(&format!(
123
+ " Vtable location checks (.rdata range) — {} registered vtables:",
124
+ vtables.len()
125
+ ));
126
+ for &(name, ghidra_addr) in &vtables {
127
+ let addr = rb(ghidra_addr);
128
  let in_rdata = is_in_rdata(addr);
129
  result.check(
130
  &format!("{} location", name),
 
144
 
145
  let _ = log_validation("");
146
  let _ = log_validation(" Vtable first-entry checks (should point to .text):");
147
+ for &(name, ghidra_addr) in &vtables {
148
+ let addr = rb(ghidra_addr);
149
  unsafe {
150
  let first_entry = read_u32(addr);
151
  let in_text = is_in_text(first_entry);
 
257
  }
258
  }
259
 
260
+ // ---------------------------------------------------------------------------
261
+ // Typed vtable slot validation (from #[vtable(...)] metadata)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ fn validate_typed_vtable_slots(result: &mut ValidationResult) {
265
+ let _ = log_validation("");
266
+ let _ = log_validation("--- Typed Vtable Slot Validation (#[vtable(...)]) ---");
267
+
268
+ let mut vtable_count = 0;
269
+ let mut slot_count = 0;
270
+
271
+ for info in openwa_core::registry::all_vtable_info() {
272
+ if info.ghidra_va == 0 {
273
+ continue;
274
+ }
275
+ vtable_count += 1;
276
+ let vt_runtime = rb(info.ghidra_va);
277
+
278
+ for slot in info.slots {
279
+ slot_count += 1;
280
+ unsafe {
281
+ let slot_addr = vt_runtime + slot.index * 4;
282
+ let actual = read_u32(slot_addr);
283
+ let in_text = is_in_text(actual);
284
+ result.check(
285
+ &format!("{}::{} [slot {}]", info.class_name, slot.name, slot.index),
286
+ in_text,
287
+ &format!(
288
+ "0x{:08X} {}",
289
+ actual,
290
+ if in_text { "in .text" } else { "NOT in .text — hooked or corrupt?" }
291
+ ),
292
+ );
293
+ }
294
+ }
295
+ }
296
+
297
+ let _ = log_validation(&format!(
298
+ " Checked {} named slots across {} typed vtables",
299
+ slot_count, vtable_count
300
+ ));
301
+ }
302
+
303
  // ---------------------------------------------------------------------------
304
  // Struct offset validation
305
  // ---------------------------------------------------------------------------
 
1724
  let mut result = ValidationResult::new();
1725
 
1726
  validate_addresses(&mut result);
1727
+ validate_typed_vtable_slots(&mut result);
1728
  validate_struct_offsets(&mut result);
1729
 
1730
  let _ = log_validation("");