A core, DMA, rendering, window, or audio error returned before the save was written, so every completed in-game save since launch was lost. The window now writes changed bytes on that path too. The diagnostic stays the session error; a refused or failed write is reported as output. A failed state restore leaves save memory undefined and is never written. Terminal runs keep the strict rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhfHfHEuzv2AUTd6erW1gU
302 lines
14 KiB
Rust
302 lines
14 KiB
Rust
mod audio;
|
|
mod cartridge;
|
|
mod desktop;
|
|
|
|
use std::{error::Error, ffi::OsString, io, path::PathBuf, process::ExitCode};
|
|
|
|
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,
|
|
Rom(cartridge::Options),
|
|
TestSuite(PathBuf),
|
|
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> {
|
|
if args.first().is_some_and(|arg| arg == "--test-suite") {
|
|
return cartridge::suite::parse_args(args).map(RunMode::TestSuite);
|
|
}
|
|
if args.iter().any(|arg| {
|
|
[
|
|
"--rom",
|
|
"--steps",
|
|
"--window",
|
|
"--frames",
|
|
"--rtc",
|
|
"--audio",
|
|
"--speed",
|
|
"--save-type",
|
|
"--save-file",
|
|
"--state-file",
|
|
]
|
|
.iter()
|
|
.any(|flag| arg == flag)
|
|
}) {
|
|
return cartridge::parse_args(args).map(RunMode::Rom);
|
|
}
|
|
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]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
match run() {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(error) => {
|
|
eprintln!("{error}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<(), Box<dyn Error>> {
|
|
match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>())? {
|
|
RunMode::Window => desktop::run(None),
|
|
RunMode::Rom(options) => cartridge::execute(options, &mut io::stdout().lock()),
|
|
RunMode::TestSuite(path) => cartridge::suite::execute(&path, &mut io::stdout().lock()),
|
|
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\
|
|
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
|
|
--rom PATH --window Open a raw ROM with keyboard input\n\
|
|
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\
|
|
--speed MULTIPLIER ROM-window speed: integer 1..=16 (default: 1); mutes audio above 1x\n\
|
|
--audio Enable macOS output at 1x (default: muted)\n\
|
|
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
|
|
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
|
|
--save-file PATH Load raw Flash image; persist on clean exit or window diagnostic\n\
|
|
--state-file PATH ROM window save state; F5 saves, F9 loads (default: ROM path + .state)\n\
|
|
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\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. ROM mode uses our limited BIOS replacement.\n\
|
|
Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. Saves require --save-type and --save-file.\n\
|
|
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; F5=save state; F9=load state; Escape exits.\n\
|
|
Commercial games are 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_bare_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());
|
|
}
|
|
}
|