From 8083bc80699116d5bc890df67e9f89524b093200 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sun, 1 Nov 2026 14:10:39 -0500 Subject: [PATCH] Memory viewer --- src/app.rs | 3 + src/config.rs | 3 + src/memory.rs | 71 ++++++++- src/window.rs | 2 + src/window/game.rs | 10 +- src/window/memory.rs | 334 +++++++++++++++++++++++++++++++++++++++++++ src/window/utils.rs | 4 +- 7 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 src/window/memory.rs diff --git a/src/app.rs b/src/app.rs index b276224..2ab4d18 100644 --- a/src/app.rs +++ b/src/app.rs @@ -179,6 +179,9 @@ impl Application { if args.registers { initial_windows.push(ChildWindow::Registers); } + if args.memory { + initial_windows.push(ChildWindow::Memory); + } if args.terminal { initial_windows.push(ChildWindow::Terminal); } diff --git a/src/config.rs b/src/config.rs index 8006480..b212cf9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,9 @@ pub struct CliArgs { /// Open registers window #[arg(short, long)] pub registers: bool, + /// Open memory window + #[arg(short, long)] + pub memory: bool, /// Open terminal #[arg(short, long)] pub terminal: bool, diff --git a/src/memory.rs b/src/memory.rs index 1b4cc86..fa597e4 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -37,7 +37,53 @@ impl MemoryClient { .send_command(EmulatorCommand::WatchMemory(range, Arc::downgrade(®ion))); region }); - MemoryView { region } + MemoryView { + region, + sim, + start, + length, + } + } + + // Update an existing view to point to a new region of memory. + // If the old and new regions overlap, it'll retain any old values. + pub fn retarget(&self, view: &mut MemoryView, sim: SimId, start: u32, length: usize) { + let range = MemoryRange { sim, start, length }; + let mut regions = self.regions.lock().unwrap_or_else(|e| e.into_inner()); + let region = regions + .get(&range) + .and_then(|r| r.upgrade()) + .unwrap_or_else(|| { + let region = Arc::new(MemoryRegion::new(start, length)); + if view.range().overlaps(range) { + let (old_index, new_index) = if start < view.start { + (0, view.start as usize - start as usize) + } else { + (start as usize - view.start as usize, 0) + }; + let len = (view.length - old_index).min(length - new_index); + let mut new_data = vec![0u8; length]; + let old_data = view.borrow(); + for (dst, src) in new_data[new_index..(new_index + len)] + .iter_mut() + .zip(old_data.range::(old_index, len)) + { + *dst = src; + } + drop(old_data); + region.update(&new_data); + } + regions.insert(range, Arc::downgrade(®ion)); + self.client + .send_command(EmulatorCommand::WatchMemory(range, Arc::downgrade(®ion))); + region + }); + *view = MemoryView { + region, + sim, + start, + length, + } } pub fn write(&self, sim: SimId, address: u32, data: &T) { @@ -64,12 +110,23 @@ fn aligned_memory(start: u32, length: usize) -> BoxBytes { pub struct MemoryView { region: Arc, + sim: SimId, + start: u32, + length: usize, } impl MemoryView { pub fn borrow(&self) -> MemoryRef<'_> { self.region.borrow() } + + pub fn range(&self) -> MemoryRange { + MemoryRange { + sim: self.sim, + start: self.start, + length: self.length, + } + } } pub struct MemoryRef<'a> { @@ -183,6 +240,14 @@ pub struct MemoryRange { pub length: usize, } +impl MemoryRange { + pub fn overlaps(&self, other: MemoryRange) -> bool { + self.sim == other.sim + && (self.start as usize) < other.start as usize + other.length + && (other.start as usize) < self.start as usize + self.length + } +} + const BUFFERS: usize = 4; pub struct MemoryRegion { gens: [AtomicU64; BUFFERS], @@ -195,10 +260,6 @@ impl Debug for MemoryRegion { .finish_non_exhaustive() } } -// SAFETY: BoxBytes is meant to be Send+Sync, will be in a future version -unsafe impl Send for MemoryRegion {} -// SAFETY: BoxBytes is meant to be Send+Sync, will be in a future version -unsafe impl Sync for MemoryRegion {} impl MemoryRegion { fn new(start: u32, length: usize) -> Self { diff --git a/src/window.rs b/src/window.rs index 248c781..ae16252 100644 --- a/src/window.rs +++ b/src/window.rs @@ -7,6 +7,7 @@ pub use game_screen::{DisplayMode, GameScreen}; pub use gdb::GdbServerWindow; pub use hotkeys::HotkeysWindow; pub use input::InputWindow; +pub use memory::MemoryWindow; pub use profile::ProfileWindow; pub use terminal::TerminalWindow; pub use vip::{ @@ -22,6 +23,7 @@ mod game_screen; mod gdb; mod hotkeys; mod input; +mod memory; mod profile; mod terminal; mod utils; diff --git a/src/window/game.rs b/src/window/game.rs index e3dbe9b..d86c7ff 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -16,8 +16,8 @@ use crate::{ memory::MemoryClient, window::{ AboutWindow, BgMapWindow, CharacterDataWindow, FrameBufferWindow, GdbServerWindow, - HotkeysWindow, InitArgs, InputWindow, ObjectWindow, ProfileWindow, RegisterWindow, - TerminalWindow, WorldWindow, utils::UiData, + HotkeysWindow, InitArgs, InputWindow, MemoryWindow, ObjectWindow, ProfileWindow, + RegisterWindow, TerminalWindow, WorldWindow, utils::UiData, }, }; use anyhow::Context as _; @@ -156,6 +156,7 @@ impl GameWindow { &self.memory, &self.persistence, )), + ChildWindow::Memory => AppWrapper::of_dyn(MemoryWindow::new(self.sim_id, &self.memory)), ChildWindow::Terminal => { AppWrapper::of_dyn(TerminalWindow::new(self.sim_id, &self.client)) } @@ -445,6 +446,9 @@ impl GameWindow { if ui.button("GDB Server").clicked() { self.open(ChildWindow::Debugger { port: None }); } + if ui.button("Memory").clicked() { + self.open(ChildWindow::Memory); + } ui.separator(); if ui.button("Character Data").clicked() { self.open(ChildWindow::CharacterData); @@ -878,6 +882,7 @@ impl DerefMut for AppWrapper { pub enum ChildWindow { About, CharacterData, + Memory, BgMap, Objects, Worlds, @@ -898,6 +903,7 @@ impl ChildWindow { match self { Self::About => "About", Self::CharacterData => "CharacterData", + Self::Memory => "Memory", Self::BgMap => "BgMap", Self::Objects => "Objects", Self::Worlds => "Worlds", diff --git a/src/window/memory.rs b/src/window/memory.rs new file mode 100644 index 0000000..4a29a0e --- /dev/null +++ b/src/window/memory.rs @@ -0,0 +1,334 @@ +use std::{fmt::LowerHex, sync::Arc}; + +use egui::{ + Align, CentralPanel, Label, Layout, Response, RichText, Separator, TextEdit, Ui, + ViewportBuilder, Widget, +}; +use egui_extras::{Column, Size, StripBuilder, TableBuilder}; +use serde::{Deserialize, Serialize}; + +use crate::{ + emulator::SimId, + memory::{MemoryClient, MemoryRef, MemoryValue, MemoryView}, + window::{AppWindow, utils::UiData}, +}; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum ValueSize { + Byte, + Halfword, + Word, +} + +struct HexEditResponse { + submitted: bool, + response: Response, +} + +struct HexEdit<'a> { + text: &'a mut String, + button_label: &'static str, +} + +impl<'a> HexEdit<'a> { + #[must_use] + fn new(text: &'a mut String, button_label: &'static str) -> Self { + Self { text, button_label } + } + + fn show(self, ui: &mut Ui) -> HexEditResponse { + const HEX_WORD_WIDTH: f32 = 80.0; + let text_response = TextEdit::singleline(self.text) + .desired_width(HEX_WORD_WIDTH) + .ui(ui); + let button_response = ui.button(self.button_label); + let submitted = (text_response.lost_focus() + && text_response.ctx.input(|i| i.key_pressed(egui::Key::Enter))) + || button_response.clicked(); + let response = text_response.union(button_response); + HexEditResponse { + submitted, + response, + } + } +} + +impl Widget for HexEdit<'_> { + fn ui(self, ui: &mut Ui) -> egui::Response { + self.show(ui).response + } +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +struct State { + address: u32, + value_size: ValueSize, +} +impl Default for State { + fn default() -> Self { + Self { + address: 0x05000000, + value_size: ValueSize::Byte, + } + } +} + +pub struct MemoryWindow { + sim_id: SimId, + memory: Arc, + view: MemoryView, + scroll_to: Option, + jump_to: String, + editing_value: String, + state: UiData, +} + +impl MemoryWindow { + pub fn new(sim_id: SimId, memory: &Arc) -> Self { + let state: UiData = UiData::new(); + Self { + sim_id, + memory: memory.clone(), + view: memory.watch(sim_id, 0x0, 0), + scroll_to: None, + jump_to: String::new(), + editing_value: String::new(), + state, + } + } + + fn show_top_bar(&mut self, ui: &mut Ui) { + ui.radio_value(&mut self.state.value_size, ValueSize::Byte, "8-bit"); + ui.radio_value(&mut self.state.value_size, ValueSize::Halfword, "16-bit"); + ui.radio_value(&mut self.state.value_size, ValueSize::Word, "32-bit"); + self.state.address &= match self.state.value_size { + ValueSize::Byte => 0x07ffffff, + ValueSize::Halfword => 0x07fffffe, + ValueSize::Word => 0x07fffffc, + }; + if HexEdit::new(&mut self.jump_to, "Jump to address") + .show(ui) + .submitted + { + let address_str = std::mem::take(&mut self.jump_to); + if let Ok(address) = u64::from_str_radix(&address_str, 16) { + let address = (address & 0x07ffffff) as u32; + self.state.address = address; + self.scroll_to = Some(address); + } + } + } + + fn show_memory(&mut self, ui: &mut Ui) { + let row_height_sans_spacing = ui.text_style_height(&egui::TextStyle::Monospace); + let max_height = ui.max_rect().height(); + let mut table = TableBuilder::new(ui) + .column(Column::auto()) + .column(Column::initial(0.0)) + .column(Column::auto()) + .column(Column::initial(0.0)) + .column(Column::remainder()) + .max_scroll_height(max_height) + .animate_scrolling(false); + + if let Some(scroll_to) = self.scroll_to.take() { + table = table.scroll_to_row( + (scroll_to as usize / 0x10).saturating_sub(2), + Some(Align::Min), + ); + } + + table.body(|body| { + let total_rows = 0x00800000; // whole 27-bit non-mirrored address space, with 16 bytes per row + let range = self.view.range(); + let current_start = range.start as usize; + let current_length = range.length; + let rows = self.view.borrow(); + let mut new_start = None; + let mut new_length = 0; + body.rows(row_height_sans_spacing, total_rows, |mut row| { + let address = row.index() * 0x10; + let offset = address + .checked_sub(current_start) + .filter(|offset| offset + 0x10 <= current_length); + + if new_start.is_none() { + new_start = Some(address); + } + new_length += 0x10; + row.col(|ui| { + let label = Label::new(RichText::new(format!("{address:08x}")).monospace()); + if ui.add(label).clicked() { + self.state.address = address as u32; + }; + }); + row.col(|ui| { + ui.separator(); + }); + row.col(|ui| { + if let Some(offset) = offset { + fn show_memory( + ui: &mut Ui, + rows: &MemoryRef, + start: usize, + offset: usize, + selected: u32, + ) -> Option { + let mut clicked = None; + for (i, value) in rows + .range::(offset / size_of::(), 0x10 / size_of::()) + .enumerate() + { + let val_str = + format!("{value:08x}").split_off(8 - size_of::() * 2); + let text = RichText::new(val_str).monospace(); + let address = (start + offset + i * size_of::()) as u32; + let label = if address == selected { + Label::new( + text.background_color(ui.visuals().selection.bg_fill) + .color(ui.visuals().selection.stroke.color), + ) + } else { + Label::new(text) + }; + if ui.add(label).clicked() { + clicked = Some(address); + } + } + clicked + } + let selected = match self.state.value_size { + ValueSize::Byte => show_memory::( + ui, + &rows, + current_start, + offset, + self.state.address, + ), + ValueSize::Halfword => show_memory::( + ui, + &rows, + current_start, + offset, + self.state.address, + ), + ValueSize::Word => show_memory::( + ui, + &rows, + current_start, + offset, + self.state.address, + ), + }; + if let Some(selected) = selected { + self.state.address = selected; + } + }; + }); + row.col(|ui| { + ui.separator(); + }); + row.col(|ui| { + if let Some(offset) = offset { + let printable: String = rows + .range::(offset, 0x10) + .map(|c| { + if (32..128).contains(&c) { + c as char + } else { + '.' + } + }) + .collect(); + ui.label(RichText::new(printable).monospace()); + } + }); + }); + drop(rows); + if let Some(start) = new_start + && (start != current_start || new_length != current_length) + { + self.memory + .retarget(&mut self.view, self.sim_id, start as u32, new_length); + } + }); + } + + fn show_bottom_bar(&mut self, ui: &mut Ui) { + ui.label(format!("Current address: {:08x}", self.state.address)); + if HexEdit::new(&mut self.editing_value, "Set value") + .show(ui) + .submitted + { + let value = std::mem::take(&mut self.editing_value); + fn set_memory( + address: u32, + value: String, + sim: SimId, + memory: &MemoryClient, + ) { + let bytes = value.as_bytes(); + let (data, consumed) = + ::from_radix_16_checked(bytes); + if let Some(data) = data + && consumed == bytes.len() + { + memory.write(sim, address, &data); + } + } + match self.state.value_size { + ValueSize::Byte => { + set_memory::(self.state.address, value, self.sim_id, &self.memory) + } + ValueSize::Halfword => { + set_memory::(self.state.address, value, self.sim_id, &self.memory) + } + ValueSize::Word => { + set_memory::(self.state.address, value, self.sim_id, &self.memory) + } + } + } + } +} + +impl AppWindow for MemoryWindow { + fn sim_id(&self) -> Option { + Some(self.sim_id) + } + + fn initial_viewport(&self) -> ViewportBuilder { + ViewportBuilder::default() + .with_title("Memory") + .with_inner_size((640.0, 480.0)) + } + + fn show(&mut self, ui: &mut Ui) { + let row_height = + ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y; + let table_height = ui.available_height() - (row_height * 4.5); + if self.state.load(ui) { + self.scroll_to = Some(self.state.address); + } + CentralPanel::default().show(ui, |ui| { + StripBuilder::new(ui) + .size(Size::exact(row_height)) + .size(Size::initial(0.0)) + .size(Size::exact(table_height)) + .size(Size::initial(0.0)) + .size(Size::exact(row_height)) + .cell_layout(Layout::left_to_right(Align::Center)) + .vertical(|mut strip| { + strip.cell(|ui| self.show_top_bar(ui)); + strip.cell(|ui| { + Separator::default().horizontal().ui(ui); + }); + strip.cell(|ui| self.show_memory(ui)); + strip.cell(|ui| { + Separator::default().horizontal().ui(ui); + }); + strip.cell(|ui| self.show_bottom_bar(ui)); + }); + }); + self.state.save(ui); + } +} diff --git a/src/window/utils.rs b/src/window/utils.rs index 54edb86..396d90e 100644 --- a/src/window/utils.rs +++ b/src/window/utils.rs @@ -432,13 +432,15 @@ where } } - pub fn load(&mut self, ui: &Ui) { + pub fn load(&mut self, ui: &Ui) -> bool { + let just_loaded = !self.loaded; if !self.loaded { self.current = ui .data_mut(|d| d.get_persisted(ui.id())) .unwrap_or_default(); self.loaded = true; } + just_loaded } pub fn save(&mut self, ui: &Ui) {