Skip to main content

monsoon_cli/cli/
config.rs

1//! Configuration file support for the NES emulator CLI.
2//!
3//! This module provides TOML configuration file parsing that can be merged
4//! with command-line arguments. CLI arguments take precedence over config file values.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9use std::str::FromStr;
10
11use serde::Deserialize;
12
13use crate::cli::args::BuiltinPalette;
14use crate::cli::{CliArgs, OutputFormat, SavestateFormat, VideoExportMode, VideoFormat};
15
16/// Default video FPS string value (1x multiplier)
17pub const DEFAULT_VIDEO_FPS: &str = "1x";
18
19/// TOML configuration file structure
20#[derive(Debug, Clone, Default, Deserialize)]
21#[serde(default)]
22pub struct ConfigFile {
23    /// Global options
24    #[serde(default)]
25    pub global: GlobalConfig,
26
27    /// ROM configuration
28    #[serde(default)]
29    pub rom: RomConfig,
30
31    /// Savestate configuration
32    #[serde(default)]
33    pub savestate: SavestateConfig,
34
35    /// Memory configuration
36    #[serde(default)]
37    pub memory: MemoryConfig,
38
39    /// Power configuration
40    #[serde(default)]
41    pub power: PowerConfig,
42
43    /// Palette configuration
44    #[serde(default)]
45    pub palette: PaletteConfig,
46
47    /// Video/screenshot configuration
48    #[serde(default)]
49    pub video: VideoConfig,
50
51    /// Execution control configuration
52    #[serde(default)]
53    pub execution: ExecutionConfig,
54
55    /// Output configuration
56    #[serde(default)]
57    pub output: OutputConfig,
58}
59
60#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(default)]
62pub struct GlobalConfig {
63    pub quiet: Option<bool>,
64    pub verbose: Option<bool>,
65}
66
67#[derive(Debug, Clone, Default, Deserialize)]
68#[serde(default)]
69pub struct RomConfig {
70    pub path: Option<PathBuf>,
71    pub rom_info: Option<bool>,
72}
73
74#[derive(Debug, Clone, Default, Deserialize)]
75#[serde(default)]
76pub struct SavestateConfig {
77    pub load: Option<PathBuf>,
78    pub save: Option<PathBuf>,
79    pub state_stdin: Option<bool>,
80    pub state_stdout: Option<bool>,
81    pub save_on: Option<String>,
82    pub format: SavestateFormat,
83}
84
85#[derive(Debug, Clone, Default, Deserialize)]
86#[serde(default)]
87pub struct MemoryConfig {
88    pub read_cpu: Option<String>,
89    pub read_ppu: Option<String>,
90    pub dump_oam: Option<bool>,
91    pub dump_nametables: Option<bool>,
92    pub dump_palette: Option<bool>,
93    pub init_file: Option<PathBuf>,
94
95    /// CPU memory initialization: address -> values
96    #[serde(default)]
97    pub init_cpu: HashMap<String, Vec<u8>>,
98
99    /// PPU memory initialization: address -> values
100    #[serde(default)]
101    pub init_ppu: HashMap<String, Vec<u8>>,
102
103    /// OAM memory initialization: address -> values
104    #[serde(default)]
105    pub init_oam: HashMap<String, Vec<u8>>,
106}
107
108#[derive(Debug, Clone, Default, Deserialize)]
109#[serde(default)]
110pub struct PowerConfig {
111    pub no_power: Option<bool>,
112    pub reset: Option<bool>,
113}
114
115#[derive(Debug, Clone, Default, Deserialize)]
116#[serde(default)]
117pub struct PaletteConfig {
118    pub path: Option<PathBuf>,
119    pub builtin: Option<String>,
120}
121
122#[derive(Debug, Clone, Default, Deserialize)]
123#[serde(default)]
124pub struct VideoConfig {
125    pub screenshot: Option<PathBuf>,
126    pub screenshot_on: Option<String>,
127    pub video_path: Option<PathBuf>,
128    pub video_format: Option<String>,
129    /// Video FPS: Can be a multiplier like "2x", "3x" or a fixed value like "60.0"
130    pub video_fps: Option<String>,
131    /// Video export mode: "accurate" or "smooth"
132    pub video_mode: Option<String>,
133    pub video_scale: Option<String>,
134    /// Screen renderer ID (e.g., "PaletteLookup")
135    pub renderer: Option<String>,
136}
137
138#[derive(Debug, Clone, Default, Deserialize)]
139#[serde(default)]
140pub struct ExecutionConfig {
141    pub cycles: Option<u128>,
142    pub frames: Option<u64>,
143    pub until_opcode: Option<String>,
144    pub until_mem: Option<Vec<String>>,
145    pub until_hlt: Option<bool>,
146    pub trace: Option<PathBuf>,
147    #[serde(default)]
148    pub breakpoints: Vec<String>,
149    /// Memory watchpoints (format: "ADDR" or "ADDR:r" or "ADDR:w" or "ADDR:rw")
150    #[serde(default)]
151    pub watch_mem: Vec<String>,
152    /// Alternative: stop_conditions as array of strings
153    #[serde(default)]
154    pub stop_conditions: Vec<String>,
155}
156
157#[derive(Debug, Clone, Default, Deserialize)]
158#[serde(default)]
159pub struct OutputConfig {
160    pub path: Option<PathBuf>,
161    pub format: Option<String>,
162    /// Shorthand for format = "json"
163    pub json: Option<bool>,
164    /// Shorthand for format = "toml"
165    pub toml: Option<bool>,
166    /// Shorthand for format = "binary"
167    pub binary: Option<bool>,
168}
169
170impl ConfigFile {
171    /// Load configuration from a TOML file
172    pub fn load(path: &PathBuf) -> Result<Self, ConfigError> {
173        let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError(e.to_string()))?;
174        toml::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))
175    }
176
177    /// Merge config file values with CLI arguments.
178    /// CLI arguments take precedence over config file values.
179    pub fn merge_with_cli(&self, cli: &mut CliArgs) {
180        // Global options
181
182        if !cli.quiet {
183            cli.quiet = self.global.quiet.unwrap_or(false);
184        }
185        if !cli.verbose {
186            cli.verbose = self.global.verbose.unwrap_or(false);
187        }
188
189        // ROM options
190        if cli.rom.rom.is_none() {
191            cli.rom.rom = self.rom.path.clone();
192        }
193        if !cli.rom.rom_info {
194            cli.rom.rom_info = self.rom.rom_info.unwrap_or(false);
195        }
196
197        // Savestate options
198        if cli.savestate.load_state.is_none() {
199            cli.savestate.load_state = self.savestate.load.clone();
200        }
201        if cli.savestate.save_state.is_none() {
202            cli.savestate.save_state = self.savestate.save.clone();
203        }
204        if !cli.savestate.state_stdin {
205            cli.savestate.state_stdin = self.savestate.state_stdin.unwrap_or(false);
206        }
207        if !cli.savestate.state_stdout {
208            cli.savestate.state_stdout = self.savestate.state_stdout.unwrap_or(false);
209        }
210        if cli.savestate.save_state_on.is_none() {
211            cli.savestate.save_state_on = self.savestate.save_on.clone();
212        }
213        if cli.savestate.state_format == SavestateFormat::Binary {
214            cli.savestate.state_format = self.savestate.format;
215        }
216
217        // Memory options
218        if cli.memory.read_cpu.is_none() {
219            cli.memory.read_cpu = self.memory.read_cpu.clone();
220        }
221        if cli.memory.read_ppu.is_none() {
222            cli.memory.read_ppu = self.memory.read_ppu.clone();
223        }
224        if !cli.memory.dump_oam {
225            cli.memory.dump_oam = self.memory.dump_oam.unwrap_or(false);
226        }
227        if !cli.memory.dump_nametables {
228            cli.memory.dump_nametables = self.memory.dump_nametables.unwrap_or(false);
229        }
230        if !cli.memory.dump_palette {
231            cli.memory.dump_palette = self.memory.dump_palette.unwrap_or(false);
232        }
233        if cli.memory.init_file.is_none() {
234            cli.memory.init_file = self.memory.init_file.clone();
235        }
236        // Merge init_cpu from config if CLI has none
237        if cli.memory.init_cpu.is_empty() {
238            for (addr, values) in &self.memory.init_cpu {
239                let values_str = values
240                    .iter()
241                    .map(|v| format!("0x{:02X}", v))
242                    .collect::<Vec<_>>()
243                    .join(",");
244                cli.memory.init_cpu.push(format!("{}={}", addr, values_str));
245            }
246        }
247        // Merge init_ppu from config if CLI has none
248        if cli.memory.init_ppu.is_empty() {
249            for (addr, values) in &self.memory.init_ppu {
250                let values_str = values
251                    .iter()
252                    .map(|v| format!("0x{:02X}", v))
253                    .collect::<Vec<_>>()
254                    .join(",");
255                cli.memory.init_ppu.push(format!("{}={}", addr, values_str));
256            }
257        }
258        // Merge init_oam from config if CLI has none
259        if cli.memory.init_oam.is_empty() {
260            for (addr, values) in &self.memory.init_oam {
261                let values_str = values
262                    .iter()
263                    .map(|v| format!("0x{:02X}", v))
264                    .collect::<Vec<_>>()
265                    .join(",");
266                cli.memory.init_oam.push(format!("{}={}", addr, values_str));
267            }
268        }
269
270        // Power options
271        if !cli.power.no_power {
272            cli.power.no_power = self.power.no_power.unwrap_or(false);
273        }
274        if !cli.power.reset {
275            cli.power.reset = self.power.reset.unwrap_or(false);
276        }
277
278        // Palette options
279        if cli.palette.palette.is_none() {
280            cli.palette.palette = self.palette.path.clone();
281        }
282        if cli.palette.palette_builtin.is_none()
283            && let Some(ref builtin) = self.palette.builtin
284        {
285            cli.palette.palette_builtin = BuiltinPalette::from_str(builtin).ok();
286        }
287
288        // Video options
289        if cli.video.screenshot.is_none() {
290            cli.video.screenshot = self.video.screenshot.clone();
291        }
292        if cli.video.screenshot_on.is_none() {
293            cli.video.screenshot_on = self.video.screenshot_on.clone();
294        }
295        if cli.video.video_path.is_none() {
296            cli.video.video_path = self.video.video_path.clone();
297        }
298        if let Some(ref fmt) = self.video.video_format {
299            // Only override if CLI is at default
300            if cli.video.video_format == VideoFormat::Raw {
301                cli.video.video_format = VideoFormat::from_str(fmt).unwrap_or(VideoFormat::Raw);
302            }
303        }
304        if cli.video.video_scale.is_none() {
305            if self.video.video_scale.is_none() {
306                cli.video.video_scale = Some("native".to_string());
307            } else {
308                cli.video.video_scale = self.video.video_scale.clone();
309            }
310        }
311        // Only override video_fps if CLI is at default
312        if cli.video.video_fps == DEFAULT_VIDEO_FPS
313            && let Some(ref fps) = self.video.video_fps
314        {
315            cli.video.video_fps = fps.clone();
316        }
317        // Only override video_mode if CLI is at default
318        if cli.video.video_mode == VideoExportMode::Accurate
319            && let Some(ref mode) = self.video.video_mode
320        {
321            cli.video.video_mode =
322                VideoExportMode::from_str(mode).unwrap_or(VideoExportMode::Accurate);
323        }
324        // Renderer option
325        if cli.video.renderer.is_none() {
326            cli.video.renderer = self.video.renderer.clone();
327        }
328
329        // Execution options
330        if cli.execution.cycles.is_none() {
331            cli.execution.cycles = self.execution.cycles;
332        }
333        if cli.execution.frames.is_none() {
334            cli.execution.frames = self.execution.frames;
335        }
336        if cli.execution.until_opcode.is_none()
337            && let Some(ref op) = self.execution.until_opcode
338        {
339            cli.execution.until_opcode = parse_hex_u8_opt(op);
340        }
341        if cli.execution.until_mem.is_none() {
342            cli.execution.until_mem = self.execution.until_mem.clone();
343        }
344        if !cli.execution.until_hlt {
345            cli.execution.until_hlt = self.execution.until_hlt.unwrap_or(false);
346        }
347        if cli.execution.trace.is_none() {
348            cli.execution.trace = self.execution.trace.clone();
349        }
350        if cli.execution.breakpoint.is_empty() {
351            for bp in &self.execution.breakpoints {
352                if let Some(addr) = parse_hex_u16_opt(bp) {
353                    cli.execution.breakpoint.push(addr);
354                }
355            }
356        }
357        if cli.execution.watch_mem.is_empty() {
358            cli.execution.watch_mem = self.execution.watch_mem.clone();
359        }
360
361        // Parse stop_conditions into appropriate fields
362        for cond in &self.execution.stop_conditions {
363            if let Some(rest) = cond.strip_prefix("pc:") {
364                // pc: condition is now handled as a breakpoint
365                if let Some(addr) = parse_hex_u16_opt(rest) {
366                    cli.execution.breakpoint.push(addr);
367                }
368            } else if let Some(rest) = cond.strip_prefix("frames:") {
369                if cli.execution.frames.is_none() {
370                    cli.execution.frames = rest.parse().ok();
371                }
372            } else if let Some(rest) = cond.strip_prefix("cycles:")
373                && cli.execution.cycles.is_none()
374            {
375                cli.execution.cycles = rest.parse().ok();
376            }
377        }
378
379        // Output options
380        if cli.output.output.is_none() {
381            cli.output.output = self.output.path.clone();
382        }
383        // Handle shorthand flags from config (precedence: json > toml > binary > format)
384        // This matches the CLI behavior in OutputArgs::effective_format()
385        if !cli.output.json && self.output.json.unwrap_or(false) {
386            cli.output.json = true;
387        }
388        if !cli.output.toml && self.output.toml.unwrap_or(false) {
389            cli.output.toml = true;
390        }
391        if !cli.output.binary && self.output.binary.unwrap_or(false) {
392            cli.output.binary = true;
393        }
394        if let Some(ref fmt) = self.output.format {
395            // Only override if CLI is at default and no shorthand flags
396            if cli.output.output_format == OutputFormat::Hex
397                && !cli.output.json
398                && !cli.output.toml
399                && !cli.output.binary
400            {
401                cli.output.output_format = OutputFormat::from_str(fmt).unwrap_or(OutputFormat::Hex);
402            }
403        }
404    }
405}
406
407/// Parse a hex string to u16, returning None on failure
408fn parse_hex_u16_opt(s: &str) -> Option<u16> {
409    let s = s
410        .strip_prefix("0x")
411        .or_else(|| s.strip_prefix("0X"))
412        .unwrap_or(s);
413    u16::from_str_radix(s, 16).ok()
414}
415
416/// Parse a hex string to u8, returning None on failure
417fn parse_hex_u8_opt(s: &str) -> Option<u8> {
418    let s = s
419        .strip_prefix("0x")
420        .or_else(|| s.strip_prefix("0X"))
421        .unwrap_or(s);
422    u8::from_str_radix(s, 16).ok()
423}
424
425/// Configuration loading errors
426#[derive(Debug, Clone)]
427pub enum ConfigError {
428    IoError(String),
429    ParseError(String),
430}
431
432impl std::fmt::Display for ConfigError {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        match self {
435            ConfigError::IoError(e) => write!(f, "Failed to read config file: {}", e),
436            ConfigError::ParseError(e) => write!(f, "Failed to parse config file: {}", e),
437        }
438    }
439}
440
441impl std::error::Error for ConfigError {}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_parse_empty_config() {
449        let config: ConfigFile = toml::from_str("").unwrap();
450        assert!(config.rom.path.is_none());
451    }
452
453    #[test]
454    fn test_parse_basic_config() {
455        let toml_str = r#"
456            [rom]
457            path = "game.nes"
458
459            [execution]
460            frames = 100
461        "#;
462        let config: ConfigFile = toml::from_str(toml_str).unwrap();
463        assert_eq!(config.rom.path, Some(PathBuf::from("game.nes")));
464        assert_eq!(config.execution.frames, Some(100));
465    }
466
467    #[test]
468    fn test_parse_memory_init() {
469        let toml_str = r#"
470            [memory.init_cpu]
471            "0x0050" = [0xFF, 0x00, 0x10]
472            "0x0060" = [0x01]
473        "#;
474        let config: ConfigFile = toml::from_str(toml_str).unwrap();
475        assert_eq!(
476            config.memory.init_cpu.get("0x0050"),
477            Some(&vec![0xFF, 0x00, 0x10])
478        );
479        assert_eq!(config.memory.init_cpu.get("0x0060"), Some(&vec![0x01]));
480    }
481
482    #[test]
483    fn test_parse_stop_conditions() {
484        let toml_str = r#"
485            [execution]
486            stop_conditions = ["pc:0x8500", "frames:3600"]
487        "#;
488        let config: ConfigFile = toml::from_str(toml_str).unwrap();
489        assert_eq!(config.execution.stop_conditions.len(), 2);
490    }
491}