Skip to main content

monsoon_cli/cli/
mod.rs

1//! CLI module for the NES emulator.
2//!
3//! This module provides a comprehensive command-line interface for programmatic
4//! control of the emulator. It is designed with extensibility and robustness in
5//! mind.
6//!
7//! # Architecture
8//!
9//! The CLI is organized into several submodules, each with a specific
10//! responsibility:
11//!
12//! | Module | Purpose |
13//! |--------|---------|
14//! | [`args`] | Command-line argument definitions using clap derive macros |
15//! | [`config`] | TOML configuration file support with merge logic |
16//! | [`error`] | Comprehensive error types with helpful messages |
17//! | [`execution`] | Execution engine with generic stop conditions |
18//! | [`output`] | Extensible output formatting system |
19//!
20//! # Design Principles
21//!
22//! 1. **Separation of Concerns**: Each module has a single responsibility
23//! 2. **Extensibility**: Adding new features requires minimal changes
24//! 3. **Error Handling**: All errors are structured with helpful messages
25//! 4. **Builder Pattern**: Configuration objects use fluent builder APIs
26//! 5. **Crate-Ready**: All public types are designed for library use
27//!
28//! # Extensibility Guide
29//!
30//! ## Adding a New Output Format
31//!
32//! ```rust,ignore
33//! // 1. Add variant to OutputFormat enum in args.rs
34//! pub enum OutputFormat {
35//!     Hex, Json, Toml, Binary,
36//!     Xml,  // New!
37//! }
38//!
39//! // 2. Implement MemoryFormatter trait in output.rs
40//! pub struct XmlFormatter;
41//! impl MemoryFormatter for XmlFormatter {
42//!     fn format(&self, dump: &MemoryDump) -> Result<Vec<u8>, String> {
43//!         // ... format as XML ...
44//!     }
45//!     fn file_extension(&self) -> &'static str { "xml" }
46//! }
47//!
48//! // 3. Register in OutputFormat::formatter() and extension()
49//! impl OutputFormat {
50//!     pub fn formatter(&self) -> Box<dyn MemoryFormatter> {
51//!         match self {
52//!             // ... existing ...
53//!             OutputFormat::Xml => Box::new(XmlFormatter),
54//!         }
55//!     }
56//!     pub fn extension(&self) -> &'static str {
57//!         match self {
58//!             // ... existing ...
59//!             OutputFormat::Xml => "xml",
60//!         }
61//!     }
62//! }
63//! ```
64//!
65//! ## Adding a New Stop Condition
66//!
67//! ```rust,ignore
68//! // 1. Add variant to StopCondition enum in execution.rs
69//! pub enum StopCondition {
70//!     Cycles(u128), Frames(u64), PcEquals(u16),
71//!     ScanlineEquals(u16),  // New!
72//! }
73//!
74//! // 2. Add corresponding StopReason variant
75//! pub enum StopReason {
76//!     CyclesReached(u128), FramesReached(u64), PcReached(u16),
77//!     ScanlineReached(u16),  // New!
78//! }
79//!
80//! // 3. Implement check in ExecutionConfig::check_conditions()
81//! StopCondition::ScanlineEquals(target) if emu.ppu.scanline == *target => {
82//!     return Some(StopReason::ScanlineReached(*target));
83//! }
84//!
85//! // 4. Add CLI argument in args.rs
86//! #[arg(long)]
87//! pub until_scanline: Option<u16>,
88//!
89//! // 5. Add builder method in ExecutionConfig
90//! pub fn with_scanline(mut self, scanline: u16) -> Self {
91//!     self.stop_conditions.push(StopCondition::ScanlineEquals(scanline));
92//!     self
93//! }
94//! ```
95//!
96//! ## Adding a New Memory Type
97//!
98//! ```rust,ignore
99//! // 1. Add variant to MemoryType enum in output.rs
100//! pub enum MemoryType {
101//!     Cpu, Ppu, Oam, Nametables,
102//!     PaletteRam,  // New!
103//! }
104//!
105//! // 2. Add factory method to MemoryDump
106//! impl MemoryDump {
107//!     pub fn palette_ram(data: Vec<u8>) -> Self {
108//!         Self::new(MemoryType::PaletteRam, 0x3F00, data)
109//!     }
110//! }
111//!
112//! // 3. Add CLI argument in args.rs
113//! #[arg(long)]
114//! pub dump_palette_ram: bool,
115//!
116//! // 4. Handle in main.rs output_results()
117//! if args.memory.dump_palette_ram {
118//!     let dump = create_palette_ram_dump(emu);
119//!     writer.write(&dump)?;
120//! }
121//! ```
122//!
123//! # Usage Examples
124//!
125//! ## Command Line
126//!
127//! ```bash
128//! # Basic headless run
129//! nes_main --headless --rom game.nes --frames 100
130//!
131//! # With config file
132//! nes_main --config run.toml
133//!
134//! # Memory dump to file
135//! nes_main -H --rom game.nes --frames 60 --read-cpu 0x0000-0x07FF --json -o memory.json
136//!
137//! # Pipe-based savestate workflow
138//! nes_main -H --rom game.nes --frames 100 --state-stdout | \
139//! nes_main -H --rom game.nes --state-stdin --frames 50 --save-state final.sav
140//! ```
141//!
142//! ## Programmatic (Crate API)
143//!
144//! ```rust,ignore
145//! use lockstep::cli::{ExecutionConfig, ExecutionEngine, SavestateConfig};
146//! use std::path::PathBuf;
147//!
148//! // Create execution config with builder pattern
149//! let exec_config = ExecutionConfig::new()
150//!     .with_frames(100)
151//!     .with_pc_breakpoint(0x8000)
152//!     .with_verbose(true);
153//!
154//! // Create savestate config
155//! let save_config = SavestateConfig::new()
156//!     .save_to_file(PathBuf::from("output.sav"));
157//!
158//! // Run emulation
159//! let mut engine = ExecutionEngine::new()
160//!     .with_config(exec_config)
161//!     .with_savestate_config(save_config);
162//!
163//! engine.load_rom(&PathBuf::from("game.nes"))?;
164//! engine.power_on();
165//!
166//! let result = engine.run()?;
167//! println!("Stopped: {:?} after {} frames", result.stop_reason, result.total_frames);
168//!
169//! engine.save_savestate()?;
170//! ```
171//!
172//! See `docs/CLI_INTERFACE.md` for full documentation.
173
174pub mod args;
175pub mod config;
176pub mod error;
177pub mod execution;
178pub mod headless;
179pub mod memory_init;
180pub mod output;
181pub mod video;
182
183pub use args::{
184    CliArgs, OutputFormat, SavestateFormat, VideoExportMode, VideoFormat, parse_hex_u16,
185};
186use clap::Parser;
187pub use config::ConfigFile;
188pub use error::{CliError, CliResult};
189pub use execution::{
190    ExecutionConfig, ExecutionEngine, ExecutionResult, MemoryAccessType, SavestateConfig,
191    SavestateDestination, SavestateSource, StopCondition, StopReason,
192};
193pub use headless::{create_renderer_from_args, list_renderers, run_headless};
194pub use memory_init::{MemoryInit, MemoryInitConfig, apply_memory_init, apply_memory_init_config};
195pub use output::{
196    InterpretedNametable, InterpretedNametables, InterpretedOam, MemoryDump, MemoryFormatter,
197    MemoryType, OamSprite, OutputWriter,
198};
199pub use video::{
200    FpsConfig, StreamingVideoEncoder, VideoEncoder, VideoError, VideoResolution, create_encoder,
201    encode_frames, is_ffmpeg_available,
202};
203
204// =============================================================================
205// Argument Parsing
206// =============================================================================
207
208/// Parse CLI arguments and optionally merge with a config file.
209///
210/// This function:
211/// 1. Parses command-line arguments using clap
212/// 2. If `--config` is specified, loads and merges the TOML config file
213/// 3. Returns the final merged configuration
214///
215/// CLI arguments always take precedence over config file values.
216pub fn parse_args() -> Result<CliArgs, Box<dyn std::error::Error>> {
217    let mut args = CliArgs::parse();
218
219    // If a config file is specified, load and merge it
220    if let Some(ref config_path) = args.config {
221        let config = ConfigFile::load(config_path)?;
222        config.merge_with_cli(&mut args);
223    }
224
225    Ok(args)
226}
227
228// =============================================================================
229// Argument Validation
230// =============================================================================
231
232/// Validate CLI arguments for consistency and completeness.
233///
234/// This function performs comprehensive validation of all CLI arguments,
235/// checking for:
236/// - Required arguments in certain modes
237/// - Conflicting argument combinations
238/// - Valid argument values
239///
240/// # Errors
241///
242/// Returns a structured `CliError` with helpful messages if validation fails.
243///
244/// # Example
245///
246/// ```rust,ignore
247/// let args = parse_args()?;
248/// validate_args(&args)?;  // Will error if args are invalid
249/// ```
250pub fn validate_args(args: &CliArgs) -> Result<(), CliError> {
251    validate_headless_requirements(args)?;
252    validate_savestate_options(args)?;
253    validate_output_format(args)?;
254    validate_memory_args(args)?;
255    validate_execution_args(args)?;
256    Ok(())
257}
258
259/// Validate that headless mode has required input.
260fn validate_headless_requirements(args: &CliArgs) -> Result<(), CliError> {
261    if args.rom.rom.is_none() && args.savestate.load_state.is_none() && !args.savestate.state_stdin
262    {
263        return Err(CliError::MissingArgument {
264            arg: "--rom, --load-state, or --state-stdin".to_string(),
265            context: "Headless mode requires an input source (ROM, savestate file, or stdin)"
266                .to_string(),
267        });
268    }
269    Ok(())
270}
271
272/// Validate savestate argument combinations.
273fn validate_savestate_options(args: &CliArgs) -> Result<(), CliError> {
274    // Can't use both state-stdin and load-state
275    if args.savestate.state_stdin && args.savestate.load_state.is_some() {
276        return Err(CliError::conflicting_args(
277            "--state-stdin",
278            "--load-state",
279            "can only load from one source at a time",
280        ));
281    }
282
283    // Can't use both state-stdout and save-state
284    if args.savestate.state_stdout && args.savestate.save_state.is_some() {
285        return Err(CliError::conflicting_args(
286            "--state-stdout",
287            "--save-state",
288            "can only save to one destination at a time",
289        ));
290    }
291
292    Ok(())
293}
294
295/// Validate output format arguments.
296fn validate_output_format(args: &CliArgs) -> Result<(), CliError> {
297    let format_flags: Vec<&str> = [
298        (args.output.json, "--json"),
299        (args.output.toml, "--toml"),
300        (args.output.binary, "--binary"),
301    ]
302    .iter()
303    .filter_map(|(flag, name)| flag.then_some(*name))
304    .collect();
305
306    if format_flags.len() > 1 {
307        return Err(CliError::InvalidArgumentCombination {
308            args: format_flags.iter().map(|s| s.to_string()).collect(),
309            reason: "can only specify one output format flag".to_string(),
310        });
311    }
312
313    Ok(())
314}
315
316/// Validate memory-related arguments.
317fn validate_memory_args(args: &CliArgs) -> Result<(), CliError> {
318    // Validate CPU memory range if specified
319    if let Some(ref range) = args.memory.read_cpu {
320        validate_memory_range_syntax(range, "--read-cpu")?;
321    }
322
323    // Validate PPU memory range if specified
324    if let Some(ref range) = args.memory.read_ppu {
325        validate_memory_range_syntax(range, "--read-ppu")?;
326    }
327
328    Ok(())
329}
330
331/// Validate memory range syntax without parsing the actual values.
332fn validate_memory_range_syntax(range: &str, arg_name: &str) -> Result<(), CliError> {
333    // Must contain either '-' or ':'
334    if !range.contains('-') && !range.contains(':') {
335        return Err(CliError::invalid_arg_with_hint(
336            arg_name,
337            range,
338            "invalid memory range format",
339            "Use START-END (e.g., 0x0000-0x07FF) or START:LENGTH (e.g., 0x6000:0x100)",
340        ));
341    }
342    Ok(())
343}
344
345/// Validate execution-related arguments.
346fn validate_execution_args(args: &CliArgs) -> Result<(), CliError> {
347    // Validate memory condition syntax if specified
348    if let Some(ref cond) = args.execution.until_mem {
349        validate_memory_condition_syntax(cond)?;
350    }
351
352    Ok(())
353}
354
355/// Validate memory condition syntax.
356fn validate_memory_condition_syntax(cond: &Vec<String>) -> Result<(), CliError> {
357    for s in cond {
358        if !s.contains("==") && !s.contains("!=") {
359            return Err(CliError::invalid_stop_condition(
360                s,
361                "missing comparison operator",
362            ));
363        }
364    }
365    Ok(())
366}
367
368// =============================================================================
369// Memory Range Parsing
370// =============================================================================
371
372/// Parse a memory range string in format `START-END` or `START:LENGTH`.
373///
374/// Both `START` and `END`/`LENGTH` should be hexadecimal values (with or
375/// without 0x prefix).
376///
377/// # Errors
378///
379/// Returns an error if:
380/// - The format is invalid (not START-END or START:LENGTH)
381/// - The hex values cannot be parsed
382/// - The resulting range would be invalid (end < start)
383///
384/// # Examples
385///
386/// ```
387/// use monsoon_cli::cli::parse_memory_range;
388///
389/// assert_eq!(
390///     parse_memory_range("0x0000-0x07FF").unwrap(),
391///     (0x0000, 0x07FF)
392/// );
393/// assert_eq!(parse_memory_range("6000:100").unwrap(), (0x6000, 0x60FF));
394/// ```
395pub fn parse_memory_range(range: &str) -> Result<(u16, u16), String> {
396    if let Some((start_str, end_str)) = range.split_once('-') {
397        let start = parse_hex_u16(start_str)?;
398        let end = parse_hex_u16(end_str)?;
399        if end < start {
400            return Err(format!(
401                "Invalid memory range '{}': end address (0x{:04X}) is less than start (0x{:04X})",
402                range, end, start
403            ));
404        }
405        Ok((start, end))
406    } else if let Some((start_str, len_str)) = range.split_once(':') {
407        let start = parse_hex_u16(start_str)?;
408        let len = parse_hex_u16(len_str)?;
409
410        if len == 0 {
411            return Err(format!(
412                "Invalid memory range '{}': length cannot be zero",
413                range
414            ));
415        }
416
417        // Calculate end address, checking for overflow
418        let end = start.checked_add(len.saturating_sub(1)).unwrap_or({
419            // Overflow - clamp to max address
420            0xFFFF
421        });
422
423        Ok((start, end))
424    } else {
425        Err(format!(
426            "Invalid memory range format: '{}'. Use START-END or START:LENGTH (e.g., \
427             0x0000-0x07FF or 0x6000:0x100)",
428            range
429        ))
430    }
431}