feat: upgrade debug tools with registry, typed reflection, and symbolic addresses
Browse filesWire 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 +188 -0
- crates/openwa-core/src/lib.rs +1 -0
- crates/openwa-core/src/registry.rs +33 -3
- crates/openwa-core/src/task/worm.rs +2 -0
- crates/openwa-debug-cli/src/main.rs +220 -51
- crates/openwa-debug-proto/src/lib.rs +59 -0
- crates/openwa-derive/src/lib.rs +60 -1
- crates/openwa-wormkit/src/debug_server.rs +176 -0
- crates/openwa-wormkit/src/debug_watchpoint.rs +7 -13
- crates/openwa-wormkit/src/snapshot.rs +44 -3
- crates/openwa-wormkit/src/validation/mod.rs +61 -22
|
@@ -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 |
+
}
|
|
@@ -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;
|
|
@@ -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 |
|
|
@@ -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],
|
|
@@ -62,7 +62,7 @@ fn main() {
|
|
| 62 |
eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\"");
|
| 63 |
process::exit(1);
|
| 64 |
}
|
| 65 |
-
let expr =
|
| 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 |
-
///
|
| 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
|
| 309 |
-
|
|
|
|
| 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,
|
| 314 |
} else {
|
| 315 |
-
(s,
|
| 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, ¤t_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)");
|
|
@@ -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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 {
|
|
@@ -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 =
|
| 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 |
-
|
| 148 |
}
|
| 149 |
if in_wa {
|
| 150 |
-
|
| 151 |
-
&mut trace,
|
| 152 |
-
format_args!("{:08X}", ghidra_ret),
|
| 153 |
-
);
|
| 154 |
} else {
|
| 155 |
-
|
| 156 |
-
|
| 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=
|
| 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 |
}
|
|
@@ -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 |
-
//
|
| 148 |
-
let
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
@@ -108,28 +108,23 @@ fn validate_addresses(result: &mut ValidationResult) {
|
|
| 108 |
let _ = log_validation("");
|
| 109 |
let _ = log_validation("--- Address Validation ---");
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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(
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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("");
|