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