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