321 lines
10 KiB
Rust
321 lines
10 KiB
Rust
//! Host-only raw ROM loading, execution modes, and diagnostics.
|
|
|
|
pub(crate) mod rtc_clock;
|
|
pub mod suite;
|
|
pub mod window;
|
|
|
|
use std::{
|
|
error::Error,
|
|
ffi::OsString,
|
|
fs::{self, File},
|
|
io::{self, Read, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use gba_core::{
|
|
bios,
|
|
cartridge::{CartridgeHardware, RtcError, SaveDevice},
|
|
machine::{Machine, MachineError, StepKind},
|
|
memory::ROM_CAPACITY,
|
|
};
|
|
|
|
const MAX_STEPS: u64 = 100_000_000;
|
|
const MAX_FRAMES: u64 = 100_000;
|
|
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Mode {
|
|
Terminal { steps: u64 },
|
|
Window { frames: Option<u64> },
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct Options {
|
|
path: PathBuf,
|
|
mode: Mode,
|
|
hardware: CartridgeHardware,
|
|
save_device: SaveDevice,
|
|
audio: bool,
|
|
}
|
|
|
|
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|
let invalid = || io::Error::new(io::ErrorKind::InvalidInput, USAGE);
|
|
let mut path = None;
|
|
let mut steps = None;
|
|
let mut frames = None;
|
|
let mut window = false;
|
|
let mut audio = false;
|
|
let mut hardware = CartridgeHardware::None;
|
|
let mut save_device = SaveDevice::None;
|
|
let mut args = args.iter();
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--rom" && path.is_none() {
|
|
let value = args
|
|
.next()
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(invalid)?;
|
|
path = Some(PathBuf::from(value));
|
|
} else if arg == "--rtc" && hardware == CartridgeHardware::None {
|
|
hardware = CartridgeHardware::Rtc;
|
|
} else if arg == "--save-type" && save_device == SaveDevice::None {
|
|
save_device = match args.next().and_then(|value| value.to_str()) {
|
|
Some("flash64") => SaveDevice::Flash64,
|
|
Some("flash128") => SaveDevice::Flash128,
|
|
_ => return Err(invalid()),
|
|
};
|
|
} else if arg == "--audio" && !audio {
|
|
audio = true;
|
|
} else if arg == "--window" && !window {
|
|
window = true;
|
|
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
|
|
let value = args
|
|
.next()
|
|
.and_then(|value| value.to_str())
|
|
.ok_or_else(invalid)?;
|
|
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
|
return Err(invalid());
|
|
}
|
|
let count = value.parse::<u64>().map_err(|_| invalid())?;
|
|
let max = if arg == "--steps" {
|
|
MAX_STEPS
|
|
} else {
|
|
MAX_FRAMES
|
|
};
|
|
if !(1..=max).contains(&count) {
|
|
return Err(invalid());
|
|
}
|
|
if arg == "--steps" {
|
|
steps = Some(count);
|
|
} else {
|
|
frames = Some(count);
|
|
}
|
|
} else {
|
|
return Err(invalid());
|
|
}
|
|
}
|
|
if audio && !window {
|
|
return Err(invalid());
|
|
}
|
|
let mode = match (window, steps, frames) {
|
|
(false, Some(steps), None) => Mode::Terminal { steps },
|
|
(true, None, frames) => Mode::Window { frames },
|
|
_ => return Err(invalid()),
|
|
};
|
|
Ok(Options {
|
|
path: path.ok_or_else(invalid)?,
|
|
mode,
|
|
hardware,
|
|
save_device,
|
|
audio,
|
|
})
|
|
}
|
|
|
|
fn validate_file(metadata: &fs::Metadata) -> io::Result<()> {
|
|
if !metadata.is_file() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"ROM must be a regular file",
|
|
));
|
|
}
|
|
if metadata.len() > ROM_CAPACITY as u64 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"ROM exceeds the 32 MiB cartridge limit",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_bounded(reader: impl Read, capacity: usize) -> io::Result<Vec<u8>> {
|
|
let mut bytes = Vec::new();
|
|
reader.take(capacity as u64 + 1).read_to_end(&mut bytes)?;
|
|
if bytes.len() > capacity {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"ROM exceeds the cartridge byte limit",
|
|
));
|
|
}
|
|
if bytes.len() < 4 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"ROM needs at least 4 bytes for the initial ARM instruction",
|
|
));
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn load(path: &Path) -> io::Result<Vec<u8>> {
|
|
let result = (|| {
|
|
// Check before opening to reject ordinary directories and named pipes.
|
|
// This is not a guarantee against concurrent filesystem replacement.
|
|
validate_file(&fs::metadata(path)?)?;
|
|
let file = File::open(path)?;
|
|
validate_file(&file.metadata()?)?;
|
|
// Bound the read independently of metadata in case the file grows.
|
|
read_bounded(file, ROM_CAPACITY)
|
|
})();
|
|
result.map_err(|error: io::Error| {
|
|
io::Error::new(error.kind(), format!("cannot load ROM {path:?}: {error}"))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
struct Stats {
|
|
steps: u64,
|
|
instructions: u64,
|
|
irq_entries: u64,
|
|
dma_units: u64,
|
|
halt_idle: u64,
|
|
}
|
|
|
|
impl Stats {
|
|
fn record(&mut self, kind: StepKind) {
|
|
match kind {
|
|
StepKind::Instruction => self.instructions += 1,
|
|
StepKind::IrqEntry => self.irq_entries += 1,
|
|
StepKind::Dma { .. } => self.dma_units += 1,
|
|
StepKind::HaltIdle => self.halt_idle += 1,
|
|
StepKind::StopIdle => return,
|
|
}
|
|
self.steps += 1;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Reason {
|
|
StepLimit,
|
|
Stopped,
|
|
Diagnostic(MachineError),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Report {
|
|
stats: Stats,
|
|
reason: Reason,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
|
|
run_bounded_with_clock(machine, limit, None).unwrap()
|
|
}
|
|
|
|
fn run_bounded_with_clock(
|
|
machine: &mut Machine,
|
|
limit: u64,
|
|
mut clock: Option<&mut rtc_clock::RtcHostClock>,
|
|
) -> Result<Report, RtcError> {
|
|
let mut stats = Stats::default();
|
|
let reason = loop {
|
|
if stats.steps % 4096 == 0 {
|
|
if let Some(clock) = clock.as_deref_mut() {
|
|
clock.sync(machine.memory_mut())?;
|
|
}
|
|
}
|
|
// STOP has no host input source here. Do not spin with a frozen clock.
|
|
// Check before the budget so STOP on the last step is reported correctly.
|
|
if machine.stopped() {
|
|
break Reason::Stopped;
|
|
}
|
|
if stats.steps == limit {
|
|
break Reason::StepLimit;
|
|
}
|
|
match machine.step() {
|
|
Ok(StepKind::StopIdle) => break Reason::Stopped,
|
|
Ok(kind) => stats.record(kind),
|
|
Err(error) => break Reason::Diagnostic(error),
|
|
}
|
|
};
|
|
if let Some(clock) = clock {
|
|
clock.sync(machine.memory_mut())?;
|
|
}
|
|
Ok(Report { stats, reason })
|
|
}
|
|
|
|
fn write_report(writer: &mut impl Write, machine: &Machine, report: &Report) -> io::Result<()> {
|
|
let reason = match report.reason {
|
|
Reason::StepLimit => "step limit reached",
|
|
Reason::Stopped => "STOP; input required (no input source in terminal mode)",
|
|
Reason::Diagnostic(_) => "emulation diagnostic",
|
|
};
|
|
writeln!(writer, "Result: {reason}")?;
|
|
write_state(writer, machine, &report.stats)
|
|
}
|
|
|
|
fn write_state(writer: &mut impl Write, machine: &Machine, stats: &Stats) -> io::Result<()> {
|
|
writeln!(
|
|
writer,
|
|
"Steps: {}; instructions: {}; IRQ entries: {}; DMA units: {}; HALT idle: {}",
|
|
stats.steps, stats.instructions, stats.irq_entries, stats.dma_units, stats.halt_idle,
|
|
)?;
|
|
let cpu = machine.cpu();
|
|
writeln!(writer, "Nominal cycles: {}", machine.cycles())?;
|
|
writeln!(
|
|
writer,
|
|
"PC={:#010x} CPSR={:#010x} {:?}/{:?} halted={} stopped={}",
|
|
cpu.pc(),
|
|
cpu.cpsr(),
|
|
cpu.mode(),
|
|
cpu.instruction_set(),
|
|
machine.halted(),
|
|
machine.stopped(),
|
|
)?;
|
|
for (row, registers) in cpu.registers().chunks(4).enumerate() {
|
|
for (column, register) in registers.iter().enumerate() {
|
|
if column != 0 {
|
|
write!(writer, " ")?;
|
|
}
|
|
write!(writer, "r{}={register:#010x}", row * 4 + column)?;
|
|
}
|
|
writeln!(writer)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn Error>> {
|
|
let bytes = load(&options.path)?;
|
|
writeln!(writer, "ROM: {:?} ({} bytes)", options.path, bytes.len())?;
|
|
writeln!(
|
|
writer,
|
|
"Original BIOS replacement; no commercial-game compatibility claim."
|
|
)?;
|
|
if options.hardware == CartridgeHardware::Rtc {
|
|
writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?;
|
|
}
|
|
if options.save_device != SaveDevice::None {
|
|
writeln!(writer, "Flash selected: {} KiB Macronix, erased array; identification/bank reads only. No programming, erase, or save files.", options.save_device.capacity() / 1024)?;
|
|
}
|
|
let steps = match options.mode {
|
|
Mode::Terminal { steps } => steps,
|
|
Mode::Window { frames } => {
|
|
return crate::desktop::run_rom(
|
|
bytes,
|
|
frames,
|
|
options.hardware,
|
|
options.save_device,
|
|
options.audio,
|
|
writer,
|
|
)
|
|
}
|
|
};
|
|
writeln!(writer, "Machine-step limit: {steps} (includes boot)")?;
|
|
let mut machine = bios::boot(bytes)?;
|
|
machine
|
|
.memory_mut()
|
|
.set_cartridge_hardware(options.hardware);
|
|
machine.memory_mut().set_save_device(options.save_device);
|
|
let mut clock = if options.hardware == CartridgeHardware::Rtc {
|
|
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
|
|
} else {
|
|
None
|
|
};
|
|
let report = run_bounded_with_clock(&mut machine, steps, clock.as_mut())?;
|
|
write_report(writer, &machine, &report)?;
|
|
writer.flush()?;
|
|
if let Reason::Diagnostic(error) = report.reason {
|
|
return Err(Box::new(error));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|