feat: add live object tracker with field-level pointer identification
Browse filesLive object tracker in registry.rs enables detect_pointer() to resolve
heap pointers into "DDGame.rng_state" style names:
- register_live_object() / unregister_live_object() for tracking
- identify_live_pointer() checks if a pointer falls inside any tracked
object and resolves the field name via StructFields
- detect_pointer() now checks live objects before vtable fallback
DDGameWrapper, DDGame, and GameSession are registered during
construct_ddgame_wrapper() in the wormkit DLL.
Also fixes proc macro to submit StructRegistration via inventory,
enabling struct_fields_for("DDGame") global lookups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crates/openwa-core/src/mem.rs
CHANGED
|
@@ -185,7 +185,32 @@ pub unsafe fn detect_pointer(value: u32, delta: u32) -> Option<KnownPointer> {
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
|
| 188 |
-
// 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if can_read(value, 4) {
|
| 190 |
let first = *(value as *const u32);
|
| 191 |
let ghidra_first = first.wrapping_sub(delta);
|
|
@@ -201,7 +226,7 @@ pub unsafe fn detect_pointer(value: u32, delta: u32) -> Option<KnownPointer> {
|
|
| 201 |
}
|
| 202 |
}
|
| 203 |
|
| 204 |
-
//
|
| 205 |
classify_pointer(value, delta).map(|info| KnownPointer {
|
| 206 |
raw_value: info.raw_value,
|
| 207 |
ghidra_value: info.ghidra_value,
|
|
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
|
| 188 |
+
// 2. Check if pointer falls inside a tracked live object
|
| 189 |
+
if let Some(m) = registry::identify_live_pointer(value) {
|
| 190 |
+
let name = match m.field {
|
| 191 |
+
Some(field) if m.offset == field.offset => {
|
| 192 |
+
format!("{}.{}", m.object.class_name, field.name)
|
| 193 |
+
}
|
| 194 |
+
Some(field) => {
|
| 195 |
+
let inner_off = m.offset - field.offset;
|
| 196 |
+
format!("{}.{}+0x{:X}", m.object.class_name, field.name, inner_off)
|
| 197 |
+
}
|
| 198 |
+
None => format!("{}+0x{:X}", m.object.class_name, m.offset),
|
| 199 |
+
};
|
| 200 |
+
return Some(KnownPointer {
|
| 201 |
+
raw_value: value,
|
| 202 |
+
ghidra_value: ghidra_val,
|
| 203 |
+
segment: PointerKind::Object,
|
| 204 |
+
name: Some(name),
|
| 205 |
+
class_name: Some(m.object.class_name),
|
| 206 |
+
detail: Some(format!(
|
| 207 |
+
"base=0x{:08X} offset=0x{:X}",
|
| 208 |
+
m.object.ptr, m.offset
|
| 209 |
+
)),
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// 3. If heap pointer, check if first DWORD is a known vtable
|
| 214 |
if can_read(value, 4) {
|
| 215 |
let first = *(value as *const u32);
|
| 216 |
let ghidra_first = first.wrapping_sub(delta);
|
|
|
|
| 226 |
}
|
| 227 |
}
|
| 228 |
|
| 229 |
+
// 4. Fall back to segment-based classification
|
| 230 |
classify_pointer(value, delta).map(|info| KnownPointer {
|
| 231 |
raw_value: info.raw_value,
|
| 232 |
ghidra_value: info.ghidra_value,
|
crates/openwa-core/src/registry.rs
CHANGED
|
@@ -277,6 +277,91 @@ pub fn all_struct_fields() -> impl Iterator<Item = &'static StructFields> {
|
|
| 277 |
struct_map().values().copied()
|
| 278 |
}
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
#[cfg(test)]
|
| 281 |
mod tests {
|
| 282 |
use super::*;
|
|
|
|
| 277 |
struct_map().values().copied()
|
| 278 |
}
|
| 279 |
|
| 280 |
+
// =========================================================================
|
| 281 |
+
// Live object tracker
|
| 282 |
+
// =========================================================================
|
| 283 |
+
|
| 284 |
+
/// A tracked live heap object.
|
| 285 |
+
#[derive(Debug, Clone)]
|
| 286 |
+
pub struct LiveObject {
|
| 287 |
+
/// Runtime base address.
|
| 288 |
+
pub ptr: u32,
|
| 289 |
+
/// Object size in bytes (0 if unknown).
|
| 290 |
+
pub size: u32,
|
| 291 |
+
/// Class/struct name (e.g., "DDGame").
|
| 292 |
+
pub class_name: &'static str,
|
| 293 |
+
/// Field registry for this struct (if available).
|
| 294 |
+
pub fields: Option<&'static StructFields>,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
use std::sync::Mutex;
|
| 298 |
+
|
| 299 |
+
static LIVE_OBJECTS: Mutex<Vec<LiveObject>> = Mutex::new(Vec::new());
|
| 300 |
+
|
| 301 |
+
/// Register a live heap object for pointer identification.
|
| 302 |
+
///
|
| 303 |
+
/// Call this from constructor hooks when a game object is allocated.
|
| 304 |
+
pub fn register_live_object(obj: LiveObject) {
|
| 305 |
+
if let Ok(mut v) = LIVE_OBJECTS.lock() {
|
| 306 |
+
// Replace if same pointer already tracked (re-allocation)
|
| 307 |
+
if let Some(existing) = v.iter_mut().find(|o| o.ptr == obj.ptr) {
|
| 308 |
+
*existing = obj;
|
| 309 |
+
} else {
|
| 310 |
+
v.push(obj);
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/// Unregister a live heap object (e.g., on destruction).
|
| 316 |
+
pub fn unregister_live_object(ptr: u32) {
|
| 317 |
+
if let Ok(mut v) = LIVE_OBJECTS.lock() {
|
| 318 |
+
v.retain(|o| o.ptr != ptr);
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/// Result of identifying a pointer as being inside a tracked live object.
|
| 323 |
+
#[derive(Debug)]
|
| 324 |
+
pub struct LiveObjectMatch {
|
| 325 |
+
pub object: LiveObject,
|
| 326 |
+
/// Byte offset of the pointer within the object.
|
| 327 |
+
pub offset: u32,
|
| 328 |
+
/// Field at this offset (if known).
|
| 329 |
+
pub field: Option<&'static FieldEntry>,
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/// Check if a runtime pointer falls inside any tracked live object.
|
| 333 |
+
///
|
| 334 |
+
/// Returns the matching object and the field at the pointer's offset.
|
| 335 |
+
pub fn identify_live_pointer(runtime_ptr: u32) -> Option<LiveObjectMatch> {
|
| 336 |
+
let v = LIVE_OBJECTS.lock().ok()?;
|
| 337 |
+
for obj in v.iter() {
|
| 338 |
+
let end = if obj.size > 0 {
|
| 339 |
+
obj.ptr + obj.size
|
| 340 |
+
} else {
|
| 341 |
+
// Unknown size — use a generous 256KB bound
|
| 342 |
+
obj.ptr + 0x40000
|
| 343 |
+
};
|
| 344 |
+
if runtime_ptr >= obj.ptr && runtime_ptr < end {
|
| 345 |
+
let offset = runtime_ptr - obj.ptr;
|
| 346 |
+
let field = obj.fields.and_then(|f| {
|
| 347 |
+
f.field_at(offset)
|
| 348 |
+
.or_else(|| f.field_containing(offset).map(|(fe, _)| fe))
|
| 349 |
+
});
|
| 350 |
+
return Some(LiveObjectMatch {
|
| 351 |
+
object: obj.clone(),
|
| 352 |
+
offset,
|
| 353 |
+
field,
|
| 354 |
+
});
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
None
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
/// Return a snapshot of all currently tracked live objects.
|
| 361 |
+
pub fn live_objects() -> Vec<LiveObject> {
|
| 362 |
+
LIVE_OBJECTS.lock().map(|v| v.clone()).unwrap_or_default()
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
#[cfg(test)]
|
| 366 |
mod tests {
|
| 367 |
use super::*;
|
crates/openwa-wormkit/src/replacements/game_session.rs
CHANGED
|
@@ -97,6 +97,17 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
|
|
| 97 |
let timer_obj = (*session).timer_obj;
|
| 98 |
let net_game = (*session).net_game;
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
let _ = log_line(&format!(
|
| 101 |
"[GameSession] display=0x{:08X}, net_game=0x{:08X}, timer=0x{:08X}, game_info(EDI)=0x{:08X}",
|
| 102 |
display as u32, net_game as u32, timer_obj as u32, game_info as u32,
|
|
@@ -147,6 +158,23 @@ pub(crate) unsafe fn construct_ddgame_wrapper(
|
|
| 147 |
this as u32, (*this).ddgame as u32,
|
| 148 |
));
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
this
|
| 151 |
}
|
| 152 |
|
|
|
|
| 97 |
let timer_obj = (*session).timer_obj;
|
| 98 |
let net_game = (*session).net_game;
|
| 99 |
|
| 100 |
+
// Register GameSession as a live object.
|
| 101 |
+
{
|
| 102 |
+
use openwa_core::registry::{self, LiveObject};
|
| 103 |
+
registry::register_live_object(LiveObject {
|
| 104 |
+
ptr: session as u32,
|
| 105 |
+
size: 0x120,
|
| 106 |
+
class_name: "GameSession",
|
| 107 |
+
fields: registry::struct_fields_for("GameSession"),
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
let _ = log_line(&format!(
|
| 112 |
"[GameSession] display=0x{:08X}, net_game=0x{:08X}, timer=0x{:08X}, game_info(EDI)=0x{:08X}",
|
| 113 |
display as u32, net_game as u32, timer_obj as u32, game_info as u32,
|
|
|
|
| 158 |
this as u32, (*this).ddgame as u32,
|
| 159 |
));
|
| 160 |
|
| 161 |
+
// Register live objects for pointer identification in debug tools.
|
| 162 |
+
use openwa_core::registry::{self, LiveObject};
|
| 163 |
+
registry::register_live_object(LiveObject {
|
| 164 |
+
ptr: this as u32,
|
| 165 |
+
size: core::mem::size_of::<DDGameWrapper>() as u32,
|
| 166 |
+
class_name: "DDGameWrapper",
|
| 167 |
+
fields: registry::struct_fields_for("DDGameWrapper"),
|
| 168 |
+
});
|
| 169 |
+
if !(*this).ddgame.is_null() {
|
| 170 |
+
registry::register_live_object(LiveObject {
|
| 171 |
+
ptr: (*this).ddgame as u32,
|
| 172 |
+
size: 0x98D8, // DDGame size
|
| 173 |
+
class_name: "DDGame",
|
| 174 |
+
fields: registry::struct_fields_for("DDGame"),
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
this
|
| 179 |
}
|
| 180 |
|