feat(debugui): task tree entity census, transient filtering, scrollable inspector
Browse files- Replace SharedDataTable-based census with full DFS from CTaskTurnGame root,
so parent-hierarchy entities (CTaskTurnGame etc.) are included and the
liveness check no longer wrongly prunes them
- Filter transient entities (CTaskSeaBubble) from census by default; toolbar
checkbox toggles visibility and shows hidden count in the entity label
- Wrap inspector body in a ScrollArea so long entity trees are fully reachable
- Make census ScrollArea fill panel width (auto_shrink disabled, num_columns=3)
- Add CTASK_SEA_BUBBLE_VTABLE (0x669E88) to address.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
crates/openwa-core/src/address.rs
CHANGED
|
@@ -69,6 +69,8 @@ pub mod va {
|
|
| 69 |
pub const CTASK_OILDRUM_VTABLE: u32 = 0x0066_4338;
|
| 70 |
/// CTaskCloud vtable - cloud/airstrike entity (constructor 0x5482E0)
|
| 71 |
pub const CTASK_CLOUD_VTABLE: u32 = 0x0066_9D38;
|
|
|
|
|
|
|
| 72 |
/// Sprite vtable (0x70-byte objects, 8 entries)
|
| 73 |
pub const SPRITE_VTABLE: u32 = 0x0066_418C;
|
| 74 |
|
|
|
|
| 69 |
pub const CTASK_OILDRUM_VTABLE: u32 = 0x0066_4338;
|
| 70 |
/// CTaskCloud vtable - cloud/airstrike entity (constructor 0x5482E0)
|
| 71 |
pub const CTASK_CLOUD_VTABLE: u32 = 0x0066_9D38;
|
| 72 |
+
/// CTaskSeaBubble vtable - water bubble particle (constructor 0x554FE0)
|
| 73 |
+
pub const CTASK_SEA_BUBBLE_VTABLE: u32 = 0x0066_9E88;
|
| 74 |
/// Sprite vtable (0x70-byte objects, 8 entries)
|
| 75 |
pub const SPRITE_VTABLE: u32 = 0x0066_418C;
|
| 76 |
|
crates/openwa-debugui/src/app.rs
CHANGED
|
@@ -3,7 +3,7 @@ use openwa_core::address::va;
|
|
| 3 |
use openwa_core::ddgame::DDGame;
|
| 4 |
use openwa_core::ddgame_wrapper::DDGameWrapper;
|
| 5 |
use openwa_core::rebase::rb;
|
| 6 |
-
use openwa_core::task::{CTask, CTaskWorm
|
| 7 |
|
| 8 |
use crate::log;
|
| 9 |
|
|
@@ -23,6 +23,13 @@ const KNOWN_VTABLES: &[(u32, &str)] = &[
|
|
| 23 |
(va::CTASK_MINE_VTABLE, "CTaskMine"),
|
| 24 |
(va::CTASK_OILDRUM_VTABLE, "CTaskOilDrum"),
|
| 25 |
(va::CTASK_CLOUD_VTABLE, "CTaskCloud"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
];
|
| 27 |
|
| 28 |
fn vtable_name(runtime_vtable: u32) -> Option<&'static str> {
|
|
@@ -64,15 +71,6 @@ unsafe fn get_ddgame() -> Option<*const DDGame> {
|
|
| 64 |
Some(ddgame_ptr)
|
| 65 |
}
|
| 66 |
|
| 67 |
-
/// Returns a SharedDataTable via CTaskLand, or None if map not loaded.
|
| 68 |
-
unsafe fn get_shared_table(ddgame: *const DDGame) -> Option<SharedDataTable> {
|
| 69 |
-
let task_land = (*ddgame).task_land;
|
| 70 |
-
if task_land.is_null() { return None; }
|
| 71 |
-
let shared_data = (*(task_land as *const CTask)).shared_data;
|
| 72 |
-
if shared_data.is_null() { return None; }
|
| 73 |
-
Some(SharedDataTable::from_ptr(shared_data))
|
| 74 |
-
}
|
| 75 |
-
|
| 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
|
|
@@ -89,22 +87,47 @@ unsafe fn read_children(task: *const CTask) -> Vec<u32> {
|
|
| 89 |
}
|
| 90 |
|
| 91 |
// ---------------------------------------------------------------------------
|
| 92 |
-
// Live entity snapshot (built once per frame)
|
| 93 |
// ---------------------------------------------------------------------------
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
out
|
| 106 |
}
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
// ---------------------------------------------------------------------------
|
| 109 |
// DebugApp
|
| 110 |
// ---------------------------------------------------------------------------
|
|
@@ -116,11 +139,13 @@ pub struct DebugApp {
|
|
| 116 |
nav_history: Vec<u32>,
|
| 117 |
/// Whether the log panel should auto-scroll to the bottom.
|
| 118 |
log_auto_scroll: bool,
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
impl Default for DebugApp {
|
| 122 |
fn default() -> Self {
|
| 123 |
-
Self { selected_entity: None, nav_history: Vec::new(), log_auto_scroll: true }
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
|
@@ -163,6 +188,12 @@ impl eframe::App for DebugApp {
|
|
| 163 |
}
|
| 164 |
self.nav_history.retain(|a| live_addrs.contains(a));
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
egui::SidePanel::right("inspector_panel")
|
| 167 |
.min_width(260.0)
|
| 168 |
.default_width(300.0)
|
|
@@ -202,12 +233,29 @@ impl DebugApp {
|
|
| 202 |
return;
|
| 203 |
}
|
| 204 |
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
ui.separator();
|
| 207 |
|
| 208 |
-
egui::ScrollArea::vertical()
|
|
|
|
|
|
|
| 209 |
egui::Grid::new("census_grid")
|
| 210 |
.striped(true)
|
|
|
|
| 211 |
.min_col_width(60.0)
|
| 212 |
.show(ui, |ui| {
|
| 213 |
ui.strong("Type");
|
|
@@ -215,7 +263,7 @@ impl DebugApp {
|
|
| 215 |
ui.strong("Vtable");
|
| 216 |
ui.end_row();
|
| 217 |
|
| 218 |
-
for &(vtable, entity) in
|
| 219 |
let name = unsafe { entity_type_name(entity) };
|
| 220 |
let is_selected = self.selected_entity == Some(entity);
|
| 221 |
if ui.selectable_label(is_selected, name).clicked() {
|
|
@@ -263,7 +311,9 @@ impl DebugApp {
|
|
| 263 |
return;
|
| 264 |
}
|
| 265 |
|
| 266 |
-
|
|
|
|
|
|
|
| 267 |
let vtable = *(addr as *const u32);
|
| 268 |
let name = entity_type_name(addr);
|
| 269 |
|
|
@@ -375,7 +425,7 @@ impl DebugApp {
|
|
| 375 |
});
|
| 376 |
}
|
| 377 |
|
| 378 |
-
}
|
| 379 |
}
|
| 380 |
}
|
| 381 |
|
|
|
|
| 3 |
use openwa_core::ddgame::DDGame;
|
| 4 |
use openwa_core::ddgame_wrapper::DDGameWrapper;
|
| 5 |
use openwa_core::rebase::rb;
|
| 6 |
+
use openwa_core::task::{CTask, CTaskWorm};
|
| 7 |
|
| 8 |
use crate::log;
|
| 9 |
|
|
|
|
| 23 |
(va::CTASK_MINE_VTABLE, "CTaskMine"),
|
| 24 |
(va::CTASK_OILDRUM_VTABLE, "CTaskOilDrum"),
|
| 25 |
(va::CTASK_CLOUD_VTABLE, "CTaskCloud"),
|
| 26 |
+
(va::CTASK_SEA_BUBBLE_VTABLE, "CTaskSeaBubble"),
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
/// Vtables of entities that are created/destroyed every frame (particles,
|
| 30 |
+
/// bubbles, etc.). Filtered from the census by default to reduce noise.
|
| 31 |
+
const TRANSIENT_VTABLES: &[u32] = &[
|
| 32 |
+
va::CTASK_SEA_BUBBLE_VTABLE,
|
| 33 |
];
|
| 34 |
|
| 35 |
fn vtable_name(runtime_vtable: u32) -> Option<&'static str> {
|
|
|
|
| 71 |
Some(ddgame_ptr)
|
| 72 |
}
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
/// Read child task pointers from a CTask's children array.
|
| 75 |
///
|
| 76 |
/// The array is **sparse**: slots are nulled when a child is removed rather than
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
// ---------------------------------------------------------------------------
|
| 90 |
+
// Live entity snapshot (built once per frame via full task-tree traversal)
|
| 91 |
// ---------------------------------------------------------------------------
|
| 92 |
|
| 93 |
+
/// Walk up parent pointers from `start` to find the root task (no parent).
|
| 94 |
+
/// Returns None if task_land is null or the chain doesn't terminate within
|
| 95 |
+
/// MAX_DEPTH steps (guard against corrupt/circular pointers).
|
| 96 |
+
unsafe fn find_root_task(ddgame: *const DDGame) -> Option<u32> {
|
| 97 |
+
let task_land = (*ddgame).task_land as u32;
|
| 98 |
+
if task_land == 0 { return None; }
|
| 99 |
+
let mut current = task_land;
|
| 100 |
+
for _ in 0..64 {
|
| 101 |
+
let parent = (*(current as *const CTask)).parent as u32;
|
| 102 |
+
if parent == 0 { return Some(current); }
|
| 103 |
+
current = parent;
|
| 104 |
+
}
|
| 105 |
+
None // chain didn't terminate — corrupt data
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/// DFS the task tree from `root`, returning (vtable, addr) for every node.
|
| 109 |
+
/// A visited set prevents infinite loops from corrupt/circular pointers.
|
| 110 |
+
unsafe fn collect_task_tree(root: u32) -> Vec<(u32, u32)> {
|
| 111 |
+
let mut out = Vec::new();
|
| 112 |
+
let mut stack = vec![root];
|
| 113 |
+
let mut visited = std::collections::HashSet::new();
|
| 114 |
+
while let Some(addr) = stack.pop() {
|
| 115 |
+
if addr == 0 || !visited.insert(addr) { continue; }
|
| 116 |
+
let vtable = *(addr as *const u32);
|
| 117 |
+
out.push((vtable, addr));
|
| 118 |
+
for child in read_children(addr as *const CTask) {
|
| 119 |
+
if child != 0 { stack.push(child); }
|
| 120 |
+
}
|
| 121 |
}
|
| 122 |
out
|
| 123 |
}
|
| 124 |
|
| 125 |
+
unsafe fn collect_live_entities() -> Vec<(u32, u32)> {
|
| 126 |
+
let Some(ddgame) = get_ddgame() else { return Vec::new(); };
|
| 127 |
+
let Some(root) = find_root_task(ddgame) else { return Vec::new(); };
|
| 128 |
+
collect_task_tree(root)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
// ---------------------------------------------------------------------------
|
| 132 |
// DebugApp
|
| 133 |
// ---------------------------------------------------------------------------
|
|
|
|
| 139 |
nav_history: Vec<u32>,
|
| 140 |
/// Whether the log panel should auto-scroll to the bottom.
|
| 141 |
log_auto_scroll: bool,
|
| 142 |
+
/// Show transient entities (sea bubbles, etc.) in the census.
|
| 143 |
+
show_transient: bool,
|
| 144 |
}
|
| 145 |
|
| 146 |
impl Default for DebugApp {
|
| 147 |
fn default() -> Self {
|
| 148 |
+
Self { selected_entity: None, nav_history: Vec::new(), log_auto_scroll: true, show_transient: false }
|
| 149 |
}
|
| 150 |
}
|
| 151 |
|
|
|
|
| 188 |
}
|
| 189 |
self.nav_history.retain(|a| live_addrs.contains(a));
|
| 190 |
|
| 191 |
+
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
|
| 192 |
+
ui.horizontal(|ui| {
|
| 193 |
+
ui.checkbox(&mut self.show_transient, "Show transient");
|
| 194 |
+
});
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
egui::SidePanel::right("inspector_panel")
|
| 198 |
.min_width(260.0)
|
| 199 |
.default_width(300.0)
|
|
|
|
| 233 |
return;
|
| 234 |
}
|
| 235 |
|
| 236 |
+
let visible: Vec<&(u32, u32)> = if self.show_transient {
|
| 237 |
+
rows.iter().collect()
|
| 238 |
+
} else {
|
| 239 |
+
rows.iter()
|
| 240 |
+
.filter(|&&(vtable, _)| {
|
| 241 |
+
!TRANSIENT_VTABLES.iter().any(|&t| rb(t) == vtable)
|
| 242 |
+
})
|
| 243 |
+
.collect()
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
if visible.len() == rows.len() {
|
| 247 |
+
ui.label(format!("{} entities", rows.len()));
|
| 248 |
+
} else {
|
| 249 |
+
ui.label(format!("{} entities ({} hidden transient)", visible.len(), rows.len() - visible.len()));
|
| 250 |
+
}
|
| 251 |
ui.separator();
|
| 252 |
|
| 253 |
+
egui::ScrollArea::vertical()
|
| 254 |
+
.auto_shrink([false, false])
|
| 255 |
+
.show(ui, |ui| {
|
| 256 |
egui::Grid::new("census_grid")
|
| 257 |
.striped(true)
|
| 258 |
+
.num_columns(3)
|
| 259 |
.min_col_width(60.0)
|
| 260 |
.show(ui, |ui| {
|
| 261 |
ui.strong("Type");
|
|
|
|
| 263 |
ui.strong("Vtable");
|
| 264 |
ui.end_row();
|
| 265 |
|
| 266 |
+
for &&(vtable, entity) in &visible {
|
| 267 |
let name = unsafe { entity_type_name(entity) };
|
| 268 |
let is_selected = self.selected_entity == Some(entity);
|
| 269 |
if ui.selectable_label(is_selected, name).clicked() {
|
|
|
|
| 311 |
return;
|
| 312 |
}
|
| 313 |
|
| 314 |
+
egui::ScrollArea::vertical()
|
| 315 |
+
.auto_shrink([false, false])
|
| 316 |
+
.show(ui, |ui| { unsafe {
|
| 317 |
let vtable = *(addr as *const u32);
|
| 318 |
let name = entity_type_name(addr);
|
| 319 |
|
|
|
|
| 425 |
});
|
| 426 |
}
|
| 427 |
|
| 428 |
+
}});
|
| 429 |
}
|
| 430 |
}
|
| 431 |
|