Skip to main content

gasm_host/
headless.rs

1//! Headless runs: N frames with virtual time, scripted input and (optionally)
2//! hashes of everything the guest presents. Reproducible: the same module and
3//! options give the same hashes on every runner (runners/web/headless.mjs).
4
5use std::time::{Duration, Instant};
6
7use crate::gfx::{Gfx, HEADLESS_SIZE};
8use crate::host::{Stop, VirtualClock};
9use crate::present::Present;
10use crate::script::{Script, ScriptState};
11use crate::session::Session;
12
13pub struct Options {
14    pub frames: u64,
15    /// render on the GPU (offscreen) and write the last frame as PNG
16    pub screenshot: Option<String>,
17    /// also write the last 2D frame as the window would show it: `present` at `window` size
18    pub screenshot_filtered: Option<String>,
19    pub present: Present,
20    pub window: (u32, u32),
21    pub script: Script,
22    /// pace frames at the guest's rate
23    pub realtime: bool,
24    pub hash: bool,
25}
26
27/// What a run produced (`print` writes the hash lines scripts compare).
28pub struct Report {
29    pub frames: u64,
30    pub presented: u64,
31    pub size: (usize, usize),
32    pub video_hash: u32,
33    pub audio_hash: u32,
34    pub audio_frames: u64,
35    pub frame_rate: f64,
36    pub seconds: f64,
37    /// the guest called proc_exit(code)
38    pub exit: Option<i32>,
39}
40
41impl Report {
42    /// The hash lines (stdout): a contract between runners and scripts.
43    pub fn print(&self) {
44        println!("frames={} presented={} size={}x{}", self.frames, self.presented, self.size.0, self.size.1);
45        println!("video_fnv32={:08x} audio_fnv32={:08x} audio_frames={}", self.video_hash, self.audio_hash, self.audio_frames);
46    }
47}
48
49pub fn run(session: Session, opts: &Options) -> Result<Report, String> {
50    // Render on the GPU only when a screenshot is wanted; otherwise a null backend.
51    let gpu = opts.screenshot.is_some() || opts.screenshot_filtered.is_some();
52    let gfx = match gpu {
53        true => Gfx::offscreen(HEADLESS_SIZE.0, HEADLESS_SIZE.1).unwrap_or_else(|e| {
54            eprintln!("[gasm] no GPU for screenshots ({e}); gfx output will be blank");
55            Gfx::null()
56        }),
57        false => Gfx::null(),
58    };
59    if let Some(t) = crate::host::static_title(&session.wasm) {
60        eprintln!("[gasm] title: {t} (gasm.title)");
61    }
62    let mut game = match session.start(None, gfx, true, opts.hash) {
63        Ok(g) => g,
64        Err(Stop::Exit(code)) => {
65            return Ok(Report { frames: 0, presented: 0, size: (0, 0), video_hash: 0x811c_9dc5, audio_hash: 0x811c_9dc5, audio_frames: 0, frame_rate: 60.0, seconds: 0.0, exit: Some(code) });
66        }
67        Err(Stop::Trap(e)) => return Err(e),
68    };
69    let t0 = Instant::now();
70    let mut exit = None;
71    let mut ran = 0;
72    let mut clock = VirtualClock::default();
73    let mut script_state = ScriptState::default();
74    for i in 0..opts.frames {
75        // between frames the host is reached through with_host (a gasm_run guest owns it)
76        game.with_host(|host| {
77            let rate = host.frame_rate;
78            host.virtual_time_ms = Some(clock.at(i, rate));
79            host.pads = [opts.script.pad(i), 0, 0, 0];
80            host.text = Some(opts.script.text(i));
81            let (w, h) = host.gfx.size();
82            let mode = host.input_mode;
83            host.input = opts.script.raw(i, &mut script_state, (w as f32, h as f32), mode);
84            host.show_frame = gpu && i + 1 == opts.frames;
85        });
86        ran = i + 1;
87        let result = game.frame();
88        if let Some(t) = game.with_host(|host| std::mem::take(&mut host.title_changed).then(|| host.title.clone())) {
89            eprintln!("[gasm] title: {}", t.as_deref().unwrap_or("(default)"));
90        }
91        match result {
92            Ok(()) => {}
93            Err(Stop::Exit(code)) => {
94                exit = Some(code);
95                break;
96            }
97            Err(Stop::Trap(e)) => return Err(format!("frame {i}: {e}")),
98        }
99        if opts.realtime {
100            let due = t0 + Duration::from_secs_f64((i + 1) as f64 / game.with_host(|h| h.frame_rate));
101            if let Some(wait) = due.checked_duration_since(Instant::now()) {
102                std::thread::sleep(wait);
103            }
104        }
105    }
106    if exit.is_none() {
107        game.exit();
108    }
109    if let Some(path) = &opts.screenshot_filtered {
110        let h = game.host_mut();
111        let img = match h.gfx.used {
112            true => h.gfx.read_offscreen(), // gfx guests render at drawable size: no filter
113            false if h.width > 0 => {
114                let rgba = std::mem::take(&mut h.rgba);
115                h.gfx.present = opts.present;
116                let img = h.gfx.render_video(&rgba, h.width as u32, h.height as u32, h.aspect, opts.window);
117                h.rgba = rgba;
118                img
119            }
120            false => None,
121        };
122        if let Some((w, hgt, rgba)) = img {
123            write_png(path, w, hgt, &rgba)?;
124            eprintln!("[gasm] wrote {path}");
125        }
126    }
127    let h = game.host();
128    if let Some(path) = &opts.screenshot {
129        let (w, hgt, rgba) = match h.gfx.read_offscreen() {
130            Some(img) if h.gfx.used => img,
131            _ => (h.width as u32, h.height as u32, h.rgba.clone()),
132        };
133        if w > 0 {
134            write_png(path, w, hgt, &rgba)?;
135            eprintln!("[gasm] wrote {path}");
136        }
137    }
138    Ok(Report {
139        frames: ran,
140        presented: h.frames_presented,
141        size: (h.width, h.height),
142        video_hash: h.video_hash.0,
143        audio_hash: h.audio_hash.0,
144        audio_frames: h.audio_frames,
145        frame_rate: h.frame_rate,
146        seconds: t0.elapsed().as_secs_f64(),
147        exit,
148    })
149}
150
151pub fn write_png(path: &str, w: u32, h: u32, rgba: &[u8]) -> Result<(), String> {
152    let file = std::fs::File::create(path).map_err(|e| format!("{path}: {e}"))?;
153    let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w, h);
154    enc.set_color(png::ColorType::Rgba);
155    enc.set_depth(png::BitDepth::Eight);
156    let mut writer = enc.write_header().map_err(|e| e.to_string())?;
157    writer.write_image_data(rgba).map_err(|e| e.to_string())
158}