fix: correct CTask children field names and children count display
Browse filesRuntime analysis of CTask__AppendChild (0x4fdc70) and CTask__UnlinkChild
(0x4fdce0) reveals the true semantics of the children sub-struct:
+0x08 children_capacity — starts 0x10, doubles on overflow
+0x0C children_dirty — flag set to 1 when any child is unlinked
+0x10 children_watermark — insertion counter, never decremented on remove
+0x14 children_data — sparse pointer array; dead slots are nulled
The append-only watermark means CTaskFilter's "(N children)" label was
rising unboundedly as sea bubbles spawned. Fixed by computing live count
from non-null slots rather than reading children_watermark directly.
Also adds dump_ctask_children() to validation (F8 hotkey) for live
verification of these fields. Ghidra functions renamed accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@@ -23,13 +23,18 @@ pub struct CTask {
|
|
| 23 |
pub vtable: *mut u8,
|
| 24 |
/// 0x04: Parent task in the hierarchy
|
| 25 |
pub parent: *mut u8,
|
| 26 |
-
/// 0x08: Children
|
| 27 |
-
pub
|
| 28 |
-
/// 0x0C:
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
///
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
pub children_data: *mut u8,
|
| 34 |
/// 0x18: Children hash list pointer (set to 0 in constructor)
|
| 35 |
pub children_hash: *mut u8,
|
|
|
|
| 23 |
pub vtable: *mut u8,
|
| 24 |
/// 0x04: Parent task in the hierarchy
|
| 25 |
pub parent: *mut u8,
|
| 26 |
+
/// 0x08: Children array capacity — starts at 0x10, doubles via realloc when full.
|
| 27 |
+
pub children_capacity: u32,
|
| 28 |
+
/// 0x0C: Set to 1 by `FUN_004fdce0` when a child slot is nulled (dirty flag).
|
| 29 |
+
/// Zero at construction. Not decremented; purely a "child was removed" marker.
|
| 30 |
+
pub children_dirty: u32,
|
| 31 |
+
/// 0x10: Insert watermark — incremented on every child insertion, never decremented
|
| 32 |
+
/// on removal. Dead children leave null slots; `children_data[0..children_watermark]`
|
| 33 |
+
/// is a sparse array. This grows without bound within a session (e.g., sea bubbles
|
| 34 |
+
/// continuously spawn/die, each consuming a new slot and doubling capacity as needed).
|
| 35 |
+
pub children_watermark: u32,
|
| 36 |
+
/// 0x14: Pointer to children data array (sparse, allocated 0x60 bytes initially,
|
| 37 |
+
/// reallocated to `children_capacity * 8 + 0x20` bytes on overflow).
|
| 38 |
pub children_data: *mut u8,
|
| 39 |
/// 0x18: Children hash list pointer (set to 0 in constructor)
|
| 40 |
pub children_hash: *mut u8,
|
|
@@ -76,11 +76,11 @@ unsafe fn get_ddgame() -> Option<*const DDGame> {
|
|
| 76 |
/// Read child task pointers from a CTask's children array.
|
| 77 |
///
|
| 78 |
/// The array is **sparse**: slots are nulled when a child is removed rather than
|
| 79 |
-
/// compacted. `
|
| 80 |
/// by CTask::HandleMessage), not the live-child count. We return all slots up to
|
| 81 |
/// that bound so the caller can filter nulls and display the live set.
|
| 82 |
unsafe fn read_children(task: *const CTask) -> Vec<u32> {
|
| 83 |
-
let slots = (*task).
|
| 84 |
let data = (*task).children_data as *const u32;
|
| 85 |
if data.is_null() || slots == 0 { return Vec::new(); }
|
| 86 |
// Hard safety cap: 4096 slots × 4 bytes = 16 KB max read
|
|
@@ -343,8 +343,8 @@ impl DebugApp {
|
|
| 343 |
}
|
| 344 |
ui.end_row();
|
| 345 |
|
| 346 |
-
//
|
| 347 |
-
ui.label("child slots"); ui.label(format!("{}
|
| 348 |
ui.label("class_type"); ui.label(format!("{:?}", (*task).class_type)); ui.end_row();
|
| 349 |
});
|
| 350 |
});
|
|
@@ -353,7 +353,7 @@ impl DebugApp {
|
|
| 353 |
// read_children returns all slots (sparse); filter nulls for live count.
|
| 354 |
let children = read_children(task);
|
| 355 |
let live_count = children.iter().filter(|&&a| a != 0).count();
|
| 356 |
-
let slot_count = (*task).
|
| 357 |
if live_count > 0 || slot_count > 0 {
|
| 358 |
egui::CollapsingHeader::new(format!("Children ({} live / {} slots)", live_count, slot_count))
|
| 359 |
.default_open(true)
|
|
@@ -365,7 +365,8 @@ impl DebugApp {
|
|
| 365 |
|
| 366 |
// Expand inline if the child itself has children
|
| 367 |
let child_task = child_addr as *const CTask;
|
| 368 |
-
let
|
|
|
|
| 369 |
|
| 370 |
if grandchild_count > 0 {
|
| 371 |
// Show as a sub-collapsing header with its own children
|
|
@@ -382,7 +383,6 @@ impl DebugApp {
|
|
| 382 |
*navigate_to = Some(child_addr);
|
| 383 |
}
|
| 384 |
// Show grandchildren
|
| 385 |
-
let grandchildren = read_children(child_task);
|
| 386 |
for gc_addr in &grandchildren {
|
| 387 |
let gc_addr = *gc_addr;
|
| 388 |
if gc_addr == 0 { continue; }
|
|
|
|
| 76 |
/// Read child task pointers from a CTask's children array.
|
| 77 |
///
|
| 78 |
/// The array is **sparse**: slots are nulled when a child is removed rather than
|
| 79 |
+
/// compacted. `children_watermark` is the insertion counter (loop upper bound used
|
| 80 |
/// by CTask::HandleMessage), not the live-child count. We return all slots up to
|
| 81 |
/// that bound so the caller can filter nulls and display the live set.
|
| 82 |
unsafe fn read_children(task: *const CTask) -> Vec<u32> {
|
| 83 |
+
let slots = (*task).children_watermark as usize;
|
| 84 |
let data = (*task).children_data as *const u32;
|
| 85 |
if data.is_null() || slots == 0 { return Vec::new(); }
|
| 86 |
// Hard safety cap: 4096 slots × 4 bytes = 16 KB max read
|
|
|
|
| 343 |
}
|
| 344 |
ui.end_row();
|
| 345 |
|
| 346 |
+
// children_watermark = total insertions (sparse array); live = non-null slots
|
| 347 |
+
ui.label("child slots"); ui.label(format!("{} watermark / {} cap", (*task).children_watermark, (*task).children_capacity)); ui.end_row();
|
| 348 |
ui.label("class_type"); ui.label(format!("{:?}", (*task).class_type)); ui.end_row();
|
| 349 |
});
|
| 350 |
});
|
|
|
|
| 353 |
// read_children returns all slots (sparse); filter nulls for live count.
|
| 354 |
let children = read_children(task);
|
| 355 |
let live_count = children.iter().filter(|&&a| a != 0).count();
|
| 356 |
+
let slot_count = (*task).children_watermark as usize;
|
| 357 |
if live_count > 0 || slot_count > 0 {
|
| 358 |
egui::CollapsingHeader::new(format!("Children ({} live / {} slots)", live_count, slot_count))
|
| 359 |
.default_open(true)
|
|
|
|
| 365 |
|
| 366 |
// Expand inline if the child itself has children
|
| 367 |
let child_task = child_addr as *const CTask;
|
| 368 |
+
let grandchildren = read_children(child_task);
|
| 369 |
+
let grandchild_count = grandchildren.iter().filter(|&&a| a != 0).count();
|
| 370 |
|
| 371 |
if grandchild_count > 0 {
|
| 372 |
// Show as a sub-collapsing header with its own children
|
|
|
|
| 383 |
*navigate_to = Some(child_addr);
|
| 384 |
}
|
| 385 |
// Show grandchildren
|
|
|
|
| 386 |
for gc_addr in &grandchildren {
|
| 387 |
let gc_addr = *gc_addr;
|
| 388 |
if gc_addr == 0 { continue; }
|
|
@@ -262,7 +262,7 @@ fn validate_struct_offsets(result: &mut ValidationResult) {
|
|
| 262 |
let _ = log_validation(" CTask:");
|
| 263 |
check_offset!(result, CTask, vtable, 0x00);
|
| 264 |
check_offset!(result, CTask, parent, 0x04);
|
| 265 |
-
check_offset!(result, CTask,
|
| 266 |
check_offset!(result, CTask, children_data, 0x14);
|
| 267 |
check_offset!(result, CTask, class_type, 0x20);
|
| 268 |
check_offset!(result, CTask, shared_data, 0x24);
|
|
@@ -1048,6 +1048,85 @@ fn dump_turngame() {
|
|
| 1048 |
}
|
| 1049 |
}
|
| 1050 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
// ---------------------------------------------------------------------------
|
| 1052 |
// CTaskTeam entity dump
|
| 1053 |
// ---------------------------------------------------------------------------
|
|
@@ -1181,6 +1260,8 @@ pub fn run() -> Result<(), String> {
|
|
| 1181 |
dump_turngame();
|
| 1182 |
let _ = log_validation(" Running CTaskTeam entity dump (5s mark)...");
|
| 1183 |
dump_ctaskteam_entities();
|
|
|
|
|
|
|
| 1184 |
|
| 1185 |
// Wait briefly for replay to start, then dump game state
|
| 1186 |
// (fast-forward finishes the replay in ~10-15s total, so dump early)
|
|
@@ -1240,11 +1321,11 @@ pub fn start_hotkeys() {
|
|
| 1240 |
const VK_F10: i32 = 0x79;
|
| 1241 |
const VK_F11: i32 = 0x7A;
|
| 1242 |
const VK_F12: i32 = 0x7B;
|
| 1243 |
-
let _ = log_validation(" Hotkeys: F8=
|
| 1244 |
loop {
|
| 1245 |
std::thread::sleep(std::time::Duration::from_millis(100));
|
| 1246 |
unsafe {
|
| 1247 |
-
if GetAsyncKeyState(VK_F8) & 1 != 0 {
|
| 1248 |
if GetAsyncKeyState(VK_F9) & 1 != 0 { dump_team_blocks(); }
|
| 1249 |
if GetAsyncKeyState(VK_F10) & 1 != 0 { dump_landscape(); }
|
| 1250 |
if GetAsyncKeyState(VK_F11) & 1 != 0 { dump_worm_tasks(); }
|
|
|
|
| 262 |
let _ = log_validation(" CTask:");
|
| 263 |
check_offset!(result, CTask, vtable, 0x00);
|
| 264 |
check_offset!(result, CTask, parent, 0x04);
|
| 265 |
+
check_offset!(result, CTask, children_capacity, 0x08);
|
| 266 |
check_offset!(result, CTask, children_data, 0x14);
|
| 267 |
check_offset!(result, CTask, class_type, 0x20);
|
| 268 |
check_offset!(result, CTask, shared_data, 0x24);
|
|
|
|
| 1048 |
}
|
| 1049 |
}
|
| 1050 |
|
| 1051 |
+
// ---------------------------------------------------------------------------
|
| 1052 |
+
// CTask children sub-struct live dump
|
| 1053 |
+
// ---------------------------------------------------------------------------
|
| 1054 |
+
//
|
| 1055 |
+
// We need to confirm the layout of the four fields at CTask+0x08..+0x17:
|
| 1056 |
+
// +0x08 children_capacity (u32, starts 0x10)
|
| 1057 |
+
// +0x0C children_dirty (u32, flag)
|
| 1058 |
+
// +0x10 children_watermark (u32, insertion counter)
|
| 1059 |
+
// +0x14 children_data (*mut u8, array pointer)
|
| 1060 |
+
//
|
| 1061 |
+
// We dump the raw DWORDs at these offsets for a few representative tasks so
|
| 1062 |
+
// we can verify against what WA actually stores at runtime.
|
| 1063 |
+
|
| 1064 |
+
fn dump_ctask_children() {
|
| 1065 |
+
use crate::replacements::input::dump_region;
|
| 1066 |
+
|
| 1067 |
+
let _ = log_validation("");
|
| 1068 |
+
let _ = log_validation("--- CTask children sub-struct dump ---");
|
| 1069 |
+
|
| 1070 |
+
unsafe {
|
| 1071 |
+
let session_ptr = read_u32(rb(va::G_GAME_SESSION));
|
| 1072 |
+
if session_ptr == 0 { let _ = log_validation(" No game session."); return; }
|
| 1073 |
+
let wrapper_addr = read_u32(session_ptr + 0xA0);
|
| 1074 |
+
if wrapper_addr == 0 { let _ = log_validation(" No DDGameWrapper."); return; }
|
| 1075 |
+
let ddgame_ptr = read_u32(wrapper_addr + 0x488);
|
| 1076 |
+
if ddgame_ptr == 0 { let _ = log_validation(" No DDGame."); return; }
|
| 1077 |
+
|
| 1078 |
+
let task_land_ptr = read_u32(ddgame_ptr + 0x54C);
|
| 1079 |
+
if task_land_ptr == 0 { let _ = log_validation(" CTaskLand NULL."); return; }
|
| 1080 |
+
|
| 1081 |
+
// Walk up to root (CTaskTurnGame)
|
| 1082 |
+
let mut cursor = task_land_ptr;
|
| 1083 |
+
for _ in 0..10 {
|
| 1084 |
+
let parent = read_u32(cursor + 0x04);
|
| 1085 |
+
if parent == 0 { break; }
|
| 1086 |
+
cursor = parent;
|
| 1087 |
+
}
|
| 1088 |
+
let root = cursor;
|
| 1089 |
+
|
| 1090 |
+
// Print the children sub-struct fields for root, then walk its first
|
| 1091 |
+
// child (CTaskTeam level) and each of its children (CTaskFilter level).
|
| 1092 |
+
let print_task_children = |label: &str, addr: u32| {
|
| 1093 |
+
if addr == 0 { return; }
|
| 1094 |
+
let cap = read_u32(addr + 0x08);
|
| 1095 |
+
let dirty = read_u32(addr + 0x0C);
|
| 1096 |
+
let wmark = read_u32(addr + 0x10);
|
| 1097 |
+
let data = read_u32(addr + 0x14);
|
| 1098 |
+
let _ = log_validation(&format!(
|
| 1099 |
+
" {} @ 0x{:08X} cap={} dirty={} watermark={} data=0x{:08X}",
|
| 1100 |
+
label, addr, cap, dirty, wmark, data
|
| 1101 |
+
));
|
| 1102 |
+
// Raw dump of the 0x30-byte CTask base so we can cross-check all fields
|
| 1103 |
+
dump_region(addr as *const u8, 0x00, 0x30, "CTask");
|
| 1104 |
+
};
|
| 1105 |
+
|
| 1106 |
+
print_task_children("root (CTaskTurnGame)", root);
|
| 1107 |
+
|
| 1108 |
+
// First two children of root (CTaskTeam instances)
|
| 1109 |
+
let root_watermark = read_u32(root + 0x10) as usize;
|
| 1110 |
+
let root_data = read_u32(root + 0x14);
|
| 1111 |
+
let mut teams_found = 0;
|
| 1112 |
+
for i in 0..root_watermark.min(64) {
|
| 1113 |
+
let child = read_u32(root_data + i as u32 * 4);
|
| 1114 |
+
if child == 0 { continue; }
|
| 1115 |
+
print_task_children(&format!(" child[{}] (CTaskTeam?)", i), child);
|
| 1116 |
+
// And print the first few children of this child (CTaskFilter level)
|
| 1117 |
+
let child_wmark = read_u32(child + 0x10) as usize;
|
| 1118 |
+
let child_data = read_u32(child + 0x14);
|
| 1119 |
+
for j in 0..child_wmark.min(16) {
|
| 1120 |
+
let gc = read_u32(child_data + j as u32 * 4);
|
| 1121 |
+
if gc == 0 { continue; }
|
| 1122 |
+
print_task_children(&format!(" grandchild[{}] (CTaskFilter?)", j), gc);
|
| 1123 |
+
}
|
| 1124 |
+
teams_found += 1;
|
| 1125 |
+
if teams_found >= 2 { break; }
|
| 1126 |
+
}
|
| 1127 |
+
}
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
// ---------------------------------------------------------------------------
|
| 1131 |
// CTaskTeam entity dump
|
| 1132 |
// ---------------------------------------------------------------------------
|
|
|
|
| 1260 |
dump_turngame();
|
| 1261 |
let _ = log_validation(" Running CTaskTeam entity dump (5s mark)...");
|
| 1262 |
dump_ctaskteam_entities();
|
| 1263 |
+
let _ = log_validation(" Running CTask children sub-struct dump (5s mark)...");
|
| 1264 |
+
dump_ctask_children();
|
| 1265 |
|
| 1266 |
// Wait briefly for replay to start, then dump game state
|
| 1267 |
// (fast-forward finishes the replay in ~10-15s total, so dump early)
|
|
|
|
| 1321 |
const VK_F10: i32 = 0x79;
|
| 1322 |
const VK_F11: i32 = 0x7A;
|
| 1323 |
const VK_F12: i32 = 0x7B;
|
| 1324 |
+
let _ = log_validation(" Hotkeys: F8=ctask children, F9=team blocks, F10=landscape, F11=worm tasks, F12=entity census");
|
| 1325 |
loop {
|
| 1326 |
std::thread::sleep(std::time::Duration::from_millis(100));
|
| 1327 |
unsafe {
|
| 1328 |
+
if GetAsyncKeyState(VK_F8) & 1 != 0 { dump_ctask_children(); }
|
| 1329 |
if GetAsyncKeyState(VK_F9) & 1 != 0 { dump_team_blocks(); }
|
| 1330 |
if GetAsyncKeyState(VK_F10) & 1 != 0 { dump_landscape(); }
|
| 1331 |
if GetAsyncKeyState(VK_F11) & 1 != 0 { dump_worm_tasks(); }
|