Skip to main content

dotzuki_runner/
headless.rs

1//! Headless driver for [`RunnerGame`] — run frames without a window.
2//!
3//! Used by `dotzuki run --headless` (CI smoke tests, screenshot harnesses) and
4//! by the integration tests. The driver synthesises an [`InputState`] per
5//! frame (auto-pressing A on a configurable cadence so dialogue advances),
6//! updates the game, then renders the final frame into a [`FrameBuffer`]
7//! that can be dumped to a PNG.
8
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use dotzuki_engine::render::{FrameBuffer, Rgba};
13use dotzuki_engine::render_config::RenderConfig;
14use dotzuki_renderer::input::{GbButton, InputState};
15
16use crate::game::{RunnerGame, SCREEN_H, SCREEN_W};
17
18/// Options for [`run_headless`].
19#[derive(Debug, Clone)]
20pub struct HeadlessOptions {
21    /// Frames to simulate (default 120 ≈ 2 s at the GB cadence).
22    pub frames: u32,
23    /// Press A every N frames (advances dialogue/choices); 0 disables.
24    /// Ignored while `input_script` is non-empty.
25    pub press_a_every: u32,
26    /// Scripted input: exact-frame button presses `(frame, button)`. When
27    /// non-empty it REPLACES the auto-A cadence — menu-driving harnesses use
28    /// it to reach submenus a blind auto-A never would.
29    pub input_script: Vec<(u32, GbButton)>,
30    /// Optional PNG dump of the final framebuffer.
31    pub screenshot: Option<std::path::PathBuf>,
32}
33
34impl Default for HeadlessOptions {
35    fn default() -> Self {
36        Self {
37            frames: 120,
38            press_a_every: 30,
39            input_script: Vec::new(),
40            screenshot: None,
41        }
42    }
43}
44
45/// Run `game` headless for `opts.frames` frames and return the final
46/// framebuffer (also written to `opts.screenshot` when set).
47///
48/// # Errors
49///
50/// Fails only when the screenshot cannot be encoded/written.
51pub fn run_headless(game: &mut RunnerGame, opts: &HeadlessOptions) -> Result<FrameBuffer> {
52    let mut input = InputState::new();
53    for frame in 0..opts.frames {
54        let mask = if !opts.input_script.is_empty() {
55            opts.input_script
56                .iter()
57                .filter(|(f, _)| *f == frame)
58                .fold(0, |mask, (_, b)| mask | b.bit_mask())
59        } else if opts.press_a_every > 0 && frame % opts.press_a_every == 0 {
60            GbButton::A.bit_mask()
61        } else {
62            0
63        };
64        input.set_from_bitmask(mask);
65        game.update(&input);
66        input.begin_frame();
67    }
68
69    let mut fb = FrameBuffer::new(
70        RenderConfig::new(SCREEN_W as u32, SCREEN_H as u32),
71        Rgba::BLACK,
72    );
73    game.draw(&mut fb);
74
75    if let Some(path) = &opts.screenshot {
76        save_png(&fb, path)?;
77    }
78    Ok(fb)
79}
80
81/// Write a framebuffer out as an 8-bit RGBA PNG.
82pub fn save_png(fb: &FrameBuffer, path: &Path) -> Result<()> {
83    if let Some(parent) = path.parent() {
84        if !parent.as_os_str().is_empty() {
85            std::fs::create_dir_all(parent)
86                .with_context(|| format!("failed to create {}", parent.display()))?;
87        }
88    }
89    image::save_buffer(
90        path,
91        &fb.data,
92        fb.width(),
93        fb.height(),
94        image::ColorType::Rgba8,
95    )
96    .with_context(|| format!("failed to write screenshot {}", path.display()))
97}