diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 0000000..34a685f --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,609 @@ +//! Native desktop presentation. No window-library types enter the emulator core. + +mod affine; +mod affine_raster; +mod bitmap; +mod effects; +mod mosaic; +mod raster; +pub use affine::run as run_affine; +pub use affine_raster::run as run_affine_raster; +pub use bitmap::run as run_bitmap; +pub use effects::run as run_effects; +pub use mosaic::run as run_mosaic; +pub use raster::run as run_raster; + +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +use gba_rust::{ + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + graphics_demo::{GraphicsDemo, BACKGROUND, DEMO_STATE, SQUARE_SIZE}, + input::{Button, Buttons}, + tile_demo::{TileDemo, TILE_STATE}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::{Key, KeyRepeat, Scale, Window, WindowOptions}; + +const TITLE: &str = + "GBA Rust | Display test (not a game) | Arrows: move | Space: pause | Esc: exit"; +const CURSOR_SIZE: usize = 8; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Input { + left: bool, + right: bool, + up: bool, + down: bool, + toggle_pause: bool, +} + +/// minifb 0.28.0's macOS backend compares mfb_is_active() to zero, +/// although the native function returns true for an active window. +/// Keep this workaround tied to the exact dependency version in Cargo.toml. +fn is_focused(reported_active: bool) -> bool { + if cfg!(target_os = "macos") { + !reported_active + } else { + reported_active + } +} + +fn read_buttons(reported_active: bool, key_down: impl Fn(Key) -> bool) -> Buttons { + if !is_focused(reported_active) { + return Buttons::default(); + } + [ + (Key::Z, Button::A), + (Key::X, Button::B), + (Key::Backspace, Button::Select), + (Key::Enter, Button::Start), + (Key::Right, Button::Right), + (Key::Left, Button::Left), + (Key::Up, Button::Up), + (Key::Down, Button::Down), + (Key::W, Button::R), + (Key::Q, Button::L), + ] + .into_iter() + .fold(Buttons::default(), |buttons, (key, button)| { + buttons.with(button, key_down(key)) + }) +} + +fn read_input(reported_active: bool, key_down: impl Fn(Key) -> bool, pause_pressed: bool) -> Input { + if !is_focused(reported_active) { + return Input::default(); + } + Input { + left: key_down(Key::Left), + right: key_down(Key::Right), + up: key_down(Key::Up), + down: key_down(Key::Down), + toggle_pause: pause_pressed, + } +} + +struct DisplayDemo { + tick: usize, + x: usize, + y: usize, + paused: bool, +} + +impl Default for DisplayDemo { + fn default() -> Self { + Self { + tick: 0, + x: (WIDTH - CURSOR_SIZE) / 2, + y: (HEIGHT - CURSOR_SIZE) / 2, + paused: false, + } + } +} + +impl DisplayDemo { + fn update(&mut self, input: Input) { + if input.toggle_pause { + self.paused = !self.paused; + } + if !self.paused { + self.tick = (self.tick + 1) % WIDTH; + } + // Opposite directions cancel. Movement remains available while paused. + let dx = i32::from(input.right) - i32::from(input.left); + let dy = i32::from(input.down) - i32::from(input.up); + self.x = (self.x as i32 + dx * 2).clamp(0, (WIDTH - CURSOR_SIZE) as i32) as usize; + self.y = (self.y as i32 + dy * 2).clamp(0, (HEIGHT - CURSOR_SIZE) as i32) as usize; + } + + fn draw(&self, frame: &mut Framebuffer) { + // Original host-generated test image, not output from the emulated CPU. + for y in 0..HEIGHT { + for x in 0..WIDTH { + let red = (x * 31 / (WIDTH - 1)) as u16; + let green = (y * 31 / (HEIGHT - 1)) as u16; + let blue = if (x / 16 + y / 16) % 2 == 0 { 8 } else { 12 }; + frame.set_pixel(x, y, red | (green << 5) | (blue << 10)); + } + } + // Color bars check RGB555 channel order and pixel scaling. + let bars = [0x001f, 0x03e0, 0x7c00, 0x03ff, 0x7fe0, 0x7c1f, 0x7fff, 0]; + for y in 8..24 { + for x in 8..WIDTH - 8 { + frame.set_pixel(x, y, bars[(x - 8) * bars.len() / (WIDTH - 16)]); + } + } + for y in 32..HEIGHT { + frame.set_pixel(self.tick, y, 0x7fff); + } + for y in self.y..self.y + CURSOR_SIZE { + for x in self.x..self.x + CURSOR_SIZE { + let border = x == self.x + || y == self.y + || x == self.x + CURSOR_SIZE - 1 + || y == self.y + CURSOR_SIZE - 1; + frame.set_pixel(x, y, if border { 0 } else { 0x7fff }); + } + } + // Green status square while animating, red while paused. + for y in 1..5 { + for x in 1..5 { + frame.set_pixel(x, y, if self.paused { 0x001f } else { 0x03e0 }); + } + } + } +} + +/// Must run on the main thread for macOS AppKit. +fn create_window(title: &str) -> Result> { + let mut window = Window::new( + title, + WIDTH, + HEIGHT, + WindowOptions { + scale: Scale::X4, + resize: false, + ..WindowOptions::default() + }, + ).map_err(|error| io::Error::other(format!( + "could not open the desktop window: {error}. Run from a logged-in macOS desktop session; use --cpu-demo for terminal-only output." + )))?; + // Default host-test cap. The graphics window replaces this with a GBA-rate cap. + window.set_target_fps(60); + Ok(window) +} + +/// Original host-generated display test. Keep this separate from CPU graphics. +pub fn run(frame_limit: Option) -> Result<(), Box> { + let mut window = create_window(TITLE)?; + let mut frame = Framebuffer::default(); + let mut demo = DisplayDemo::default(); + let mut presented = 0; + println!("Display test: 240x160 at 4x scale. Arrows move; Space pauses; Escape exits."); + + while window.is_open() && !window.is_key_down(Key::Escape) { + let reported_active = window.is_active(); + let input = read_input( + reported_active, + |key| window.is_key_down(key), + window.is_key_pressed(Key::Space, KeyRepeat::No), + ); + demo.update(input); + demo.draw(&mut frame); + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if frame_limit.is_some_and(|limit| presented >= limit) { + println!("Window smoke test passed: {presented} frames submitted."); + return Ok(()); + } + } + + if frame_limit.is_some() { + return Err(io::Error::other("window closed before the smoke test finished").into()); + } + Ok(()) +} + +/// Original CPU-driven Mode 3 demo. The smoke test supplies scripted buttons; +/// normal execution reads physical keyboard input through the focus workaround. +pub fn run_graphics(smoke_test: bool) -> Result<(), Box> { + let mut window = create_window( + "GBA Rust | CPU Mode 3 demo | Arrows: move | Z/X: color | Enter: reset | Esc: exit", + )?; + window.set_target_fps(0); + let frame_period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = GraphicsDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("CPU Mode 3 demo: Arrows move; Z/X change color; Enter resets; Escape exits."); + println!("BIOS VBlankIntrWait uses HALT and IRQ dispatch. Presentation targets 59.73 Hz; rows capture at HBlank and publish at VBlank."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let frame_started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::A, (20..40).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate_graphics_smoke(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("CPU graphics smoke test passed: 60 VBlank-synchronized frames; scripted movement and colors verified."); + return Ok(()); + } + // Slow hosts do not skip emulated frames. Sleeping only limits presentation; + // it never advances the emulated display clock. + thread::sleep(frame_period.saturating_sub(frame_started.elapsed())); + } + if smoke_test { + return Err( + io::Error::other("window closed before the graphics smoke test finished").into(), + ); + } + Ok(()) +} + +fn validate_graphics_smoke( + demo: &GraphicsDemo, + frame: &Framebuffer, + index: usize, +) -> Result<(), Box> { + let x = if index < 30 { + 112 + 2 * (index + 1) + } else { + 172 - 2 * (index - 29) + }; + let color = if (20..40).contains(&index) { + 0x001f + } else { + 0x7fff + }; + let memory = demo.machine().memory(); + let position = memory.display_position(); + if u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err( + io::Error::other("graphics smoke test: presentation missed VBlank entry").into(), + ); + } + if memory.read32(DEMO_STATE)? != index as u32 + 1 + || memory.read32(DEMO_STATE + 4)? != x as u32 + || memory.read32(DEMO_STATE + 8)? != 72 + || memory.read32(DEMO_STATE + 12)? != u32::from(color) + { + return Err(io::Error::other("graphics smoke test: unexpected CPU demo state").into()); + } + for (offset, &pixel) in frame.pixels().iter().enumerate() { + let inside = (x..x + SQUARE_SIZE).contains(&(offset % WIDTH)) + && (72..72 + SQUARE_SIZE).contains(&(offset / WIDTH)); + if pixel != rgb555_to_rgb888(if inside { color } else { BACKGROUND }) { + return Err(io::Error::other(format!( + "graphics smoke test: unexpected pixel {offset}" + )) + .into()); + } + } + Ok(()) +} + +/// Mode 0 uses the same native window and focus workaround as the Mode 3 demo. +pub fn run_tiles(smoke_test: bool) -> Result<(), Box> { + let mut window = create_window( + "GBA Rust | Arrows: scroll | Q: rotate | W: zoom | Z: flip | X: behind | Enter: reset | Esc: exit", + )?; + window.set_target_fps(0); + let frame_period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = TileDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("CPU Mode 0 demo: Arrows scroll; Q rotates 45 degrees; W zooms 2x; X puts the sprite behind gold crosses."); + println!("Hold Q/W together to rotate and zoom. Z flips only while Q/W are released. Enter resets scrolling; Escape exits."); + println!("A CPU-configured sprite stands over two scrolling backgrounds. Original content, not a game."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with( + Button::A, + (5..15).contains(&presented) || (35..55).contains(&presented), + ) + .with(Button::B, (30..50).contains(&presented)) + .with(Button::L, (10..30).contains(&presented)) + .with(Button::R, (20..50).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate_tile_smoke(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Tile/sprite smoke test passed: 60 VBlank-synchronized frames; scroll, affine matrices, sprite attributes, and all pixels verified."); + return Ok(()); + } + thread::sleep(frame_period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err(io::Error::other("window closed before the tile smoke test finished").into()); + } + Ok(()) +} + +fn validate_tile_smoke( + demo: &TileDemo, + frame: &Framebuffer, + index: usize, +) -> Result<(), Box> { + let sx = if index < 30 { + 2 * (index + 1) + } else { + 60 - 2 * (index - 29) + }; + let sy = 2 * (index + 1); + let flipped = (5..15).contains(&index) || (35..55).contains(&index); + let behind = (30..50).contains(&index); + let rotated = (10..30).contains(&index); + let zoomed = (20..50).contains(&index); + let affine = rotated || zoomed; + let coefficient: i16 = if rotated { 181 } else { 256 } / if zoomed { 2 } else { 1 }; + let off_diagonal = if rotated { coefficient } else { 0 }; + let matrix = [coefficient, off_diagonal, -off_diagonal, coefficient]; + let memory = demo.machine().memory(); + let position = memory.display_position(); + if memory.read32(TILE_STATE)? != index as u32 + 1 + || memory.read32(TILE_STATE + 4)? != sx as u32 + || memory.read32(TILE_STATE + 8)? != sy as u32 + || memory.read16(gba_rust::memory::OAM_START)? != if affine { 0x340 } else { 72 } + || memory.read16(gba_rust::memory::OAM_START + 2)? + != if affine { + 0x4068 + } else { + 0x4070 | if flipped { 0x1000 } else { 0 } + } + || memory.read16(gba_rust::memory::OAM_START + 4)? != if behind { 0x400 } else { 0 } + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err( + io::Error::other("tile smoke test: unexpected CPU state or display position").into(), + ); + } + for (i, value) in matrix.into_iter().enumerate() { + if memory.read16(gba_rust::memory::OAM_START + 6 + i as u32 * 8)? != value as u16 { + return Err(io::Error::other("tile smoke test: unexpected affine matrix").into()); + } + } + for y in 0..HEIGHT { + for x in 0..WIDTH { + // Independent scene formula, not a second call to the tile renderer. + let fx = (x + sx / 2) % 256; + let fy = (y + sy / 2) % 256; + let cross = fx / 8 % 4 == 1 && fy / 8 % 4 == 1 && (fx % 8 == 3 || fy % 8 == 3); + let bx = (x + sx) % 512; + let by = (y + sy) % 512; + let terrain = (bx / 32 + by / 32) % 2; + let shade = (bx / 2 + by / 2) % 2; + let mut color = if cross { + 0x03ff + } else { + [0x0260, 0x03a0, 0x7d20, 0x7e80][terrain * 2 + shade] + }; + let source = if affine && (104..136).contains(&x) && (64..96).contains(&y) { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + Some(( + ((f64::from(matrix[0]) * dx + f64::from(matrix[1]) * dy) / 256.0 + 8.0).floor() + as i32, + ((f64::from(matrix[2]) * dx + f64::from(matrix[3]) * dy) / 256.0 + 8.0).floor() + as i32, + )) + } else if !affine && (112..128).contains(&x) && (72..88).contains(&y) { + Some(( + if flipped { + 127 - x as i32 + } else { + x as i32 - 112 + }, + y as i32 - 72, + )) + } else { + None + }; + if let Some((tx, ty)) = source.filter(|&(tx, ty)| { + (2..14).contains(&tx) && (1..15).contains(&ty) && !(cross && behind) + }) { + color = if tx == 10 && ty == 7 { + 0 + } else if tx == 12 && (10..13).contains(&ty) { + 0x03ff + } else if (5..10).contains(&ty) { + 0x7fff + } else { + 0x001f + }; + } + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err( + io::Error::other(format!("tile smoke test: unexpected pixel {x},{y}")).into(), + ); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focused_keyboard_maps_every_gba_button() { + for (key, button) in [ + (Key::Z, Button::A), + (Key::X, Button::B), + (Key::Backspace, Button::Select), + (Key::Enter, Button::Start), + (Key::Right, Button::Right), + (Key::Left, Button::Left), + (Key::Up, Button::Up), + (Key::Down, Button::Down), + (Key::W, Button::R), + (Key::Q, Button::L), + ] { + let buttons = read_buttons(!cfg!(target_os = "macos"), |pressed| pressed == key); + assert_eq!(buttons.bits(), button as u16); + } + assert_eq!( + read_buttons(!cfg!(target_os = "macos"), |_| true).bits(), + 0x3ff + ); + } + + #[test] + fn focus_loss_releases_all_gba_buttons_without_polling_keys() { + let buttons = read_buttons(cfg!(target_os = "macos"), |_| { + panic!("do not poll unfocused keys") + }); + assert_eq!(buttons, Buttons::default()); + } + + #[test] + fn focused_arrow_keys_reach_movement_on_this_platform() { + // The macOS backend reports false for a focused window in minifb 0.28.0. + let focused_report = !cfg!(target_os = "macos"); + for (pressed, dx, dy) in [ + (Key::Left, -2, 0), + (Key::Right, 2, 0), + (Key::Up, 0, -2), + (Key::Down, 0, 2), + ] { + let mut demo = DisplayDemo::default(); + let (x, y) = (demo.x as i32, demo.y as i32); + demo.update(read_input(focused_report, |key| key == pressed, false)); + assert_eq!((demo.x as i32, demo.y as i32), (x + dx, y + dy)); + } + } + + #[test] + fn unfocused_input_is_ignored_without_polling_keys() { + let unfocused_report = cfg!(target_os = "macos"); + let input = read_input( + unfocused_report, + |_| panic!("do not poll movement keys while unfocused"), + true, + ); + assert_eq!(input, Input::default()); + } + + #[test] + fn focused_space_press_reaches_pause() { + let mut demo = DisplayDemo::default(); + demo.update(read_input(!cfg!(target_os = "macos"), |_| false, true)); + assert!(demo.paused); + } + + #[test] + fn movement_stays_inside_the_frame() { + let mut demo = DisplayDemo::default(); + for _ in 0..WIDTH { + demo.update(Input { + left: true, + up: true, + ..Input::default() + }); + } + assert_eq!((demo.x, demo.y), (0, 0)); + for _ in 0..WIDTH { + demo.update(Input { + right: true, + down: true, + ..Input::default() + }); + } + assert_eq!( + (demo.x, demo.y), + (WIDTH - CURSOR_SIZE, HEIGHT - CURSOR_SIZE) + ); + } + + #[test] + fn opposing_directions_cancel() { + let mut demo = DisplayDemo::default(); + let position = (demo.x, demo.y); + demo.update(Input { + left: true, + right: true, + up: true, + down: true, + ..Input::default() + }); + assert_eq!((demo.x, demo.y), position); + } + + #[test] + fn pause_stops_animation_but_not_movement() { + let mut demo = DisplayDemo::default(); + let x = demo.x; + demo.update(Input { + toggle_pause: true, + right: true, + ..Input::default() + }); + assert!(demo.paused); + assert_eq!(demo.tick, 0); + assert_eq!(demo.x, x + 2); + demo.update(Input::default()); + assert_eq!(demo.tick, 0); + demo.update(Input { + toggle_pause: true, + ..Input::default() + }); + assert!(!demo.paused); + assert_eq!(demo.tick, 1); + } + + #[test] + fn animation_wraps_and_draws_a_changing_frame() { + let mut demo = DisplayDemo::default(); + let mut frame = Framebuffer::default(); + demo.draw(&mut frame); + let first = frame.pixels().to_vec(); + demo.update(Input::default()); + demo.draw(&mut frame); + assert_ne!(frame.pixels(), first); + for _ in 1..WIDTH { + demo.update(Input::default()); + } + assert_eq!(demo.tick, 0); + demo.draw(&mut frame); + assert_eq!(frame.pixels(), first); + } +} diff --git a/src/desktop/affine.rs b/src/desktop/affine.rs new file mode 100644 index 0000000..5654f81 --- /dev/null +++ b/src/desktop/affine.rs @@ -0,0 +1,120 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + affine_demo::{AffineDemo, AFFINE_STATE}, + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + input::{Button, Buttons}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(smoke_test: bool) -> Result<(), Box> { + let mut window=create_window("GBA Rust | Affine BG | Arrows: pan | Q: rotate | W: zoom | Z: clip | Enter: reset | Esc: exit")?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = AffineDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("Mode 2 background: Arrows pan; hold Q to rotate 45 degrees; W zooms 2x; Z disables wrapping."); + println!("Q/W can be combined. Enter resets panning; Escape exits. Original CPU-driven content, not a game."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with(Button::L, (10..30).contains(&presented)) + .with(Button::R, (20..50).contains(&presented)) + .with(Button::A, (30..55).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Affine background smoke test passed: 60 VBlank-synchronized frames; CPU pan state and every pixel verified."); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err( + io::Error::other("window closed before affine background smoke test finished").into(), + ); + } + Ok(()) +} + +fn validate(demo: &AffineDemo, frame: &Framebuffer, index: usize) -> Result<(), Box> { + let pan_x = if index < 30 { + 2 * (index + 1) + } else { + 60 - 2 * (index - 29) + }; + let pan_y = 2 * (index + 1); + let rotated = (10..30).contains(&index); + let zoomed = (20..50).contains(&index); + let clipped = (30..55).contains(&index); + let m = demo.machine().memory(); + let position = m.display_position(); + if m.read32(AFFINE_STATE)? != index as u32 + 1 + || m.read32(AFFINE_STATE + 4)? != pan_x as u32 + || m.read32(AFFINE_STATE + 8)? != pan_y as u32 + || m.read16(gba_rust::io::BG2CNT)? != if clipped { 0x5000 } else { 0x7000 } + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err(io::Error::other( + "affine background smoke test: unexpected CPU state or display position", + ) + .into()); + } + let a = f64::from(match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }) / 256.0; + let b = if rotated { a } else { 0.0 }; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + let tx = (128.0 + pan_x as f64 + a * dx + b * dy).floor() as i32; + let ty = (128.0 + pan_y as f64 - b * dx + a * dy).floor() as i32; + let color = if clipped && (!(0..256).contains(&tx) || !(0..256).contains(&ty)) { + 0x4000 + } else { + let tx = tx.rem_euclid(256) as usize; + let ty = ty.rem_euclid(256) as usize; + let terrain = (tx / 32 + 2 * (ty / 32)) % 4; + let shade = (tx / 2 + ty / 2) % 2; + [ + 0x0260, 0x03a0, 0x7d20, 0x7e80, 0x001f, 0x421f, 0x03ff, 0x7fff, + ][terrain * 2 + shade] + }; + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err(io::Error::other(format!( + "affine background smoke test: unexpected pixel {x},{y}" + )) + .into()); + } + } + } + Ok(()) +} diff --git a/src/desktop/affine_raster.rs b/src/desktop/affine_raster.rs new file mode 100644 index 0000000..d5b2a68 --- /dev/null +++ b/src/desktop/affine_raster.rs @@ -0,0 +1,128 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + affine_raster_demo::{AffineRasterDemo, AFFINE_RASTER_STATE}, + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + input::{Button, Buttons}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(smoke_test: bool) -> Result<(), Box> { + let mut window=create_window("GBA Rust | Affine raster | Arrows: pan | Q: rotate | W: zoom | Z: bypass | Enter: reset | Esc: exit")?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = AffineRasterDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("Mode 2 background: Arrows pan; hold Q to rotate 45 degrees; W zooms 2x; Z bypasses distortion."); + println!("Q/W can be combined. Enter resets panning; Escape exits. Original CPU-driven content, not a game."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with(Button::L, (10..30).contains(&presented)) + .with(Button::R, (20..50).contains(&presented)) + .with(Button::A, (40..50).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Affine raster smoke test passed: 60 VBlank-synchronized frames; CPU pan state and every pixel verified."); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err( + io::Error::other("window closed before affine raster smoke test finished").into(), + ); + } + Ok(()) +} + +fn validate( + demo: &AffineRasterDemo, + frame: &Framebuffer, + index: usize, +) -> Result<(), Box> { + let pan_x = if index < 30 { + 2 * (index + 1) + } else { + 60 - 2 * (index - 29) + }; + let pan_y = 2 * (index + 1); + let rotated = (10..30).contains(&index); + let zoomed = (20..50).contains(&index); + let bypassed = (40..50).contains(&index); + let m = demo.machine().memory(); + let position = m.display_position(); + if m.read32(AFFINE_RASTER_STATE)? != index as u32 + 1 + || m.read32(AFFINE_RASTER_STATE + 4)? != pan_x as u32 + || m.read32(AFFINE_RASTER_STATE + 8)? != pan_y as u32 + || m.read16(gba_rust::io::BG2CNT)? != 0x7000 + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err(io::Error::other( + "affine raster smoke test: unexpected CPU state or display position", + ) + .into()); + } + let a = f64::from(match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }) / 256.0; + let b = if rotated { a } else { 0.0 }; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + let phase = y % 32; + let shift = if bypassed { + 0 + } else { + 2 * phase.min(32 - phase) + }; + let tx = (128.0 + pan_x as f64 + a * dx + b * dy + shift as f64).floor() as i32; + let ty = (128.0 + pan_y as f64 - b * dx + a * dy).floor() as i32; + let color = { + let tx = tx.rem_euclid(256) as usize; + let ty = ty.rem_euclid(256) as usize; + let terrain = (tx / 32 + 2 * (ty / 32)) % 4; + let shade = (tx / 2 + ty / 2) % 2; + [ + 0x0260, 0x03a0, 0x7d20, 0x7e80, 0x001f, 0x421f, 0x03ff, 0x7fff, + ][terrain * 2 + shade] + }; + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err(io::Error::other(format!( + "affine raster smoke test: unexpected pixel {x},{y}" + )) + .into()); + } + } + } + Ok(()) +} diff --git a/src/desktop/bitmap.rs b/src/desktop/bitmap.rs new file mode 100644 index 0000000..0ad6b33 --- /dev/null +++ b/src/desktop/bitmap.rs @@ -0,0 +1,141 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE}, + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + input::{Button, Buttons}, + io::DISPCNT, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(mode: BitmapMode, smoke_test: bool) -> Result<(), Box> { + let mut window=create_window(&format!("GBA Rust | Bitmap {} | Arrows: pan | Q: rotate | W: zoom | Z: page1 | Enter: reset | Esc: exit",mode as u16))?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = BitmapDemo::new(mode)?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!( + "Mode {} bitmap: Pages alternate every 32 updates. Hold Z to force page1.", + mode as u16 + ); + println!("Arrows pan; Q rotates 45 degrees; W zooms 2x; Enter resets panning; Escape exits."); + println!( + "The CPU copies two original images and switches DISPCNT during VBlank. No game content." + ); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with(Button::L, (10..30).contains(&presented)) + .with(Button::R, (20..50).contains(&presented)) + .with(Button::A, (10..20).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, mode, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Mode {} bitmap smoke test passed: 60 VBlank-synchronized frames; CPU state, page selection, and every pixel verified.",mode as u16); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err(io::Error::other("window closed before bitmap smoke test finished").into()); + } + Ok(()) +} + +fn validate( + demo: &BitmapDemo, + frame: &Framebuffer, + mode: BitmapMode, + index: usize, +) -> Result<(), Box> { + let sx = if index < 30 { + 2 * (index + 1) + } else { + 60 - 2 * (index - 29) + }; + let sy = 2 * (index + 1); + let rotated = (10..30).contains(&index); + let zoomed = (20..50).contains(&index); + let page = usize::from((index + 1) & 32 != 0 || (10..20).contains(&index)); + let m = demo.machine().memory(); + let position = m.display_position(); + if m.read32(BITMAP_STATE)? != index as u32 + 1 + || m.read32(BITMAP_STATE + 4)? != sx as u32 + || m.read32(BITMAP_STATE + 8)? != sy as u32 + || m.read16(DISPCNT)? != (0x400 | mode as u16 | (page as u16 * 16)) + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err(io::Error::other( + "bitmap smoke test: unexpected CPU state, page, or display position", + ) + .into()); + } + let (w, h) = if mode == BitmapMode::Mode4 { + (240, 160) + } else { + (160, 128) + }; + let a = f64::from(match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }) / 256.0; + let b = if rotated { a } else { 0.0 }; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + let tx = (f64::from(w / 2) + sx as f64 + a * dx + b * dy).floor() as i32; + let ty = (f64::from(h / 2) + sy as f64 - b * dx + a * dy).floor() as i32; + let color = if tx < 0 || ty < 0 || tx >= w || ty >= h { + 0x4000 + } else { + let (tx, ty) = (tx as usize, ty as usize); + if mode == BitmapMode::Mode4 { + let i = (tx / 8 + 3 * (ty / 8) + page * 64) % 256; + if i == 0 { + 0x4000 + } else { + ((i % 32) | ((i / 8 % 32) << 5) | ((i * 3 % 32) << 10)) as u16 + } + } else { + (((tx / 5 + page * 11) % 32) + | (((ty / 4 + page * 7) % 32) << 5) + | (((tx / 8 + ty / 8 + page * 5) % 32) << 10)) as u16 + } + }; + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err(io::Error::other(format!( + "bitmap smoke test: unexpected pixel {x},{y}" + )) + .into()); + } + } + } + Ok(()) +} diff --git a/src/desktop/effects.rs b/src/desktop/effects.rs new file mode 100644 index 0000000..8dd6a69 --- /dev/null +++ b/src/desktop/effects.rs @@ -0,0 +1,186 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + effects_demo::{EffectsDemo, EFFECTS_STATE}, + input::{Button, Buttons}, + io::{BLDALPHA, BLDCNT, DISPCNT, WININ, WINOUT}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(smoke_test: bool) -> Result<(), Box> { + let mut window = create_window( + "GBA Rust | Effects | Arrows: move | Z: blend | X: darken | Enter: reset | Esc: exit", + )?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = EffectsDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + let mut vertical_active = false; + println!("Window/effects demo: Arrows scroll and move the unchanged rectangle. Outside it, colors become brighter."); + println!("Hold Z to blend or X to darken; Z takes precedence. Enter resets; Escape exits."); + println!( + "Tile controls also remain active: Q rotates, W zooms, Z flips, X lowers sprite priority." + ); + println!("The CPU updates WIN0 and color registers during VBlank. Original test content, not a game."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with(Button::A, (20..40).contains(&presented)) + .with(Button::B, presented >= 30) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, presented, &mut vertical_active)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Window/effects smoke test passed: 60 VBlank-synchronized frames; CPU state, effect registers, and every pixel verified."); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err(io::Error::other("window closed before effects smoke test finished").into()); + } + Ok(()) +} + +fn validate( + demo: &EffectsDemo, + frame: &Framebuffer, + index: usize, + vertical_active: &mut bool, +) -> Result<(), Box> { + let sx = if index < 30 { + 2 * (index + 1) + } else { + 60 - 2 * (index - 29) + }; + let sy = 2 * (index + 1); + let alpha = (20..40).contains(&index); + let behind = index >= 30; + let control = if alpha { + 0x251 + } else if behind { + 0xff + } else { + 0xbf + }; + let m = demo.machine().memory(); + let position = m.display_position(); + if m.read32(EFFECTS_STATE)? != index as u32 + 1 + || m.read32(EFFECTS_STATE + 4)? != sx as u32 + || m.read32(EFFECTS_STATE + 8)? != sy as u32 + || m.read16(DISPCNT)? != 0x3340 + || m.read16(WININ)? != 0x1f + || m.read16(WINOUT)? != 0x3f + || m.read16(BLDCNT)? != control + || m.read16(BLDALPHA)? != 0x808 + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err( + io::Error::other("effects smoke test: unexpected CPU state or registers").into(), + ); + } + // The scripted ARM update writes WIN0V during row160, after its comparison. + // Track every subsequent line independently of the renderer, including VBlank. + // The previous call retained state after row160's old-bounds comparison. + let top = (sy + 40) & 255; + let bottom = (top + 80) & 255; + let compare = |active: &mut bool, row| { + if row == top { + *active = true; + } + if row == bottom { + *active = false; + } + }; + for row in 161..228 { + compare(vertical_active, row); + } + for y in 0..HEIGHT { + compare(vertical_active, y); + for x in 0..WIDTH { + // Independent scene and five-bit arithmetic, without invoking the renderer. + let fx = (x + sx / 2) % 256; + let fy = (y + sy / 2) % 256; + let cross = fx / 8 % 4 == 1 && fy / 8 % 4 == 1 && (fx % 8 == 3 || fy % 8 == 3); + let bx = (x + sx) % 512; + let by = (y + sy) % 512; + let terrain: u16 = [0x260, 0x3a0, 0x7d20, 0x7e80] + [((bx / 32 + by / 32) % 2) * 2 + (bx / 2 + by / 2) % 2]; + let tx = if alpha { + 127 - x as i32 + } else { + x as i32 - 112 + }; + let ty = y as i32 - 72; + let object = (2..14).contains(&tx) && (1..15).contains(&ty); + let obj_color = if tx == 10 && ty == 7 { + 0 + } else if tx == 12 && (10..13).contains(&ty) { + 0x3ff + } else if (5..10).contains(&ty) { + 0x7fff + } else { + 31 + }; + let raw = if object && !(cross && behind) { + obj_color + } else if cross { + 0x3ff + } else { + terrain + }; + let inside = x.wrapping_sub(sx + 64) < 112 && *vertical_active; + let blend = alpha && (cross || object) && !(cross && object); + let mut color = 0; + for shift in [0, 5, 10] { + let c = (raw >> shift) & 31; + let channel = if inside { + c + } else if alpha { + if blend { + (c + ((terrain >> shift) & 31)) / 2 + } else { + c + } + } else if behind { + c - c / 2 + } else { + c + (31 - c) / 2 + }; + color |= channel << shift; + } + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err(io::Error::other(format!( + "effects smoke test: unexpected pixel {x},{y} in frame {index}" + )) + .into()); + } + } + } + compare(vertical_active, 160); // Publication follows entry to row160. + Ok(()) +} diff --git a/src/desktop/mosaic.rs b/src/desktop/mosaic.rs new file mode 100644 index 0000000..963d723 --- /dev/null +++ b/src/desktop/mosaic.rs @@ -0,0 +1,134 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES}, + input::{Button, Buttons}, + io::{BG0CNT, BG1CNT, DISPCNT, MOSAIC}, + memory::OAM_START, + mosaic_demo::{MosaicDemo, MOSAIC_STATE}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(smoke_test: bool) -> Result<(), Box> { + let mut window = create_window("GBA Rust | Mosaic | Arrows: scroll | Z: clear BG | X: clear OBJ | Q/W: transform | Esc: exit")?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = MosaicDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("Mosaic demo: The CPU cycles block sizes from 1x1 to 16x16, changing every eight VBlank updates."); + println!("Arrows scroll; hold Z to bypass background mosaic, X to bypass sprite mosaic, or both to bypass all mosaic."); + println!("Q rotates the sprite; W zooms; Enter resets scrolling; Escape exits. Original test content, not a game."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default() + .with( + if presented < 64 { + Button::Right + } else { + Button::Left + }, + true, + ) + .with(Button::Down, true) + .with(Button::A, (32..64).contains(&presented)) + .with(Button::B, (64..96).contains(&presented)) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 128 { + println!("Mosaic smoke test passed: 128 VBlank-synchronized frames; CPU state, independent BG/OBJ controls, size cycling, and every pixel verified."); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err(io::Error::other("window closed before mosaic smoke test finished").into()); + } + Ok(()) +} + +fn validate(demo: &MosaicDemo, frame: &Framebuffer, index: usize) -> Result<(), Box> { + let sx = if index < 64 { + 2 * (index + 1) + } else { + 128 - 2 * (index - 63) + }; + let sy = 2 * (index + 1); + let size = 1 + ((index + 1) / 8) % 16; + let bg_size = if (32..64).contains(&index) { 1 } else { size }; + let obj_size = if (64..96).contains(&index) { 1 } else { size }; + let m = demo.machine().memory(); + let position = m.display_position(); + if m.read32(MOSAIC_STATE)? != index as u32 + 1 + || m.read32(MOSAIC_STATE + 4)? != sx as u32 + || m.read32(MOSAIC_STATE + 8)? != sy as u32 + || m.read16(DISPCNT)? != 0x1340 + || m.read16(BG0CNT)? != 0x1844 + || m.read16(BG1CNT)? != 0xd0c1 + || m.read16(OAM_START)? != 0x1048 + || m.read16(OAM_START + 2)? != 0x4070 + || m.read16(OAM_START + 4)? != 0 + || m.read32(MOSAIC)? != 0 + || u32::from(position.scanline) != VISIBLE_LINES + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + { + return Err( + io::Error::other("mosaic smoke test: unexpected CPU state or registers").into(), + ); + } + for y in 0..HEIGHT { + for x in 0..WIDTH { + // Independent scene formula: sample the screen grid before scrolling. + let mx = x / bg_size * bg_size; + let my = y / bg_size * bg_size; + let fx = (mx + sx / 2) % 256; + let fy = (my + sy / 2) % 256; + let cross = fx / 8 % 4 == 1 && fy / 8 % 4 == 1 && (fx % 8 == 3 || fy % 8 == 3); + let bx = (mx + sx) % 512; + let by = (my + sy) % 512; + let mut color = if cross { + 0x3ff + } else { + [0x260, 0x3a0, 0x7d20, 0x7e80] + [((bx / 32 + by / 32) % 2) * 2 + (bx / 2 + by / 2) % 2] + }; + if (112..128).contains(&x) && (72..88).contains(&y) { + // All texels in this regular sprite's canvas carry its mosaic flag, + // including transparent ones. Its first partial blocks clamp. + let tx = (x / obj_size * obj_size).max(112) - 112; + let ty = (y / obj_size * obj_size).max(72) - 72; + if (2..14).contains(&tx) && (1..15).contains(&ty) { + color = if tx == 10 && ty == 7 { + 0 + } else if tx == 12 && (10..13).contains(&ty) { + 0x3ff + } else if (5..10).contains(&ty) { + 0x7fff + } else { + 31 + }; + } + } + if frame.pixels()[y * WIDTH + x] != rgb555_to_rgb888(color) { + return Err(io::Error::other(format!( + "mosaic smoke test: unexpected pixel {x},{y} in frame {index}" + )) + .into()); + } + } + } + Ok(()) +} diff --git a/src/desktop/raster.rs b/src/desktop/raster.rs new file mode 100644 index 0000000..7e3f362 --- /dev/null +++ b/src/desktop/raster.rs @@ -0,0 +1,90 @@ +use super::{create_window, read_buttons}; +use gba_rust::{ + display::{CPU_HZ, CYCLES_PER_FRAME}, + dma::DMA_BASE, + input::{Button, Buttons}, + raster_demo::{RasterDemo, RASTER_STATE}, + video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH}, +}; +use minifb::Key; +use std::{ + error::Error, + io, thread, + time::{Duration, Instant}, +}; + +pub fn run(smoke_test: bool) -> Result<(), Box> { + let mut window = create_window( + "GBA Rust | HBlank raster | Left/Right: move bands | Enter: reset | Esc: exit", + )?; + window.set_target_fps(0); + let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let mut demo = RasterDemo::new()?; + let mut frame = Framebuffer::default(); + let mut presented = 0; + println!("Raster demo: HBlank DMA changes the backdrop palette after each captured row."); + println!("Left/Right move the bands; Enter resets; Escape exits. Original CPU-driven content, not a game."); + println!("Rows sample at HBlank and publish at VBlank. Pixel fetch timing is not yet modeled."); + while window.is_open() && !window.is_key_down(Key::Escape) { + let started = Instant::now(); + let buttons = if smoke_test { + Buttons::default().with( + if presented < 30 { + Button::Right + } else { + Button::Left + }, + true, + ) + } else { + read_buttons(window.is_active(), |key| window.is_key_down(key)) + }; + demo.frame(buttons, &mut frame)?; + if smoke_test { + validate(&demo, &frame, presented)?; + } + window.update_with_buffer(frame.pixels(), WIDTH, HEIGHT)?; + presented += 1; + if smoke_test && presented == 60 { + println!("Raster smoke test passed: 60 captured frames; CPU phase, HBlank DMA, VBlank publication, and every pixel verified."); + return Ok(()); + } + thread::sleep(period.saturating_sub(started.elapsed())); + } + if smoke_test { + return Err(io::Error::other("window closed before raster smoke test finished").into()); + } + Ok(()) +} + +fn validate(demo: &RasterDemo, frame: &Framebuffer, index: usize) -> Result<(), Box> { + let phase = if index < 30 { index + 1 } else { 59 - index }; + let memory = demo.machine().memory(); + let position = memory.display_position(); + if memory.read32(RASTER_STATE)? != index as u32 + 1 + || memory.read32(RASTER_STATE + 4)? != phase as u32 + || memory.read16(DMA_BASE + 10)? != 0xa240 + || position.scanline != 160 + || u32::from(position.line_cycle) >= demo.machine().last_timing().total() + || memory.captured_vblank() != Some(position.vblanks) + { + return Err(io::Error::other( + "raster smoke test: unexpected CPU state or capture position", + ) + .into()); + } + for y in 0..HEIGHT { + let value = ((y + phase) % 32) as u16; + let color = value + (31 - value) * 32 + (value / 2) * 1024; + if frame.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .any(|&p| p != rgb555_to_rgb888(color)) + { + return Err(io::Error::other(format!( + "raster smoke test: unexpected row {y} in frame {index}" + )) + .into()); + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..096f0e6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,103 @@ +//! An educational GBA starting point, not a game-compatible emulator. + +pub mod affine_demo; +pub mod affine_raster_demo; +pub mod bios; +pub mod bitmap_demo; +pub mod cpu; +pub mod display; +pub mod dma; +pub mod effects_demo; +pub mod graphics_demo; +pub mod input; +pub mod io; +pub mod machine; +pub mod memory; +pub mod mosaic_demo; +pub mod raster_demo; +pub mod tile_demo; +pub mod timer_demo; +pub mod timing; +pub mod video; + +/// Steps needed to reach and execute the demo's final self-branch. +pub const DEMO_STEPS: usize = 40; + +/// Steps needed to execute both software interrupts and the final self-branch. +pub const EXCEPTION_DEMO_STEPS: usize = 11; + +/// Original test image, not a Nintendo BIOS or a BIOS-service replacement. +/// The SWI vector branches to a handler that increments r10 and returns. +pub fn demo_bios() -> Vec { + let mut bytes = vec![0; memory::BIOS_SIZE]; + bytes[8..12].copy_from_slice(&0xea00_000c_u32.to_le_bytes()); // B 0x40 + bytes[0x40..0x44].copy_from_slice(&0xe28a_a001_u32.to_le_bytes()); // ADD r10, r10, #1 + bytes[0x44..0x48].copy_from_slice(&0xe1b0_f00e_u32.to_le_bytes()); // MOVS pc, lr + bytes +} + +/// Original ARM and Thumb software-interrupt test code. +pub fn exception_demo_program() -> Vec { + let mut bytes: Vec = [ + 0xef00_0001_u32, // ARM SWI #1 + 0xe28f_0001, // ADD r0, pc, #1 (Thumb offset 12, bit zero set) + 0xe12f_ff10, // BX r0 + ] + .into_iter() + .flat_map(u32::to_le_bytes) + .collect(); + bytes.extend(0xdf02_u16.to_le_bytes()); // Thumb SWI #2 + bytes.extend(0xe7fe_u16.to_le_bytes()); // B . + bytes +} + +/// Original ARM/Thumb, RAM, stack, and swap test code, not a Nintendo ROM. +pub fn demo_program() -> Vec { + let mut bytes: Vec = [ + 0xe3a0_0003_u32, // MOV r0, #3 + 0xe3a0_1000, // MOV r1, #0 + 0xe281_1001, // loop: ADD r1, r1, #1 + 0xe250_0001, // SUBS r0, r0, #1 + 0x1aff_fffc, // BNE loop + 0xe351_0003, // CMP r1, #3 + 0x03a0_202a, // MOVEQ r2, #42 + 0xe3a0_3402, // MOV r3, #0x02000000 (work RAM) + 0xe583_2000, // STR r2, [r3] + 0xe593_4000, // LDR r4, [r3] + 0xe3a0_d403, // MOV sp, #0x03000000 (internal work RAM) + 0xe28d_dc01, // ADD sp, sp, #0x100 + 0xeb00_0000, // BL subroutine at offset 56 + 0xea00_0005, // B Thumb setup at offset 80 + 0xe92d_4010, // subroutine: STMDB sp!, {r4, lr} + 0xe1a0_5084, // MOV r5, r4, LSL #1 + 0xe006_0594, // MUL r6, r4, r5 + 0xe103_7096, // SWP r7, r6, [r3] (old RAM value into r7) + 0xe3a0_4000, // MOV r4, #0 (demonstrate restoration from the stack) + 0xe8bd_8010, // LDMIA sp!, {r4, pc} (restore r4 and return) + 0xe28f_800d, // ADD r8, pc, #13 (Thumb entry at offset 100, bit 0 set) + 0xe28f_9004, // ADD r9, pc, #4 (ARM return at offset 96) + 0xe12f_ff18, // BX r8 + 0xe1a0_0000, // NOP, skipped + 0xeaff_fffe, // ARM return: B . + ] + .into_iter() + .flat_map(u32::to_le_bytes) + .collect(); + bytes.extend( + [ + 0x2006_u16, // Thumb entry: MOV r0, #6 + 0x2107, // MOV r1, #7 + 0xf000, // BL prefix + 0xf803, // BL suffix: target offset 114 + 0x6058, // STR r0, [r3, #4] + 0x282a, // CMP r0, #42 + 0x4748, // BX r9 (return to ARM) + 0xb500, // Thumb subroutine: PUSH {lr} + 0x4348, // MUL r0, r1 + 0xbd00, // POP {pc} (stays in Thumb) + ] + .into_iter() + .flat_map(u16::to_le_bytes), + ); + bytes +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..37a26ad --- /dev/null +++ b/src/main.rs @@ -0,0 +1,250 @@ +mod desktop; + +use std::{error::Error, ffi::OsString, io}; + +use gba_rust::{ + bitmap_demo::BitmapMode, + cpu::Cpu, + demo_bios, demo_program, exception_demo_program, + io::{IF, TIMER_BASE}, + memory::{Memory, ROM_START}, + timer_demo::{timer_demo, TIMER_DEMO_STEPS}, + DEMO_STEPS, EXCEPTION_DEMO_STEPS, +}; + +#[derive(Debug, PartialEq, Eq)] +enum RunMode { + Window, + SmokeTest, + CpuDemo, + TimerDemo, + GraphicsDemo, + GraphicsSmokeTest, + EffectsDemo, + EffectsSmokeTest, + MosaicDemo, + MosaicSmokeTest, + RasterDemo, + RasterSmokeTest, + TileDemo, + TileSmokeTest, + AffineDemo, + AffineSmokeTest, + AffineRasterDemo, + AffineRasterSmokeTest, + BitmapDemo(BitmapMode), + BitmapSmokeTest(BitmapMode), + Help, +} + +fn parse_args(args: &[OsString]) -> Result { + match args { + [] => Ok(RunMode::Window), + [arg] if arg == "--smoke-test" => Ok(RunMode::SmokeTest), + [arg] if arg == "--cpu-demo" => Ok(RunMode::CpuDemo), + [arg] if arg == "--timer-demo" => Ok(RunMode::TimerDemo), + [arg] if arg == "--graphics-demo" => Ok(RunMode::GraphicsDemo), + [arg] if arg == "--graphics-smoke-test" => Ok(RunMode::GraphicsSmokeTest), + [arg] if arg == "--raster-demo" => Ok(RunMode::RasterDemo), + [arg] if arg == "--raster-smoke-test" => Ok(RunMode::RasterSmokeTest), + [arg] if arg == "--mosaic-demo" => Ok(RunMode::MosaicDemo), + [arg] if arg == "--mosaic-smoke-test" => Ok(RunMode::MosaicSmokeTest), + [arg] if arg == "--effects-demo" => Ok(RunMode::EffectsDemo), + [arg] if arg == "--effects-smoke-test" => Ok(RunMode::EffectsSmokeTest), + [arg] if arg == "--tile-demo" => Ok(RunMode::TileDemo), + [arg] if arg == "--tile-smoke-test" => Ok(RunMode::TileSmokeTest), + [arg] if arg == "--affine-raster-demo" => Ok(RunMode::AffineRasterDemo), + [arg] if arg == "--affine-raster-smoke-test" => Ok(RunMode::AffineRasterSmokeTest), + [arg] if arg == "--affine-demo" => Ok(RunMode::AffineDemo), + [arg] if arg == "--affine-smoke-test" => Ok(RunMode::AffineSmokeTest), + [arg] if arg == "--bitmap4-demo" => Ok(RunMode::BitmapDemo(BitmapMode::Mode4)), + [arg] if arg == "--bitmap5-demo" => Ok(RunMode::BitmapDemo(BitmapMode::Mode5)), + [arg] if arg == "--bitmap4-smoke-test" => Ok(RunMode::BitmapSmokeTest(BitmapMode::Mode4)), + [arg] if arg == "--bitmap5-smoke-test" => Ok(RunMode::BitmapSmokeTest(BitmapMode::Mode5)), + [arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: gba-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; ROM loading is not supported yet", + )), + } +} + +fn main() -> Result<(), Box> { + match parse_args(&std::env::args_os().skip(1).collect::>())? { + RunMode::Window => desktop::run(None), + RunMode::SmokeTest => desktop::run(Some(60)), + RunMode::CpuDemo => run_cpu_demo(), + RunMode::TimerDemo => run_timer_demo(), + RunMode::GraphicsDemo => desktop::run_graphics(false), + RunMode::GraphicsSmokeTest => desktop::run_graphics(true), + RunMode::RasterDemo => desktop::run_raster(false), + RunMode::RasterSmokeTest => desktop::run_raster(true), + RunMode::MosaicDemo => desktop::run_mosaic(false), + RunMode::MosaicSmokeTest => desktop::run_mosaic(true), + RunMode::EffectsDemo => desktop::run_effects(false), + RunMode::EffectsSmokeTest => desktop::run_effects(true), + RunMode::TileDemo => desktop::run_tiles(false), + RunMode::TileSmokeTest => desktop::run_tiles(true), + RunMode::AffineRasterDemo => desktop::run_affine_raster(false), + RunMode::AffineRasterSmokeTest => desktop::run_affine_raster(true), + RunMode::AffineDemo => desktop::run_affine(false), + RunMode::AffineSmokeTest => desktop::run_affine(true), + RunMode::BitmapDemo(mode) => desktop::run_bitmap(mode, false), + RunMode::BitmapSmokeTest(mode) => desktop::run_bitmap(mode, true), + RunMode::Help => { + println!( + "GBA Rust — emulator foundation, not game-compatible yet\n\ + No arguments Open the native 240x160 display test at 4x scale\n\ + --cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\ + --timer-demo Run the timer IRQ demo with nominal cycle costs\n\ + --graphics-demo Open the CPU-driven Mode 3 demo\n\ + --graphics-smoke-test Verify 60 CPU-driven frames with scripted input\n\ + --raster-demo Open the HBlank DMA color-band demo\n\ + --raster-smoke-test Verify 60 scanline-captured raster frames\n\ + --mosaic-demo Open the CPU-driven mosaic demo\n\ + --mosaic-smoke-test Verify 128 background and sprite mosaic frames\n\ + --effects-demo Open the CPU-driven window and color-effects demo\n\ + --effects-smoke-test Verify 60 window and color-effects frames\n\ + --tile-demo Open the CPU-driven Mode 0 scrolling demo\n\ + --tile-smoke-test Verify 60 tile frames with scripted input\n\ + --affine-raster-demo Open the HBlank DMA affine distortion demo\n\ + --affine-raster-smoke-test Verify 60 affine raster frames\n\ + --affine-demo Open the CPU-driven Mode 2 background demo\n\ + --affine-smoke-test Verify 60 transformed background frames\n\ + --bitmap4-demo Open the indexed-color page-flipping demo\n\ + --bitmap5-demo Open the 160x128 RGB555 page-flipping demo\n\ + --bitmap4-smoke-test Verify 60 Mode 4 bitmap frames\n\ + --bitmap5-smoke-test Verify 60 Mode 5 bitmap frames\n\ + --smoke-test Open the host display test for 60 frames\n\ + --help, -h Show this help\n\n\ + Display test: Arrows move; Space pauses; Escape exits.\n\ + CPU graphics: Arrows move; Z/X change color; Enter resets; Escape exits.\n\ + Raster: Left/Right move bands; Enter resets; Escape exits.\n\ + Mosaic: Arrows scroll; Z bypasses BG mosaic; X bypasses OBJ mosaic; Q/W transform; Enter resets; Escape exits.\n\ + Window effects: Arrows move; Z blends; X darkens; Enter resets; Escape exits.\n\ + Tile graphics: Arrows scroll; Q rotates; W zooms; Z flips without Q/W; X changes priority; Enter resets; Escape exits.\n\ + Affine raster: Arrows pan; Q rotates; W zooms; Z bypasses distortion; Enter resets; Escape exits.\n\ + Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\ + Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\ + All demos use original test content. Game ROM loading is not supported." + ); + Ok(()) + } + } +} + +fn run_cpu_demo() -> Result<(), Box> { + let mut memory = Memory::new(demo_program())?; + let mut cpu = Cpu::new(ROM_START); + + println!("GBA core demo — not yet able to run games"); + // Fixed instruction count: the demo ends in an intentional infinite branch. + for _ in 0..DEMO_STEPS { + let pc = cpu.pc(); + let instruction_set = cpu.instruction_set(); + cpu.step(&mut memory)?; + let flags = cpu.flags(); + println!( + "{pc:#010x} {instruction_set:?}: r0={} r1={} r2={} r4={} r5={} r6={} r7={} sp={:#010x} NZCV={}{}{}{} next_pc={:#010x}", + cpu.registers()[0], + cpu.registers()[1], + cpu.registers()[2], + cpu.registers()[4], + cpu.registers()[5], + cpu.registers()[6], + cpu.registers()[7], + cpu.registers()[13], + u8::from(flags.negative), + u8::from(flags.zero), + u8::from(flags.carry), + u8::from(flags.overflow), + cpu.pc(), + ); + } + + println!("Exception demo — original test handler, not Nintendo BIOS services"); + let mut memory = Memory::with_bios(exception_demo_program(), demo_bios())?; + let mut cpu = Cpu::new(ROM_START); + for _ in 0..EXCEPTION_DEMO_STEPS { + let pc = cpu.pc(); + let mode = cpu.mode(); + let state = cpu.instruction_set(); + cpu.step(&mut memory)?; + println!( + "{pc:#010x} {mode:?}/{state:?} -> {:?}/{:?} CPSR={:#010x} handler_calls={} next_pc={:#010x}", + cpu.mode(), cpu.instruction_set(), cpu.cpsr(), cpu.registers()[10], cpu.pc(), + ); + } + Ok(()) +} + +fn run_timer_demo() -> Result<(), Box> { + let mut machine = timer_demo()?; + println!( + "Timer IRQ demo — nominal instruction/bus costs; no prefetch or sub-instruction timing" + ); + for _ in 0..TIMER_DEMO_STEPS { + let event = machine.step()?; + println!( + "cycle={} (+{}) {event:?} mode={:?} pc={:#010x} timer0={:#06x} IF={:#06x} handler_calls={}", + machine.cycles(), + machine.last_timing().total(), + machine.cpu().mode(), + machine.cpu().pc(), + machine.memory().read16(TIMER_BASE)?, + machine.memory().read16(IF)?, + machine.cpu().registers()[10], + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_line_selects_window_demo_smoke_test_or_help() { + assert_eq!(parse_args(&[]).unwrap(), RunMode::Window); + for (argument, expected) in [ + ("--cpu-demo", RunMode::CpuDemo), + ("--timer-demo", RunMode::TimerDemo), + ("--bitmap4-demo", RunMode::BitmapDemo(BitmapMode::Mode4)), + ("--bitmap5-demo", RunMode::BitmapDemo(BitmapMode::Mode5)), + ( + "--bitmap4-smoke-test", + RunMode::BitmapSmokeTest(BitmapMode::Mode4), + ), + ( + "--bitmap5-smoke-test", + RunMode::BitmapSmokeTest(BitmapMode::Mode5), + ), + ("--affine-raster-demo", RunMode::AffineRasterDemo), + ("--affine-raster-smoke-test", RunMode::AffineRasterSmokeTest), + ("--affine-demo", RunMode::AffineDemo), + ("--affine-smoke-test", RunMode::AffineSmokeTest), + ("--raster-demo", RunMode::RasterDemo), + ("--raster-smoke-test", RunMode::RasterSmokeTest), + ("--mosaic-demo", RunMode::MosaicDemo), + ("--mosaic-smoke-test", RunMode::MosaicSmokeTest), + ("--effects-demo", RunMode::EffectsDemo), + ("--effects-smoke-test", RunMode::EffectsSmokeTest), + ("--tile-demo", RunMode::TileDemo), + ("--tile-smoke-test", RunMode::TileSmokeTest), + ("--graphics-demo", RunMode::GraphicsDemo), + ("--graphics-smoke-test", RunMode::GraphicsSmokeTest), + ("--smoke-test", RunMode::SmokeTest), + ("--help", RunMode::Help), + ("-h", RunMode::Help), + ] { + assert_eq!(parse_args(&[argument.into()]).unwrap(), expected); + } + } + + #[test] + fn command_line_rejects_rom_paths_and_conflicting_options() { + assert!(parse_args(&["emerald.gba".into()]).is_err()); + assert!(parse_args(&["--unknown".into()]).is_err()); + assert!(parse_args(&["--cpu-demo".into(), "--smoke-test".into()]).is_err()); + } +}