use eframe::egui; use openwa_core::address::va; use openwa_core::engine::{game_session, DDGame}; use openwa_core::rebase::rb; use openwa_core::registry; use openwa_core::task::{ CTask, CTaskCloud, CTaskFire, CTaskTeam, CTaskTurnGame, CTaskWorm, TurnGameCtx, }; use crate::log; // --------------------------------------------------------------------------- // Known task types for census display // --------------------------------------------------------------------------- /// Vtables of entities that are created/destroyed every frame (particles, /// bubbles, etc.). Filtered from the census by default to reduce noise. const TRANSIENT_VTABLES: &[u32] = &[va::CTASK_SEA_BUBBLE_VTABLE]; fn vtable_name(runtime_vtable: u32) -> Option<&'static str> { let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE); let ghidra_va = runtime_vtable.wrapping_sub(delta); openwa_core::registry::vtable_class_name(ghidra_va) } /// Returns a display name for the entity at `addr`. /// Tries the known-vtable map first; falls back to CTask.class_type. unsafe fn entity_type_name(addr: u32) -> String { if addr == 0 { return "(null)".to_owned(); } let vtable = *(addr as *const u32); if let Some(name) = vtable_name(vtable) { return name.to_owned(); } let task = addr as *const CTask; format!("{:?}", (*task).class_type) } /// One-line label for a task: "TypeName @ 0xADDR" unsafe fn entity_label(addr: u32) -> String { if addr == 0 { return "(null)".to_owned(); } format!("{} @ {:#010X}", entity_type_name(addr), addr) } // --------------------------------------------------------------------------- // Game-memory helpers (all unsafe — call from the UI update function only) // --------------------------------------------------------------------------- /// Returns a pointer to DDGame, or None if not in-game. unsafe fn get_ddgame() -> Option<*const DDGame> { let ptr = game_session::get_ddgame(); if ptr.is_null() { None } else { Some(ptr) } } /// Unlock all weapons: set ammo to unlimited (-1) and delays to 0 for all teams. unsafe fn cheat_unlock_all_weapons() { let Some(ddgame) = get_ddgame() else { log::push("[Cheats] Not in game"); return; }; let ddgame = ddgame as *mut DDGame; let arena = &mut (*ddgame).team_arena; for team in &mut arena.weapon_slots.teams { for ammo in &mut team.ammo { *ammo = -1; // unlimited } for delay in &mut team.delay { *delay = 0; // no delay } } log::push("[Cheats] All weapons unlocked (infinite ammo, no delays)"); } /// Read child task pointers from a CTask's children array. /// /// The array is **sparse**: slots are nulled when a child is removed rather than /// compacted. `children_watermark` is the insertion counter (loop upper bound used /// by CTask::HandleMessage), not the live-child count. We return all slots up to /// that bound so the caller can filter nulls and display the live set. unsafe fn read_children(task: *const CTask) -> Vec { let slots = (*task).children_watermark as usize; let data = (*task).children_data as *const u32; if data.is_null() || slots == 0 { return Vec::new(); } // Hard safety cap: 4096 slots × 4 bytes = 16 KB max read let slots = slots.min(4096); (0..slots).map(|i| *data.add(i)).collect() } // --------------------------------------------------------------------------- // Live entity snapshot (built once per frame via full task-tree traversal) // --------------------------------------------------------------------------- /// Walk up parent pointers from `start` to find the root task (no parent). /// Returns None if task_land is null or the chain doesn't terminate within /// MAX_DEPTH steps (guard against corrupt/circular pointers). unsafe fn find_root_task(ddgame: *const DDGame) -> Option { let task_land = (*ddgame).task_land as u32; if task_land == 0 { return None; } let mut current = task_land; for _ in 0..64 { let parent = (*(current as *const CTask)).parent as u32; if parent == 0 { return Some(current); } current = parent; } None // chain didn't terminate — corrupt data } /// DFS the task tree from `root`, returning (vtable, addr) for every node. /// A visited set prevents infinite loops from corrupt/circular pointers. unsafe fn collect_task_tree(root: u32) -> Vec<(u32, u32)> { let mut out = Vec::new(); let mut stack = vec![root]; let mut visited = std::collections::HashSet::new(); while let Some(addr) = stack.pop() { if addr == 0 || !visited.insert(addr) { continue; } let vtable = *(addr as *const u32); out.push((vtable, addr)); for child in read_children(addr as *const CTask) { if child != 0 { stack.push(child); } } } out } unsafe fn collect_live_entities() -> Vec<(u32, u32)> { let Some(ddgame) = get_ddgame() else { return Vec::new(); }; let Some(root) = find_root_task(ddgame) else { return Vec::new(); }; collect_task_tree(root) } // --------------------------------------------------------------------------- // DebugApp // --------------------------------------------------------------------------- pub struct DebugApp { /// Currently selected entity address for the struct inspector. selected_entity: Option, /// Navigation history — addresses we came from (supports ← Back). nav_history: Vec, /// Whether the log panel should auto-scroll to the bottom. log_auto_scroll: bool, /// Show transient entities (sea bubbles, etc.) in the census. show_transient: bool, } impl Default for DebugApp { fn default() -> Self { Self { selected_entity: None, nav_history: Vec::new(), log_auto_scroll: true, show_transient: false, } } } impl DebugApp { /// Navigate to `addr`, pushing the current selection onto the history stack. fn navigate_to(&mut self, addr: u32) { if let Some(cur) = self.selected_entity { if cur != addr { self.nav_history.push(cur); } } self.selected_entity = Some(addr); } /// Navigate back to the previous address, if any. fn navigate_back(&mut self) { if let Some(prev) = self.nav_history.pop() { self.selected_entity = Some(prev); } } } impl eframe::App for DebugApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Repaint at ~30 fps so the display stays live. ctx.request_repaint_after(std::time::Duration::from_millis(33)); // Build the live entity snapshot for this frame and prune any stale // selections before rendering — prevents UAF crashes when an entity is // destroyed (match ended, worm died, etc.) while it is being inspected. let live_entities = unsafe { collect_live_entities() }; let live_addrs: std::collections::HashSet = live_entities.iter().map(|&(_, a)| a).collect(); if let Some(addr) = self.selected_entity { if addr != 0 && !live_addrs.contains(&addr) { self.selected_entity = None; self.nav_history.clear(); } } self.nav_history.retain(|a| live_addrs.contains(a)); egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("Cheats", |ui| { if ui.button("Unlock all weapons").clicked() { unsafe { cheat_unlock_all_weapons() }; ui.close_menu(); } }); }); ui.horizontal(|ui| { ui.checkbox(&mut self.show_transient, "Show transient"); }); }); egui::SidePanel::right("inspector_panel") .min_width(260.0) .default_width(300.0) .show(ctx, |ui| { let mut navigate_to: Option = None; let mut go_back = false; self.show_inspector(ui, &mut navigate_to, &mut go_back); if go_back { self.navigate_back(); } if let Some(addr) = navigate_to { self.navigate_to(addr); } }); egui::TopBottomPanel::bottom("log_panel") .min_height(140.0) .default_height(160.0) .show(ctx, |ui| { self.show_log(ui); }); egui::CentralPanel::default().show(ctx, |ui| { let mut navigate_to: Option = None; self.show_census(ui, &live_entities, &mut navigate_to); if let Some(addr) = navigate_to { self.navigate_to(addr); } }); } } // --------------------------------------------------------------------------- // Panel: Entity Census // --------------------------------------------------------------------------- impl DebugApp { fn show_census( &mut self, ui: &mut egui::Ui, rows: &[(u32, u32)], navigate_to: &mut Option, ) { ui.heading("Entity Census"); if rows.is_empty() { ui.colored_label(egui::Color32::YELLOW, "No game session — waiting..."); return; } let visible: Vec<&(u32, u32)> = if self.show_transient { rows.iter().collect() } else { rows.iter() .filter(|&&(vtable, _)| !TRANSIENT_VTABLES.iter().any(|&t| rb(t) == vtable)) .collect() }; if visible.len() == rows.len() { ui.label(format!("{} entities", rows.len())); } else { ui.label(format!( "{} entities ({} hidden transient)", visible.len(), rows.len() - visible.len() )); } ui.separator(); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { egui::Grid::new("census_grid") .striped(true) .num_columns(3) .min_col_width(60.0) .show(ui, |ui| { ui.strong("Type"); ui.strong("Address"); ui.strong("Vtable"); ui.end_row(); for &&(vtable, entity) in &visible { let name = unsafe { entity_type_name(entity) }; let is_selected = self.selected_entity == Some(entity); if ui.selectable_label(is_selected, name).clicked() { *navigate_to = Some(entity); } ui.label(format!("{:#010X}", entity)); ui.label(format!("{:#010X}", vtable)); ui.end_row(); } }); }); } } // --------------------------------------------------------------------------- // Raw field viewer for CGameTask-derived types // --------------------------------------------------------------------------- /// CGameTask field labels for the base class region (0x00..0xFC). /// Display all DWORDs of a CGameTask-derived entity with labelled fields. /// /// Field names are resolved from the global registry via inheritance-aware /// lookup (entity → CGameTask → CTask). No hardcoded label tables needed. unsafe fn show_game_task_raw_fields( ui: &mut egui::Ui, addr: u32, type_name: &str, total_size: usize, ) { let base = addr as *const u32; let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE); // Sections: CTask base, CGameTask unknowns, pos/speed, more unknowns, emitter, // then type-specific in 0x80-byte chunks to keep each section manageable. let mut sections: Vec<(usize, usize, String)> = vec![ (0x000, 0x030, "CTask base".into()), (0x030, 0x084, "CGameTask +0x30".into()), (0x084, 0x098, "pos / speed / angle".into()), (0x098, 0x0E8, "CGameTask +0x98".into()), (0x0E8, 0x0FC, "SoundEmitter".into()), ]; // Split type-specific region into chunks of 0x80 bytes let mut chunk_start = 0x0FC; while chunk_start < total_size { let chunk_end = (chunk_start + 0x80).min(total_size); sections.push(( chunk_start, chunk_end, format!("{} +0x{:03X}..0x{:03X}", type_name, chunk_start, chunk_end), )); chunk_start = chunk_end; } for (start, end, section_name) in §ions { let (start, end) = (*start, *end); if start >= total_size { break; } let end = end.min(total_size); let header = format!("{} (0x{:03X}..0x{:03X})", section_name, start, end); let default_open = false; egui::CollapsingHeader::new(header) .id_source(format!("{}_{}_{:03X}", type_name, addr, start)) .default_open(default_open) .show(ui, |ui| { egui::Grid::new(format!("raw_{}_{}_{:03X}", type_name, addr, start)) .striped(true) .num_columns(4) .show(ui, |ui| { ui.strong("Offset"); ui.strong("Value"); ui.strong("Field"); ui.strong("Points to"); ui.end_row(); let dwords = (end - start) / 4; for i in 0..dwords { let off = start + i * 4; let val = *base.add(off / 4); let field_name = registry::field_at_inherited(type_name, off as u32) .map(|f| f.name) .unwrap_or(""); ui.label(format!("+0x{:03X}", off)); ui.label(format!("{:#010X} ({})", val, val as i32)); ui.label(field_name); // Pointer identification via registry use openwa_core::mem; let ptr_label = if val >= 0x10000 { mem::identify_pointer(val, delta).and_then(|id| id.name) } else { None }; if let Some(label) = ptr_label { ui.colored_label(egui::Color32::LIGHT_BLUE, format!("→ {}", label)); } else { ui.label(""); } ui.end_row(); } }); }); } } // --------------------------------------------------------------------------- // Panel: Struct Inspector // --------------------------------------------------------------------------- impl DebugApp { fn show_inspector( &mut self, ui: &mut egui::Ui, navigate_to: &mut Option, go_back: &mut bool, ) { // Navigation bar ui.horizontal(|ui| { ui.heading("Inspector"); ui.add_space(8.0); if ui .add_enabled(!self.nav_history.is_empty(), egui::Button::new("← Back")) .clicked() { *go_back = true; } if !self.nav_history.is_empty() { ui.weak(format!("({} deep)", self.nav_history.len())); } }); let Some(addr) = self.selected_entity else { ui.colored_label(egui::Color32::GRAY, "Select an entity in the census."); return; }; if addr == 0 { ui.label("(null entity)"); return; } egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { unsafe { let vtable = *(addr as *const u32); let name = entity_type_name(addr); ui.label(format!("Entity: {:#010X}", addr)); ui.label(format!("Type: {}", name)); ui.label(format!("Vtable: {:#010X}", vtable)); ui.separator(); let task = addr as *const CTask; // --- CTask base --- egui::CollapsingHeader::new("CTask base") .default_open(true) .show(ui, |ui| { egui::Grid::new("ctask_grid").striped(true).show(ui, |ui| { // Parent — clickable link let parent = (*task).parent as u32; ui.label("parent"); if parent != 0 { if ui.link(entity_label(parent)).clicked() { *navigate_to = Some(parent); } } else { ui.label("(none)"); } ui.end_row(); // children_watermark = total insertions (sparse array); live = non-null slots ui.label("child slots"); ui.label(format!( "{} watermark / {} cap", (*task).children_watermark, (*task).children_capacity )); ui.end_row(); ui.label("class_type"); ui.label(format!("{:?}", (*task).class_type)); ui.end_row(); }); }); // --- Children tree --- // read_children returns all slots (sparse); filter nulls for live count. let children = read_children(task); let live_count = children.iter().filter(|&&a| a != 0).count(); let slot_count = (*task).children_watermark as usize; if live_count > 0 || slot_count > 0 { egui::CollapsingHeader::new(format!( "Children ({} live / {} slots)", live_count, slot_count )) .default_open(true) .show(ui, |ui| { for child_addr in &children { let child_addr = *child_addr; if child_addr == 0 { continue; } let child_name = entity_type_name(child_addr); // Expand inline if the child itself has children let child_task = child_addr as *const CTask; let grandchildren = read_children(child_task); let grandchild_count = grandchildren.iter().filter(|&&a| a != 0).count(); if grandchild_count > 0 { // Show as a sub-collapsing header with its own children let header_label = format!( "{} @ {:#010X} ({} children)", child_name, child_addr, grandchild_count ); egui::CollapsingHeader::new(&header_label) .id_source(child_addr) .default_open(false) .show(ui, |ui| { // Link to inspect this child in detail if ui.link("→ Inspect").clicked() { *navigate_to = Some(child_addr); } // Show grandchildren for gc_addr in &grandchildren { let gc_addr = *gc_addr; if gc_addr == 0 { continue; } if ui.link(entity_label(gc_addr)).clicked() { *navigate_to = Some(gc_addr); } } }); } else { // Leaf child — single clickable link let label = format!(" {} @ {:#010X}", child_name, child_addr); if ui.link(label).clicked() { *navigate_to = Some(child_addr); } } } }); } // --- CTaskMine-specific fields --- if name == "CTaskMine" { show_game_task_raw_fields(ui, addr, "CTaskMine", 0x128); } // --- CTaskOilDrum-specific fields --- if name == "CTaskOilDrum" { show_game_task_raw_fields(ui, addr, "CTaskOilDrum", 0x110); } // --- CTaskCrate-specific fields --- if name == "CTaskCrate" { show_game_task_raw_fields(ui, addr, "CTaskCrate", 0x4B0); } // --- CTaskCloud-specific fields --- if name == "CTaskCloud" { let cloud = addr as *const CTaskCloud; egui::CollapsingHeader::new("CTaskCloud") .default_open(true) .show(ui, |ui| { egui::Grid::new("cloud_grid").striped(true).show(ui, |ui| { ui.label("pos_x"); ui.label(format!("{:.1}", (*cloud).pos_x.to_f32())); ui.end_row(); ui.label("anim_phase"); ui.label(format!("{:.1}", (*cloud).anim_phase.to_f32())); ui.end_row(); ui.label("vel_x"); ui.label(format!("{:.4}", (*cloud).vel_x.to_f32())); ui.end_row(); ui.label("phase_speed"); ui.label(format!("{:.4}", (*cloud).phase_speed.to_f32())); ui.end_row(); ui.label("wind_accel"); ui.label(format!("{:.4}", (*cloud).wind_accel.to_f32())); ui.end_row(); ui.label("wind_target"); ui.label(format!("{:.4}", (*cloud).wind_target.to_f32())); ui.end_row(); ui.label("layer_depth"); ui.label(format!("{:.1}", (*cloud).layer_depth.to_f32())); ui.end_row(); ui.label("sprite_id"); ui.label(format!("{:#06X}", (*cloud).sprite_id)); ui.end_row(); }); }); } // --- CTaskTurnGame-specific fields --- if name == "CTaskTurnGame" { let tg = addr as *const CTaskTurnGame; egui::CollapsingHeader::new("CTaskTurnGame") .default_open(true) .show(ui, |ui| { egui::Grid::new("tg_grid").striped(true).show(ui, |ui| { ui.label("current_team"); ui.label(format!("{}", (*tg).current_team)); ui.end_row(); ui.label("current_worm"); ui.label(format!("{}", (*tg).current_worm)); ui.end_row(); ui.label("arena_team"); ui.label(format!("{}", (*tg).arena_team)); ui.end_row(); ui.label("arena_worm"); ui.label(format!("{}", (*tg).arena_worm)); ui.end_row(); ui.label("worm_active"); ui.label(format!("{}", (*tg).worm_active != 0)); ui.end_row(); ui.label("turn_ended"); ui.label(format!("{}", (*tg).turn_ended != 0)); ui.end_row(); ui.label("no_time_lim"); ui.label(format!("{}", (*tg).no_time_limit != 0)); ui.end_row(); ui.label("turn_timer"); ui.label(format!("{:.1}s", (*tg).turn_timer as f32 / 1000.0)); ui.end_row(); ui.label("retreat"); ui.label(format!( "{:.1}s", (*tg).retreat_timer as f32 / 1000.0 )); ui.end_row(); ui.label("idle_timer"); ui.label(format!("{:.1}s", (*tg).idle_timer as f32 / 1000.0)); ui.end_row(); ui.label("num_teams"); ui.label(format!("{}", (*tg).num_teams)); ui.end_row(); ui.label("active_frm"); ui.label(format!("{}", (*tg).active_worm_frames)); ui.end_row(); ui.label("retreat_frm"); ui.label(format!("{}", (*tg).retreat_frames)); ui.end_row(); }); }); let ctx = &(*tg).game_ctx as *const TurnGameCtx; egui::CollapsingHeader::new("TurnGameCtx (+0x30)") .default_open(false) .show(ui, |ui| { egui::Grid::new("ctx_grid").striped(true).show(ui, |ui| { ui.label("land_height"); ui.label(format!("{:.1}", (*ctx).land_height.to_f32())); ui.end_row(); ui.label("land_height_2"); ui.label(format!("{:.1}", (*ctx).land_height_2.to_f32())); ui.end_row(); ui.label("sentinel_18"); ui.label(format!("{}", (*ctx)._sentinel_18)); ui.end_row(); ui.label("sentinel_28"); ui.label(format!("{}", (*ctx)._sentinel_28)); ui.end_row(); ui.label("sentinel_38"); ui.label(format!("{}", (*ctx)._sentinel_38)); ui.end_row(); ui.label("team_count"); ui.label(format!("{}", (*ctx).team_count)); ui.end_row(); ui.label("_slot_d0"); ui.label(format!("{}", (*ctx)._slot_d0)); ui.end_row(); let ta = (*ctx)._hud_textbox_a; let tb = (*ctx)._hud_textbox_b; ui.label("hud_tb_a"); ui.label(if ta == 0 { "(null)".into() } else { format!("{:#010X}", ta) }); ui.end_row(); ui.label("hud_tb_b"); ui.label(if tb == 0 { "(null)".into() } else { format!("{:#010X}", tb) }); ui.end_row(); }); }); } // --- CTaskTeam-specific fields --- if name == "CTaskTeam" { let team = addr as *const CTaskTeam; egui::CollapsingHeader::new("CTaskTeam") .default_open(true) .show(ui, |ui| { egui::Grid::new("team_grid").striped(true).show(ui, |ui| { ui.label("team_index"); ui.label(format!("{}", (*team).team_index)); ui.end_row(); ui.label("alive_worms"); ui.label(format!("{}", (*team).alive_worm_count)); ui.end_row(); ui.label("worm_count"); ui.label(format!("{}", (*team).worm_count)); ui.end_row(); ui.label("last_weapon"); ui.label(format!("{}", (*team).last_launched_weapon)); ui.end_row(); ui.label("pos_x"); ui.label(format!("{:.2}", (*team).pos_x.to_f32())); ui.end_row(); ui.label("pos_y"); ui.label(format!("{:.2}", (*team).pos_y.to_f32())); ui.end_row(); }); }); } // --- CTaskFire-specific fields --- if name == "CTaskFire" { let fire = addr as *const CTaskFire; egui::CollapsingHeader::new("CTaskFire") .default_open(true) .show(ui, |ui| { egui::Grid::new("fire_grid").striped(true).show(ui, |ui| { ui.label("spawn_x"); ui.label(format!("{:.1}", (*fire).spawn_x.to_f32())); ui.end_row(); ui.label("spawn_y"); ui.label(format!("{:.1}", (*fire).spawn_y.to_f32())); ui.end_row(); ui.label("timer"); ui.label(format!("{}", (*fire).timer)); ui.end_row(); ui.label("burn_rate"); ui.label(format!("{}", (*fire).burn_rate)); ui.end_row(); ui.label("spread_ctr"); ui.label(format!("{}", (*fire).spread_counter)); ui.end_row(); ui.label("lifetime"); ui.label(format!("{}", (*fire).lifetime)); ui.end_row(); ui.label("slot_index"); ui.label(format!("{}", (*fire).slot_index)); ui.end_row(); }); }); } // --- CTaskWorm-specific fields --- if name == "CTaskWorm" || (*task).class_type == openwa_core::game::ClassType::Worm { // Summary header with key info let worm = addr as *const CTaskWorm; let name_bytes = &(*worm).worm_name; let nul = name_bytes .iter() .position(|&b| b == 0) .unwrap_or(name_bytes.len()); let worm_name = std::str::from_utf8(&name_bytes[..nul]).unwrap_or("?"); ui.label(format!( "Worm: \"{}\" state={:#04X} team={} idx={}", worm_name, (*worm).state(), (*worm).team_index, (*worm).worm_index )); ui.separator(); show_game_task_raw_fields(ui, addr, "CTaskWorm", 0x3FC); } // --- CTaskMissile-specific fields --- if name == "CTaskMissile" { use openwa_core::task::CTaskMissile; let m = &*(addr as *const CTaskMissile); ui.label(format!( "Missile: type={:?} slot={} homing={} dir={}", m.missile_type(), m.slot_id, m.homing_enabled, m.direction )); ui.separator(); show_game_task_raw_fields(ui, addr, "CTaskMissile", 0x41C); } } }); } } // --------------------------------------------------------------------------- // Panel: Log Stream // --------------------------------------------------------------------------- impl DebugApp { fn show_log(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.heading("Log"); ui.checkbox(&mut self.log_auto_scroll, "auto-scroll"); if ui.button("Clear").clicked() { log::clear(); } }); ui.separator(); let entries = log::snapshot(200); egui::ScrollArea::vertical() .auto_shrink([false; 2]) .stick_to_bottom(self.log_auto_scroll) .show(ui, |ui| { for (ts, text) in &entries { let elapsed = ts.elapsed().as_secs_f32(); let color = if elapsed < 1.0 { egui::Color32::WHITE } else if elapsed < 5.0 { egui::Color32::LIGHT_GRAY } else { egui::Color32::GRAY }; ui.colored_label(color, text); } }); } }