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}