Skip to main content

shuyi_cli/contract/
bake.rs

1//! `bake [--target <id>] [--out <file>] [--sheet <file>]`: the game's
2//! sources baked into one pack (`.shuyipak`, ADR-0023), with what went
3//! in, the sizes, and how long baking and loading took. A source with
4//! errors is refused, with every error. `check --baked` uses the same
5//! bake to prove the pack equal to its sources ([`check_baked`]).
6//!
7//! The game says what it bakes for a target, from which bundle and with
8//! which formats ([`GameTools::bake`]). A target must have a profile, the
9//! engine's or the game's ([`target::profiles`]); the game picks its
10//! layers and kinds by it. For a one-bit target it bakes its pictures as
11//! one-bit picture files (`Bake::under_as`), from the target's own layer
12//! (`Bundle::for_target`) where it has one: the answer names the pictures
13//! dithered, for an artist to check, and those its layer replaced, and
14//! `--sheet` draws each dithered one beside its source ([`sheet`]).
15
16use std::fmt::Write as _;
17use std::fs;
18use std::time::{Duration, Instant};
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use shuyi_assets::ContentError;
23use shuyi_assets::bitmap::OneBitPicture;
24use shuyi_assets::bundle::{Bundle, Layer};
25use shuyi_assets::format::{AssetKind, Formats};
26use shuyi_assets::image::PNG_SIGNATURE;
27use shuyi_assets::pack::{Bake, Baked, Pack};
28
29use super::{Answer, Op, Problem, codes, target};
30use crate::game::{GameBake, GameTools, code_of};
31use crate::project::Project;
32
33pub mod sheet;
34
35/// Where a pack goes when `--out` doesn't say, from the project's root:
36/// `target/shuyi/game.shuyipak`, or `<target>.shuyipak` for a target.
37pub const OUT_DIR: &str = "target/shuyi";
38
39/// How many times each load is timed: the fastest counts.
40pub const LOAD_RUNS: u32 = 5;
41
42/// What `bake` bakes.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(deny_unknown_fields)]
45#[non_exhaustive]
46pub struct BakeInput {
47    /// The target to bake for, by its profile's id, passed to the game;
48    /// none for its default bundle.
49    #[serde(default)]
50    pub target: Option<String>,
51    /// The pack's file, from the project's root; `target/shuyi/game.shuyipak`
52    /// (or `<target>.shuyipak`) if none.
53    #[serde(default)]
54    pub out: Option<String>,
55    /// A contact sheet to write, from the project's root: a PNG of every
56    /// picture the bake dithered, each beside its source, for review.
57    #[serde(default)]
58    pub sheet: Option<String>,
59}
60
61/// What `bake` made.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct BakeOutput {
65    /// Every error of the sources: the pack is made only when there is
66    /// none.
67    pub errors: Vec<Problem>,
68    /// The pack, if it was made.
69    pub pack: Option<PackMade>,
70}
71
72/// A pack `bake` wrote.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75#[non_exhaustive]
76pub struct PackMade {
77    /// Its file, from the project's root.
78    pub file: String,
79    /// The target it was baked for.
80    pub target: Option<String>,
81    /// The files in it.
82    pub files: u32,
83    /// How many of them hold baked assets; the others hold their own
84    /// bytes.
85    pub baked: u32,
86    /// The kinds baked, by name.
87    pub kinds: Vec<String>,
88    /// The sources' bytes, added up.
89    pub source_bytes: u64,
90    /// The pack's bytes.
91    pub pack_bytes: u64,
92    /// How long baking took, in microseconds.
93    pub bake_us: u64,
94    /// How long reading every baked asset from its source took, in
95    /// microseconds: the fastest of a few runs.
96    pub load_sources_us: u64,
97    /// How long opening the pack and reading every baked asset from it
98    /// took, in microseconds: the fastest of a few runs.
99    pub load_pack_us: u64,
100    /// The pictures made one-bit by dithering a PNG of the game's own
101    /// layers, as messages show them: what an artist checks.
102    #[serde(default)]
103    pub dithered: Vec<String>,
104    /// The pictures read from the target's own layer
105    /// (`assets@<target>/`) in place of the game's.
106    #[serde(default)]
107    pub replaced: Vec<String>,
108    /// The contact sheet written, from the project's root, if one was
109    /// asked for.
110    #[serde(default)]
111    pub sheet: Option<String>,
112}
113
114/// The pictures of `baked`, baked from `bundle` for `target` as one-bit
115/// pictures: those dithered from a PNG of a layer that isn't the
116/// target's, and those read from the target's layer, each by path.
117fn one_bit_pictures(
118    baked: &Baked,
119    bundle: &Bundle,
120    target: Option<&str>,
121) -> (Vec<String>, Vec<String>) {
122    let mut dithered = Vec::new();
123    let mut replaced = Vec::new();
124    for file in &baked.files {
125        if !file.kinds.contains(&OneBitPicture::NAME) {
126            continue;
127        }
128        let layer = bundle.layer_of(&file.path).and_then(Layer::target);
129        if target.is_some() && layer == target {
130            replaced.push(file.path.clone());
131        } else if bundle
132            .bytes(&file.path)
133            .is_some_and(|bytes| bytes.starts_with(PNG_SIGNATURE))
134        {
135            dithered.push(file.path.clone());
136        }
137    }
138    (dithered, replaced)
139}
140
141/// `duration` in whole microseconds.
142fn micros(duration: Duration) -> u64 {
143    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
144}
145
146/// The fastest of [`LOAD_RUNS`] runs of `run`.
147fn fastest(mut run: impl FnMut()) -> u64 {
148    (0..LOAD_RUNS)
149        .map(|_| {
150            let start = Instant::now();
151            run();
152            micros(start.elapsed())
153        })
154        .min()
155        .unwrap_or_default()
156}
157
158/// The game's bake for `target`, its formats with the pack's.
159fn prepared(game: &dyn GameTools, target: Option<&str>) -> Result<GameBake, String> {
160    let mut prepared = game.bake(target)?;
161    prepared
162        .bake
163        .register(&mut prepared.formats)
164        .map_err(|e| e.to_string())?;
165    Ok(prepared)
166}
167
168/// A bundle of pack `bytes`, opened with `stamp`.
169fn opened(bytes: &[u8], stamp: &str) -> Result<Bundle, String> {
170    let pack = Pack::open(bytes, stamp).map_err(|e| e.to_string())?;
171    Ok(Bundle::new(vec![pack.into_owned().into_layer("pack")]))
172}
173
174/// How long reading every asset `bake` names from `bundle` takes, the
175/// pack opened first if `pack` holds one.
176fn load_time(bake: &Bake, formats: &Formats, bundle: &Bundle, pack: Option<&[u8]>) -> u64 {
177    fastest(|| match pack {
178        Some(bytes) => {
179            if let Ok(bundle) = opened(bytes, bake.stamp()) {
180                let _ = bake.load(formats, &bundle);
181            }
182        }
183        None => {
184            let _ = bake.load(formats, bundle);
185        }
186    })
187}
188
189/// The pack's file for `input`, from the project's root.
190fn out_file(input: &BakeInput) -> String {
191    input.out.clone().unwrap_or_else(|| {
192        let name = input.target.as_deref().unwrap_or("game");
193        format!("{OUT_DIR}/{name}.{}", shuyi_assets::pack::EXTENSION)
194    })
195}
196
197/// `bake`: `game`'s pack for `input`, written under `project`'s root.
198pub fn bake(game: &dyn GameTools, project: &Project, input: &BakeInput) -> Answer<BakeOutput> {
199    let target = input.target.as_deref();
200    if let Some(Err((code, why))) =
201        target.map(|target| target::profile(&project.config, &project.root, target))
202    {
203        return Answer::failed(Op::Bake, code, why);
204    }
205    let GameBake {
206        bake,
207        bundle,
208        formats,
209    } = match prepared(game, target) {
210        Ok(prepared) => prepared,
211        Err(e) => return Answer::failed(Op::Bake, code_of(&e), e),
212    };
213    let start = Instant::now();
214    let baked = match bake.bake(&formats, &bundle, target) {
215        Ok(baked) => baked,
216        Err(errors) => {
217            let errors = errors.iter().map(Problem::from).collect();
218            return Answer::new(
219                Op::Bake,
220                false,
221                BakeOutput { errors, pack: None },
222                Vec::new(),
223            );
224        }
225    };
226    let bake_us = micros(start.elapsed());
227    let file = out_file(input);
228    let path = project.root.join(&file);
229    let written = path
230        .parent()
231        .map_or(Ok(()), fs::create_dir_all)
232        .and_then(|()| fs::write(&path, &baked.bytes));
233    if let Err(e) = written {
234        return Answer::failed(Op::Bake, codes::FILE, format!("{file}: {e}"));
235    }
236    let (dithered, replaced) = one_bit_pictures(&baked, &bundle, target);
237    if let Some(sheet) = &input.sheet {
238        let drawn = opened(&baked.bytes, bake.stamp())
239            .and_then(|pack| sheet::draw(&bundle, &pack, &dithered))
240            .and_then(|png| {
241                let path = project.root.join(sheet);
242                path.parent()
243                    .map_or(Ok(()), fs::create_dir_all)
244                    .and_then(|()| fs::write(&path, png))
245                    .map_err(|e| e.to_string())
246            });
247        if let Err(e) = drawn {
248            return Answer::failed(Op::Bake, codes::FILE, format!("{sheet}: {e}"));
249        }
250    }
251    let shown = |paths: Vec<String>| paths.iter().map(|p| bundle.display_path(p)).collect();
252    let count = |n: usize| u32::try_from(n).unwrap_or(u32::MAX);
253    let pack = PackMade {
254        file,
255        target: input.target.clone(),
256        files: count(baked.files.len()),
257        baked: count(baked.files.iter().filter(|f| !f.kinds.is_empty()).count()),
258        kinds: bake.kinds().iter().map(|&k| k.to_owned()).collect(),
259        source_bytes: baked.source_bytes() as u64,
260        pack_bytes: baked.bytes.len() as u64,
261        bake_us,
262        load_sources_us: load_time(&bake, &formats, &bundle, None),
263        load_pack_us: load_time(&bake, &formats, &bundle, Some(&baked.bytes)),
264        dithered: shown(dithered),
265        replaced: shown(replaced),
266        sheet: input.sheet.clone(),
267    };
268    let output = BakeOutput {
269        errors: Vec::new(),
270        pack: Some(pack),
271    };
272    let limit = "times are of this machine and this build: a release build, or the web build, \
273                 loads at its own speed";
274    Answer::new(Op::Bake, true, output, vec![limit.to_owned()])
275}
276
277/// `check --baked`'s part: every way the pack `game` bakes (for no
278/// target) differs from its sources ([`Bake::check`]), or why it could
279/// not be baked.
280pub fn check_baked(game: &dyn GameTools) -> Result<Vec<ContentError>, String> {
281    let GameBake {
282        bake,
283        bundle,
284        formats,
285    } = prepared(game, None)?;
286    let baked = match bake.bake(&formats, &bundle, None) {
287        Ok(baked) => baked,
288        Err(errors) => return Ok(errors),
289    };
290    let pack = opened(&baked.bytes, bake.stamp())?;
291    Ok(bake.check(&formats, &bundle, &pack))
292}
293
294/// Reads `bake`'s arguments (not `--json`).
295pub fn parse_args(args: &[String]) -> Result<BakeInput, String> {
296    let mut input = BakeInput::default();
297    let mut iter = args.iter();
298    while let Some(arg) = iter.next() {
299        let mut value = || {
300            iter.next()
301                .cloned()
302                .ok_or_else(|| format!("{arg} needs a value"))
303        };
304        match arg.as_str() {
305            "--target" => input.target = Some(value()?),
306            "--out" => input.out = Some(value()?),
307            "--sheet" => input.sheet = Some(value()?),
308            other => return Err(format!("unknown argument: {other}")),
309        }
310    }
311    Ok(input)
312}
313
314/// `bake`'s usage, with `command` as the tool's name.
315pub fn usage(command: &str) -> String {
316    format!("usage: {command} bake [--target <id>] [--out <file>] [--sheet <file>] [--json]")
317}
318
319/// `n` bytes, with a thousands separator.
320fn bytes(n: u64) -> String {
321    let digits = n.to_string();
322    let mut out = String::new();
323    for (i, c) in digits.chars().enumerate() {
324        if i > 0 && (digits.len() - i).is_multiple_of(3) {
325            out.push(',');
326        }
327        out.push(c);
328    }
329    out
330}
331
332/// `us` microseconds, as milliseconds with a decimal.
333fn ms(us: u64) -> String {
334    format!("{}.{} ms", us / 1000, us % 1000 / 100)
335}
336
337/// What `bake` made, for people.
338pub fn text(output: &BakeOutput) -> String {
339    let mut out = String::new();
340    if !output.errors.is_empty() {
341        let _ = writeln!(
342            out,
343            "bake: {} error(s) in the sources; no pack made",
344            output.errors.len()
345        );
346        for error in &output.errors {
347            let _ = writeln!(out, "  {error}");
348        }
349        out.push_str(&codes::help_lines(&output.errors));
350    }
351    if let Some(pack) = &output.pack {
352        let target = pack
353            .target
354            .as_ref()
355            .map(|t| format!(" for {t}"))
356            .unwrap_or_default();
357        let percent = (pack.pack_bytes * 100)
358            .checked_div(pack.source_bytes)
359            .unwrap_or(100);
360        // Writing to a `String` can't fail.
361        let _ = writeln!(
362            out,
363            "bake: {}{target}: {} files, {} baked ({})",
364            pack.file,
365            pack.files,
366            pack.baked,
367            pack.kinds.join(", ")
368        );
369        let _ = writeln!(
370            out,
371            "  size: sources {} bytes, pack {} bytes ({percent}%)",
372            bytes(pack.source_bytes),
373            bytes(pack.pack_bytes)
374        );
375        let _ = writeln!(out, "  baked in {}", ms(pack.bake_us));
376        let _ = writeln!(
377            out,
378            "  load: sources {}, pack {}",
379            ms(pack.load_sources_us),
380            ms(pack.load_pack_us)
381        );
382        if !pack.dithered.is_empty() {
383            let _ = writeln!(
384                out,
385                "  dithered {} picture(s), to check: {}",
386                pack.dithered.len(),
387                pack.dithered.join(", ")
388            );
389        }
390        if !pack.replaced.is_empty() {
391            let _ = writeln!(
392                out,
393                "  from the target's layer: {}",
394                pack.replaced.join(", ")
395            );
396        }
397        if let Some(sheet) = &pack.sheet {
398            let _ = writeln!(out, "  contact sheet: {sheet}");
399        }
400    }
401    out
402}
403
404#[cfg(test)]
405mod tests;