Skip to main content

monsoon_cli/cli/
headless.rs

1//! Headless mode execution for the NES emulator CLI.
2//!
3//! This module handles all headless execution logic, including:
4//! - ROM information display
5//! - Memory initialization
6//! - Video export (streaming and buffered modes)
7//! - Screenshot capture
8//! - Memory dump output
9
10use std::path::Path;
11use std::time::Instant;
12
13use monsoon_core::emulation::nes::Nes;
14use monsoon_core::emulation::palette_util::RgbColor;
15use monsoon_core::emulation::ppu_util::{TOTAL_OUTPUT_HEIGHT, TOTAL_OUTPUT_WIDTH};
16use monsoon_core::emulation::rom::RomFile;
17use monsoon_core::emulation::screen_renderer::{ScreenRenderer, create_renderer};
18
19use crate::cli::{
20    CliArgs, ExecutionConfig, ExecutionEngine, ExecutionResult, FpsConfig, MemoryDump, MemoryInit,
21    MemoryInitConfig, MemoryType, OutputWriter, SavestateConfig, StopReason, StreamingVideoEncoder,
22    VideoFormat, VideoResolution, apply_memory_init, apply_memory_init_config, is_ffmpeg_available,
23    parse_memory_range,
24};
25
26// =============================================================================
27// NES Constants
28// =============================================================================
29
30/// NES output width in pixels (256). Re-exported from core as u32 for image
31/// library APIs which require u32 dimensions rather than usize.
32pub const NES_WIDTH: u32 = TOTAL_OUTPUT_WIDTH as u32;
33
34/// NES output height in pixels (240). Re-exported from core as u32 for image
35/// library APIs which require u32 dimensions rather than usize.
36pub const NES_HEIGHT: u32 = TOTAL_OUTPUT_HEIGHT as u32;
37
38/// Create a screen renderer based on CLI arguments.
39///
40/// Uses the `--renderer` argument to select a renderer by ID.
41/// Falls back to "PaletteLookup" if no renderer is specified.
42pub fn create_renderer_from_args(args: &CliArgs) -> Box<dyn ScreenRenderer> {
43    let renderer_name = args.video.renderer.as_deref().unwrap_or("PaletteLookup");
44    let renderers = crate::get_all_renderers();
45    create_renderer(Some(renderer_name), renderers)
46}
47
48/// List all available renderers and print them to stdout.
49pub fn list_renderers() {
50    let renderers = crate::get_all_renderers();
51    println!("Available renderers:");
52    for reg in &renderers {
53        println!("  {} - {}", reg.key, reg.display_name);
54    }
55}
56
57// =============================================================================
58// Main Headless Entry Point
59// =============================================================================
60
61/// Run the emulator in headless mode.
62///
63/// This is the main entry point for headless CLI execution.
64pub fn run_headless(args: &CliArgs) -> Result<(), String> {
65    // Handle renderer listing (exits early)
66    if args.video.list_renderers {
67        list_renderers();
68        return Ok(());
69    }
70
71    // Handle ROM info display (exits early)
72    if args.rom.rom_info {
73        return handle_rom_info(args);
74    }
75
76    let start = Instant::now();
77
78    // Create the screen renderer based on CLI args
79    let mut renderer = create_renderer_from_args(args);
80
81    if args.verbose {
82        eprintln!("Using renderer: {}", renderer.get_display_name());
83    }
84
85    // Build execution and savestate configs from CLI args
86    let exec_config = ExecutionConfig::from_cli_args(args);
87    let savestate_config = SavestateConfig::from_cli_args(args);
88
89    // Create and configure the execution engine
90    let mut engine = ExecutionEngine::new()
91        .with_config(exec_config)
92        .with_savestate_config(savestate_config);
93
94    // Load ROM
95    if let Some(ref rom_path) = args.rom.rom {
96        engine.load_rom(rom_path)?;
97    }
98
99    // Load savestate if configured
100    engine.load_savestate()?;
101
102    // Power on (unless --no-power is specified)
103    if !args.power.no_power {
104        engine.power_on();
105    }
106
107    // Handle reset
108    if args.power.reset {
109        engine.reset();
110    }
111
112    // Apply memory initialization (after power on, before execution)
113    apply_memory_initialization(engine.emulator_mut(), args)?;
114
115    // Determine if we should use streaming video export
116    // Streaming mode: frames are written directly to encoder during execution
117    // This significantly reduces memory usage for long recordings
118    let use_streaming = args.video.video_path.is_some();
119
120    let result = if use_streaming {
121        run_with_streaming_video(&mut engine, &mut renderer, args)?
122    } else {
123        // Standard buffered mode
124        engine.run()?
125    };
126
127    // Print execution summary if verbose
128    if args.verbose {
129        eprintln!("Execution time: {:?}", start.elapsed());
130        eprintln!("Total cycles: {}", result.total_cycles);
131        eprintln!("Total frames: {}", result.total_frames);
132        eprintln!("Stop reason: {:?}", result.stop_reason);
133    }
134
135    // Save savestate if configured
136    engine.save_savestate()?;
137
138    // Output memory dumps
139    output_results(engine.emulator(), args)?;
140
141    // Save screenshot (only if we have frames - in buffered mode)
142    if !use_streaming {
143        save_screenshot(&engine.frames, &mut renderer, args)?;
144    }
145
146    // Video was already saved in streaming mode, skip in buffered mode if already
147    // done
148    if !use_streaming {
149        save_video(&engine.frames, &mut renderer, args)?;
150    }
151
152    // Check for error stop reason
153    match result.stop_reason {
154        StopReason::Error(e) => Err(e),
155        _ => Ok(()),
156    }
157}
158
159// =============================================================================
160// ROM Info
161// =============================================================================
162
163/// Handle --rom-info flag
164fn handle_rom_info(args: &CliArgs) -> Result<(), String> {
165    let rom_path = args
166        .rom
167        .rom
168        .as_ref()
169        .ok_or("--rom-info requires --rom to be specified")?;
170    print_rom_info(rom_path)
171}
172
173/// Print ROM information to stdout.
174pub fn print_rom_info(rom_path: &Path) -> Result<(), String> {
175    let path_str = rom_path.to_string_lossy().to_string();
176    let data = std::fs::read(rom_path).map_err(|e| format!("Failed to read ROM file: {}", e))?;
177    let rom =
178        RomFile::load(&data, Some(path_str)).map_err(|e| format!("Failed to parse ROM: {}", e))?;
179
180    println!("ROM Information:");
181    println!("  File: {}", rom_path.display());
182    if let Some(ref name) = rom.name {
183        println!("  Name: {}", name);
184    }
185    println!("  Mapper: {}", rom.mapper);
186    println!("  PRG ROM: {} KB", rom.prg_memory.prg_rom_size / 1024);
187    println!("  CHR ROM: {} KB", rom.chr_memory.chr_rom_size / 1024);
188    println!(
189        "  PRG RAM: {} KB (battery-backed: {})",
190        rom.prg_memory.prg_ram_size / 1024,
191        if rom.is_battery_backed { "yes" } else { "no" }
192    );
193    println!(
194        "  Mirroring: {}",
195        if rom.hardwired_nametable_layout {
196            "Vertical"
197        } else {
198            "Horizontal"
199        }
200    );
201    println!(
202        "  Checksum (SHA-256): {}",
203        rom.data_checksum
204            .iter()
205            .map(|b| format!("{:02x}", b))
206            .collect::<String>()
207    );
208
209    Ok(())
210}
211
212// =============================================================================
213// Memory Initialization
214// =============================================================================
215
216/// Apply memory initialization based on CLI args
217pub fn apply_memory_initialization(emu: &mut Nes, args: &CliArgs) -> Result<(), String> {
218    // Parse CPU memory init commands
219    let cpu_inits: Vec<MemoryInit> = args
220        .memory
221        .init_cpu
222        .iter()
223        .map(|s| MemoryInit::parse(s))
224        .collect::<Result<Vec<_>, _>>()?;
225
226    // Parse PPU memory init commands
227    let ppu_inits: Vec<MemoryInit> = args
228        .memory
229        .init_ppu
230        .iter()
231        .map(|s| MemoryInit::parse(s))
232        .collect::<Result<Vec<_>, _>>()?;
233
234    // Parse OAM memory init commands
235    let oam_inits: Vec<MemoryInit> = args
236        .memory
237        .init_oam
238        .iter()
239        .map(|s| MemoryInit::parse(s))
240        .collect::<Result<Vec<_>, _>>()?;
241
242    // Apply direct init commands
243    if !cpu_inits.is_empty() || !ppu_inits.is_empty() || !oam_inits.is_empty() {
244        apply_memory_init(emu, &cpu_inits, &ppu_inits, &oam_inits);
245        if args.verbose {
246            eprintln!(
247                "Applied memory init: {} CPU, {} PPU, {} OAM operations",
248                cpu_inits.len(),
249                ppu_inits.len(),
250                oam_inits.len()
251            );
252        }
253    }
254
255    // Load init config from file if specified
256    if let Some(ref init_file) = args.memory.init_file {
257        let config = MemoryInitConfig::load_from_file(init_file)?;
258        apply_memory_init_config(emu, &config);
259        if args.verbose {
260            eprintln!(
261                "Applied memory init from file: {} CPU, {} PPU, {} OAM addresses",
262                config.cpu.len(),
263                config.ppu.len(),
264                config.oam.len()
265            );
266        }
267    }
268
269    Ok(())
270}
271
272// =============================================================================
273// Streaming Video Export
274// =============================================================================
275
276/// Run emulation with streaming video export.
277///
278/// This mode writes frames directly to the encoder as they're generated,
279/// instead of buffering all frames in memory. This significantly reduces
280/// memory usage for long recordings.
281fn run_with_streaming_video(
282    engine: &mut ExecutionEngine,
283    renderer: &mut Box<dyn ScreenRenderer>,
284    args: &CliArgs,
285) -> Result<ExecutionResult, String> {
286    let video_path = args.video.video_path.as_ref().unwrap();
287
288    // Check if format requires FFmpeg and warn if not available
289    if args.video.video_format == VideoFormat::Mp4 && !is_ffmpeg_available() {
290        return Err(
291            "MP4 export requires FFmpeg to be installed. Use --video-format png or --video-format \
292             ppm for self-contained export."
293                .to_string(),
294        );
295    }
296
297    // Parse video resolution
298    let resolution = VideoResolution::parse(args.video.video_scale.as_ref().unwrap())
299        .map_err(|e| format!("Invalid video scale: {}", e))?;
300
301    // Parse FPS configuration
302    let fps_config = FpsConfig::parse(&args.video.video_fps, args.video.video_mode)
303        .map_err(|e| format!("Invalid video FPS: {}", e))?;
304
305    let (dst_width, dst_height) = resolution.dimensions(NES_WIDTH, NES_HEIGHT);
306
307    // Print export info
308    if !args.quiet {
309        print_video_info(
310            video_path,
311            &args.video.video_format,
312            &resolution,
313            NES_WIDTH,
314            NES_HEIGHT,
315            dst_width,
316            dst_height,
317            true,
318            &fps_config,
319        );
320    }
321
322    // Create streaming encoder with FPS config
323    let mut encoder = StreamingVideoEncoder::with_fps_config(
324        args.video.video_format,
325        video_path,
326        NES_WIDTH,
327        NES_HEIGHT,
328        &resolution,
329        fps_config,
330    )
331    .map_err(|e| format!("Failed to create video encoder: {}", e))?;
332
333    // Run with streaming video export
334    let result = engine.run_with_video_encoder(&mut encoder, renderer)?;
335
336    // Finalize encoder
337    encoder
338        .finish()
339        .map_err(|e| format!("Failed to finalize video: {}", e))?;
340
341    if !args.quiet {
342        eprintln!("Exported {} frames successfully", encoder.frames_written());
343    }
344
345    // Handle screenshot in streaming mode (save last frame)
346    if args.video.screenshot.is_some() {
347        let last_frame = engine.emulator().get_pixel_buffer();
348        let rgb_frame = renderer.buffer_to_image(&last_frame);
349        save_single_screenshot(rgb_frame, args)?;
350    }
351
352    Ok(result)
353}
354
355/// Print video export information
356#[allow(clippy::too_many_arguments)]
357fn print_video_info(
358    video_path: &Path,
359    format: &VideoFormat,
360    resolution: &VideoResolution,
361    src_width: u32,
362    src_height: u32,
363    dst_width: u32,
364    dst_height: u32,
365    streaming: bool,
366    fps_config: &FpsConfig,
367) {
368    let mode_str = if streaming { " [streaming mode]" } else { "" };
369    let fps_str = format!(
370        "{:.4} fps ({:?}, {}x)",
371        fps_config.output_fps(),
372        fps_config.mode,
373        fps_config.multiplier
374    );
375
376    // Add note about mid-frame capture for multipliers > 1
377    let capture_note = if fps_config.multiplier > 1 {
378        format!(" ({} captures per PPU frame)", fps_config.multiplier)
379    } else {
380        String::new()
381    };
382
383    if *resolution == VideoResolution::Native {
384        eprintln!(
385            "Exporting to {} as {:?} ({}x{}, {}{}){}...",
386            video_path.display(),
387            format,
388            src_width,
389            src_height,
390            fps_str,
391            capture_note,
392            mode_str
393        );
394    } else if matches!(format, VideoFormat::Mp4) {
395        eprintln!(
396            "Exporting to {} as {:?} ({}x{} → {}x{} via FFmpeg nearest-neighbor, {}{}){}...",
397            video_path.display(),
398            format,
399            src_width,
400            src_height,
401            dst_width,
402            dst_height,
403            fps_str,
404            capture_note,
405            mode_str
406        );
407    } else {
408        let scale_note = if streaming {
409            " [streaming mode, scaling only supported for MP4]"
410        } else {
411            ""
412        };
413        eprintln!(
414            "Exporting to {} as {:?} ({}x{}, {}{}){}...",
415            video_path.display(),
416            format,
417            src_width,
418            src_height,
419            fps_str,
420            capture_note,
421            scale_note
422        );
423    }
424}
425
426// =============================================================================
427// Screenshot Export
428// =============================================================================
429
430/// Save a single screenshot (used in streaming mode)
431fn save_single_screenshot(frame: &[RgbColor], args: &CliArgs) -> Result<(), String> {
432    if let Some(ref screenshot_path) = args.video.screenshot {
433        let img: image::RgbaImage = image::ImageBuffer::from_fn(NES_WIDTH, NES_HEIGHT, |x, y| {
434            let color = frame[(y * NES_WIDTH + x) as usize];
435            image::Rgba([color.r, color.g, color.b, 255])
436        });
437
438        img.save(screenshot_path)
439            .map_err(|e| format!("Failed to save screenshot: {}", e))?;
440
441        if !args.quiet {
442            eprintln!("Screenshot saved to {}", screenshot_path.display());
443        }
444    }
445    Ok(())
446}
447
448/// Save screenshot to file from buffered frames
449pub fn save_screenshot(
450    frames: &[Vec<u16>],
451    renderer: &mut Box<dyn ScreenRenderer>,
452    args: &CliArgs,
453) -> Result<(), String> {
454    if let Some(ref screenshot_path) = args.video.screenshot {
455        if frames.is_empty() {
456            eprintln!("Warning: No frames to screenshot");
457            return Ok(());
458        }
459
460        // Use the last frame for screenshot
461        let frame = frames.last().unwrap();
462        let rgb_frame = renderer.buffer_to_image(frame);
463
464        if !args.quiet {
465            eprintln!("Saving screenshot to {}...", screenshot_path.display());
466        }
467
468        // Convert RgbColor to RGB bytes for PNG
469        let rgb_data: Vec<u8> = rgb_frame.iter().flat_map(|c| [c.r, c.g, c.b]).collect();
470
471        // Create PNG using image crate
472        let img: image::ImageBuffer<image::Rgb<u8>, Vec<u8>> =
473            image::ImageBuffer::from_raw(NES_WIDTH, NES_HEIGHT, rgb_data)
474                .ok_or_else(|| "Failed to create image buffer".to_string())?;
475
476        img.save(screenshot_path)
477            .map_err(|e| format!("Failed to save screenshot: {}", e))?;
478
479        if !args.quiet {
480            eprintln!("Screenshot saved successfully");
481        }
482    }
483
484    Ok(())
485}
486
487// =============================================================================
488// Buffered Video Export
489// =============================================================================
490
491/// Save recorded frames to video file
492pub fn save_video(
493    frames: &[Vec<u16>],
494    renderer: &mut Box<dyn ScreenRenderer>,
495    args: &CliArgs,
496) -> Result<(), String> {
497    if let Some(ref video_path) = args.video.video_path {
498        // Check if format requires FFmpeg and warn if not available
499        if args.video.video_format == VideoFormat::Mp4 && !is_ffmpeg_available() {
500            return Err(
501                "MP4 export requires FFmpeg to be installed. Use --video-format png or \
502                 --video-format ppm for self-contained export."
503                    .to_string(),
504            );
505        }
506
507        if frames.is_empty() {
508            eprintln!("Warning: No frames to export");
509            return Ok(());
510        }
511
512        // Parse video resolution
513        let resolution = VideoResolution::parse(&args.video.video_scale.clone().unwrap())
514            .map_err(|e| format!("Invalid video scale: {}", e))?;
515
516        // Parse FPS configuration
517        let fps_config = FpsConfig::parse(&args.video.video_fps, args.video.video_mode)
518            .map_err(|e| format!("Invalid video FPS: {}", e))?;
519
520        let (dst_width, dst_height) = resolution.dimensions(NES_WIDTH, NES_HEIGHT);
521
522        if !args.quiet {
523            eprintln!(
524                "Exporting {} frames to {} as {:?} ({:.2} fps, {:?})...",
525                frames.len(),
526                video_path.display(),
527                args.video.video_format,
528                fps_config.output_fps(),
529                fps_config.mode
530            );
531            if resolution != VideoResolution::Native && args.video.video_format == VideoFormat::Mp4
532            {
533                eprintln!(
534                    "  Resolution: {}x{} → {}x{} via FFmpeg nearest-neighbor",
535                    NES_WIDTH, NES_HEIGHT, dst_width, dst_height
536                );
537            }
538        }
539
540        // Use streaming encoder for proper scaling support
541        let mut encoder = StreamingVideoEncoder::with_fps_config(
542            args.video.video_format,
543            video_path,
544            NES_WIDTH,
545            NES_HEIGHT,
546            &resolution,
547            fps_config,
548        )
549        .map_err(|e| format!("Failed to create video encoder: {}", e))?;
550
551        for frame in frames {
552            let rgb_frame = renderer.buffer_to_image(frame);
553            encoder.write_frame(rgb_frame).map_err(|e| e.to_string())?;
554        }
555
556        encoder.finish().map_err(|e| e.to_string())?;
557
558        if !args.quiet {
559            eprintln!("Exported {} frames successfully", encoder.frames_written());
560        }
561    }
562
563    Ok(())
564}
565
566// =============================================================================
567// Memory Dump Output
568// =============================================================================
569
570/// Output results based on CLI args using the output module abstraction.
571pub fn output_results(emu: &Nes, args: &CliArgs) -> Result<(), String> {
572    // Reset the output writer state for this run
573    OutputWriter::reset();
574
575    // Create the output writer with configured format and destination
576    let writer = OutputWriter::new(args.output.output.clone(), args.output.effective_format());
577
578    // Process each requested memory dump
579    if let Some(ref range) = args.memory.read_cpu {
580        let dump = create_cpu_dump(emu, range)?;
581        writer.write(&dump)?;
582    }
583
584    if let Some(ref range) = args.memory.read_ppu {
585        let dump = create_ppu_dump(emu, range)?;
586        writer.write(&dump)?;
587    }
588
589    if args.memory.dump_oam {
590        let dump = create_oam_dump(emu);
591        writer.write(&dump)?;
592    }
593
594    if args.memory.dump_nametables {
595        let dump = create_nametables_dump(emu);
596        writer.write(&dump)?;
597    }
598
599    if args.memory.dump_palette {
600        let dump = create_palette_dump(emu);
601        writer.write(&dump)?;
602    }
603
604    Ok(())
605}
606
607// =============================================================================
608// Memory Dump Creation
609// =============================================================================
610
611/// Create a CPU memory dump from the emulator
612fn create_cpu_dump(emu: &Nes, range: &str) -> Result<MemoryDump, String> {
613    let (start, end) = parse_memory_range(range)?;
614    let mem = &emu.get_memory_debug(Some(start..=end))[0];
615    Ok(MemoryDump::new(MemoryType::Cpu, start, mem.to_vec()))
616}
617
618/// Create a PPU memory dump from the emulator
619fn create_ppu_dump(emu: &Nes, range: &str) -> Result<MemoryDump, String> {
620    let (start, end) = parse_memory_range(range)?;
621    let mem = &emu.get_memory_debug(Some(start..=end))[1];
622    Ok(MemoryDump::new(MemoryType::Ppu, start, mem.to_vec()))
623}
624
625/// Create an OAM memory dump from the emulator
626fn create_oam_dump(emu: &Nes) -> MemoryDump {
627    let mem = emu.get_oam_debug();
628    MemoryDump::oam(mem)
629}
630
631/// Create a nametables memory dump from the emulator
632fn create_nametables_dump(emu: &Nes) -> MemoryDump {
633    let mem = emu.get_memory_debug(Some(0x2000..=0x2FFF))[1].to_vec();
634    MemoryDump::nametables(mem)
635}
636
637/// Create a palette RAM memory dump from the emulator
638fn create_palette_dump(emu: &Nes) -> MemoryDump {
639    // Palette RAM is at PPU addresses $3F00-$3F1F (32 bytes)
640    let mem = emu.get_memory_debug(Some(0x3F00..=0x3F1F))[1].to_vec();
641    MemoryDump::palette_ram(mem)
642}