Skip to main content

shuyi_cli/
frame_png.rs

1//! `frame-png`: renders a frame of a game in the test Harness to a PNG
2//! without a window, for looking at a skin, a picture or a mockup. Never a
3//! gate: snapshots stay text.
4//!
5//! The command plays a [`Script`] of keys, controller buttons, actions by
6//! name and waits in the game's Harness (`shuyi_platform::script`'s, the one
7//! the live link and the capture plans play), which the game's [`GameTools::frame`] starts with
8//! the game's own options, and writes the picture the game paints of it.
9//! A game paints a frame with [`Painter`], or, for a one-bit screen, with
10//! the one-bit renderer and [`one_bit_picture`]. With `--target <id>`, a
11//! target whose display is black and white gets the picture its device
12//! shows ([`GameTools::frame_one_bit`]: the frame drawn in its roles'
13//! patterns, ADR-0008 §4). With `--debug <channels>`, the debug view's
14//! channels are on from the start (a [`Step::Debug`] first in the script,
15//! ADR-0085): the picture shows what a person sees with the view on.
16//!
17//! [`Painter`] decodes a game's font atlas and images from PNG and paints
18//! with the raster, `shuyi_ui::frame::raster`: the reference picture of a
19//! frame, which `shuyi-app` must match. Its module docs say what it draws.
20
21use std::fmt::Write as _;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use shuyi_assets::font::FontAtlasDef;
26use shuyi_assets::image::ImageTable;
27use shuyi_assets::target::{Color, TargetProfile};
28use shuyi_platform::harness::MAX_WAIT;
29use shuyi_platform::script::check_waits;
30pub use shuyi_platform::script::{Script, Step};
31use shuyi_ui::frame::raster;
32pub use shuyi_ui::frame::raster::{Image, one_bit_picture};
33use shuyi_ui::frame::{GlyphBuffer, Rgb};
34
35use crate::codec::{decode_png, encode_png};
36use crate::game::{GameArg, GameOption, GameTools, game_arg};
37
38/// Largest `--scale`.
39pub const MAX_SCALE: u32 = 8;
40
41/// The usage text, with `command` as the tool's name and the game's own
42/// options and notes.
43pub fn usage(command: &str, options: &[GameOption], notes: &str) -> String {
44    let mut out = format!(
45        "\
46usage: {command} frame-png <out.png> [steps] [options]
47
48Starts the game in the test Harness, runs the steps in the order given,
49and writes the frame to <out.png> (a relative path is from the project's
50root).
51
52steps (each may repeat):
53  --keys \"F2 Up f\"   press and release each key chord in turn
54  --pad \"South\"      press and release each controller button in turn
55                     (\"East+DpadDown\": those buttons together)
56  --act Confirm      press the game's action by its key, or else its button
57  --wait 0.5         let that many seconds pass (60 frames a second, rounded up)
58  --crank -30        wind the crank that many degrees in one frame
59                     (backwards is negative)
60
61options:
62  --scale N            whole-number scale, 1 to {MAX_SCALE} (the game says what by default)
63  --target ID          as target ID's display shows it: on a one-bit one (the
64                       Playdate), black and white in the skin's patterns
65  --debug CHANNELS     the debug view's channels on from the start, by id,
66                       comma-separated (grid,collision; all: every channel),
67                       with debug tools on
68"
69    );
70    for option in options {
71        let name = match option.value {
72            Some(value) => format!("{} {value}", option.flag),
73            None => option.flag.to_owned(),
74        };
75        let mut lines = option.help.lines();
76        // Writing to a `String` can't fail.
77        let _ = writeln!(out, "  {name:<20} {}", lines.next().unwrap_or_default());
78        for line in lines {
79            let _ = writeln!(out, "  {:<20} {line}", "");
80        }
81    }
82    out.push_str(
83        "
84Key and button names are the Harness's test-script names (Chord::parse,
85Button::parse): input to a test script, not game code, so the
86never-hard-code-a-key rule doesn't apply to them. --act names the game's
87action instead, and presses whatever the player's keymap binds to it.",
88    );
89    if !notes.is_empty() {
90        out.push_str("\n\n");
91        out.push_str(notes);
92    }
93    out
94}
95
96/// Reads the script flag `flag` with its value `value`, if it is one:
97/// `--keys` (or `--script`), `--pad`, `--act`, `--wait` or `--crank`. A
98/// step no player can play ([`Step::check`]) is an error, not a panic
99/// later. The longest wait is the player's to check (the Harness's with
100/// [`check`]), and an action's name is looked up in the game's keymap.
101pub fn parse_step(flag: &str, value: &str) -> Option<Result<Step, String>> {
102    let step = match flag {
103        "--keys" | "--script" => Step::Keys(value.to_owned()),
104        "--pad" => Step::Pad(value.to_owned()),
105        "--act" => Step::Act(value.to_owned()),
106        "--wait" => match value.parse::<f32>() {
107            Ok(seconds) => Step::Wait(seconds),
108            Err(_) => {
109                return Some(Err(format!("--wait: not a number of seconds: {value}")));
110            }
111        },
112        "--crank" => match value.parse::<f32>() {
113            Ok(degrees) => Step::Crank(degrees),
114            Err(_) => {
115                return Some(Err(format!("--crank: not a number of degrees: {value}")));
116            }
117        },
118        _ => return None,
119    };
120    Some(step.check().map(|()| step).map_err(|e| format!("--{e}")))
121}
122
123/// Whether the Harness can play every step of `script`: each step
124/// ([`Script::check`]), and waits of at most [`MAX_WAIT`] seconds. An error
125/// names the step.
126pub fn check(script: &Script) -> Result<(), String> {
127    script.check()?;
128    check_waits(&script.steps, MAX_WAIT)
129}
130
131/// The command's arguments.
132#[derive(Debug, Clone, PartialEq)]
133pub struct Options {
134    /// Where the PNG goes.
135    pub out: PathBuf,
136    /// What to do before taking the frame.
137    pub script: Script,
138    /// Pixels per console pixel, if given.
139    pub scale: Option<u32>,
140    /// The target whose display to show it as, by its profile's id, if
141    /// given.
142    pub target: Option<String>,
143    /// The game's own options, in the order given.
144    pub game: Vec<GameArg>,
145}
146
147/// Parses `frame-png`'s arguments, the game's `options` among them,
148/// checking key and button names and waits up front ([`check`]) so a typo
149/// is an error, not a Harness panic.
150pub fn parse_args(args: &[String], options: &[GameOption]) -> Result<Options, String> {
151    let mut out = None;
152    let mut parsed = Options {
153        out: PathBuf::new(),
154        script: Script::default(),
155        scale: None,
156        target: None,
157        game: Vec::new(),
158    };
159    let steps = &mut parsed.script.steps;
160    let mut debug = Vec::new();
161    let mut iter = args.iter();
162    while let Some(arg) = iter.next() {
163        let mut value = || {
164            iter.next()
165                .ok_or_else(|| format!("{arg} needs a value"))
166                .cloned()
167        };
168        match arg.as_str() {
169            "--keys" | "--pad" | "--act" | "--wait" | "--crank" => {
170                let text = value()?;
171                if let Some(step) = parse_step(arg, &text) {
172                    steps.push(step?);
173                }
174            }
175            "--scale" => {
176                let text = value()?;
177                parsed.scale = Some(
178                    text.parse()
179                        .ok()
180                        .filter(|s| (1..=MAX_SCALE).contains(s))
181                        .ok_or_else(|| {
182                            format!("--scale: a whole number 1 to {MAX_SCALE}, not {text}")
183                        })?,
184                );
185            }
186            "--target" => parsed.target = Some(value()?),
187            "--debug" => debug.push(value()?),
188            flag if flag.starts_with("--") => {
189                parsed.game.push(game_arg(flag, options, value)?);
190            }
191            path if out.is_none() => out = Some(PathBuf::from(path)),
192            extra => return Err(format!("one output path only, not also {extra}")),
193        }
194    }
195    parsed.out = out.ok_or("missing <out.png>")?;
196    // The channels are on from the first frame, wherever the flag is.
197    if !debug.is_empty() {
198        let step = Step::Debug(debug.join(","));
199        step.check().map_err(|e| format!("--{e}"))?;
200        parsed.script.steps.insert(0, step);
201    }
202    check(&parsed.script)?;
203    Ok(parsed)
204}
205
206/// What a game shows of a frame. Build it with [`Shot::new`] (ADR-0039).
207#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct Shot {
210    /// The picture.
211    pub image: Image,
212    /// What it shows, for the summary: the top screen's name.
213    pub shows: String,
214    /// What the summary warns about it, if anything (a picture of art the
215    /// game may not hand out).
216    pub warning: Option<String>,
217}
218
219impl Shot {
220    /// The picture `image` of the screen `shows`, with no warning.
221    pub fn new(image: Image, shows: impl Into<String>) -> Self {
222        Self {
223            image,
224            shows: shows.into(),
225            warning: None,
226        }
227    }
228}
229
230/// Runs the command: the game plays the script and paints the frame, as
231/// the display of `target` (the profile of [`Options::target`]) shows it,
232/// which is written under `root` (if `out` is relative); returns what to
233/// print.
234pub fn run(
235    root: &Path,
236    options: &Options,
237    game: &dyn GameTools,
238    target: Option<&TargetProfile>,
239) -> Result<String, String> {
240    let (game_options, script, scale) = (&options.game, &options.script, options.scale);
241    let shot = match target {
242        Some(profile) if profile.display.color == Color::OneBit => {
243            game.frame_one_bit(&profile.id, game_options, script, scale)?
244        }
245        _ => game.frame(game_options, script, scale)?,
246    };
247    let png = encode_png(shot.image.width, shot.image.height, &shot.image.rgba)?;
248    let path = root.join(&options.out);
249    if let Some(dir) = path.parent() {
250        fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
251    }
252    fs::write(&path, png).map_err(|e| format!("writing {}: {e}", path.display()))?;
253    let shown = format!("{} → {}", shot.shows, options.out.display());
254    Ok(summary(&shown, &shot.image, shot.warning.as_deref()))
255}
256
257/// What `run` prints for frame `frame` of `shown` (screen → path), with
258/// the game's `warning` about it, if any.
259fn summary(shown: &str, frame: &Image, warning: Option<&str>) -> String {
260    let mut out = format!("frame-png: {shown} ({}×{})", frame.width, frame.height);
261    if let Some(warning) = warning {
262        out.push_str("\nwarning: ");
263        out.push_str(warning);
264    }
265    out
266}
267
268/// Decodes PNG file `png`.
269pub fn decode_image(png: &[u8]) -> Result<Image, String> {
270    let (width, height, rgba) = decode_png(png)?;
271    Ok(Image {
272        width,
273        height,
274        rgba,
275    })
276}
277
278/// The raster's [`raster::Painter`], with its font atlas and images
279/// decoded from PNG files, ready to render any number of frames (each
280/// image is decoded once).
281pub struct Painter(raster::Painter);
282
283impl Painter {
284    /// Decodes atlas image `atlas_png` and every image in `images`, whose
285    /// files `read` returns (an image `read` lacks, or that doesn't decode,
286    /// draws as a [`MISSING_COLOR`](raster::MISSING_COLOR) rectangle).
287    /// `clear` is the console background (the palette's `black`).
288    pub fn new<'a>(
289        atlas: FontAtlasDef,
290        atlas_png: &[u8],
291        images: &ImageTable,
292        read: impl Fn(&str) -> Option<&'a [u8]>,
293        clear: Rgb,
294    ) -> Result<Self, String> {
295        let atlas_image = decode_image(atlas_png).map_err(|e| format!("font atlas image: {e}"))?;
296        let images = images
297            .ids()
298            .filter_map(|id| Some((id, decode_image(read(id.path())?).ok()?)))
299            .collect();
300        Ok(Self(raster::Painter::new(
301            atlas,
302            atlas_image,
303            images,
304            clear,
305        )))
306    }
307
308    /// Draws `buf` with each console pixel `scale` × `scale` pixels big
309    /// ([`raster::Painter::render`]).
310    pub fn render(&self, buf: &GlyphBuffer, scale: u32) -> Image {
311        self.0.render(buf, scale)
312    }
313
314    /// The raster's painter this one decoded.
315    pub fn raster(&self) -> &raster::Painter {
316        &self.0
317    }
318}
319
320#[cfg(test)]
321mod tests;