1use 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
38pub const MAX_SCALE: u32 = 8;
40
41pub 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 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
96pub 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
123pub fn check(script: &Script) -> Result<(), String> {
127 script.check()?;
128 check_waits(&script.steps, MAX_WAIT)
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub struct Options {
134 pub out: PathBuf,
136 pub script: Script,
138 pub scale: Option<u32>,
140 pub target: Option<String>,
143 pub game: Vec<GameArg>,
145}
146
147pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct Shot {
210 pub image: Image,
212 pub shows: String,
214 pub warning: Option<String>,
217}
218
219impl Shot {
220 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
230pub 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
257fn 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
268pub 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
278pub struct Painter(raster::Painter);
282
283impl Painter {
284 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 pub fn render(&self, buf: &GlyphBuffer, scale: u32) -> Image {
311 self.0.render(buf, scale)
312 }
313
314 pub fn raster(&self) -> &raster::Painter {
316 &self.0
317 }
318}
319
320#[cfg(test)]
321mod tests;