use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rsemu::core::HostObjects;
use rsemu::core::clock::GlobalTime;
use rsemu::host::chardev::{CharDevice, CharPort, ports};
use rsemu::host::terminal::Terminal;
use rsemu::machine::{Machine, catalog};
const USAGE: &str = "\
rsemu — a multiplatform emulator built bottom-up on a generic framework
USAGE:
rsemu <COMMAND> [OPTIONS]
COMMANDS:
run <machine> Run a machine description
debug <machine> Run it under a debugger, stopped, on :1234
machines List machines this build can emulate
devices List registered device classes
describe <class> Show a device class: properties, defaults, buses
convert <machine> Convert a machine file between its text and JSON forms
RUN OPTIONS:
<machine> A path to a .machine file, or a name from `rsemu machines`
--cart <file> Bind the `cart` media slot (a NES cartridge)
--rom <file> Bind the `rom` media slot
--monitor <name> Bind the `rom` slot to one of rsemu's own monitor
images instead of a file: `rsmon` (the default, ours,
MIT) or `wozmon` (the 1976 Woz Monitor, public domain)
--disk <file> Bind the `disk` media slot
--bios <file> Bind the `bios` media slot: a PC's system firmware.
rsemu ships none — point this at your own copy, the
way you would point qemu at one. Running a firmware
binary as a guest is ordinary use whatever its licence;
redistributing it is not, which is why there is a flag
here and no file in the repository
--vgabios <file> Bind the `vgabios` media slot: a video option ROM
--floppy <file> Bind the `floppy` media slot: a raw diskette image
--flash0 <file> Bind the `flash0` media slot: a NOR flash bank's
contents. `riscv-virt` boots UEFI out of it.
--flash1 <file> Bind the `flash1` media slot: the second NOR bank,
which is where UEFI keeps its variables.
--initrd <file> Bind the `initrd` media slot: a ramdisk staged in
guest RAM, which the generated device tree then points
the kernel at
--media <n>=<file> Bind any media slot by name
-p <name>=<value> Override a `param` declared in the machine file
--for <duration> How much virtual time to run, as `1s`, `500ms`, `2m`
(default 1s, or forever with a console attached)
--console <name> Attach this terminal to a named character port. A
machine that opens exactly one is picked up on its own,
so `rsemu run apple1` is interactive already
--headless Do not attach a terminal, whatever the machine opened
--screenshot <file> Write the machine's display to a PNG when the run ends.
Needs a build with `display-png` and a machine with a
display; a machine with neither says so rather than
writing nothing
--record-audio <f> Write the machine's sound to a RIFF/WAVE file when the
run ends. Needs a machine with an audio device. The
device's ring has to hold the whole run, so a recording
is capped at about 18 seconds; longer runs say what they
lost.
--audio-rate <hz> Sample rate for --record-audio (default 44100)
--gdb <addr> Listen for GDB on <addr> and hold the machine stopped
until it attaches. `1234`, `:1234` and `host:1234` all
work; a bare port binds the loopback interface only,
because the far end can read and write all of guest
memory. `rsemu debug` implies `--gdb :1234`
-q, --quiet Only print the summary
OPTIONS:
-h, --help Print this help
-V, --version Print version and build configuration
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(first) = args.first().map(String::as_str) else {
print!("{USAGE}");
return ExitCode::from(2);
};
match first {
"-h" | "--help" | "help" => {
print!("{USAGE}");
ExitCode::SUCCESS
}
"-V" | "--version" | "version" => {
println!("{}", rsemu::build_info());
ExitCode::SUCCESS
}
"machines" => machines(),
"devices" => devices(),
"describe" => describe(args.get(1).map(String::as_str)),
"run" => run(&args[1..]),
#[cfg(feature = "gdb")]
"debug" => debug(&args[1..]),
"convert" => {
eprintln!(
"rsemu: {}",
rsemu::Error::Unimplemented("the JSON projection (ROADMAP.md §5)")
);
ExitCode::from(2)
}
other => {
eprintln!("rsemu: unknown command `{other}`\n");
eprint!("{USAGE}");
ExitCode::from(2)
}
}
}
fn machines() -> ExitCode {
let machines = catalog::machines();
if machines.is_empty() {
println!("no machines in this build; rebuild with a `machine-*` feature");
return ExitCode::SUCCESS;
}
for entry in machines {
println!("{:<12} {}", entry.name, entry.summary);
if !entry.media.is_empty() {
let slots: Vec<String> = entry
.media
.iter()
.map(|s| format!("--{s} <file>"))
.collect();
println!("{:<12} media {}", "", slots.join(", "));
}
}
ExitCode::SUCCESS
}
fn devices() -> ExitCode {
let registry = match catalog::registry() {
Ok(r) => r,
Err(e) => return fail(&e),
};
if registry.is_empty() {
println!("no device classes in this build");
return ExitCode::SUCCESS;
}
for class in registry.classes() {
println!("{:<16} {}", class.name, class.summary);
}
ExitCode::SUCCESS
}
fn describe(class: Option<&str>) -> ExitCode {
let Some(name) = class else {
eprintln!("rsemu: describe needs a class name; `rsemu devices` lists them");
return ExitCode::from(2);
};
let registry = match catalog::registry() {
Ok(r) => r,
Err(e) => return fail(&e),
};
let Some(class) = registry.get(name) else {
let e = registry
.create(name, &rsemu::core::props::Props::new())
.expect_err("a class that is not there cannot construct");
return fail(&e);
};
println!("{} (v{})", class.name, class.version);
println!(" {}", class.summary);
if class.properties.is_empty() {
println!(" no properties");
return ExitCode::SUCCESS;
}
println!(" properties:");
for p in class.properties {
println!(
" {:<10} {:<10} {:<9} {}",
p.name,
p.kind.as_str(),
if p.required { "required" } else { "optional" },
p.summary
);
}
ExitCode::SUCCESS
}
struct RunArgs {
machine: String,
media: Vec<(String, String)>,
monitor: Option<String>,
params: Vec<(String, String)>,
span: GlobalTime,
span_given: bool,
console: Option<String>,
headless: bool,
screenshot: Option<String>,
record_audio: Option<String>,
audio_rate: u32,
quiet: bool,
#[cfg(feature = "gdb")]
gdb: Option<String>,
}
fn run(args: &[String]) -> ExitCode {
let parsed = match parse_run(args) {
Ok(a) => a,
Err(e) => {
eprintln!("rsemu: {e}");
return ExitCode::from(2);
}
};
let mut images: Vec<(String, Vec<u8>)> = Vec::new();
for (slot, path) in &parsed.media {
match std::fs::read(path) {
Ok(bytes) => images.push((slot.clone(), bytes)),
Err(e) => {
eprintln!("rsemu: cannot read {path}: {e}");
return ExitCode::FAILURE;
}
}
}
if !images.iter().any(|(slot, _)| slot == "rom") {
match builtin_rom(parsed.monitor.as_deref(), &parsed.machine) {
Ok(Some(image)) => images.push((String::from("rom"), image)),
Ok(None) => {}
Err(e) => {
eprintln!("rsemu: {e}");
return ExitCode::from(2);
}
}
}
if !images.iter().any(|(slot, _)| slot == "firmware")
&& let Some(image) = builtin_firmware(&parsed.machine)
{
images.push((String::from("firmware"), image));
}
for slot in ["flash0", "flash1", "initrd", "disk"] {
if !images.iter().any(|(bound, _)| bound == slot) {
images.push((String::from(slot), Vec::new()));
}
}
let (name, source) = match load_description(&parsed.machine) {
Ok(pair) => pair,
Err(e) => {
eprintln!("rsemu: {e}");
return ExitCode::from(2);
}
};
let mut options = match catalog::build_options() {
Ok(o) => o,
Err(e) => return fail(&e),
};
for (slot, bytes) in &images {
options
.realize
.media
.insert(slot.as_str(), bytes.as_slice());
}
for (key, value) in &parsed.params {
options = options.with_param(key.clone(), value.clone());
}
if let Err(e) = install_capture(&mut options, &parsed) {
return fail(&e);
}
let registry = match catalog::registry() {
Ok(r) => r,
Err(e) => return fail(&e),
};
let mut machine = match rsemu::machine::build(&name, &source, ®istry, &options) {
Ok(m) => m,
Err(e) => return fail(&e),
};
if !parsed.quiet {
describe_machine(&machine);
}
#[cfg(feature = "gdb")]
if let Some(addr) = parsed.gdb.clone() {
let port = match console_port(&parsed, &options.realize.hosts) {
Ok(port) => port,
Err(e) => {
eprintln!("rsemu: {e}");
return ExitCode::from(2);
}
};
return debug_session(&mut machine, &addr, port.as_ref(), &parsed);
}
match console_port(&parsed, &options.realize.hosts) {
Err(e) => {
eprintln!("rsemu: {e}");
return ExitCode::from(2);
}
Ok(Some(port)) => return interact(&mut machine, &port, &parsed),
Ok(None) => {}
}
if let Err(e) = machine.run_for(parsed.span) {
eprintln!("rsemu: {e}");
summarise(&machine);
write_screenshot(&parsed, &options.realize.hosts);
write_recording(&parsed, &options.realize.hosts);
return ExitCode::FAILURE;
}
summarise(&machine);
let drew = write_screenshot(&parsed, &options.realize.hosts);
let played = write_recording(&parsed, &options.realize.hosts);
if !drew || !played {
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
#[allow(unused_variables, unused_mut)]
fn install_capture(
options: &mut rsemu::machine::BuildOptions,
args: &RunArgs,
) -> rsemu::Result<()> {
#[cfg(feature = "dev-nes-ppu")]
rsemu::host::display::nes::capture::install(options)?;
#[cfg(feature = "dev-pc-video")]
rsemu::host::display::pc::capture::install(options)?;
#[cfg(feature = "dev-nes-apu")]
rsemu::host::audio::nes::capture::install(options, ring_for(args))?;
Ok(())
}
#[cfg(feature = "dev-nes-apu")]
fn ring_for(args: &RunArgs) -> u64 {
if args.record_audio.is_none() {
return 0;
}
args.span.as_nanos() / 1_000
}
#[cfg(feature = "display-png")]
#[allow(unused_variables)]
fn take_scanout(hosts: &HostObjects) -> Option<Box<dyn rsemu::host::display::Scanout>> {
#[cfg(feature = "dev-pc-video")]
if let Some(s) = rsemu::host::display::pc::capture::take(hosts) {
return Some(Box::new(s));
}
#[cfg(feature = "dev-nes-ppu")]
if let Some(s) = rsemu::host::display::nes::capture::take(hosts) {
return Some(Box::new(s));
}
None
}
fn write_screenshot(args: &RunArgs, hosts: &HostObjects) -> bool {
let Some(path) = args.screenshot.as_deref() else {
return true;
};
#[cfg(not(feature = "display-png"))]
{
let _ = (path, hosts);
eprintln!("rsemu: --screenshot needs a build with the `display-png` feature");
false
}
#[cfg(feature = "display-png")]
{
use rsemu::host::display::{Surface, png};
let Some(scanout) = take_scanout(hosts) else {
eprintln!("rsemu: --screenshot: this machine has no display");
return false;
};
let mut surface = Surface::for_scanout(scanout.as_ref());
scanout.capture(&mut surface);
let bytes = match png::encode(&surface) {
Ok(b) => b,
Err(e) => {
eprintln!("rsemu: --screenshot: {e}");
return false;
}
};
match std::fs::write(path, &bytes) {
Ok(()) => {
if !args.quiet {
println!(
"screenshot {path} ({}x{}, {} bytes)",
surface.width(),
surface.height(),
bytes.len()
);
}
true
}
Err(e) => {
eprintln!("rsemu: cannot write {path}: {e}");
false
}
}
}
}
#[allow(unused_variables)]
fn take_audio(hosts: &HostObjects) -> Option<Box<dyn rsemu::host::audio::AudioSource>> {
#[cfg(feature = "dev-nes-apu")]
if let Some(s) = rsemu::host::audio::nes::capture::take(hosts) {
return Some(Box::new(s));
}
None
}
fn write_recording(args: &RunArgs, hosts: &HostObjects) -> bool {
let Some(path) = args.record_audio.as_deref() else {
return true;
};
use rsemu::host::audio::{AudioStream, SampleFormat, wav};
let Some(source) = take_audio(hosts) else {
eprintln!("rsemu: --record-audio: this machine has no audio device");
return false;
};
let mut stream = AudioStream::new(source, args.audio_rate, SampleFormat::S16);
stream.set_limit_frames(u64::MAX);
stream.pull();
let bytes = wav::encode(stream.info(), stream.buffer());
match std::fs::write(path, &bytes) {
Ok(()) => {
let frames = stream.buffer().frames();
if !args.quiet {
let ms = frames.saturating_mul(1000) / u64::from(args.audio_rate.max(1));
println!(
"audio {path} ({} Hz, {frames} frames, {}.{:03} s, {} bytes)",
args.audio_rate,
ms / 1000,
ms % 1000,
bytes.len()
);
}
let lost = stream.dropped();
if lost > 0 {
eprintln!(
"rsemu: --record-audio: {lost} samples were lost. The device's ring holds \
about 18 seconds of audio and this run was longer, so the file is its \
*tail* — a ring keeps the newest. Record a shorter --for."
);
}
true
}
Err(e) => {
eprintln!("rsemu: cannot write {path}: {e}");
false
}
}
}
#[cfg(feature = "gdb")]
fn debug(args: &[String]) -> ExitCode {
let mut args = args.to_vec();
if !args.iter().any(|a| a == "--gdb") {
args.push(String::from("--gdb"));
args.push(String::from(":1234"));
}
run(&args)
}
#[cfg(feature = "gdb")]
fn debug_session(
machine: &mut Machine,
addr: &str,
port: Option<&Arc<CharPort>>,
args: &RunArgs,
) -> ExitCode {
use rsemu::host::gdb::{ExitReason, GdbServer};
let mut server = match GdbServer::bind(addr) {
Ok(s) => s,
Err(e) => {
eprintln!("rsemu: cannot listen on `{addr}`: {e}");
return ExitCode::FAILURE;
}
};
if !args.quiet {
match server.local_addr() {
Ok(bound) => eprintln!(" gdbstub listening on {bound} — the machine is stopped"),
Err(_) => eprintln!(" gdbstub listening — the machine is stopped"),
}
eprintln!(" attach with: gdb -ex 'target remote {addr}'");
let mut thread = 0u32;
for entry in machine.devices() {
let Some(arch) = rsemu::host::gdb::arch::for_class(entry.class().name) else {
continue;
};
thread += 1;
match arch.architecture {
Some(name) => eprintln!(
" thread {thread}: {} ({}), gdb architecture `{name}`",
entry.path(),
entry.class().name
),
None => eprintln!(
" thread {thread}: {} ({}) — upstream gdb has no architecture for\n \
this core, so it rejects the target description and `target remote`\n \
fails. The protocol is served in full to any client that reads the\n \
description rather than insisting on a gdbarch.",
entry.path(),
entry.class().name
),
}
}
eprintln!();
}
let terminal = port.map(|_| Terminal::open());
let status = match rsemu::host::gdb::serve(machine, &mut server, |_| {
if let (Some(term), Some(port)) = (terminal.as_ref(), port) {
term.pump(port);
if term.interrupted() {
return false;
}
}
true
}) {
Ok(ExitReason::Killed | ExitReason::Stopped) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("rsemu: {e}");
ExitCode::FAILURE
}
};
if let Some(term) = terminal {
term.flush();
}
if !args.quiet {
println!();
summarise(machine);
}
status
}
fn console_port(args: &RunArgs, hosts: &HostObjects) -> Result<Option<Arc<CharPort>>, String> {
if args.headless {
return Ok(None);
}
let opened = |name: &str| ports::get(hosts, name).ok().flatten();
if let Some(name) = &args.console {
return opened(name).map(Some).ok_or_else(|| {
format!(
"no character port named `{name}`; this machine opened {}",
list(&ports::names(hosts))
)
});
}
let names = ports::names(hosts);
match names.len() {
0 => Ok(None),
1 => Ok(opened(&names[0])),
_ => Err(format!(
"this machine has {} character ports ({}); pick one with --console, or --headless",
names.len(),
list(&names)
)),
}
}
fn list(names: &[String]) -> String {
if names.is_empty() {
return String::from("none");
}
names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<String>>()
.join(", ")
}
const SLICE: GlobalTime = GlobalTime::from_nanos(10_000_000);
const IDLE_SLICES: u32 = 200;
fn interact(machine: &mut Machine, port: &CharPort, args: &RunArgs) -> ExitCode {
let term = Terminal::open();
if !args.quiet {
if term.is_raw() {
eprintln!(" console attached — Ctrl-C to stop\n");
} else {
eprintln!(
" console attached, cooked mode — stdin could not be put in raw mode.\n \
On a terminal that means input arrives a line at a time and is\n \
echoed twice, once by the host and once by the guest.\n"
);
}
}
let deadline = args
.span_given
.then(|| machine.now().saturating_add(args.span));
let started = Instant::now();
let mut elapsed = GlobalTime::ZERO;
let mut idle = 0u32;
let status = loop {
if term.interrupted() {
break ExitCode::SUCCESS;
}
if deadline.is_some_and(|d| machine.now() >= d) {
break ExitCode::SUCCESS;
}
let mut moved = term.pump(port);
if let Err(e) = machine.run_until(machine.now().saturating_add(SLICE)) {
eprintln!("\r\nrsemu: {e}");
break ExitCode::FAILURE;
}
moved += term.pump(port);
if term.at_eof() && moved == 0 {
idle += 1;
if idle >= IDLE_SLICES {
break ExitCode::SUCCESS;
}
} else {
idle = 0;
}
elapsed = elapsed.saturating_add(SLICE);
let target = Duration::from_nanos(elapsed.as_nanos());
if let Some(wait) = target.checked_sub(started.elapsed()) {
std::thread::sleep(wait);
}
};
term.flush();
drop(term);
if !args.quiet {
println!();
summarise(machine);
}
status
}
fn load_description(what: &str) -> Result<(String, String), String> {
let path = Path::new(what);
if path.is_file() {
let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {what}: {e}"))?;
return Ok((what.to_string(), text));
}
if let Some(entry) = catalog::machine(what) {
return Ok((entry.name.to_string(), entry.source.to_string()));
}
if what.contains('/') || what.ends_with(".machine") {
return Err(format!("no such machine file: {what}"));
}
Err(format!(
"no machine named `{what}`; `rsemu machines` lists this build's catalog"
))
}
fn builtin_firmware(machine: &str) -> Option<Vec<u8>> {
let stem = machine
.rsplit('/')
.next()
.unwrap_or(machine)
.strip_suffix(".machine")
.unwrap_or_else(|| machine.rsplit('/').next().unwrap_or(machine));
match stem {
#[cfg(feature = "machine-spi-panel")]
"spi-panel" => Some(rsemu::dev::lcd::demo::PANEL_DEMO.to_vec()),
_ => {
let _ = stem;
None
}
}
}
fn builtin_rom(monitor: Option<&str>, machine: &str) -> Result<Option<Vec<u8>>, String> {
#[cfg(feature = "dev-wdc")]
if monitor == Some("wozmon") {
return Ok(Some(rsemu::dev::wdc::WOZMON_IMAGE.to_vec()));
}
if let Some(name) = monitor
&& name != "rsmon"
{
return Err(format!(
"--monitor {name}: this build has `rsmon`{}",
if cfg!(feature = "dev-wdc") {
" and `wozmon`"
} else {
""
}
));
}
let stem = machine
.rsplit('/')
.next()
.unwrap_or(machine)
.strip_suffix(".machine")
.unwrap_or_else(|| machine.rsplit('/').next().unwrap_or(machine));
match stem {
#[cfg(feature = "dev-wdc")]
"beneater-6502" => Ok(Some(rsemu::dev::wdc::RSMON_IMAGE.to_vec())),
#[cfg(feature = "dev-apple1")]
"apple1" => Ok(Some(rsemu::dev::apple1::RSMON.to_vec())),
_ => Ok(None),
}
}
fn parse_run(args: &[String]) -> Result<RunArgs, String> {
let mut out = RunArgs {
machine: String::new(),
media: Vec::new(),
screenshot: None,
record_audio: None,
audio_rate: 44_100,
monitor: None,
params: Vec::new(),
span: GlobalTime::from_nanos(1_000_000_000),
span_given: false,
console: None,
headless: false,
quiet: false,
#[cfg(feature = "gdb")]
gdb: None,
};
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
let mut value = |name: &str| -> Result<String, String> {
i += 1;
args.get(i)
.cloned()
.ok_or_else(|| format!("{name} needs a value"))
};
match arg {
"--cart" | "--rom" | "--disk" | "--bios" | "--vgabios" | "--floppy" | "--flash0"
| "--flash1" | "--initrd" => {
let slot = arg.trim_start_matches('-').to_string();
let path = value(arg)?;
out.media.push((slot, path));
}
"--media" => {
let spec = value(arg)?;
let (slot, path) = spec
.split_once('=')
.ok_or_else(|| format!("--media wants <name>=<file>, got `{spec}`"))?;
out.media.push((slot.to_string(), path.to_string()));
}
"-p" | "--param" => {
let spec = value(arg)?;
let (key, val) = spec
.split_once('=')
.ok_or_else(|| format!("-p wants <name>=<value>, got `{spec}`"))?;
out.params.push((key.to_string(), val.to_string()));
}
"--for" => {
let text = value(arg)?;
let d = rsemu::core::props::parse_duration(&text)
.map_err(|e| format!("--for {text}: {e}"))?;
out.span = GlobalTime::from_nanos(d.as_picos() / 1_000);
out.span_given = true;
}
"--monitor" => out.monitor = Some(value(arg)?),
#[cfg(feature = "gdb")]
"--gdb" => out.gdb = Some(value(arg)?),
"--console" => out.console = Some(value(arg)?),
"--headless" => out.headless = true,
"--screenshot" => out.screenshot = Some(value(arg)?),
"--record-audio" => out.record_audio = Some(value(arg)?),
"--audio-rate" => {
let text = value(arg)?;
let hz: u32 = text
.parse()
.map_err(|_| format!("--audio-rate {text}: not a number of hertz"))?;
if !(8_000..=384_000).contains(&hz) {
return Err(format!("--audio-rate {hz}: outside 8000..=384000 Hz"));
}
out.audio_rate = hz;
}
"-q" | "--quiet" => out.quiet = true,
other if other.starts_with('-') => {
return Err(format!("unknown option `{other}`"));
}
other => {
if !out.machine.is_empty() {
return Err(format!("`{other}`: only one machine at a time"));
}
out.machine = other.to_string();
}
}
i += 1;
}
if out.machine.is_empty() {
return Err(String::from(
"run needs a machine; `rsemu machines` lists this build's catalog",
));
}
Ok(out)
}
fn describe_machine(machine: &Machine) {
println!("machine \"{}\"", machine.name());
for space in machine.spaces() {
println!(" space {:<8} {} bits", space.name(), space.space().bits());
}
for device in machine.devices() {
let clock = match device.domain() {
Some(_) => "clocked",
None => "",
};
println!(
" object {:<8} {:<16} {clock}",
device.path(),
device.class().name
);
}
}
fn summarise(machine: &Machine) {
println!("ran to {} ns of virtual time", machine.now().as_nanos());
for device in machine.devices() {
let Some(domain) = device.domain() else {
continue;
};
if let Ok(ticks) = machine.clocks().ticks(domain) {
println!(" {:<8} {ticks} ticks", device.path());
}
}
match machine.state_hash() {
Ok(hash) => println!("state hash {hash:#018x}"),
Err(e) => eprintln!("rsemu: cannot hash state: {e}"),
}
}
fn fail(e: &rsemu::Error) -> ExitCode {
eprintln!("rsemu: {e}");
ExitCode::FAILURE
}