use crate::platform::autorun::{AutorunFormat, AutorunMode};
use crate::platform::debugging::Tracing;
use crate::platform::debugging::breakpoints::BreakpointKind;
pub(crate) struct CliFlag {
pub flag: &'static str,
pub help: Option<&'static str>,
pub has_value: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RamInitMode {
Zero,
Random,
SeededRandom(u64),
}
#[derive(Debug, Clone)]
pub struct FrontendConfig {
pub audio_enabled: bool,
pub vsync_enabled: bool,
pub gamepads_enabled: bool,
pub fullscreen: bool,
pub fullscreen_display: Option<i32>,
pub shader_path: Option<String>,
pub debugger_enabled: bool,
pub load_state: bool,
pub tracing: Tracing,
pub window_height: u32,
pub debugger_alpha: f32,
pub rom_path: Option<String>,
pub autorun_mode: AutorunMode,
pub autorun_headless: bool,
pub autorun_extend: bool,
pub autorun_overwrite: bool,
pub autorun_from_checkpoint: Option<i64>,
pub autorun_trim_checkpoints: Option<usize>,
pub autorun_convert: bool,
pub autorun_recalculate: bool,
pub autorun_format: AutorunFormat,
pub cartridge_search_paths: Vec<String>,
pub scan_cartridges: bool,
pub rebuild_cartridge_catalog: bool,
#[cfg_attr(not(feature = "tui"), allow(dead_code))]
pub tui_mode: bool,
pub ram_init_mode: RamInitMode,
pub breakpoints: Vec<BreakpointKind>,
pub metadata_db_path: Option<String>,
pub image_cache_path: Option<String>,
pub include_unofficial_roms: bool,
}
impl Default for FrontendConfig {
fn default() -> Self {
Self {
audio_enabled: true,
vsync_enabled: true,
gamepads_enabled: true,
fullscreen: false,
fullscreen_display: None,
shader_path: None,
debugger_enabled: false,
load_state: false,
tracing: Tracing::default(),
window_height: 896,
debugger_alpha: 0.7,
rom_path: None,
autorun_mode: AutorunMode::None,
autorun_headless: false,
autorun_extend: false,
autorun_overwrite: false,
autorun_from_checkpoint: None,
autorun_trim_checkpoints: None,
autorun_convert: false,
autorun_recalculate: false,
autorun_format: AutorunFormat::Binary,
cartridge_search_paths: Vec::new(),
scan_cartridges: true,
rebuild_cartridge_catalog: false,
tui_mode: false,
#[cfg(target_arch = "wasm32")]
ram_init_mode: RamInitMode::Zero,
#[cfg(not(target_arch = "wasm32"))]
ram_init_mode: RamInitMode::Random,
breakpoints: Vec::new(),
metadata_db_path: None,
image_cache_path: None,
include_unofficial_roms: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Config {
pub frontend: FrontendConfig,
pub nes: crate::nes::console::NesConfig,
pub gb: crate::gb::console::config::GbConfig,
pub gba: crate::gba::console::config::GbaConfig,
}
impl FrontendConfig {
pub(crate) fn apply_args(&mut self, args: &[String]) -> Result<(), String> {
if let Some(audio) = parse_bool_arg(args, "--audio")? {
self.audio_enabled = audio;
}
if has_negation_flag(args, &["--no-audio", "--disable-audio"]) {
self.audio_enabled = false;
}
if let Some(vsync) = parse_bool_arg(args, "--vsync")? {
self.vsync_enabled = vsync;
}
if has_negation_flag(args, &["--no-vsync", "--disable-vsync"]) {
self.vsync_enabled = false;
}
if let Some(gamepads) = parse_bool_arg(args, "--gamepads")? {
self.gamepads_enabled = gamepads;
}
if let Some(debugger) = parse_bool_arg(args, "--debugger")? {
self.debugger_enabled = debugger;
}
if let Some(load_state) = parse_bool_arg(args, "--load-state")? {
self.load_state = load_state;
}
if let Some(fullscreen) = parse_bool_arg(args, "--fullscreen")? {
self.fullscreen = fullscreen;
}
self.tracing.apply_args(args);
if let Some(height) = parse_u32_arg(args, "--window-height")? {
self.window_height = height;
}
if let Some(alpha) = parse_f32_arg(args, "--debugger-alpha")? {
self.debugger_alpha = alpha.clamp(0.1, 1.0);
}
let cli_ram_init_mode = parse_cli_string_arg(args, "--ram-init-mode");
if let Some(value) = cli_ram_init_mode.as_ref() {
self.apply_config_value("ram_init_mode", value)?;
}
self.apply_cartridge_catalog_args(args)?;
#[cfg(feature = "tui")]
if args.iter().any(|arg| arg == "--tui") {
self.tui_mode = true;
}
#[cfg(not(feature = "tui"))]
if args.iter().any(|arg| arg == "--tui") {
return Err("--tui requires the `tui` feature (build with --features tui)".to_string());
}
let has_create_recording = args.iter().any(|arg| arg == "--create-recording");
let has_extend_recording = args.iter().any(|arg| arg == "--extend-recording");
let has_playback = args.iter().any(|arg| arg == "--playback");
let has_playback_headless = args.iter().any(|arg| arg == "--playback-headless");
if has_create_recording && has_extend_recording {
return Err(
"Cannot specify both --create-recording and --extend-recording".to_string(),
);
}
if (has_create_recording || has_extend_recording) && (has_playback || has_playback_headless)
{
return Err("Cannot specify both a recording flag and a playback flag".to_string());
}
if has_create_recording {
self.autorun_mode = AutorunMode::Record;
self.autorun_overwrite = true;
} else if has_extend_recording {
self.autorun_mode = AutorunMode::Record;
self.autorun_extend = true;
} else if has_playback || has_playback_headless {
self.autorun_mode = AutorunMode::Playback;
self.autorun_headless = has_playback_headless;
}
if let Some(v) = parse_i64_arg(args, "--playback-from-checkpoint")? {
self.autorun_from_checkpoint = Some(v);
if self.autorun_mode == AutorunMode::None {
self.autorun_mode = AutorunMode::Playback;
}
}
if let Some(v) = parse_i64_arg(args, "--playback-headless-from-checkpoint")? {
self.autorun_from_checkpoint = Some(v);
self.autorun_mode = AutorunMode::Playback;
self.autorun_headless = true;
}
if let Some(v) = parse_u32_arg(args, "--trim-checkpoints")? {
self.autorun_trim_checkpoints = Some(v as usize);
}
if let Some(convert_autorun_requested) = parse_bool_arg(args, "--convert-autorun")? {
self.autorun_convert = convert_autorun_requested;
}
if let Some(recalculate_autorun_requested) = parse_bool_arg(args, "--recalculate-autorun")?
{
self.autorun_recalculate = recalculate_autorun_requested;
}
if let Some(format_str) = parse_cli_string_arg(args, "--autorun-format") {
self.autorun_format = match format_str.as_str() {
"binary" => AutorunFormat::Binary,
"json" => AutorunFormat::Json,
other => {
return Err(format!(
"Unknown autorun format '{other}': expected 'binary' or 'json'"
));
}
};
}
if self.autorun_trim_checkpoints.is_some() && self.autorun_convert {
return Err("Cannot specify both --trim-checkpoints and --convert-autorun".to_string());
}
if self.autorun_trim_checkpoints.is_some() && self.autorun_recalculate {
return Err(
"Cannot specify both --trim-checkpoints and --recalculate-autorun".to_string(),
);
}
if self.autorun_convert && self.autorun_recalculate {
return Err(
"Cannot specify both --convert-autorun and --recalculate-autorun".to_string(),
);
}
if self.autorun_recalculate && self.autorun_mode != AutorunMode::None {
return Err(
"Cannot combine --recalculate-autorun with recording/playback flags".to_string(),
);
}
if self.autorun_recalculate && self.autorun_from_checkpoint.is_some() {
return Err(
"Cannot combine --recalculate-autorun with checkpoint playback flags".to_string(),
);
}
if self.autorun_mode != AutorunMode::None || self.autorun_recalculate {
if let Some(value) = cli_ram_init_mode.as_ref()
&& !value.eq_ignore_ascii_case("zero")
{
return Err("Autorun recording/playback requires --ram-init-mode zero".to_string());
}
self.ram_init_mode = RamInitMode::Zero;
}
if let Some(value) = parse_cli_string_arg(args, "--breakpoint") {
self.breakpoints =
parse_breakpoint_list(&value).map_err(|e| format!("--breakpoint: {e}"))?;
}
Ok(())
}
pub(crate) fn apply_config_value(&mut self, key: &str, value: &str) -> Result<(), String> {
let key = key.replace('-', "_");
match key.as_str() {
"audio" => {
if let Ok(b) = parse_bool(value) {
self.audio_enabled = b;
}
}
"vsync" => {
if let Ok(b) = parse_bool(value) {
self.vsync_enabled = b;
}
}
"gamepads" => {
if let Ok(b) = parse_bool(value) {
self.gamepads_enabled = b;
}
}
"fullscreen" => {
if let Ok(b) = parse_bool(value) {
self.fullscreen = b;
}
}
"display" => {
if let Ok(d) = value.parse::<i32>()
&& d >= 0
{
self.fullscreen_display = Some(d);
}
}
"debugger" => {
if let Ok(b) = parse_bool(value) {
self.debugger_enabled = b;
}
}
"load_state" => {
if let Ok(b) = parse_bool(value) {
self.load_state = b;
}
}
"window_height" => {
if let Ok(s) = value.parse::<u32>() {
self.window_height = s;
}
}
"debugger_alpha" => {
if let Ok(v) = value.parse::<f32>() {
self.debugger_alpha = v.clamp(0.1, 1.0);
}
}
"trace_cpu" => {
if let Ok(level) = value.parse::<u8>() {
self.tracing.cpu = level;
if level > 0 {
self.tracing.enabled = true;
}
}
}
"trace_ppu" => {
if let Ok(level) = value.parse::<u8>() {
self.tracing.ppu = Tracing::clamp_ppu_level(level);
if level > 0 {
self.tracing.enabled = true;
}
}
}
"trace_apu" => {
if let Ok(level) = value.parse::<u8>() {
self.tracing.apu = level;
if level > 0 {
self.tracing.enabled = true;
}
}
}
"trace_mapper" => {
if let Ok(level) = value.parse::<u8>() {
self.tracing.mapper = Tracing::clamp_mapper_level(level);
if level > 0 {
self.tracing.enabled = true;
}
}
}
"trace_nestest" => {
if let Ok(b) = parse_bool(value) {
self.tracing.nestest = b;
if b {
self.tracing.enabled = true;
}
}
}
"ram_init_mode" => match value.to_lowercase().as_str() {
"zero" | "0" => self.ram_init_mode = RamInitMode::Zero,
"random" => self.ram_init_mode = RamInitMode::Random,
_ => {
if let Some(seed_str) = value
.strip_prefix("seeded-random:")
.or_else(|| value.strip_prefix("seeded_random:"))
.or_else(|| value.strip_prefix("seeded:"))
{
if let Ok(seed) = seed_str.parse::<u64>() {
self.ram_init_mode = RamInitMode::SeededRandom(seed);
} else {
eprintln!(
"Warning: invalid seed '{}' for 'ram_init_mode'; \
keeping current mode. Use format 'seeded-random:12345'.",
seed_str
);
}
} else {
eprintln!(
"Warning: invalid value '{}' for 'ram_init_mode'; \
keeping current mode. Valid values: zero, random, seeded-random:SEED",
value
);
}
}
},
"cartridge_search_paths" => {
self.cartridge_search_paths = parse_search_paths(value);
}
"scan_cartridges" => {
if let Ok(scan) = parse_bool(value) {
self.scan_cartridges = scan;
}
}
"rebuild_cartridge_catalog" => {
if let Ok(rebuild) = parse_bool(value) {
self.rebuild_cartridge_catalog = rebuild;
}
}
"metadata_db_path" => {
self.metadata_db_path = Some(value.to_string());
}
"image_cache_path" => {
self.image_cache_path = Some(value.to_string());
}
"include_unofficial_roms" => {
if let Ok(include) = parse_bool(value) {
self.include_unofficial_roms = include;
}
}
_ => {}
}
Ok(())
}
fn apply_cartridge_catalog_args(&mut self, args: &[String]) -> Result<(), String> {
if let Some(paths) = parse_cli_string_arg(args, "--cartridge-search-paths") {
self.cartridge_search_paths = parse_search_paths(&paths);
}
if let Some(scan) = parse_bool_arg(args, "--scan-cartridges")? {
self.scan_cartridges = scan;
}
if has_negation_flag(args, &["--no-scan-cartridges"]) {
self.scan_cartridges = false;
}
if args.iter().any(|arg| arg == "--rebuild-cartridge-catalog") {
self.rebuild_cartridge_catalog = true;
}
if let Some(path) = parse_cli_string_arg(args, "--metadata-db-path") {
self.metadata_db_path = Some(path);
}
if let Some(path) = parse_cli_string_arg(args, "--image-cache-path") {
self.image_cache_path = Some(path);
}
if let Some(include) = parse_bool_arg(args, "--include-unofficial-roms")? {
self.include_unofficial_roms = include;
}
Ok(())
}
pub fn resolved_metadata_db_path(&self) -> std::path::PathBuf {
if let Some(ref p) = self.metadata_db_path {
std::path::PathBuf::from(p)
} else {
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default();
home.join(".neser").join("metadata.db")
}
}
pub fn resolved_image_cache_path(&self) -> std::path::PathBuf {
if let Some(ref p) = self.image_cache_path {
std::path::PathBuf::from(p)
} else {
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default();
home.join(".neser").join("image_cache")
}
}
pub fn resolved_favorites_path(&self) -> std::path::PathBuf {
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default();
home.join(".neser").join("favorites.json")
}
}
#[derive(Debug)]
pub enum ParseResult {
Help,
Version,
Config(Box<Config>),
}
pub(crate) fn parse_cli_string_arg(args: &[String], flag: &str) -> Option<String> {
args.windows(2)
.find_map(|w| (w[0] == flag).then(|| w[1].clone()))
.or_else(|| {
args.iter().find_map(|arg| {
arg.split_once('=')
.filter(|(f, _)| *f == flag)
.map(|(_, v)| v.to_string())
})
})
}
pub(crate) const PLATFORM_CLI_FLAGS: &[CliFlag] = &[
CliFlag {
flag: "--help",
help: None,
has_value: false,
},
CliFlag {
flag: "-h",
help: None,
has_value: false,
},
CliFlag {
flag: "--version",
help: Some("Print version information and exit"),
has_value: false,
},
CliFlag {
flag: "--trace",
help: Some("Enable CPU trace output"),
has_value: false,
},
CliFlag {
flag: "--trace-cpu",
help: Some("Enable CPU trace output"),
has_value: false,
},
CliFlag {
flag: "--fullscreen",
help: Some(
"Run emulator in fullscreen mode (optionally: true/false, default when flag present: true)",
),
has_value: false,
},
CliFlag {
flag: "--display",
help: Some("Select display index for fullscreen (e.g., --display 1)"),
has_value: true,
},
CliFlag {
flag: "--config",
help: Some("Specify config file path (default: ./neser.conf or ~/.neser/neser.conf)"),
has_value: true,
},
CliFlag {
flag: "--window-height",
help: Some("Window height in pixels (windowed mode only, e.g., --window-height 896)"),
has_value: true,
},
CliFlag {
flag: "--debugger-alpha",
help: Some("Debugger window opacity: 0.1 (transparent) to 1.0 (opaque, default: 0.7)"),
has_value: true,
},
CliFlag {
flag: "--audio",
help: Some("Enable audio output (optionally: true/false, default when flag present: true)"),
has_value: false,
},
CliFlag {
flag: "--no-audio",
help: Some("Disable audio output (equivalent to --audio false)"),
has_value: false,
},
CliFlag {
flag: "--disable-audio",
help: Some("Disable audio output (equivalent to --audio false)"),
has_value: false,
},
CliFlag {
flag: "--vsync",
help: Some("Enable VSync (optionally: true/false, default when flag present: true)"),
has_value: false,
},
CliFlag {
flag: "--no-vsync",
help: Some("Disable VSync (equivalent to --vsync false)"),
has_value: false,
},
CliFlag {
flag: "--disable-vsync",
help: Some("Disable VSync (equivalent to --vsync false)"),
has_value: false,
},
CliFlag {
flag: "--gamepads",
help: Some(
"Enable gamepad/joystick support (optionally: true/false, default when flag present: true)",
),
has_value: false,
},
CliFlag {
flag: "--debugger",
help: Some(
"Open debugger windows (CPU/PPU/APU) on startup (optionally: true/false, default when flag present: true)",
),
has_value: false,
},
CliFlag {
flag: "--load-state",
help: Some("Load save-state on startup"),
has_value: false,
},
CliFlag {
flag: "--create-recording",
help: Some("Record controller input to <ROM>.autorun file (replaces existing)"),
has_value: false,
},
CliFlag {
flag: "--extend-recording",
help: Some("Extend an existing autorun recording with new input"),
has_value: false,
},
CliFlag {
flag: "--playback",
help: Some("Play back controller input from <ROM>.autorun file"),
has_value: false,
},
CliFlag {
flag: "--playback-headless",
help: Some("Play back controller input from <ROM>.autorun file without display"),
has_value: false,
},
CliFlag {
flag: "--playback-from-checkpoint",
help: Some("Start playback from checkpoint N (0-based index, negative counts from end)"),
has_value: true,
},
CliFlag {
flag: "--playback-headless-from-checkpoint",
help: Some("Headless playback from checkpoint N (no display; negative counts from end)"),
has_value: true,
},
CliFlag {
flag: "--trim-checkpoints",
help: Some("Remove last N checkpoints (and their frames) from <ROM>.autorun file and exit"),
has_value: true,
},
CliFlag {
flag: "--convert-autorun",
help: Some("Convert <ROM>.autorun file from older versions to the current format and exit"),
has_value: false,
},
CliFlag {
flag: "--recalculate-autorun",
help: Some(
"Run headless playback, recalculate checkpoint CRCs in <ROM>.autorun file, save, and exit",
),
has_value: false,
},
CliFlag {
flag: "--autorun-format",
help: Some("Serialization format for autorun files: binary (default) or json"),
has_value: true,
},
CliFlag {
flag: "--ram-init-mode",
help: Some("RAM initialization mode: zero, random, or seeded-random:SEED (default: zero)"),
has_value: true,
},
CliFlag {
flag: "--breakpoint",
help: Some(
"Add breakpoints on startup: pc=ADDR, cycle=N, frame=N, write=ADDR (comma-separated)",
),
has_value: true,
},
CliFlag {
flag: "--cartridge-search-paths",
help: Some("Comma-separated search paths to scan recursively for .nes files on startup"),
has_value: true,
},
CliFlag {
flag: "--scan-cartridges",
help: Some(
"Enable cartridge scanning on startup (optionally: true/false, default when flag present: true)",
),
has_value: false,
},
CliFlag {
flag: "--no-scan-cartridges",
help: Some("Disable cartridge scanning on startup (equivalent to --scan-cartridges false)"),
has_value: false,
},
CliFlag {
flag: "--rebuild-cartridge-catalog",
help: Some("Rebuild cartridge catalog from scratch on startup"),
has_value: false,
},
CliFlag {
flag: "--tui",
help: Some("Launch the interactive TUI ROM browser (requires tui feature)"),
has_value: false,
},
CliFlag {
flag: "--metadata-db-path",
help: Some("Path to TheGamesDB metadata SQLite database (default: ~/.neser/metadata.db)"),
has_value: true,
},
CliFlag {
flag: "--image-cache-path",
help: Some("Path to cover art image cache directory (default: ~/.neser/image_cache/)"),
has_value: true,
},
CliFlag {
flag: "--include-unofficial-roms",
help: Some("Include unofficial ROMs (hacks, homebrew, etc.) in the browser catalog"),
has_value: false,
},
];
pub(crate) const OPTIONAL_BOOL_FLAGS: &[&str] = &[
"--nes-oam-dram-decay",
"--audio",
"--vsync",
"--gamepads",
"--nes-enable-4-score",
"--nes-pulse1",
"--nes-pulse2",
"--nes-triangle",
"--nes-noise",
"--nes-dmc",
"--debugger",
"--load-state",
"--fullscreen",
"--scan-cartridges",
"--convert-autorun",
"--recalculate-autorun",
"--include-unofficial-roms",
"--gba-color-correction",
];
pub(crate) fn parse_bool_arg(args: &[String], flag: &str) -> Result<Option<bool>, String> {
for i in 0..args.len() {
if let Some((flag_part, value_part)) = args[i].split_once('=') {
if flag_part == flag {
match parse_bool(value_part) {
Ok(b) => return Ok(Some(b)),
Err(_) => {
return Err(format!(
"Invalid value for {flag}: '{}'. Expected: true, false, yes, no, 1, or 0",
value_part
));
}
}
}
}
else if args[i] == flag {
if i + 1 < args.len() {
let next_arg = &args[i + 1];
if next_arg.starts_with('-') {
return Ok(Some(true));
}
match parse_bool(next_arg) {
Ok(b) => return Ok(Some(b)),
Err(_) => return Ok(Some(true)),
}
} else {
return Ok(Some(true));
}
}
}
Ok(None)
}
pub(crate) fn has_negation_flag(args: &[String], flags: &[&str]) -> bool {
args.iter().any(|a| flags.contains(&a.as_str()))
}
pub(crate) fn parse_u32_arg(args: &[String], flag: &str) -> Result<Option<u32>, String> {
for i in 0..args.len() {
if args[i] == flag && i + 1 < args.len() {
let value = &args[i + 1];
let parsed: u32 = value
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value))?;
return Ok(Some(parsed));
}
if let Some((flag_part, value_part)) = args[i].split_once('=')
&& flag_part == flag
{
let parsed: u32 = value_part
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value_part))?;
return Ok(Some(parsed));
}
}
Ok(None)
}
pub(crate) fn parse_i64_arg(args: &[String], flag: &str) -> Result<Option<i64>, String> {
for i in 0..args.len() {
if args[i] == flag && i + 1 < args.len() {
let value = &args[i + 1];
let parsed: i64 = value
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value))?;
return Ok(Some(parsed));
}
if let Some((flag_part, value_part)) = args[i].split_once('=')
&& flag_part == flag
{
let parsed: i64 = value_part
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value_part))?;
return Ok(Some(parsed));
}
}
Ok(None)
}
pub(crate) fn parse_f32_arg(args: &[String], flag: &str) -> Result<Option<f32>, String> {
for i in 0..args.len() {
if args[i] == flag && i + 1 < args.len() {
let value = &args[i + 1];
let parsed: f32 = value
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value))?;
return Ok(Some(parsed));
}
if let Some((flag_part, value_part)) = args[i].split_once('=')
&& flag_part == flag
{
let parsed: f32 = value_part
.parse()
.map_err(|_| format!("Invalid {} value: {}", flag, value_part))?;
return Ok(Some(parsed));
}
}
Ok(None)
}
pub(crate) fn parse_bool(value: &str) -> Result<bool, ()> {
match value.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(true),
"false" | "no" | "0" => Ok(false),
_ => Err(()),
}
}
pub(crate) fn parse_hex_u8(value: &str) -> Result<u8, ()> {
let trimmed = value.trim();
if let Some(hex) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
u8::from_str_radix(hex, 16).map_err(|_| ())
} else {
trimmed.parse::<u8>().map_err(|_| ())
}
}
pub(crate) fn parse_hex_addr(s: &str) -> Option<u16> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
u16::from_str_radix(s, 16).ok()
}
pub(crate) fn parse_breakpoint_list(spec: &str) -> Result<Vec<BreakpointKind>, String> {
spec.split(',')
.map(|entry| {
let entry = entry.trim();
let (kind, value) = entry
.split_once('=')
.ok_or_else(|| format!("invalid breakpoint '{entry}': expected format type=value (e.g. pc=C000, cycle=100, frame=60, write=2006)"))?;
match kind.trim() {
"pc" => parse_hex_addr(value)
.map(BreakpointKind::Pc)
.ok_or_else(|| format!("invalid breakpoint address '{value}': expected a hex address (e.g. C000)")),
"cycle" => value
.trim()
.parse::<u64>()
.map(BreakpointKind::Cycle)
.map_err(|_| format!("invalid breakpoint cycle '{value}': expected a decimal number")),
"frame" => value
.trim()
.parse::<u64>()
.map(BreakpointKind::Frame)
.map_err(|_| format!("invalid breakpoint frame '{value}': expected a decimal number")),
"write" => parse_hex_addr(value)
.map(BreakpointKind::WriteAddress)
.ok_or_else(|| format!("invalid breakpoint address '{value}': expected a hex address (e.g. 2006)")),
other => Err(format!(
"invalid breakpoint type '{other}': expected pc, cycle, frame, or write"
)),
}
})
.collect()
}
pub(crate) fn parse_search_paths(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|path| !path.is_empty())
.map(ToString::to_string)
.collect()
}
pub(crate) fn all_cli_flags() -> impl Iterator<Item = &'static CliFlag> {
PLATFORM_CLI_FLAGS
.iter()
.chain(crate::nes::console::CLI_FLAGS.iter())
.chain(crate::gb::console::config::GB_CLI_FLAGS.iter())
.chain(crate::gba::console::config::GBA_CLI_FLAGS.iter())
}
fn help_section_for_flag(flag: &str) -> &'static str {
if flag.starts_with("--trace")
|| matches!(flag, "--debugger" | "--debugger-alpha" | "--breakpoint")
{
"Trace and Debugging"
} else if matches!(
flag,
"--nes-controller-port1"
| "--nes-controller-port2"
| "--nes-expansion-port"
| "--nes-zapper-detection-size"
| "--gamepads"
| "--nes-enable-4-score"
| "--no-nes-4-score"
| "--disable-nes-4-score"
) {
"Input"
} else if matches!(
flag,
"--audio"
| "--no-audio"
| "--disable-audio"
| "--nes-pulse1"
| "--no-nes-pulse1"
| "--disable-nes-pulse1"
| "--nes-pulse2"
| "--no-nes-pulse2"
| "--disable-nes-pulse2"
| "--nes-triangle"
| "--no-nes-triangle"
| "--disable-nes-triangle"
| "--nes-noise"
| "--no-nes-noise"
| "--disable-nes-noise"
| "--nes-dmc"
| "--no-nes-dmc"
| "--disable-nes-dmc"
) {
"Sound"
} else if matches!(
flag,
"--fullscreen"
| "--display"
| "--nes-filter"
| "--gb-filter"
| "--gba-filter"
| "--window-height"
| "--vsync"
| "--no-vsync"
| "--disable-vsync"
| "--nes-horizontal-overscan"
| "--nes-vertical-overscan"
) {
"Video and Display"
} else if matches!(
flag,
"--create-recording"
| "--extend-recording"
| "--playback"
| "--playback-headless"
| "--playback-from-checkpoint"
| "--playback-headless-from-checkpoint"
| "--trim-checkpoints"
| "--convert-autorun"
| "--recalculate-autorun"
) {
"Autorun"
} else if matches!(
flag,
"--cartridge-search-paths"
| "--scan-cartridges"
| "--no-scan-cartridges"
| "--rebuild-cartridge-catalog"
) {
"Cartridge Catalog"
} else {
"General"
}
}
pub(crate) fn help_text() -> String {
use std::fmt::Write as _;
const HELP_SECTIONS: [&str; 7] = [
"General",
"Input",
"Trace and Debugging",
"Sound",
"Video and Display",
"Autorun",
"Cartridge Catalog",
];
let mut help = String::new();
writeln!(&mut help, "NES Emulator").unwrap();
writeln!(&mut help, "\nUsage: neser [OPTIONS] [ROM]").unwrap();
for section in HELP_SECTIONS {
let mut wrote_section = false;
for flag in all_cli_flags() {
if flag.help.is_none() || help_section_for_flag(flag.flag) != section {
continue;
}
if !wrote_section {
writeln!(&mut help, "\n{section}:").unwrap();
wrote_section = true;
}
if let Some(flag_help) = flag.help {
writeln!(&mut help, " {:<19} {}", flag.flag, flag_help).unwrap();
}
}
}
writeln!(&mut help, "\nExamples:").unwrap();
writeln!(
&mut help,
" neser game.nes # Load and run a ROM"
)
.unwrap();
writeln!(
&mut help,
" neser --nes-hardware nes-pal game.nes # Use NES PAL hardware"
)
.unwrap();
writeln!(
&mut help,
" neser --debugger game.nes # Enable debugger (no value = true)"
)
.unwrap();
writeln!(
&mut help,
" neser --breakpoint frame=120 game.nes # Break on frame 120"
)
.unwrap();
writeln!(
&mut help,
" neser --audio game.nes # Enable audio (no value = true)"
)
.unwrap();
writeln!(
&mut help,
" neser --audio=1 game.nes # Enable audio (equals syntax)"
)
.unwrap();
writeln!(
&mut help,
" neser --audio false game.nes # Disable audio (value-based)"
)
.unwrap();
writeln!(
&mut help,
" neser --audio=0 game.nes # Disable audio (equals syntax)"
)
.unwrap();
writeln!(
&mut help,
" neser --no-audio game.nes # Disable audio (prefix negation)"
)
.unwrap();
writeln!(
&mut help,
" neser --disable-nes-pulse1 --disable-nes-pulse2 game.nes # Disable specific channels"
)
.unwrap();
writeln!(&mut help).unwrap();
writeln!(
&mut help,
"Note: Boolean flags can be used without value (defaults to true), with value (true/false/yes/no/1/0),"
)
.unwrap();
writeln!(
&mut help,
" or with prefix negation (--no-*, --disable-*). All forms: --audio, --audio=1, --audio true are equivalent."
)
.unwrap();
help
}
pub(crate) fn print_help() {
print!("{}", help_text());
}
pub(crate) fn validate_args(args: &[String]) -> Result<(), String> {
let mut i = 1; let mut seen_positional = false;
while i < args.len() {
let arg = &args[i];
if let Some(flag) = all_cli_flags().find(|f| f.flag == arg) {
if flag.has_value {
if i + 1 >= args.len() {
return Err(format!("Missing value for {arg}\nTry --help for usage."));
}
i += 1; }
else if OPTIONAL_BOOL_FLAGS.contains(&arg.as_str()) {
if i + 1 < args.len() {
let next_arg = &args[i + 1];
if parse_bool(next_arg).is_ok() {
i += 1; }
}
}
i += 1;
continue;
}
if let Some((flag_part, _)) = arg.split_once('=')
&& all_cli_flags().any(|f| f.flag == flag_part)
{
i += 1;
continue;
}
if arg.starts_with('-') {
return Err(format!("Unknown argument: {arg}\nTry --help for usage."));
}
if seen_positional {
return Err(format!(
"Unexpected positional argument: {arg}\nTry --help for usage."
));
}
seen_positional = true;
i += 1;
continue;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nes::console::{Config, HardwareModel};
fn config_new(mut args: Vec<String>) -> Result<ParseResult, String> {
use std::io::Write;
use tempfile::NamedTempFile;
if args.iter().any(|a| a == "--config") {
return Config::new(&args);
}
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"").unwrap();
args.push("--config".to_string());
args.push(file.path().to_string_lossy().to_string());
Config::new(&args)
}
fn parse_config(args: Vec<String>) -> Config {
match config_new(args).unwrap() {
ParseResult::Config(c) => *c,
ParseResult::Help => panic!("Expected Config, got Help"),
ParseResult::Version => panic!("Expected Config, got Version"),
}
}
#[test]
fn test_config_help_flag() {
let args = vec!["neser".to_string(), "--help".to_string()];
match config_new(args).unwrap() {
ParseResult::Help => {}
ParseResult::Version => panic!("Expected Help, got Version"),
ParseResult::Config(_) => panic!("Expected Help"),
}
}
#[test]
fn test_config_help_flag_short() {
let args = vec!["neser".to_string(), "-h".to_string()];
match config_new(args).unwrap() {
ParseResult::Help => {}
ParseResult::Version => panic!("Expected Help, got Version"),
ParseResult::Config(_) => panic!("Expected Help"),
}
}
#[test]
fn test_config_version_flag_returns_version_before_validation() {
let args = vec![
"neser".to_string(),
"--version".to_string(),
"--not-a-real-flag".to_string(),
];
match Config::new(&args).unwrap() {
ParseResult::Version => {}
ParseResult::Help => panic!("Expected Version, got Help"),
ParseResult::Config(_) => panic!("Expected Version, got Config"),
}
}
#[test]
fn test_help_text_lists_version_flag() {
let help = help_text();
assert!(help.contains("--version"));
assert!(help.contains("Print version information and exit"));
}
#[test]
fn test_help_text_groups_flags_into_readable_sections() {
let help = help_text();
assert!(help.contains("\nInput:"));
assert!(help.contains("\nTrace and Debugging:"));
assert!(help.contains("\nSound:"));
assert!(help.contains("\nVideo and Display:"));
assert!(help.contains("\nAutorun:"));
assert!(help.contains("\nCartridge Catalog:"));
let input_section = help.find("\nInput:").unwrap();
let input_flag = help.find("--nes-controller-port1").unwrap();
assert!(input_section < input_flag);
let trace_section = help.find("\nTrace and Debugging:").unwrap();
let trace_flag = help.find("--trace-cpu").unwrap();
assert!(trace_section < trace_flag);
let sound_section = help.find("\nSound:").unwrap();
let sound_flag = help.find("--audio").unwrap();
assert!(sound_section < sound_flag);
}
#[test]
fn test_help_text_load_state_is_presence_only_flag() {
let help = help_text();
assert!(help.contains("--load-state"));
assert!(!help.contains("--no-load-state"));
assert!(!help.contains("--disable-load-state"));
}
#[test]
fn test_help_text_oam_dram_decay_shows_default_and_no_negation_aliases() {
let help = help_text();
assert!(help.contains("--nes-oam-dram-decay"));
assert!(help.contains("default: false"));
assert!(!help.contains("--no-oam-dram-decay"));
assert!(!help.contains("--disable-oam-dram-decay"));
}
#[test]
fn test_help_text_examples_use_hardware_flag() {
let help = help_text();
assert!(help.contains("neser --nes-hardware nes-pal game.nes"));
assert!(!help.contains("--tv-system"));
}
#[test]
fn test_config_audio_false() {
let args = vec![
"neser".to_string(),
"--audio".to_string(),
"false".to_string(),
];
let config = parse_config(args);
assert!(!config.frontend.audio_enabled);
}
#[test]
fn test_config_vsync_false() {
let args = vec![
"neser".to_string(),
"--vsync".to_string(),
"false".to_string(),
];
let config = parse_config(args);
assert!(!config.frontend.vsync_enabled);
}
#[test]
fn test_config_gamepads_false() {
let args = vec![
"neser".to_string(),
"--gamepads".to_string(),
"false".to_string(),
];
let config = parse_config(args);
assert!(!config.frontend.gamepads_enabled);
}
#[test]
fn test_config_fullscreen_true() {
let args = vec![
"neser".to_string(),
"--fullscreen".to_string(),
"true".to_string(),
];
let config = parse_config(args);
assert!(config.frontend.fullscreen);
assert_eq!(config.frontend.fullscreen_display, None);
}
#[test]
fn test_config_load_state_flag() {
let args = vec!["neser".to_string(), "--load-state".to_string()];
let config = parse_config(args);
assert!(config.frontend.load_state);
}
#[test]
fn test_config_fullscreen_with_display() {
let args = vec![
"neser".to_string(),
"--fullscreen".to_string(),
"true".to_string(),
"--display".to_string(),
"1".to_string(),
];
let config = parse_config(args);
assert!(config.frontend.fullscreen);
assert_eq!(config.frontend.fullscreen_display, Some(1));
}
#[test]
fn test_config_display_without_fullscreen_is_ignored() {
let args = vec![
"neser".to_string(),
"--display".to_string(),
"1".to_string(),
];
let config = parse_config(args);
assert!(!config.frontend.fullscreen);
assert_eq!(config.frontend.fullscreen_display, None);
}
#[test]
fn test_config_display_missing_value_errors() {
let args = vec![
"neser".to_string(),
"--fullscreen".to_string(),
"--display".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
}
#[test]
fn test_config_display_invalid_value_errors() {
let args = vec![
"neser".to_string(),
"--fullscreen".to_string(),
"--display".to_string(),
"abc".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
}
#[test]
fn test_config_display_negative_value_errors() {
let args = vec![
"neser".to_string(),
"--fullscreen".to_string(),
"--display".to_string(),
"-1".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
}
#[test]
fn test_config_cmdline_filter_invalid_errors() {
let args = vec![
"neser".to_string(),
"--nes-filter".to_string(),
"invalid-filter".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Invalid filter name: 'invalid-filter'. Valid options are: none, crt, smooth, ntsc, pal"
);
}
#[test]
fn test_config_debugger_enabled() {
let args = vec![
"neser".to_string(),
"--debugger".to_string(),
"true".to_string(),
];
let config = parse_config(args);
assert!(config.frontend.debugger_enabled);
}
#[test]
fn test_config_unknown_argument_errors() {
let args = vec![
"neser".to_string(),
"--definitely-not-a-real-flag".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
}
#[test]
fn test_config_positional_argument_is_rom_path() {
let args = vec!["neser".to_string(), "somefile.nes".to_string()];
let config = parse_config(args);
assert_eq!(config.frontend.rom_path.as_deref(), Some("somefile.nes"));
}
#[test]
fn test_config_tracing_enabled() {
let args = vec!["neser".to_string(), "--trace".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.cpu, 1); }
#[test]
fn test_config_tracing_nestest() {
let args = vec!["neser".to_string(), "--trace-nestest".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert!(config.frontend.tracing.nestest);
}
#[test]
fn test_config_tracing_cpu() {
let args = vec!["neser".to_string(), "--trace-cpu".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.cpu, 1);
}
#[test]
fn test_config_tracing_ppu() {
let args = vec!["neser".to_string(), "--trace-ppu".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.ppu, 1);
}
#[test]
fn test_config_tracing_apu() {
let args = vec!["neser".to_string(), "--trace-apu".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.apu, 1);
}
#[test]
fn test_config_tracing_mapper() {
let args = vec!["neser".to_string(), "--trace-mapper".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.mapper, 1);
}
#[test]
fn test_config_tracing_cpu_with_level() {
let args = vec!["neser".to_string(), "--trace-cpu=2".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.cpu, 2);
}
#[test]
fn test_config_tracing_ppu_with_level() {
let args = vec!["neser".to_string(), "--trace-ppu=3".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.ppu, 3);
}
#[test]
fn test_config_tracing_ppu_level_is_capped_at_five() {
let args = vec!["neser".to_string(), "--trace-ppu=9".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.ppu, 5);
}
#[test]
fn test_config_tracing_apu_with_level() {
let args = vec!["neser".to_string(), "--trace-apu=4".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.apu, 4);
}
#[test]
fn test_config_tracing_mapper_with_level() {
let args = vec!["neser".to_string(), "--trace-mapper=5".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.mapper, 5);
}
#[test]
fn test_config_tracing_mapper_level_is_capped_at_five() {
let args = vec!["neser".to_string(), "--trace-mapper=9".to_string()];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.mapper, 5);
}
#[test]
fn test_config_tracing_with_multiple_levels() {
let args = vec![
"neser".to_string(),
"--trace-cpu=3".to_string(),
"--trace-ppu=2".to_string(),
"--trace-apu=1".to_string(),
];
let config = parse_config(args);
assert!(config.frontend.tracing.enabled);
assert_eq!(config.frontend.tracing.cpu, 3);
assert_eq!(config.frontend.tracing.ppu, 2);
assert_eq!(config.frontend.tracing.apu, 1);
assert_eq!(config.frontend.tracing.mapper, 0);
}
#[test]
fn test_config_multiple_flags() {
use crate::nes::console::ApuChannels;
let args = vec![
"neser".to_string(),
"--nes-hardware".to_string(),
"nes-pal".to_string(),
"--audio".to_string(),
"false".to_string(),
"--fullscreen".to_string(),
"true".to_string(),
"--display".to_string(),
"2".to_string(),
"--nes-pulse1".to_string(),
"false".to_string(),
"--nes-noise".to_string(),
"false".to_string(),
];
let config = parse_config(args);
assert_eq!(config.nes.hardware_model, HardwareModel::NesPal);
assert!(!config.frontend.audio_enabled);
assert!(config.frontend.fullscreen);
assert_eq!(config.frontend.fullscreen_display, Some(2));
assert!(!config.nes.apu_channels.contains(ApuChannels::PULSE1));
assert!(config.nes.apu_channels.contains(ApuChannels::PULSE2));
assert!(!config.nes.apu_channels.contains(ApuChannels::NOISE));
}
#[test]
fn test_config_window_height() {
let args = vec![
"neser".to_string(),
"--window-height".to_string(),
"720".to_string(),
];
let config = parse_config(args);
assert_eq!(config.frontend.window_height, 720);
}
#[test]
fn test_config_window_height_invalid_errors() {
let args = vec![
"neser".to_string(),
"--window-height".to_string(),
"not_a_number".to_string(),
];
let result = config_new(args);
assert!(result.is_err());
}
#[test]
fn test_config_metadata_db_path_defaults_to_none() {
let config = parse_config(vec!["neser".to_string(), "game.nes".to_string()]);
assert!(config.frontend.metadata_db_path.is_none());
}
#[test]
fn test_config_metadata_db_path_from_cli() {
let config = parse_config(vec![
"neser".to_string(),
"--metadata-db-path".to_string(),
"/custom/metadata.db".to_string(),
"game.nes".to_string(),
]);
assert_eq!(
config.frontend.metadata_db_path.as_deref(),
Some("/custom/metadata.db")
);
}
#[test]
fn test_config_image_cache_path_defaults_to_none() {
let config = parse_config(vec!["neser".to_string(), "game.nes".to_string()]);
assert!(config.frontend.image_cache_path.is_none());
}
#[test]
fn test_config_image_cache_path_from_cli() {
let config = parse_config(vec![
"neser".to_string(),
"--image-cache-path".to_string(),
"/custom/cache".to_string(),
"game.nes".to_string(),
]);
assert_eq!(
config.frontend.image_cache_path.as_deref(),
Some("/custom/cache")
);
}
#[test]
fn test_config_metadata_db_path_from_config_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"metadata_db_path=/from/config/metadata.db\n")
.unwrap();
let config = parse_config(vec![
"neser".to_string(),
"--config".to_string(),
file.path().to_string_lossy().to_string(),
"game.nes".to_string(),
]);
assert_eq!(
config.frontend.metadata_db_path.as_deref(),
Some("/from/config/metadata.db")
);
}
#[test]
fn test_config_image_cache_path_from_config_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"image_cache_path=/from/config/cache\n")
.unwrap();
let config = parse_config(vec![
"neser".to_string(),
"--config".to_string(),
file.path().to_string_lossy().to_string(),
"game.nes".to_string(),
]);
assert_eq!(
config.frontend.image_cache_path.as_deref(),
Some("/from/config/cache")
);
}
#[test]
fn test_config_cli_overrides_config_file_metadata_db_path() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"metadata_db_path=/from/config/metadata.db\n")
.unwrap();
let config = parse_config(vec![
"neser".to_string(),
"--config".to_string(),
file.path().to_string_lossy().to_string(),
"--metadata-db-path".to_string(),
"/from/cli/metadata.db".to_string(),
"game.nes".to_string(),
]);
assert_eq!(
config.frontend.metadata_db_path.as_deref(),
Some("/from/cli/metadata.db")
);
}
#[test]
fn test_resolved_metadata_db_path_uses_configured_value() {
let cfg = FrontendConfig {
metadata_db_path: Some("/custom/metadata.db".to_string()),
..Default::default()
};
assert_eq!(
cfg.resolved_metadata_db_path(),
std::path::PathBuf::from("/custom/metadata.db")
);
}
#[test]
fn test_resolved_metadata_db_path_falls_back_to_default() {
let cfg = FrontendConfig::default();
let path = cfg.resolved_metadata_db_path();
assert!(
path.ends_with(".neser/metadata.db"),
"expected path ending with .neser/metadata.db, got: {path:?}"
);
}
#[test]
fn test_resolved_image_cache_path_uses_configured_value() {
let cfg = FrontendConfig {
image_cache_path: Some("/custom/cache".to_string()),
..Default::default()
};
assert_eq!(
cfg.resolved_image_cache_path(),
std::path::PathBuf::from("/custom/cache")
);
}
#[test]
fn test_resolved_image_cache_path_falls_back_to_default() {
let cfg = FrontendConfig::default();
let path = cfg.resolved_image_cache_path();
assert!(
path.ends_with(".neser/image_cache"),
"expected path ending with .neser/image_cache, got: {path:?}"
);
}
#[test]
fn test_apply_config_value_accepts_dashes_for_underscore_keys() {
let mut cfg = FrontendConfig::default();
cfg.apply_config_value("cartridge-search-paths", "/tmp/roms")
.unwrap();
assert_eq!(cfg.cartridge_search_paths, vec!["/tmp/roms"]);
cfg.apply_config_value("metadata-db-path", "/tmp/meta.db")
.unwrap();
assert_eq!(cfg.metadata_db_path.as_deref(), Some("/tmp/meta.db"));
cfg.apply_config_value("image-cache-path", "/tmp/cache")
.unwrap();
assert_eq!(cfg.image_cache_path.as_deref(), Some("/tmp/cache"));
cfg.apply_config_value("include-unofficial-roms", "true")
.unwrap();
assert!(cfg.include_unofficial_roms);
}
}