diff --git a/src/app.rs b/src/app.rs index 00e358f..4515406 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,7 +19,10 @@ use crate::{ emulator::{EmulatorClient, EmulatorCommand, SimId}, input::MappingProvider, persistence::Persistence, - window::{AboutWindow, AppWindow, GameWindow, GdbServerWindow, InputWindow}, + vram::VramLoader, + window::{ + AboutWindow, AppWindow, CharacterDataWindow, GameWindow, GdbServerWindow, InputWindow, + }, }; fn load_icon() -> anyhow::Result { @@ -35,6 +38,7 @@ fn load_icon() -> anyhow::Result { pub struct Application { icon: Option>, + vram: Arc, client: EmulatorClient, proxy: EventLoopProxy, mappings: MappingProvider, @@ -62,6 +66,7 @@ impl Application { } Self { icon, + vram: Arc::new(VramLoader::new(client.clone())), client, proxy, mappings, @@ -80,7 +85,7 @@ impl Application { } self.viewports.insert( viewport_id, - Viewport::new(event_loop, self.icon.clone(), window), + Viewport::new(event_loop, self.icon.clone(), self.vram.clone(), window), ); } } @@ -194,6 +199,10 @@ impl ApplicationHandler for Application { let about = AboutWindow; self.open(event_loop, Box::new(about)); } + UserEvent::OpenCharacterData(sim_id) => { + let vram = CharacterDataWindow::new(sim_id); + self.open(event_loop, Box::new(vram)); + } UserEvent::OpenDebugger(sim_id) => { let debugger = GdbServerWindow::new(sim_id, self.client.clone(), self.proxy.clone()); @@ -252,6 +261,7 @@ impl Viewport { pub fn new( event_loop: &ActiveEventLoop, icon: Option>, + vram: Arc, mut app: Box, ) -> Self { let ctx = Context::default(); @@ -273,6 +283,7 @@ impl Viewport { s.visuals.menu_rounding = Default::default(); }); egui_extras::install_image_loaders(&ctx); + ctx.add_image_loader(vram); #[allow(unused_mut)] let mut wgpu_config = egui_wgpu::WgpuConfiguration { @@ -403,6 +414,7 @@ impl Drop for Viewport { pub enum UserEvent { GamepadEvent(gilrs::Event), OpenAbout, + OpenCharacterData(SimId), OpenDebugger(SimId), OpenInput, OpenPlayer2, diff --git a/src/main.rs b/src/main.rs index 33ae747..990851f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod gdbserver; mod graphics; mod input; mod persistence; +mod vram; mod window; #[derive(Parser)] diff --git a/src/vram.rs b/src/vram.rs new file mode 100644 index 0000000..5feb347 --- /dev/null +++ b/src/vram.rs @@ -0,0 +1,372 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::{hash_map::Entry, HashMap}, + fmt::Display, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc; + +use egui::{ + load::{ImageLoader, ImagePoll, LoadError}, + ColorImage, Context, Vec2, +}; + +use crate::emulator::{EmulatorClient, EmulatorCommand, SimId}; + +enum VramRequest { + Load(String, VramResource), +} + +enum VramResponse { + Loaded(String, ColorImage), +} + +pub struct VramResource { + sim: SimId, + kind: VramResourceKind, +} + +impl VramResource { + pub fn character_data(sim: SimId, palette: VramPalette) -> Self { + Self { + sim, + kind: VramResourceKind::CharacterData { palette }, + } + } + + pub fn character(sim: SimId, palette: VramPalette, index: usize) -> Self { + Self { + sim, + kind: VramResourceKind::Character { palette, index }, + } + } + + pub fn palette_color(sim: SimId, palette: VramPalette, index: usize) -> Self { + Self { + sim, + kind: VramResourceKind::PaletteColor { palette, index }, + } + } + + pub fn to_uri(&self) -> String { + format!( + "vram://{}:{}", + self.sim.to_index(), + serde_json::to_string(&self.kind).unwrap(), + ) + } + + fn from_uri(uri: &str) -> Option { + let uri = uri.strip_prefix("vram://")?; + let (sim, uri) = match uri.split_at_checked(2)? { + ("0:", rest) => (SimId::Player1, rest), + ("1:", rest) => (SimId::Player2, rest), + _ => return None, + }; + let kind = serde_json::from_str(uri).ok()?; + Some(Self { sim, kind }) + } +} + +#[derive(Serialize, Deserialize)] +enum VramResourceKind { + Character { palette: VramPalette, index: usize }, + CharacterData { palette: VramPalette }, + PaletteColor { palette: VramPalette, index: usize }, +} + +impl VramResourceKind { + fn size(&self) -> Option { + match self { + Self::Character { .. } => Some((8.0, 8.0).into()), + Self::CharacterData { .. } => Some((8.0 * 16.0, 8.0 * 128.0).into()), + Self::PaletteColor { .. } => Some((1.0, 1.0).into()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VramPalette { + Generic, + Bg0, + Bg1, + Bg2, + Bg3, + Obj0, + Obj1, + Obj2, + Obj3, +} + +impl VramPalette { + pub const fn values() -> [VramPalette; 9] { + [ + Self::Generic, + Self::Bg0, + Self::Bg1, + Self::Bg2, + Self::Bg3, + Self::Obj0, + Self::Obj1, + Self::Obj2, + Self::Obj3, + ] + } +} + +impl Display for VramPalette { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Generic => f.write_str("Generic"), + Self::Bg0 => f.write_str("BG 0"), + Self::Bg1 => f.write_str("BG 1"), + Self::Bg2 => f.write_str("BG 2"), + Self::Bg3 => f.write_str("BG 3"), + Self::Obj0 => f.write_str("OBJ 0"), + Self::Obj1 => f.write_str("OBJ 1"), + Self::Obj2 => f.write_str("OBJ 2"), + Self::Obj3 => f.write_str("OBJ 3"), + } + } +} + +pub struct VramLoader { + cache: Mutex>, + source: Mutex>, + sink: mpsc::UnboundedSender, +} + +impl VramLoader { + pub fn new(client: EmulatorClient) -> Self { + let (tx1, rx1) = mpsc::unbounded_channel(); + let (tx2, rx2) = mpsc::unbounded_channel(); + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let worker = VramLoadingWorker::new(rx2, tx1, client); + worker.run().await; + }) + }); + Self { + cache: Mutex::new(HashMap::new()), + source: Mutex::new(rx1), + sink: tx2, + } + } +} + +impl ImageLoader for VramLoader { + fn id(&self) -> &str { + concat!(module_path!(), "::VramLoader") + } + + fn load( + &self, + _ctx: &Context, + uri: &str, + _size_hint: egui::SizeHint, + ) -> Result { + let Some(resource) = VramResource::from_uri(uri) else { + return Err(LoadError::NotSupported); + }; + let mut cache = self.cache.lock().unwrap(); + { + let mut source = self.source.lock().unwrap(); + while let Ok(response) = source.try_recv() { + match response { + VramResponse::Loaded(uri, image) => { + cache.insert( + uri, + ImagePoll::Ready { + image: Arc::new(image), + }, + ); + } + } + } + } + let poll = match cache.entry(uri.to_string()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let pending = ImagePoll::Pending { + size: resource.kind.size(), + }; + let _ = self.sink.send(VramRequest::Load(uri.to_string(), resource)); + entry.insert(pending) + } + }; + Ok(poll.clone()) + } + + fn forget(&self, uri: &str) { + self.cache.lock().unwrap().remove(uri); + } + + fn forget_all(&self) { + self.cache.lock().unwrap().clear(); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .unwrap() + .values() + .map(|c| match c { + ImagePoll::Pending { .. } => 0, + ImagePoll::Ready { image } => image.pixels.len() * size_of::(), + }) + .sum() + } +} + +struct VramLoadingWorker { + source: mpsc::UnboundedReceiver, + sink: mpsc::UnboundedSender, + client: EmulatorClient, +} + +impl VramLoadingWorker { + fn new( + source: mpsc::UnboundedReceiver, + sink: mpsc::UnboundedSender, + client: EmulatorClient, + ) -> Self { + Self { + source, + sink, + client, + } + } + async fn run(mut self) { + while let Some(request) = self.source.recv().await { + #[allow(irrefutable_let_patterns)] + if let VramRequest::Load(uri, resource) = request { + let Some(image) = self.load(resource).await else { + continue; + }; + if self.sink.send(VramResponse::Loaded(uri, image)).is_err() { + return; + } + } + } + } + + async fn load(&self, resource: VramResource) -> Option { + let sim = resource.sim; + match resource.kind { + VramResourceKind::Character { palette, index } => { + self.load_character(sim, palette, index).await + } + VramResourceKind::CharacterData { palette } => { + self.load_character_data(sim, palette).await + } + VramResourceKind::PaletteColor { palette, index } => { + self.load_palette_color(sim, palette, index).await + } + } + } + + async fn load_character( + &self, + sim: SimId, + palette: VramPalette, + index: usize, + ) -> Option { + if index >= 2048 { + return None; + } + let address = 0x00078000 + (index as u32 * 16); + let (memory, palette) = tokio::join!( + self.read_memory(sim, address, 16), + self.load_palette_colors(sim, palette), + ); + let palette = palette?; + let mut buffer = vec![]; + for byte in memory? { + for offset in (0..8).step_by(2) { + let char = (byte >> offset) & 0x3; + buffer.push(palette[char as usize]); + } + } + Some(ColorImage::from_gray([8, 8], &buffer)) + } + + async fn load_character_data(&self, sim: SimId, palette: VramPalette) -> Option { + let (memory, palette) = tokio::join!( + self.read_memory(sim, 0x00078000, 16 * 2048), + self.load_palette_colors(sim, palette), + ); + let palette = palette?; + let mut buffer = vec![0; 8 * 8 * 2048]; + for (i, byte) in memory?.into_iter().enumerate() { + let bytes = [0, 2, 4, 6].map(|off| palette[(byte as usize >> off) & 0x3]); + let char_index = i / 16; + let in_char_pos = i % 16; + let x = ((char_index % 16) * 8) + ((in_char_pos % 2) * 4); + let y = ((char_index / 16) * 8) + (in_char_pos / 2); + let write_index = (y * 16 * 8) + x; + buffer[write_index..(write_index + 4)].copy_from_slice(&bytes); + } + Some(ColorImage::from_gray([8 * 16, 8 * 128], &buffer)) + } + + async fn load_palette_color( + &self, + sim: SimId, + palette: VramPalette, + index: usize, + ) -> Option { + if index == 0 { + return Some(ColorImage::from_gray([1, 1], &[0])); + } + if index > 3 { + return None; + } + let shade = *self.load_palette_colors(sim, palette).await?.get(index)?; + Some(ColorImage::from_gray([1, 1], &[shade])) + } + + async fn load_palette_colors(&self, sim: SimId, palette: VramPalette) -> Option<[u8; 4]> { + let offset = match palette { + VramPalette::Generic => { + return Some([0, 64, 128, 255]); + } + VramPalette::Bg0 => 0, + VramPalette::Bg1 => 2, + VramPalette::Bg2 => 4, + VramPalette::Bg3 => 6, + VramPalette::Obj0 => 8, + VramPalette::Obj1 => 10, + VramPalette::Obj2 => 12, + VramPalette::Obj3 => 14, + }; + let (palettes, brightnesses) = tokio::join!( + self.read_memory(sim, 0x0005f860, 16), + self.read_memory(sim, 0x0005f824, 6), + ); + let palette = *palettes?.get(offset)?; + let brts = brightnesses?; + let shades = [ + 0, + brts[0], + brts[2], + brts[0].saturating_add(brts[2]).saturating_add(brts[4]), + ]; + Some([ + 0, + shades[(palette >> 2) as usize & 0x03], + shades[(palette >> 4) as usize & 0x03], + shades[(palette >> 6) as usize & 0x03], + ]) + } + + async fn read_memory(&self, sim: SimId, address: u32, size: usize) -> Option> { + let (tx, rx) = oneshot::channel(); + self.client + .send_command(EmulatorCommand::ReadMemory(sim, address, size, vec![], tx)); + rx.await.ok() + } +} diff --git a/src/window.rs b/src/window.rs index 85048ff..2bccf7b 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,5 @@ pub use about::AboutWindow; +pub use character_data::CharacterDataWindow; use egui::{Context, ViewportBuilder, ViewportId}; pub use game::GameWindow; pub use gdb::GdbServerWindow; @@ -8,6 +9,7 @@ use winit::event::KeyEvent; use crate::emulator::SimId; mod about; +mod character_data; mod game; mod game_screen; mod gdb; diff --git a/src/window/character_data.rs b/src/window/character_data.rs new file mode 100644 index 0000000..b908988 --- /dev/null +++ b/src/window/character_data.rs @@ -0,0 +1,253 @@ +use egui::{ + Align, CentralPanel, Color32, ComboBox, Context, Frame, Image, RichText, ScrollArea, Sense, + Slider, TextEdit, TextureOptions, Ui, UiBuilder, Vec2, ViewportBuilder, ViewportId, +}; +use egui_extras::{Column, Size, StripBuilder, TableBuilder}; + +use crate::{ + emulator::SimId, + vram::{VramPalette, VramResource}, +}; + +use super::AppWindow; + +pub struct CharacterDataWindow { + sim_id: SimId, + palette: VramPalette, + index: usize, + index_str: String, + scale: f32, + show_grid: bool, +} + +impl CharacterDataWindow { + pub fn new(sim_id: SimId) -> Self { + Self { + sim_id, + palette: VramPalette::Generic, + index: 0, + index_str: "0".into(), + scale: 4.0, + show_grid: true, + } + } + + fn show_form(&mut self, ui: &mut Ui) { + let row_height = ui.spacing().interact_size.y; + ui.vertical(|ui| { + TableBuilder::new(ui) + .column(Column::auto()) + .column(Column::remainder()) + .body(|mut body| { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label("Index"); + }); + row.col(|ui| { + let res = ui.add( + TextEdit::singleline(&mut self.index_str) + .horizontal_align(Align::Max), + ); + if res.changed() { + if let Some(index) = + self.index_str.parse().ok().filter(|id| *id < 2048) + { + self.index = index; + } + } + }); + }); + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label("Address"); + }); + row.col(|ui| { + let address = match self.index { + 0x000..0x200 => 0x00060000 + self.index, + 0x200..0x400 => 0x000e0000 + (self.index - 0x200), + 0x400..0x600 => 0x00160000 + (self.index - 0x400), + 0x600..0x800 => 0x001e0000 + (self.index - 0x600), + _ => unreachable!("can't happen"), + }; + let mut address_str = format!("{address:08x}"); + ui.add_enabled( + false, + TextEdit::singleline(&mut address_str).horizontal_align(Align::Max), + ); + }); + }); + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label("Mirror"); + }); + row.col(|ui| { + let mirror = 0x00078000 + (self.index * 16); + let mut mirror_str = format!("{mirror:08x}"); + ui.add_enabled( + false, + TextEdit::singleline(&mut mirror_str).horizontal_align(Align::Max), + ); + }); + }); + }); + let resource = VramResource::character(self.sim_id, self.palette, self.index); + let image = Image::new(resource.to_uri()) + .maintain_aspect_ratio(true) + .tint(Color32::RED) + .texture_options(TextureOptions::NEAREST); + ui.add(image); + ui.section("Colors", |ui| { + ui.horizontal(|ui| { + ui.label("Palette"); + ComboBox::from_id_salt("palette") + .selected_text(self.palette.to_string()) + .width(ui.available_width()) + .show_ui(ui, |ui| { + for palette in VramPalette::values() { + ui.selectable_value( + &mut self.palette, + palette, + palette.to_string(), + ); + } + }); + }); + TableBuilder::new(ui) + .columns(Column::remainder(), 4) + .body(|mut body| { + body.row(30.0, |mut row| { + for index in 0..4 { + let resource = + VramResource::palette_color(self.sim_id, self.palette, index); + row.col(|ui| { + let rect = ui.available_rect_before_wrap(); + let scale = rect.height() / rect.width(); + let rect = rect.scale_from_center2(Vec2::new(scale, 1.0)); + let image = Image::new(resource.to_uri()) + .tint(Color32::RED) + .fit_to_exact_size(rect.max - rect.min); + ui.put(rect, image); + }); + } + }); + }); + }); + ui.section("Display", |ui| { + ui.horizontal(|ui| { + ui.label("Scale"); + ui.spacing_mut().slider_width = ui.available_width(); + let slider = Slider::new(&mut self.scale, 1.0..=10.0) + .step_by(1.0) + .show_value(false); + ui.add(slider); + }); + ui.checkbox(&mut self.show_grid, "Show grid"); + }); + }); + } + + fn show_chardata(&mut self, ui: &mut Ui) { + let start_pos = ui.cursor().min; + let resource = VramResource::character_data(self.sim_id, self.palette); + let image = Image::new(resource.to_uri()) + .fit_to_original_size(self.scale) + .tint(Color32::RED) + .texture_options(TextureOptions::NEAREST) + .sense(Sense::click()); + let res = ui.add(image); + if res.clicked() { + if let Some(click_pos) = res.interact_pointer_pos() { + let fixed_pos = (click_pos - start_pos) / self.scale; + let x = (fixed_pos.x / 8.0) as usize; + let y = (fixed_pos.y / 8.0) as usize; + self.index = (y * 16) + x; + self.index_str = self.index.to_string(); + } + } + let painter = ui.painter_at(res.rect); + if self.show_grid { + let stroke = ui.style().visuals.widgets.noninteractive.fg_stroke; + for x in (1..16).map(|i| (i as f32) * 8.0 * self.scale) { + let p1 = res.rect.min + (x, 0.0).into(); + let p2 = res.rect.min + (x, 128.0 * 8.0 * self.scale).into(); + painter.line(vec![p1, p2], stroke); + } + for y in (1..128).map(|i| (i as f32) * 8.0 * self.scale) { + let p1 = res.rect.min + (0.0, y).into(); + let p2 = res.rect.min + (16.0 * 8.0 * self.scale, y).into(); + painter.line(vec![p1, p2], stroke); + } + } + // draw box around selected + let x1 = (self.index % 16) as f32 * 8.0 * self.scale; + let x2 = x1 + (8.0 * self.scale); + let y1 = (self.index / 16) as f32 * 8.0 * self.scale; + let y2 = y1 + (8.0 * self.scale); + painter.line( + vec![ + (res.rect.min + (x1, y1).into()), + (res.rect.min + (x2, y1).into()), + (res.rect.min + (x2, y2).into()), + (res.rect.min + (x1, y2).into()), + (res.rect.min + (x1, y1).into()), + ], + ui.style().visuals.widgets.active.fg_stroke, + ); + } +} + +impl AppWindow for CharacterDataWindow { + fn viewport_id(&self) -> ViewportId { + ViewportId::from_hash_of(format!("chardata-{}", self.sim_id)) + } + + fn sim_id(&self) -> SimId { + self.sim_id + } + + fn initial_viewport(&self) -> ViewportBuilder { + ViewportBuilder::default() + .with_title(format!("Character Data ({})", self.sim_id)) + .with_inner_size((640.0, 480.0)) + } + + fn show(&mut self, ctx: &Context) { + CentralPanel::default().show(ctx, |ui| { + ui.horizontal_top(|ui| { + StripBuilder::new(ui) + .size(Size::relative(0.3)) + .size(Size::remainder()) + .horizontal(|mut strip| { + strip.cell(|ui| { + ScrollArea::vertical().show(ui, |ui| self.show_form(ui)); + }); + strip.cell(|ui| { + ScrollArea::both().show(ui, |ui| self.show_chardata(ui)); + }); + }); + }); + }); + } +} + +trait UiExt { + fn section(&mut self, title: impl Into, add_contents: impl FnOnce(&mut Ui)); +} + +impl UiExt for Ui { + fn section(&mut self, title: impl Into, add_contents: impl FnOnce(&mut Ui)) { + let mut frame = Frame::group(self.style()); + frame.outer_margin.top += 10.0; + frame.inner_margin.top += 2.0; + let res = frame.show(self, add_contents); + let text = RichText::new(title).background_color(self.style().visuals.panel_fill); + let old_rect = res.response.rect; + let mut text_rect = old_rect; + text_rect.min.x += 6.0; + let new_rect = self + .allocate_new_ui(UiBuilder::new().max_rect(text_rect), |ui| ui.label(text)) + .response + .rect; + self.allocate_space((old_rect.max - new_rect.max) - (old_rect.min - new_rect.min)); + } +} diff --git a/src/window/game.rs b/src/window/game.rs index dd83dc5..680163a 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -132,6 +132,12 @@ impl GameWindow { .unwrap(); ui.close_menu(); } + if ui.button("Character Data").clicked() { + self.proxy + .send_event(UserEvent::OpenCharacterData(self.sim_id)) + .unwrap(); + ui.close_menu(); + } }); ui.menu_button("About", |ui| { self.proxy.send_event(UserEvent::OpenAbout).unwrap();