refactor: split into gameboy-rust workspace with core, demos, and desktop crates

Move the emulator into crates/gba-core, the original demo programs into
crates/gba-demos, and the minifb frontend into crates/desktop. The binary
is now gameboy-rust. Imports change from gba_rust to gba_core/gba_demos;
no behavior changes.

gba-core has no dependencies. Its integration tests use gba-demos as a
development dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
0xrsydn 2026-10-05 16:07:32 +07:00
commit f8a0676748
113 changed files with 304 additions and 282 deletions

View file

@ -0,0 +1,611 @@
//! 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_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::{
graphics_demo::{GraphicsDemo, BACKGROUND, DEMO_STATE, SQUARE_SIZE},
tile_demo::{TileDemo, TILE_STATE},
};
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_core::memory::OAM_START)? != if affine { 0x340 } else { 72 }
|| memory.read16(gba_core::memory::OAM_START + 2)?
!= if affine {
0x4068
} else {
0x4070 | if flipped { 0x1000 } else { 0 }
}
|| memory.read16(gba_core::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_core::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);
}
}

View file

@ -0,0 +1,120 @@
use super::{create_window, read_buttons};
use gba_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::affine_demo::{AffineDemo, AFFINE_STATE};
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_core::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_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::affine_raster_demo::{AffineRasterDemo, AFFINE_RASTER_STATE};
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_core::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(())
}

View file

@ -0,0 +1,141 @@
use super::{create_window, read_buttons};
use gba_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
io::DISPCNT,
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE};
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(())
}

View file

@ -0,0 +1,186 @@
use super::{create_window, read_buttons};
use gba_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
io::{BLDALPHA, BLDCNT, DISPCNT, WININ, WINOUT},
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::effects_demo::{EffectsDemo, EFFECTS_STATE};
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(())
}

View file

@ -0,0 +1,134 @@
use super::{create_window, read_buttons};
use gba_core::{
display::{CPU_HZ, CYCLES_PER_FRAME, VISIBLE_LINES},
input::{Button, Buttons},
io::{BG0CNT, BG1CNT, DISPCNT, MOSAIC},
memory::OAM_START,
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::mosaic_demo::{MosaicDemo, MOSAIC_STATE};
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(())
}

View file

@ -0,0 +1,90 @@
use super::{create_window, read_buttons};
use gba_core::{
display::{CPU_HZ, CYCLES_PER_FRAME},
dma::DMA_BASE,
input::{Button, Buttons},
video::{rgb555_to_rgb888, Framebuffer, HEIGHT, WIDTH},
};
use gba_demos::raster_demo::{RasterDemo, RASTER_STATE};
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(())
}

252
crates/desktop/src/main.rs Normal file
View file

@ -0,0 +1,252 @@
mod desktop;
use std::{error::Error, ffi::OsString, io};
use gba_core::{
cpu::Cpu,
io::{IF, TIMER_BASE},
memory::{Memory, ROM_START},
};
use gba_demos::{
bitmap_demo::BitmapMode,
demo_bios, demo_program, exception_demo_program,
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: gameboy-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());
}
}