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 library APIs
31/// 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 library APIs
35/// 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 done
147    if !use_streaming {
148        save_video(&engine.frames, &mut renderer, args)?;
149    }
150
151    // Check for error stop reason
152    match result.stop_reason {
153        StopReason::Error(e) => Err(e),
154        _ => Ok(()),
155    }
156}
157
158// =============================================================================
159// ROM Info
160// =============================================================================
161
162/// Handle --rom-info flag
163fn handle_rom_info(args: &CliArgs) -> Result<(), String> {
164    let rom_path = args
165        .rom
166        .rom
167        .as_ref()
168        .ok_or("--rom-info requires --rom to be specified")?;
169    print_rom_info(rom_path)
170}
171
172/// Print ROM information to stdout.
173pub fn print_rom_info(rom_path: &Path) -> Result<(), String> {
174    let path_str = rom_path.to_string_lossy().to_string();
175    let data = std::fs::read(rom_path).map_err(|e| format!("Failed to read ROM file: {}", e))?;
176    let rom =
177        RomFile::load(&data, Some(path_str)).map_err(|e| format!("Failed to parse ROM: {}", e))?;
178
179    println!("ROM Information:");
180    println!("  File: {}", rom_path.display());
181    if let Some(ref name) = rom.name {
182        println!("  Name: {}", name);
183    }
184    println!("  Mapper: {}", rom.mapper_number);
185    println!("  PRG ROM: {} KB", rom.prg_memory.prg_rom_size / 1024);
186    println!("  CHR ROM: {} KB", rom.chr_memory.chr_rom_size / 1024);
187    println!(
188        "  PRG RAM: {} KB (battery-backed: {})",
189        rom.prg_memory.prg_ram_size / 1024,
190        if rom.is_battery_backed { "yes" } else { "no" }
191    );
192    println!(
193        "  Mirroring: {}",
194        if rom.hardwired_nametable_layout {
195            "Vertical"
196        } else {
197            "Horizontal"
198        }
199    );
200    println!(
201        "  Checksum (SHA-256): {}",
202        rom.data_checksum
203            .iter()
204            .map(|b| format!("{:02x}", b))
205            .collect::<String>()
206    );
207
208    Ok(())
209}
210
211// =============================================================================
212// Memory Initialization
213// =============================================================================
214
215/// Apply memory initialization based on CLI args
216pub fn apply_memory_initialization(emu: &mut Nes, args: &CliArgs) -> Result<(), String> {
217    // Parse CPU memory init commands
218    let cpu_inits: Vec<MemoryInit> = args
219        .memory
220        .init_cpu
221        .iter()
222        .map(|s| MemoryInit::parse(s))
223        .collect::<Result<Vec<_>, _>>()?;
224
225    // Parse PPU memory init commands
226    let ppu_inits: Vec<MemoryInit> = args
227        .memory
228        .init_ppu
229        .iter()
230        .map(|s| MemoryInit::parse(s))
231        .collect::<Result<Vec<_>, _>>()?;
232
233    // Parse OAM memory init commands
234    let oam_inits: Vec<MemoryInit> = args
235        .memory
236        .init_oam
237        .iter()
238        .map(|s| MemoryInit::parse(s))
239        .collect::<Result<Vec<_>, _>>()?;
240
241    // Apply direct init commands
242    if !cpu_inits.is_empty() || !ppu_inits.is_empty() || !oam_inits.is_empty() {
243        apply_memory_init(emu, &cpu_inits, &ppu_inits, &oam_inits);
244        if args.verbose {
245            eprintln!(
246                "Applied memory init: {} CPU, {} PPU, {} OAM operations",
247                cpu_inits.len(),
248                ppu_inits.len(),
249                oam_inits.len()
250            );
251        }
252    }
253
254    // Load init config from file if specified
255    if let Some(ref init_file) = args.memory.init_file {
256        let config = MemoryInitConfig::load_from_file(init_file)?;
257        apply_memory_init_config(emu, &config);
258        if args.verbose {
259            eprintln!(
260                "Applied memory init from file: {} CPU, {} PPU, {} OAM addresses",
261                config.cpu.len(),
262                config.ppu.len(),
263                config.oam.len()
264            );
265        }
266    }
267
268    Ok(())
269}
270
271// =============================================================================
272// Streaming Video Export
273// =============================================================================
274
275/// Run emulation with streaming video export.
276///
277/// This mode writes frames directly to the encoder as they're generated,
278/// instead of buffering all frames in memory. This significantly reduces
279/// memory usage for long recordings.
280fn run_with_streaming_video(
281    engine: &mut ExecutionEngine,
282    renderer: &mut Box<dyn ScreenRenderer>,
283    args: &CliArgs,
284) -> Result<ExecutionResult, String> {
285    let video_path = args.video.video_path.as_ref().unwrap();
286
287    // Check if format requires FFmpeg and warn if not available
288    if args.video.video_format == VideoFormat::Mp4 && !is_ffmpeg_available() {
289        return Err("MP4 export requires FFmpeg to be installed. \
290             Use --video-format png or --video-format ppm for self-contained export."
291            .to_string());
292    }
293
294    // Parse video resolution
295    let resolution = VideoResolution::parse(args.video.video_scale.as_ref().unwrap())
296        .map_err(|e| format!("Invalid video scale: {}", e))?;
297
298    // Parse FPS configuration
299    let fps_config = FpsConfig::parse(&args.video.video_fps, args.video.video_mode)
300        .map_err(|e| format!("Invalid video FPS: {}", e))?;
301
302    let (dst_width, dst_height) = resolution.dimensions(NES_WIDTH, NES_HEIGHT);
303
304    // Print export info
305    if !args.quiet {
306        print_video_info(
307            video_path,
308            &args.video.video_format,
309            &resolution,
310            NES_WIDTH,
311            NES_HEIGHT,
312            dst_width,
313            dst_height,
314            true,
315            &fps_config,
316        );
317    }
318
319    // Create streaming encoder with FPS config
320    let mut encoder = StreamingVideoEncoder::with_fps_config(
321        args.video.video_format,
322        video_path,
323        NES_WIDTH,
324        NES_HEIGHT,
325        &resolution,
326        fps_config,
327    )
328    .map_err(|e| format!("Failed to create video encoder: {}", e))?;
329
330    // Run with streaming video export
331    let result = engine.run_with_video_encoder(&mut encoder, renderer)?;
332
333    // Finalize encoder
334    encoder
335        .finish()
336        .map_err(|e| format!("Failed to finalize video: {}", e))?;
337
338    if !args.quiet {
339        eprintln!("Exported {} frames successfully", encoder.frames_written());
340    }
341
342    // Handle screenshot in streaming mode (save last frame)
343    if args.video.screenshot.is_some() {
344        let last_frame = engine.emulator().get_pixel_buffer();
345        let rgb_frame = renderer.buffer_to_image(&last_frame);
346        save_single_screenshot(rgb_frame, args)?;
347    }
348
349    Ok(result)
350}
351
352/// Print video export information
353#[allow(clippy::too_many_arguments)]
354fn print_video_info(
355    video_path: &Path,
356    format: &VideoFormat,
357    resolution: &VideoResolution,
358    src_width: u32,
359    src_height: u32,
360    dst_width: u32,
361    dst_height: u32,
362    streaming: bool,
363    fps_config: &FpsConfig,
364) {
365    let mode_str = if streaming { " [streaming mode]" } else { "" };
366    let fps_str = format!(
367        "{:.4} fps ({:?}, {}x)",
368        fps_config.output_fps(),
369        fps_config.mode,
370        fps_config.multiplier
371    );
372
373    // Add note about mid-frame capture for multipliers > 1
374    let capture_note = if fps_config.multiplier > 1 {
375        format!(" ({} captures per PPU frame)", fps_config.multiplier)
376    } else {
377        String::new()
378    };
379
380    if *resolution == VideoResolution::Native {
381        eprintln!(
382            "Exporting to {} as {:?} ({}x{}, {}{}){}...",
383            video_path.display(),
384            format,
385            src_width,
386            src_height,
387            fps_str,
388            capture_note,
389            mode_str
390        );
391    } else if matches!(format, VideoFormat::Mp4) {
392        eprintln!(
393            "Exporting to {} as {:?} ({}x{} → {}x{} via FFmpeg nearest-neighbor, {}{}){}...",
394            video_path.display(),
395            format,
396            src_width,
397            src_height,
398            dst_width,
399            dst_height,
400            fps_str,
401            capture_note,
402            mode_str
403        );
404    } else {
405        let scale_note = if streaming {
406            " [streaming mode, scaling only supported for MP4]"
407        } else {
408            ""
409        };
410        eprintln!(
411            "Exporting to {} as {:?} ({}x{}, {}{}){}...",
412            video_path.display(),
413            format,
414            src_width,
415            src_height,
416            fps_str,
417            capture_note,
418            scale_note
419        );
420    }
421}
422
423// =============================================================================
424// Screenshot Export
425// =============================================================================
426
427/// Save a single screenshot (used in streaming mode)
428fn save_single_screenshot(frame: &[RgbColor], args: &CliArgs) -> Result<(), String> {
429    if let Some(ref screenshot_path) = args.video.screenshot {
430        let img: image::RgbaImage = image::ImageBuffer::from_fn(NES_WIDTH, NES_HEIGHT, |x, y| {
431            let color = frame[(y * NES_WIDTH + x) as usize];
432            image::Rgba([color.r, color.g, color.b, 255])
433        });
434
435        img.save(screenshot_path)
436            .map_err(|e| format!("Failed to save screenshot: {}", e))?;
437
438        if !args.quiet {
439            eprintln!("Screenshot saved to {}", screenshot_path.display());
440        }
441    }
442    Ok(())
443}
444
445/// Save screenshot to file from buffered frames
446pub fn save_screenshot(
447    frames: &[Vec<u16>],
448    renderer: &mut Box<dyn ScreenRenderer>,
449    args: &CliArgs,
450) -> Result<(), String> {
451    if let Some(ref screenshot_path) = args.video.screenshot {
452        if frames.is_empty() {
453            eprintln!("Warning: No frames to screenshot");
454            return Ok(());
455        }
456
457        // Use the last frame for screenshot
458        let frame = frames.last().unwrap();
459        let rgb_frame = renderer.buffer_to_image(frame);
460
461        if !args.quiet {
462            eprintln!("Saving screenshot to {}...", screenshot_path.display());
463        }
464
465        // Convert RgbColor to RGB bytes for PNG
466        let rgb_data: Vec<u8> = rgb_frame.iter().flat_map(|c| [c.r, c.g, c.b]).collect();
467
468        // Create PNG using image crate
469        let img: image::ImageBuffer<image::Rgb<u8>, Vec<u8>> =
470            image::ImageBuffer::from_raw(NES_WIDTH, NES_HEIGHT, rgb_data)
471                .ok_or_else(|| "Failed to create image buffer".to_string())?;
472
473        img.save(screenshot_path)
474            .map_err(|e| format!("Failed to save screenshot: {}", e))?;
475
476        if !args.quiet {
477            eprintln!("Screenshot saved successfully");
478        }
479    }
480
481    Ok(())
482}
483
484// =============================================================================
485// Buffered Video Export
486// =============================================================================
487
488/// Save recorded frames to video file
489pub fn save_video(
490    frames: &[Vec<u16>],
491    renderer: &mut Box<dyn ScreenRenderer>,
492    args: &CliArgs,
493) -> Result<(), String> {
494    if let Some(ref video_path) = args.video.video_path {
495        // Check if format requires FFmpeg and warn if not available
496        if args.video.video_format == VideoFormat::Mp4 && !is_ffmpeg_available() {
497            return Err("MP4 export requires FFmpeg to be installed. \
498                 Use --video-format png or --video-format ppm for self-contained export."
499                .to_string());
500        }
501
502        if frames.is_empty() {
503            eprintln!("Warning: No frames to export");
504            return Ok(());
505        }
506
507        // Parse video resolution
508        let resolution = VideoResolution::parse(&args.video.video_scale.clone().unwrap())
509            .map_err(|e| format!("Invalid video scale: {}", e))?;
510
511        // Parse FPS configuration
512        let fps_config = FpsConfig::parse(&args.video.video_fps, args.video.video_mode)
513            .map_err(|e| format!("Invalid video FPS: {}", e))?;
514
515        let (dst_width, dst_height) = resolution.dimensions(NES_WIDTH, NES_HEIGHT);
516
517        if !args.quiet {
518            eprintln!(
519                "Exporting {} frames to {} as {:?} ({:.2} fps, {:?})...",
520                frames.len(),
521                video_path.display(),
522                args.video.video_format,
523                fps_config.output_fps(),
524                fps_config.mode
525            );
526            if resolution != VideoResolution::Native && args.video.video_format == VideoFormat::Mp4
527            {
528                eprintln!(
529                    "  Resolution: {}x{} → {}x{} via FFmpeg nearest-neighbor",
530                    NES_WIDTH, NES_HEIGHT, dst_width, dst_height
531                );
532            }
533        }
534
535        // Use streaming encoder for proper scaling support
536        let mut encoder = StreamingVideoEncoder::with_fps_config(
537            args.video.video_format,
538            video_path,
539            NES_WIDTH,
540            NES_HEIGHT,
541            &resolution,
542            fps_config,
543        )
544        .map_err(|e| format!("Failed to create video encoder: {}", e))?;
545
546        for frame in frames {
547            let rgb_frame = renderer.buffer_to_image(frame);
548            encoder.write_frame(rgb_frame).map_err(|e| e.to_string())?;
549        }
550
551        encoder.finish().map_err(|e| e.to_string())?;
552
553        if !args.quiet {
554            eprintln!("Exported {} frames successfully", encoder.frames_written());
555        }
556    }
557
558    Ok(())
559}
560
561// =============================================================================
562// Memory Dump Output
563// =============================================================================
564
565/// Output results based on CLI args using the output module abstraction.
566pub fn output_results(emu: &Nes, args: &CliArgs) -> Result<(), String> {
567    // Reset the output writer state for this run
568    OutputWriter::reset();
569
570    // Create the output writer with configured format and destination
571    let writer = OutputWriter::new(args.output.output.clone(), args.output.effective_format());
572
573    // Process each requested memory dump
574    if let Some(ref range) = args.memory.read_cpu {
575        let dump = create_cpu_dump(emu, range)?;
576        writer.write(&dump)?;
577    }
578
579    if let Some(ref range) = args.memory.read_ppu {
580        let dump = create_ppu_dump(emu, range)?;
581        writer.write(&dump)?;
582    }
583
584    if args.memory.dump_oam {
585        let dump = create_oam_dump(emu);
586        writer.write(&dump)?;
587    }
588
589    if args.memory.dump_nametables {
590        let dump = create_nametables_dump(emu);
591        writer.write(&dump)?;
592    }
593
594    if args.memory.dump_palette {
595        let dump = create_palette_dump(emu);
596        writer.write(&dump)?;
597    }
598
599    Ok(())
600}
601
602// =============================================================================
603// Memory Dump Creation
604// =============================================================================
605
606/// Create a CPU memory dump from the emulator
607fn create_cpu_dump(emu: &Nes, range: &str) -> Result<MemoryDump, String> {
608    let (start, end) = parse_memory_range(range)?;
609    let mem = &emu.get_memory_debug(Some(start..=end))[0];
610    Ok(MemoryDump::new(MemoryType::Cpu, start, mem.to_vec()))
611}
612
613/// Create a PPU memory dump from the emulator
614fn create_ppu_dump(emu: &Nes, range: &str) -> Result<MemoryDump, String> {
615    let (start, end) = parse_memory_range(range)?;
616    let mem = &emu.get_memory_debug(Some(start..=end))[1];
617    Ok(MemoryDump::new(MemoryType::Ppu, start, mem.to_vec()))
618}
619
620/// Create an OAM memory dump from the emulator
621fn create_oam_dump(emu: &Nes) -> MemoryDump {
622    let mem = emu.get_oam_debug();
623    MemoryDump::oam(mem)
624}
625
626/// Create a nametables memory dump from the emulator
627fn create_nametables_dump(emu: &Nes) -> MemoryDump {
628    let mem = emu.get_memory_debug(Some(0x2000..=0x2FFF))[1].to_vec();
629    MemoryDump::nametables(mem)
630}
631
632/// Create a palette RAM memory dump from the emulator
633fn create_palette_dump(emu: &Nes) -> MemoryDump {
634    // Palette RAM is at PPU addresses $3F00-$3F1F (32 bytes)
635    let mem = emu.get_memory_debug(Some(0x3F00..=0x3F1F))[1].to_vec();
636    MemoryDump::palette_ram(mem)
637}