feat(desktop): integrate native macOS windows controls and smoke tests

Expose core modules and original demo images. Add CLI modes, native windows, scripted smoke tests, and the pinned minifb focus workaround.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit f9a426a11e
9 changed files with 1761 additions and 0 deletions

609
src/desktop.rs Normal file
View file

@ -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<Window, Box<dyn Error>> {
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<usize>) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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);
}
}

120
src/desktop/affine.rs Normal file
View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

141
src/desktop/bitmap.rs Normal file
View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

186
src/desktop/effects.rs Normal file
View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

134
src/desktop/mosaic.rs Normal file
View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

90
src/desktop/raster.rs Normal file
View file

@ -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<dyn Error>> {
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<dyn Error>> {
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(())
}

103
src/lib.rs Normal file
View file

@ -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<u8> {
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<u8> {
let mut bytes: Vec<u8> = [
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<u8> {
let mut bytes: Vec<u8> = [
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
}

250
src/main.rs Normal file
View file

@ -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<RunMode, io::Error> {
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<dyn Error>> {
match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>())? {
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<dyn Error>> {
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<dyn Error>> {
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());
}
}