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:
parent
a8944d1d5f
commit
f8a0676748
113 changed files with 304 additions and 282 deletions
611
crates/desktop/src/desktop.rs
Normal file
611
crates/desktop/src/desktop.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
crates/desktop/src/desktop/affine.rs
Normal file
120
crates/desktop/src/desktop/affine.rs
Normal 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(())
|
||||
}
|
||||
128
crates/desktop/src/desktop/affine_raster.rs
Normal file
128
crates/desktop/src/desktop/affine_raster.rs
Normal 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(())
|
||||
}
|
||||
141
crates/desktop/src/desktop/bitmap.rs
Normal file
141
crates/desktop/src/desktop/bitmap.rs
Normal 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(())
|
||||
}
|
||||
186
crates/desktop/src/desktop/effects.rs
Normal file
186
crates/desktop/src/desktop/effects.rs
Normal 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(())
|
||||
}
|
||||
134
crates/desktop/src/desktop/mosaic.rs
Normal file
134
crates/desktop/src/desktop/mosaic.rs
Normal 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(())
|
||||
}
|
||||
90
crates/desktop/src/desktop/raster.rs
Normal file
90
crates/desktop/src/desktop/raster.rs
Normal 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
252
crates/desktop/src/main.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue